diff --git a/src/__tests__/changeCategories.test.ts b/src/__tests__/changeCategories.test.ts new file mode 100644 index 0000000..a804afe --- /dev/null +++ b/src/__tests__/changeCategories.test.ts @@ -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']) + ) + }) +}) diff --git a/src/__tests__/usePendingChanges.test.ts b/src/__tests__/usePendingChanges.test.ts new file mode 100644 index 0000000..4e32609 --- /dev/null +++ b/src/__tests__/usePendingChanges.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { ref } from 'vue' +import { usePendingChanges } from '@/composables/usePendingChanges' + +function makeNodeData(desired: Record, actual: Record) { + 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) + }) +}) diff --git a/src/components/ApplyChangesDialog.vue b/src/components/ApplyChangesDialog.vue new file mode 100644 index 0000000..13e5e48 --- /dev/null +++ b/src/components/ApplyChangesDialog.vue @@ -0,0 +1,200 @@ + + + diff --git a/src/components/ConfigPanel.vue b/src/components/ConfigPanel.vue index 9bc15ac..2e3d025 100644 --- a/src/components/ConfigPanel.vue +++ b/src/components/ConfigPanel.vue @@ -12,6 +12,8 @@ import { proxmoxApi } from '@/services/proxmox' import { proxmoxCache } from '@/services/proxmox/cache' import { PREDEFINED_TAGS, getTagColor } from '@/constants/tags' import { useTagSync } from '@/composables/useTagSync' +import { usePendingChanges } from '@/composables/usePendingChanges' +import ApplyChangesDialog from '@/components/ApplyChangesDialog.vue' import { useConfirmDialog } from '@/composables/useConfirmDialog' import { useToast } from '@/composables/useToast' @@ -204,56 +206,52 @@ const filteredPredefinedTags = computed(() => { function addTag() { const tag = tagInput.value.trim().toLowerCase() if (!tag || !props.node) return - const currentTags = props.node.data.tags || [] - if (currentTags.includes(tag)) return - props.node.data.tags = [...currentTags, tag] // eslint-disable-line vue/no-mutating-props -- VueFlow nodes are reactive, direct mutation is the established pattern + + if (props.node.data.deployed && props.node.data.desiredConfig) { + const currentTags = props.node.data.desiredConfig.tags || [] + if (currentTags.includes(tag)) return + props.node.data.desiredConfig.tags = [...currentTags, tag] // eslint-disable-line vue/no-mutating-props -- VueFlow nodes are reactive + if (props.node.data.vmId) { + tagSync.pushTags('pve01', Number(props.node.data.vmId), props.node.data.desiredConfig.tags) + } + } else { + const currentTags = props.node.data.tags || [] + if (currentTags.includes(tag)) return + props.node.data.tags = [...currentTags, tag] // eslint-disable-line vue/no-mutating-props + } tagInput.value = '' showTagDropdown.value = false - if (props.node.data.deployed && props.node.data.vmId) { - tagSync.pushTags('pve01', Number(props.node.data.vmId), props.node.data.tags) - } } function addPredefinedTag(tagName) { if (!props.node) return - const currentTags = props.node.data.tags || [] - if (currentTags.includes(tagName)) return - props.node.data.tags = [...currentTags, tagName] // eslint-disable-line vue/no-mutating-props - showTagDropdown.value = false - if (props.node.data.deployed && props.node.data.vmId) { - tagSync.pushTags('pve01', Number(props.node.data.vmId), props.node.data.tags) + if (props.node.data.deployed && props.node.data.desiredConfig) { + const currentTags = props.node.data.desiredConfig.tags || [] + if (currentTags.includes(tagName)) return + props.node.data.desiredConfig.tags = [...currentTags, tagName] // eslint-disable-line vue/no-mutating-props -- VueFlow nodes are reactive + if (props.node.data.vmId) { + tagSync.pushTags('pve01', Number(props.node.data.vmId), props.node.data.desiredConfig.tags) + } + } else { + const currentTags = props.node.data.tags || [] + if (currentTags.includes(tagName)) return + props.node.data.tags = [...currentTags, tagName] // eslint-disable-line vue/no-mutating-props } + showTagDropdown.value = false } function removeTag(tagToRemove) { if (!props.node) return - props.node.data.tags = (props.node.data.tags || []).filter(t => t !== tagToRemove) // eslint-disable-line vue/no-mutating-props - if (props.node.data.deployed && props.node.data.vmId) { - tagSync.pushTags('pve01', Number(props.node.data.vmId), props.node.data.tags) + if (props.node.data.deployed && props.node.data.desiredConfig) { + props.node.data.desiredConfig.tags = (props.node.data.desiredConfig.tags || []).filter(t => t !== tagToRemove) // eslint-disable-line vue/no-mutating-props -- VueFlow nodes are reactive + if (props.node.data.vmId) { + tagSync.pushTags('pve01', Number(props.node.data.vmId), props.node.data.desiredConfig.tags) + } + } else { + props.node.data.tags = (props.node.data.tags || []).filter(t => t !== tagToRemove) // eslint-disable-line vue/no-mutating-props } } -// Live metrics formatting helpers -function formatBytes(bytes) { - if (!bytes) return '0 B' - if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB' - if (bytes >= 1048576) return (bytes / 1048576).toFixed(0) + ' MB' - return (bytes / 1024).toFixed(0) + ' KB' -} - -function formatUptime(seconds) { - if (!seconds) return '--' - const d = Math.floor(seconds / 86400) - const h = Math.floor((seconds % 86400) / 3600) - const m = Math.floor((seconds % 3600) / 60) - return d > 0 ? `${d}d ${h}h ${m}m` : `${h}h ${m}m` -} - -function metricBarColor(percent) { - if (percent > 80) return '#ef4444' - if (percent > 50) return '#f59e0b' - return '#22c55e' -} // VM actions for deployed nodes const actionLoading = ref(null) @@ -270,6 +268,8 @@ async function handleVmAction(action) { switch (action) { case 'start': await proxmoxApi.vm.start(request); break case 'stop': await proxmoxApi.vm.stop(request); break + case 'pause': await proxmoxApi.vm.pause(request); break + case 'resume': await proxmoxApi.vm.resume(request); break case 'restart': await proxmoxApi.vm.stop(request) await new Promise(r => setTimeout(r, 3000)) @@ -344,6 +344,20 @@ watch(() => props.node, (newNode) => { } } }, { immediate: true }) + +const nodeDataRef = computed(() => props.node?.data || {}) +const { + pendingChanges, + hasPendingChanges, + pendingCount, + revertField, + revertAll, + updateDesired, +} = usePendingChanges(nodeDataRef) + +const showApplyDialog = ref(false) +// Let the parent (node-card "Apply" strip) open the apply dialog directly. +defineExpose({ openApplyDialog: () => { showApplyDialog.value = true } }) \ No newline at end of file diff --git a/src/components/nodes/InfraNodeVm.vue b/src/components/nodes/InfraNodeVm.vue index 3a7837f..0710a40 100644 --- a/src/components/nodes/InfraNodeVm.vue +++ b/src/components/nodes/InfraNodeVm.vue @@ -3,9 +3,15 @@ import { computed } from 'vue' import { Handle, Position } from '@vue-flow/core' import AppIcon from '@/components/icons/AppIcon.vue' import { getTagColor } from '@/constants/tags' +import { usePendingChanges } from '@/composables/usePendingChanges' const props = defineProps(['data', 'selected']) +const nodeDataRef = computed(() => props.data || {}) +const { hasPendingChanges, pendingCount } = usePendingChanges(nodeDataRef) + +const emit = defineEmits(['open-apply-dialog']) + // Map status values to color scheme const statusColor = computed(() => { switch (props.data?.status) { @@ -35,11 +41,15 @@ const ramMB = computed(() => { return m ? parseInt(m) : 0 }) const displayTags = computed(() => { - const tags = props.data?.tags || [] + const tags = (props.data?.deployed && props.data?.desiredConfig?.tags) + ? props.data.desiredConfig.tags + : (props.data?.tags || []) return tags.slice(0, 3) }) const overflowCount = computed(() => { - const tags = props.data?.tags || [] + const tags = (props.data?.deployed && props.data?.desiredConfig?.tags) + ? props.data.desiredConfig.tags + : (props.data?.tags || []) return Math.max(0, tags.length - 3) }) @@ -136,5 +146,19 @@ function barColor(percent) { + + +
+ + {{ pendingCount }} unsaved + + +
diff --git a/src/composables/useInfrastructureImport.ts b/src/composables/useInfrastructureImport.ts index 36f5168..ecbae86 100644 --- a/src/composables/useInfrastructureImport.ts +++ b/src/composables/useInfrastructureImport.ts @@ -314,6 +314,17 @@ export function useInfrastructureImport() { // Create node const nodeId = `imported-${resource.type}-${resource.vmid}` + // Look up tags from the cached VM list + const cachedVm = proxmoxCache.vmCache.value.find(v => v.vmid === resource.vmid) + const vmTags = cachedVm?.tags ? cachedVm.tags.split(';').filter(Boolean) : [] + + const initialConfig = { + name: resource.name, + cores: config.cores || 1, + memory: typeof config.memory === 'string' ? parseInt(config.memory) : (config.memory || 0), + tags: vmTags, + description: '', + } result.nodes.push({ id: nodeId, type: resource.type, @@ -335,7 +346,9 @@ export function useInfrastructureImport() { cpuUsage: config.cpuUsage || 0, uptime: config.uptime || 0, proxmoxNode: config.node || '', - } + }, + desiredConfig: { ...initialConfig }, + actualConfig: { ...initialConfig }, } }) diff --git a/src/composables/usePendingChanges.ts b/src/composables/usePendingChanges.ts new file mode 100644 index 0000000..c6b655d --- /dev/null +++ b/src/composables/usePendingChanges.ts @@ -0,0 +1,99 @@ +import { computed, type Ref } from 'vue' +import { getChangeCategory, getChangeLabel } from '@/constants/changeCategories' +import type { PendingChange } from '@/services/proxmox/types' + +const DIFFABLE_FIELDS = ['name', 'description', 'cores', 'memory', 'tags'] + +function arraysEqual(a: unknown[], b: unknown[]): boolean { + if (a.length !== b.length) return false + const sortedA = [...a].sort() + const sortedB = [...b].sort() + return sortedA.every((v, i) => v === sortedB[i]) +} + +function fieldsEqual(desired: unknown, actual: unknown): boolean { + if (Array.isArray(desired) && Array.isArray(actual)) { + return arraysEqual(desired, actual) + } + return desired === actual +} + +export function usePendingChanges(nodeData: Ref>) { + const pendingChanges = computed(() => { + if (!nodeData.value?.deployed) return [] + const desired = nodeData.value.desiredConfig as Record | undefined + const actual = nodeData.value.actualConfig as Record | undefined + if (!desired || !actual) return [] + + const changes: PendingChange[] = [] + for (const field of DIFFABLE_FIELDS) { + const d = desired[field] + const a = actual[field] + if (d === undefined && a === undefined) continue + if (d === undefined || a === undefined || !fieldsEqual(d, a)) { + changes.push({ + field, + label: getChangeLabel(field), + desired: d, + actual: a, + category: getChangeCategory(field), + }) + } + } + return changes + }) + + const hasPendingChanges = computed(() => pendingChanges.value.length > 0) + const pendingCount = computed(() => pendingChanges.value.length) + + const liveChanges = computed(() => pendingChanges.value.filter(c => c.category === 'live')) + const restartChanges = computed(() => pendingChanges.value.filter(c => c.category === 'restart')) + const redeployChanges = computed(() => pendingChanges.value.filter(c => c.category === 'redeploy')) + + function revertField(field: string): void { + const desired = nodeData.value.desiredConfig as Record + const actual = nodeData.value.actualConfig as Record + if (!desired || !actual) return + if (Array.isArray(actual[field])) { + desired[field] = [...(actual[field] as unknown[])] + } else { + desired[field] = actual[field] + } + } + + function revertAll(): void { + const actual = nodeData.value.actualConfig as Record + const desired = nodeData.value.desiredConfig as Record + if (!actual) return + const reverted: Record = {} + for (const field of DIFFABLE_FIELDS) { + // Use actual value; fall back to current desired to avoid dropping fields + // that the WebSocket doesn't provide (e.g. cores, memory, description) + const source = actual[field] !== undefined ? actual : desired + if (source && source[field] !== undefined) { + reverted[field] = Array.isArray(source[field]) + ? [...(source[field] as unknown[])] + : source[field] + } + } + nodeData.value.desiredConfig = reverted + } + + function updateDesired(field: string, value: unknown): void { + const desired = nodeData.value.desiredConfig as Record + if (!desired) return + desired[field] = value + } + + return { + pendingChanges, + hasPendingChanges, + pendingCount, + liveChanges, + restartChanges, + redeployChanges, + revertField, + revertAll, + updateDesired, + } +} diff --git a/src/composables/useWebSocketStatus.ts b/src/composables/useWebSocketStatus.ts index 1db7eec..981abfc 100644 --- a/src/composables/useWebSocketStatus.ts +++ b/src/composables/useWebSocketStatus.ts @@ -13,6 +13,7 @@ interface VmStatus { name: string status: string cpu: number // 0-100 percentage + cores: number // vCPU count mem: number // bytes used maxmem: number // bytes total uptime: number // seconds diff --git a/src/constants/changeCategories.ts b/src/constants/changeCategories.ts new file mode 100644 index 0000000..be30834 --- /dev/null +++ b/src/constants/changeCategories.ts @@ -0,0 +1,22 @@ +export type ChangeCategory = 'live' | 'restart' | 'redeploy' + +interface ChangeCategoryDef { + category: ChangeCategory + label: string +} + +export const CHANGE_CATEGORIES: Record = { + tags: { category: 'live', label: 'Tags' }, + name: { category: 'live', label: 'Name' }, + description: { category: 'live', label: 'Description' }, + cores: { category: 'restart', label: 'CPU Cores' }, + memory: { category: 'restart', label: 'Memory' }, +} + +export function getChangeCategory(field: string): ChangeCategory { + return CHANGE_CATEGORIES[field]?.category ?? 'redeploy' +} + +export function getChangeLabel(field: string): string { + return CHANGE_CATEGORIES[field]?.label ?? field +} diff --git a/src/services/proxmox/api.ts b/src/services/proxmox/api.ts index fbb32fd..be97d6c 100644 --- a/src/services/proxmox/api.ts +++ b/src/services/proxmox/api.ts @@ -323,10 +323,54 @@ export const vm = { * Set VM tags */ async setTags(node: ProxmoxNode, vmId: number, tags: string[]): Promise { - return post('/v0/admin/proxmox/vms/vm_id/config/set-tags', { + return post('/v0/admin/proxmox/vms/vm_id/config/vm_set_tag', { proxmox_node: node, - vm_id: vmId, - tags: tags.join(','), + vm_id: String(vmId), + vm_tag_name: tags.join(','), + }) + }, + + /** + * Set VM name + */ + async setName(node: ProxmoxNode, vmId: number, name: string): Promise { + return post('/v0/admin/proxmox/vms/vm_id/config/vm_set_name', { + proxmox_node: node, + vm_id: String(vmId), + vm_name: name, + }) + }, + + /** + * Set VM description + */ + async setDescription(node: ProxmoxNode, vmId: number, description: string): Promise { + return post('/v0/admin/proxmox/vms/vm_id/config/vm_set_description', { + proxmox_node: node, + vm_id: String(vmId), + vm_description: description, + }) + }, + + /** + * Set VM CPU cores + */ + async setCpu(node: ProxmoxNode, vmId: number, cores: number): Promise { + return post('/v0/admin/proxmox/vms/vm_id/config/vm_set_cpu', { + proxmox_node: node, + vm_id: String(vmId), + vm_cores: cores, + }) + }, + + /** + * Set VM memory (MB) + */ + async setMemory(node: ProxmoxNode, vmId: number, memory: number): Promise { + return post('/v0/admin/proxmox/vms/vm_id/config/vm_set_memory', { + proxmox_node: node, + vm_id: String(vmId), + vm_memory: memory, }) }, } diff --git a/src/services/proxmox/types.ts b/src/services/proxmox/types.ts index 50a9566..da1a475 100644 --- a/src/services/proxmox/types.ts +++ b/src/services/proxmox/types.ts @@ -263,6 +263,30 @@ export interface BaseNodeData { tags?: string[] } +export interface VmDesiredConfig { + name?: string + cores?: number + memory?: number + tags?: string[] + description?: string +} + +export interface VmActualConfig { + name?: string + cores?: number + memory?: number + tags?: string[] + description?: string +} + +export interface PendingChange { + field: string + label: string + desired: unknown + actual: unknown + category: 'live' | 'restart' | 'redeploy' +} + export interface LiveMetrics { cpu: number // 0-100 percentage mem: number // bytes used diff --git a/src/stores/deploymentStore.ts b/src/stores/deploymentStore.ts index 9e574b0..a6d5ea9 100644 --- a/src/stores/deploymentStore.ts +++ b/src/stores/deploymentStore.ts @@ -442,6 +442,18 @@ export const useDeploymentStore = defineStore('deployment', () => { node.data.deployed = true node.data.vmId = payload.vm_id ? Number(payload.vm_id) : undefined + + // Initialize desired/actual config from the deployment config + const initialConfig = { + name: node.data.config?.name || '', + cores: node.data.config?.cores ? Number(node.data.config.cores) : 1, + memory: node.data.config?.memory ? Number(node.data.config.memory) : 0, + tags: node.data.tags ? [...node.data.tags] : [], + description: node.data.config?.description || '', + } + node.data.desiredConfig = { ...initialConfig } + node.data.actualConfig = { ...initialConfig } + projectStore.updateProject(currentPlan.value.projectId, { nodes: project.nodes }) } diff --git a/src/views/ProjectEditor.vue b/src/views/ProjectEditor.vue index 7641fcd..93fbd08 100644 --- a/src/views/ProjectEditor.vue +++ b/src/views/ProjectEditor.vue @@ -1,5 +1,5 @@