Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/__tests__/changeCategories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { CHANGE_CATEGORIES, getChangeCategory, getChangeLabel } from '@/constants/changeCategories'

describe('changeCategories', () => {
it('categorizes tags as live', () => {
expect(getChangeCategory('tags')).toBe('live')
})

it('categorizes name as live', () => {
expect(getChangeCategory('name')).toBe('live')
})

it('categorizes description as live', () => {
expect(getChangeCategory('description')).toBe('live')
})

it('categorizes cores as restart', () => {
expect(getChangeCategory('cores')).toBe('restart')
})

it('categorizes memory as restart', () => {
expect(getChangeCategory('memory')).toBe('restart')
})

it('returns human-readable labels', () => {
expect(getChangeLabel('cores')).toBe('CPU Cores')
expect(getChangeLabel('memory')).toBe('Memory')
expect(getChangeLabel('tags')).toBe('Tags')
expect(getChangeLabel('name')).toBe('Name')
expect(getChangeLabel('description')).toBe('Description')
})

it('returns unknown category for unmapped fields', () => {
expect(getChangeCategory('someFutureField')).toBe('redeploy')
})

it('has all fields in CHANGE_CATEGORIES', () => {
expect(Object.keys(CHANGE_CATEGORIES)).toEqual(
expect.arrayContaining(['tags', 'name', 'description', 'cores', 'memory'])
)
})
})
119 changes: 119 additions & 0 deletions src/__tests__/usePendingChanges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { usePendingChanges } from '@/composables/usePendingChanges'

function makeNodeData(desired: Record<string, unknown>, actual: Record<string, unknown>) {
return ref({
deployed: true,
desiredConfig: desired,
actualConfig: actual,
})
}

describe('usePendingChanges', () => {
it('returns empty when desired matches actual', () => {
const data = makeNodeData(
{ name: 'web01', cores: 2, memory: 2048, tags: ['admin'] },
{ name: 'web01', cores: 2, memory: 2048, tags: ['admin'] },
)
const { pendingChanges, hasPendingChanges } = usePendingChanges(data)
expect(hasPendingChanges.value).toBe(false)
expect(pendingChanges.value).toHaveLength(0)
})

it('detects tag changes', () => {
const data = makeNodeData(
{ tags: ['admin', 'web'] },
{ tags: ['admin'] },
)
const { pendingChanges } = usePendingChanges(data)
expect(pendingChanges.value).toHaveLength(1)
expect(pendingChanges.value[0].field).toBe('tags')
expect(pendingChanges.value[0].category).toBe('live')
})

it('detects core changes as restart category', () => {
const data = makeNodeData({ cores: 4 }, { cores: 2 })
const { pendingChanges, restartChanges } = usePendingChanges(data)
expect(pendingChanges.value).toHaveLength(1)
expect(restartChanges.value).toHaveLength(1)
expect(restartChanges.value[0].field).toBe('cores')
expect(restartChanges.value[0].desired).toBe(4)
expect(restartChanges.value[0].actual).toBe(2)
})

it('detects memory changes as restart category', () => {
const data = makeNodeData({ memory: 4096 }, { memory: 2048 })
const { restartChanges } = usePendingChanges(data)
expect(restartChanges.value).toHaveLength(1)
expect(restartChanges.value[0].field).toBe('memory')
})

it('detects name changes as live category', () => {
const data = makeNodeData({ name: 'new-name' }, { name: 'old-name' })
const { liveChanges } = usePendingChanges(data)
expect(liveChanges.value).toHaveLength(1)
expect(liveChanges.value[0].field).toBe('name')
})

it('detects multiple changes across categories', () => {
const data = makeNodeData(
{ name: 'new', cores: 4, tags: ['admin', 'web'] },
{ name: 'old', cores: 2, tags: ['admin'] },
)
const { pendingChanges, liveChanges, restartChanges } = usePendingChanges(data)
expect(pendingChanges.value).toHaveLength(3)
expect(liveChanges.value).toHaveLength(2)
expect(restartChanges.value).toHaveLength(1)
})

it('revertField resets a single field', () => {
const data = makeNodeData({ name: 'new', cores: 4 }, { name: 'old', cores: 2 })
const { revertField, pendingChanges } = usePendingChanges(data)
expect(pendingChanges.value).toHaveLength(2)
revertField('name')
expect(data.value.desiredConfig.name).toBe('old')
expect(pendingChanges.value).toHaveLength(1)
})

it('revertAll resets all fields', () => {
const data = makeNodeData(
{ name: 'new', cores: 4, tags: ['web'] },
{ name: 'old', cores: 2, tags: ['admin'] },
)
const { revertAll, hasPendingChanges } = usePendingChanges(data)
expect(hasPendingChanges.value).toBe(true)
revertAll()
expect(hasPendingChanges.value).toBe(false)
expect(data.value.desiredConfig.name).toBe('old')
expect(data.value.desiredConfig.cores).toBe(2)
expect(data.value.desiredConfig.tags).toEqual(['admin'])
})

it('updateDesired changes a single field', () => {
const data = makeNodeData({ cores: 2 }, { cores: 2 })
const { updateDesired, pendingChanges } = usePendingChanges(data)
expect(pendingChanges.value).toHaveLength(0)
updateDesired('cores', 8)
expect(data.value.desiredConfig.cores).toBe(8)
expect(pendingChanges.value).toHaveLength(1)
})

it('returns empty when node is not deployed', () => {
const data = ref({ deployed: false, desiredConfig: { cores: 4 }, actualConfig: { cores: 2 } })
const { hasPendingChanges } = usePendingChanges(data)
expect(hasPendingChanges.value).toBe(false)
})

it('returns empty when configs are missing', () => {
const data = ref({ deployed: true })
const { hasPendingChanges } = usePendingChanges(data)
expect(hasPendingChanges.value).toBe(false)
})

it('handles tag order insensitivity', () => {
const data = makeNodeData({ tags: ['web', 'admin'] }, { tags: ['admin', 'web'] })
const { hasPendingChanges } = usePendingChanges(data)
expect(hasPendingChanges.value).toBe(false)
})
})
200 changes: 200 additions & 0 deletions src/components/ApplyChangesDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { vm as vmApi } from '@/services/proxmox/api'
import type { PendingChange } from '@/services/proxmox/types'

const props = defineProps<{
node: { data: Record<string, unknown>; id: string }
pendingChanges: PendingChange[]
}>()

const emit = defineEmits<{
close: []
applied: []
}>()

const isApplying = ref(false)
const applyError = ref<string | null>(null)
const applyProgress = ref('')

const liveChanges = computed(() => props.pendingChanges.filter(c => c.category === 'live'))
const restartChanges = computed(() => props.pendingChanges.filter(c => c.category === 'restart'))
const redeployChanges = computed(() => props.pendingChanges.filter(c => c.category === 'redeploy'))

const needsRestart = computed(() => restartChanges.value.length > 0)
const proxmoxNode = computed(() => (props.node.data.config as Record<string, unknown>)?.proxmoxNode as string || 'pve01')
const vmId = computed(() => Number(props.node.data.vmId))
const currentStatus = computed(() => props.node.data.status as string)

function formatValue(value: unknown): string {
if (Array.isArray(value)) return value.join(', ') || '(none)'
if (value === undefined || value === null) return '(none)'
return String(value)
}

async function applyChange(change: PendingChange): Promise<void> {
const node = proxmoxNode.value
const id = vmId.value
switch (change.field) {
case 'tags':
await vmApi.setTags(node, id, change.desired as string[])
break
case 'name':
await vmApi.setName(node, id, change.desired as string)
break
case 'description':
await vmApi.setDescription(node, id, change.desired as string)
break
case 'cores':
await vmApi.setCpu(node, id, change.desired as number)
break
case 'memory':
await vmApi.setMemory(node, id, change.desired as number)
break
}
}

function waitForStatus(targetStatus: string, timeoutMs = 60000): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now()
const check = () => {
if (props.node.data.status === targetStatus) return resolve()
if (Date.now() - start > timeoutMs) return reject(new Error(`Timeout waiting for ${targetStatus}`))
setTimeout(check, 2000)
}
check()
})
}

async function handleApply() {
isApplying.value = true
applyError.value = null

try {
// 1. Apply live changes
if (liveChanges.value.length > 0) {
applyProgress.value = 'Applying live changes...'
await Promise.all(liveChanges.value.map(c => applyChange(c)))
}

// 2. Apply restart changes
if (restartChanges.value.length > 0) {
const wasRunning = currentStatus.value === 'running'

if (wasRunning) {
applyProgress.value = 'Stopping VM...'
await vmApi.stop({ proxmox_node: proxmoxNode.value, vm_id: String(vmId.value) })
await waitForStatus('stopped')
}

applyProgress.value = 'Applying config changes...'
await Promise.all(restartChanges.value.map(c => applyChange(c)))

if (wasRunning) {
applyProgress.value = 'Starting VM...'
await vmApi.start({ proxmox_node: proxmoxNode.value, vm_id: String(vmId.value) })
await waitForStatus('running')
}
}

// 3. Sync actualConfig to match desiredConfig
applyProgress.value = 'Syncing state...'
const desired = props.node.data.desiredConfig as Record<string, unknown>
if (desired) {
const newActual: Record<string, unknown> = {}
for (const [key, value] of Object.entries(desired)) {
newActual[key] = Array.isArray(value) ? [...value] : value
}
props.node.data.actualConfig = newActual // eslint-disable-line vue/no-mutating-props -- VueFlow nodes are reactive, direct mutation is the established pattern
}

emit('applied')
} catch (err) {
applyError.value = err instanceof Error ? err.message : 'Failed to apply changes'
} finally {
isApplying.value = false
applyProgress.value = ''
}
}
</script>

<template>
<div class="modal modal-open">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4">
Apply Changes &mdash; {{ (node.data.desiredConfig as Record<string, unknown>)?.name || node.data.label }}
</h3>

<!-- Live changes -->
<div v-if="liveChanges.length > 0" class="mb-3">
<div class="text-xs font-semibold text-success uppercase tracking-wider mb-1">
Immediate (no downtime)
</div>
<div v-for="c in liveChanges" :key="c.field" class="flex items-center gap-2 text-sm py-1">
<span class="text-success">&#x2713;</span>
<span class="font-medium">{{ c.label }}:</span>
<span class="text-base-content/50">{{ formatValue(c.actual) }}</span>
<span>&rarr;</span>
<span>{{ formatValue(c.desired) }}</span>
</div>
</div>

<!-- Restart changes -->
<div v-if="restartChanges.length > 0" class="mb-3">
<div class="text-xs font-semibold text-warning uppercase tracking-wider mb-1">
Requires Restart
</div>
<div v-for="c in restartChanges" :key="c.field" class="flex items-center gap-2 text-sm py-1">
<span class="text-warning">&#x26A0;</span>
<span class="font-medium">{{ c.label }}:</span>
<span class="text-base-content/50">{{ formatValue(c.actual) }}</span>
<span>&rarr;</span>
<span>{{ formatValue(c.desired) }}</span>
</div>
</div>

<!-- Redeploy changes -->
<div v-if="redeployChanges.length > 0" class="mb-3">
<div class="text-xs font-semibold text-error uppercase tracking-wider mb-1">
Requires Redeployment
</div>
<div v-for="c in redeployChanges" :key="c.field" class="flex items-center gap-2 text-sm py-1">
<span class="text-error">&#x26D4;</span>
<span class="font-medium">{{ c.label }}:</span>
<span class="text-base-content/50">{{ formatValue(c.actual) }}</span>
<span>&rarr;</span>
<span>{{ formatValue(c.desired) }}</span>
</div>
</div>

<!-- Warning -->
<div v-if="needsRestart" class="alert alert-warning text-sm mb-4">
<span>The VM will be stopped and restarted to apply restart-required changes.</span>
</div>

<!-- Error -->
<div v-if="applyError" class="alert alert-error text-sm mb-4">
<span>{{ applyError }}</span>
</div>

<!-- Progress -->
<div v-if="isApplying" class="flex items-center gap-2 mb-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-sm">{{ applyProgress }}</span>
</div>

<!-- Actions -->
<div class="modal-action">
<button class="btn btn-ghost" :disabled="isApplying" @click="emit('close')">Cancel</button>
<button
class="btn btn-warning"
:disabled="isApplying"
@click="handleApply"
>
{{ isApplying ? 'Applying...' : 'Apply Changes' }}
</button>
</div>
</div>
<div class="modal-backdrop" @click="!isApplying && emit('close')"></div>
</div>
</template>
Loading
Loading