From 7550823e435d387546db82825c2f247742f0a520 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Tue, 27 Oct 2026 17:31:21 -0600 Subject: [PATCH 1/4] handle multiple versions for a single distro properly --- apps/dashboard/src/lib/state/admin.svelte.ts | 358 ++++++++++--- .../routes/(app)/admin/images/+page.svelte | 490 ++++++++++++------ 2 files changed, 626 insertions(+), 222 deletions(-) diff --git a/apps/dashboard/src/lib/state/admin.svelte.ts b/apps/dashboard/src/lib/state/admin.svelte.ts index 3123c5e6..51714c12 100644 --- a/apps/dashboard/src/lib/state/admin.svelte.ts +++ b/apps/dashboard/src/lib/state/admin.svelte.ts @@ -95,6 +95,45 @@ export interface PveImage { volid: string; } +export type ImageDialogMode = 'create' | 'addVersions' | 'edit' | 'editGroup'; + +export interface ImageVersionDraft { + filePath: string; + key: number; + secureBoot: boolean; + version: string; +} + +export interface ImageGroup { + name: string; + representative: BaseImage; + versions: BaseImage[]; +} + +export function compareVersionsDesc(a: string, b: string): number { + const na = Number.parseFloat(a); + const nb = Number.parseFloat(b); + if (!(Number.isNaN(na) || Number.isNaN(nb)) && na !== nb) { + return nb - na; + } + return b.localeCompare(a, undefined, { numeric: true }); +} + +const ARCH_TOKEN_PATTERN = /(x86[-_]64|amd64|aarch64|arm64|i[3-6]86|ppc64le|s390x)/gi; +const IMAGE_EXTENSION_PATTERN = /\.(qcow2|img|raw|vmdk|ova|xz|gz|zst)$/i; +const PATH_SEPARATOR_PATTERN = /[/:]/; +const VERSION_TOKEN_PATTERN = /(?:^|[^0-9a-z])v?(\d+(?:\.\d+)*)(?=$|[^0-9])/i; + +/** Best-effort version guess from an image filename, e.g. "debian-12-genericcloud-amd64.qcow2" -> "12". */ +export function guessVersionFromFilename(filePath: string): string { + let name = filePath.split(PATH_SEPARATOR_PATTERN).pop() ?? ''; + while (IMAGE_EXTENSION_PATTERN.test(name)) { + name = name.replace(IMAGE_EXTENSION_PATTERN, ''); + } + name = name.replace(ARCH_TOKEN_PATTERN, ''); + return name.match(VERSION_TOKEN_PATTERN)?.[1] ?? ''; +} + export interface PveImageImportTarget { node: string; storage: string; @@ -221,20 +260,39 @@ export class AdminState { importError = $state(''); importTasks = $state([]); imgDialogOpen = $state(false); + imgMode = $state('create'); imgEditing = $state(null); + /** Name of the image family being edited or extended (as it was when the dialog opened). */ + imgGroupName = $state(null); imgSaving = $state(false); imgError = $state(''); imgName = $state(''); - imgVersion = $state(''); imgDescription = $state(''); imgIcon = $state(''); imgColor = $state('bg-gray-600'); imgIsOfficial = $state(false); imgLogoSvg = $state(''); imgAccentColor = $state('#6b7280'); - imgSecureBoot = $state(true); - imgFilePath = $state(''); imgIsa = $state('x86'); + imgRows = $state([]); + private imgRowSeq = 0; + + /** Images grouped by name, in display order (by first appearance in sortOrder). */ + get imageGroups(): ImageGroup[] { + const groups = new Map(); + for (const image of this.images) { + const existing = groups.get(image.name); + if (existing) { + existing.push(image); + } else { + groups.set(image.name, [image]); + } + } + return Array.from(groups, ([name, versions]) => { + const sorted = [...versions].sort((a, b) => compareVersionsDesc(a.version, b.version)); + return { name, representative: sorted[0], versions: sorted }; + }); + } constructor(data?: AdminPageData) { if (data) { @@ -744,85 +802,248 @@ export class AdminState { } } - imgOpenCreate() { - this.imgEditing = null; - this.imgName = ''; - this.imgVersion = ''; - this.imgDescription = ''; - this.imgIcon = ''; - this.imgColor = 'bg-gray-600'; - this.imgIsOfficial = false; - this.imgLogoSvg = ''; - this.imgAccentColor = '#6b7280'; - this.imgSecureBoot = true; - this.imgFilePath = ''; + private imgNewRow(partial: Partial = {}): ImageVersionDraft { + this.imgRowSeq += 1; + return { key: this.imgRowSeq, version: '', filePath: '', secureBoot: true, ...partial }; + } + + private imgResetShared(source?: BaseImage) { + this.imgName = source?.name ?? ''; + this.imgDescription = source?.description ?? ''; + this.imgIcon = source?.icon ?? ''; + this.imgColor = source?.color ?? 'bg-gray-600'; + this.imgIsOfficial = source?.isOfficial ?? false; + this.imgLogoSvg = source?.logoSvg ?? ''; + this.imgAccentColor = source?.accentColor ?? '#6b7280'; this.imgIsa = 'x86'; + } + + private imgShow() { this.imgError = ''; + this.imgSaving = false; this.imgDialogOpen = true; if (this.pveImages.length === 0) { this.loadPveImages(); } } + /** Brand new image family with one or more versions. */ + imgOpenCreate(filePath = '') { + this.imgMode = 'create'; + this.imgEditing = null; + this.imgGroupName = null; + this.imgResetShared(); + this.imgRows = [this.imgNewRow({ filePath, version: guessVersionFromFilename(filePath) })]; + this.imgShow(); + } + + /** Add versions to an existing family, reusing its shared details. */ + imgOpenAddVersions(groupName: string) { + const group = this.imageGroups.find((g) => g.name === groupName); + if (!group) { + this.imgOpenCreate(); + return; + } + this.imgMode = 'addVersions'; + this.imgEditing = null; + this.imgGroupName = group.name; + this.imgResetShared(group.representative); + this.imgRows = [this.imgNewRow({ secureBoot: group.representative.secureBoot })]; + this.imgShow(); + } + + /** While creating, typing an existing family name switches to "add versions" for it. */ + imgAdoptExistingName() { + if (this.imgMode !== 'create') { + return; + } + const name = this.imgName.trim(); + const group = this.imageGroups.find((g) => g.name === name); + if (!group) { + return; + } + this.imgMode = 'addVersions'; + this.imgGroupName = group.name; + this.imgResetShared(group.representative); + } + + /** Back out of "add versions" into creating a new family (keeps the rows). */ + imgDetachFromGroup() { + this.imgMode = 'create'; + this.imgGroupName = null; + this.imgName = ''; + } + + /** Edit shared details (name, logo, ...) for every version in a family. */ + imgOpenEditGroup(groupName: string) { + const group = this.imageGroups.find((g) => g.name === groupName); + if (!group) { + return; + } + this.imgMode = 'editGroup'; + this.imgEditing = null; + this.imgGroupName = group.name; + this.imgResetShared(group.representative); + this.imgRows = []; + this.imgShow(); + } + + /** Edit a single version (version label, Proxmox file, secure boot). */ imgOpenEdit(img: BaseImage) { + this.imgMode = 'edit'; this.imgEditing = img; - this.imgName = img.name; - this.imgVersion = img.version; - this.imgDescription = img.description; - this.imgIcon = img.icon ?? ''; - this.imgColor = img.color; - this.imgIsOfficial = img.isOfficial; - this.imgLogoSvg = img.logoSvg ?? ''; - this.imgAccentColor = img.accentColor; - this.imgSecureBoot = img.secureBoot; - this.imgFilePath = img.filePath; - this.imgIsa = 'x86'; - this.imgError = ''; - this.imgDialogOpen = true; - if (this.pveImages.length === 0) { - this.loadPveImages(); + this.imgGroupName = img.name; + this.imgResetShared(img); + this.imgRows = [ + this.imgNewRow({ version: img.version, filePath: img.filePath, secureBoot: img.secureBoot }) + ]; + this.imgShow(); + } + + imgAddRow() { + const last = this.imgRows.at(-1); + this.imgRows.push(this.imgNewRow({ secureBoot: last?.secureBoot ?? true })); + } + + imgRemoveRow(key: number) { + if (this.imgRows.length <= 1) { + return; + } + this.imgRows = this.imgRows.filter((row) => row.key !== key); + } + + imgRowFileChanged(row: ImageVersionDraft) { + if (!row.version.trim()) { + row.version = guessVersionFromFilename(row.filePath); + } + } + + /** Volids that are already referenced by a configured image, with a label for display. */ + get imgUsedFiles(): Map { + return new Map( + this.images + .filter((image) => image.id !== this.imgEditing?.id) + .map((image) => [image.filePath, `${image.name} ${image.version}`.trim()]) + ); + } + + get imgRowsValid() { + if (this.imgMode === 'editGroup') { + return true; } + const volids = new Set(this.pveImages.map((image) => image.volid)); + return ( + this.imgRows.length > 0 && + this.imgRows.every((row) => row.version.trim() && volids.has(row.filePath)) + ); + } + + get imgDuplicateVersions(): string[] { + const group = this.imageGroups.find((g) => g.name === this.imgName.trim()); + const taken = new Set( + (group?.versions ?? []) + .filter((image) => image.id !== this.imgEditing?.id) + .map((image) => image.version.trim().toLowerCase()) + ); + const seen = new Set(); + const dupes = new Set(); + for (const row of this.imgRows) { + const version = row.version.trim().toLowerCase(); + if (!version) { + continue; + } + if (taken.has(version) || seen.has(version)) { + dupes.add(row.version.trim()); + } + seen.add(version); + } + return [...dupes]; + } + + private imgSharedData() { + const icon = this.imgIcon.trim(); + const logoSvg = this.imgLogoSvg.trim(); + return { + name: this.imgName.trim(), + description: this.imgDescription.trim(), + color: this.imgColor, + isOfficial: this.imgIsOfficial, + accentColor: this.imgAccentColor.trim() || '#6b7280', + isa: 'x86' as const, + icon, + logoSvg: this.imgIsOfficial ? logoSvg : '' + }; } async imgSave() { - const selectedImage = this.pveImages.find((image) => image.volid === this.imgFilePath); - if (!(this.imgName.trim() && selectedImage)) { + if (!(this.imgName.trim() && this.imgRowsValid)) { return; } this.imgSaving = true; this.imgError = ''; + const shared = this.imgSharedData(); + const remoteShared = { + name: shared.name, + description: shared.description, + color: shared.color, + isOfficial: shared.isOfficial, + accentColor: shared.accentColor, + isa: shared.isa, + ...(shared.icon ? { icon: shared.icon } : {}) + }; + const localShared = { + ...remoteShared, + icon: shared.icon || null, + logoSvg: shared.logoSvg || null + }; try { - const icon = this.imgIcon.trim(); - const logoSvg = this.imgLogoSvg.trim(); - const data = { - name: this.imgName.trim(), - version: this.imgVersion.trim(), - description: this.imgDescription.trim(), - color: this.imgColor, - isOfficial: this.imgIsOfficial, - accentColor: this.imgAccentColor.trim() || '#6b7280', - secureBoot: this.imgSecureBoot, - filePath: selectedImage.volid, - isa: 'x86' as const, - ...(icon ? { icon } : {}), - ...(this.imgIsOfficial && logoSvg ? { logoSvg } : {}) - }; - const localData = { - ...data, - icon: icon || null, - logoSvg: this.imgIsOfficial ? logoSvg || null : null, - imageType: selectedImage.format || 'qcow2' - }; - - if (this.imgEditing) { - await updateImage({ imageId: this.imgEditing.id, ...data }); - const index = this.images.findIndex((image) => image.id === this.imgEditing?.id); - if (index !== -1) { - this.images[index] = { ...this.images[index], ...localData }; - } + if (this.imgMode === 'editGroup') { + const members = this.images.filter((image) => image.name === this.imgGroupName); + await Promise.all( + members.map((image) => + updateImage({ imageId: image.id, ...remoteShared, logoSvg: shared.logoSvg }) + ) + ); + this.images = this.images.map((image) => + image.name === this.imgGroupName ? { ...image, ...localShared } : image + ); + } else if (this.imgMode === 'edit' && this.imgEditing) { + const [row] = this.imgRows; + const file = this.pveImages.find((image) => image.volid === row.filePath); + const versionData = { + version: row.version.trim(), + filePath: row.filePath, + secureBoot: row.secureBoot + }; + const { id } = this.imgEditing; + await updateImage({ imageId: id, ...versionData }); + this.images = this.images.map((image) => + image.id === id ? { ...image, ...versionData, imageType: file?.format || 'qcow2' } : image + ); } else { - const result = await createImage(data); - this.images.push({ id: result.id, ...localData }); + // Sequential so sort order follows the row order. + for (const row of this.imgRows) { + const file = this.pveImages.find((image) => image.volid === row.filePath); + const versionData = { + version: row.version.trim(), + filePath: row.filePath, + secureBoot: row.secureBoot + }; + // biome-ignore lint/performance/noAwaitInLoops: preserve insertion order for sortOrder + const result = await createImage({ + ...remoteShared, + ...(shared.logoSvg ? { logoSvg: shared.logoSvg } : {}), + ...versionData + }); + this.images.push({ + id: result.id, + ...localShared, + ...versionData, + imageType: file?.format || 'qcow2' + }); + // Drop saved rows so a partial failure can be retried without duplicates. + this.imgRows = this.imgRows.filter((r) => r.key !== row.key); + } } this.imgDialogOpen = false; @@ -833,17 +1054,20 @@ export class AdminState { } } - async imgReorder(fromIndex: number, toIndex: number) { + /** Reorder whole image families; versions inside a family are sorted by version. */ + async imgReorderGroups(fromIndex: number, toIndex: number) { + const groups = this.imageGroups; if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) { return; } - if (fromIndex >= this.images.length || toIndex >= this.images.length) { + if (fromIndex >= groups.length || toIndex >= groups.length) { return; } + const nextGroups = [...groups]; + const [moved] = nextGroups.splice(fromIndex, 1); + nextGroups.splice(toIndex, 0, moved); const previous = [...this.images]; - const next = [...this.images]; - const [moved] = next.splice(fromIndex, 1); - next.splice(toIndex, 0, moved); + const next = nextGroups.flatMap((group) => group.versions); this.images = next; try { await reorderImages({ imageIds: next.map((image) => image.id) }); diff --git a/apps/dashboard/src/routes/(app)/admin/images/+page.svelte b/apps/dashboard/src/routes/(app)/admin/images/+page.svelte index 33ff790b..63caaa77 100644 --- a/apps/dashboard/src/routes/(app)/admin/images/+page.svelte +++ b/apps/dashboard/src/routes/(app)/admin/images/+page.svelte @@ -10,6 +10,7 @@ import GripVertical from '~icons/lucide/grip-vertical'; import Loader2 from '~icons/lucide/loader-2'; import Plus from '~icons/lucide/plus'; + import X from '~icons/lucide/x'; import AlertTriangle from '~icons/nucleo/alert-triangle'; import Disc from '~icons/nucleo/disc'; import Image from '~icons/nucleo/layers'; @@ -44,9 +45,9 @@ if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(index)); - const row = (event.target as HTMLElement).closest('tr'); - if (row) { - event.dataTransfer.setDragImage(row, 0, 0); + const group = (event.target as HTMLElement).closest('tbody'); + if (group) { + event.dataTransfer.setDragImage(group, 0, 0); } } } @@ -65,7 +66,7 @@ function imgDrop(event: DragEvent, index: number) { event.preventDefault(); if (imgDragIndex !== null) { - admin.imgReorder(imgDragIndex, index); + admin.imgReorderGroups(imgDragIndex, index); } imgDragIndex = null; imgDropIndex = null; @@ -81,15 +82,36 @@ return; } event.preventDefault(); - admin.imgReorder(index, event.key === 'ArrowUp' ? index - 1 : index + 1); + admin.imgReorderGroups(index, event.key === 'ArrowUp' ? index - 1 : index + 1); } + const dialogTitles = { + create: 'Add Image', + addVersions: 'Add Versions', + edit: 'Edit Version', + editGroup: 'Edit Image Details' + } as const; + + let showSharedFields = $derived(admin.imgMode === 'create' || admin.imgMode === 'editGroup'); + let showVersionRows = $derived(admin.imgMode !== 'editGroup'); + let groupVersionCount = $derived( + admin.imgGroupName + ? (admin.imageGroups.find((group) => group.name === admin.imgGroupName)?.versions.length ?? 0) + : 0 + ); + let saveLabel = $derived.by(() => { + if (admin.imgMode === 'edit' || admin.imgMode === 'editGroup') { + return 'Save'; + } + const count = admin.imgRows.length; + if (count > 1) { + return `Add ${count} versions`; + } + return admin.imgMode === 'create' ? 'Create' : 'Add version'; + }); let localImportTargets = $derived( admin.pveImageImportTargets.filter((target) => target.storage === admin.importStorage) ); - let selectedPveImage = $derived( - admin.pveImages.find((image) => image.volid === admin.imgFilePath) - );
@@ -111,7 +133,7 @@ {#if admin.images.length === 0}
-

No admin.images configured

+

No images configured

- +
- {#if img.isOfficial && img.logoSvg} - + {#if rep.isOfficial && rep.logoSvg} + {:else} {/if} -
- {img.name} - {#if img.isOfficial} - Official +
+ {group.name} + {#if rep.isOfficial} + Official + {/if} + + {group.versions.length} + version{group.versions.length === 1 ? '' : 's'} + +
+ {#if rep.description} + {rep.description} {/if}
- {img.version} - - {img.imageType || 'import'} - - - {img.filePath} - - - {img.description} -
admin.imgOpenAddVersions(group.name)} + > + Version admin.imgOpenEditGroup(group.name)} + >
- {/each} - + {#each group.versions as img (img.id)} + imgDragOver(event, index)} + ondrop={(event) => imgDrop(event, index)} + > + + + {img.version || '—'} + + + {img.imageType || 'import'} + + + {img.filePath} + + + {img.secureBoot ? 'On' : 'Off'} + + +
+ + +
+ + + {/each} + + {/each} {/if}
@@ -360,10 +426,19 @@ - + - {admin.imgEditing ? 'Edit Image' : 'Add Image'} - Configure a base image for VM provisioning. + {dialogTitles[admin.imgMode]} + + {#if admin.imgMode === 'editGroup'} + Changes apply to all {groupVersionCount} version{groupVersionCount === 1 ? '' : 's'} + of this image. + {:else if admin.imgMode === 'edit'} + Edit this version. Name, logo and description are shared, so edit them from the image row. + {:else} + Configure a base image and one or more versions for VM provisioning. + {/if} +
{#if admin.imgError} @@ -374,125 +449,230 @@
{/if} -
-
- -
-
- -
-
- -
-
- -
+
+ - x86 + {#if admin.imgIsOfficial && admin.imgLogoSvg.trim()} + + {:else} + + {/if} + +
+ {admin.imgName} + + {groupVersionCount} + existing version{groupVersionCount === 1 ? '' : 's'}{admin.imgDescription + ? ` · ${admin.imgDescription}` + : ''} +
+ {#if admin.imgMode === 'addVersions'} + + {/if}
+ {/if} + + {#if showSharedFields}
- - + + admin.imgAdoptExistingName()} + /> + + {#each admin.imageGroups as group (group.name)} + + {/each} + + {#if admin.imgMode === 'create'} +

+ Pick an existing name to add versions to it instead of re-entering details. +

+ {/if}
-
- -
- - -
- -
- - -
- {#if admin.imgIsOfficial} -
+
- Architecture +
- + x86 +
- -
Official +
+ + Official image +
+
- - + +
+ + {#if admin.imgIsOfficial} +
+
+ + +
+
+ +
+ {#if admin.imgLogoSvg.trim()} + + {:else} + + {/if} +
+
+
+
+ + +
+ {/if} {/if} - -
-
- - + {#if showVersionRows} +
+
+ + +
+ +
+
+ Version + Proxmox image + Secure Boot + Remove +
+ {#each admin.imgRows as row, rowIndex (row.key)} +
+ + + + {#if admin.imgRows.length > 1} + + {:else} + + {/if} +
+ {/each} +
+ + {#if admin.imgMode !== 'edit'} + + {/if} + + {#if admin.isoError} +

{admin.isoError}

+ {:else if admin.pveImages.length === 0 && !admin.isoLoading} +

+ No imported images found. Use Upload/Import Image first. +

+ {/if} + {#if admin.imgDuplicateVersions.length > 0} +

+ Heads up: {admin.imgDuplicateVersions.join(', ')} already exists for this image. +

+ {/if}
- - {#if admin.isoError} -

{admin.isoError}

- {:else if admin.pveImages.length === 0} -

Import an image first, then select it here.

- {/if} -
+ {/if}
From 9c80534328796c614c35d6248b04594d783f9e10 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Tue, 27 Oct 2026 17:34:54 -0600 Subject: [PATCH 2/4] fix css grid --- apps/dashboard/src/routes/(app)/admin/images/+page.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/routes/(app)/admin/images/+page.svelte b/apps/dashboard/src/routes/(app)/admin/images/+page.svelte index 63caaa77..42851b83 100644 --- a/apps/dashboard/src/routes/(app)/admin/images/+page.svelte +++ b/apps/dashboard/src/routes/(app)/admin/images/+page.svelte @@ -426,7 +426,9 @@ - + {dialogTitles[admin.imgMode]} @@ -440,7 +442,7 @@ {/if} -
+
{#if admin.imgError}
{admin.imgName} - + {groupVersionCount} existing version{groupVersionCount === 1 ? '' : 's'}{admin.imgDescription ? ` · ${admin.imgDescription}` From cdca01803178856782723848065df462e7750dc2 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Tue, 27 Oct 2026 17:41:47 -0600 Subject: [PATCH 3/4] move everything into one manage screen --- apps/dashboard/src/lib/state/admin.svelte.ts | 374 +++++++----- .../routes/(app)/admin/images/+page.svelte | 532 ++++++++++-------- 2 files changed, 510 insertions(+), 396 deletions(-) diff --git a/apps/dashboard/src/lib/state/admin.svelte.ts b/apps/dashboard/src/lib/state/admin.svelte.ts index 51714c12..1dea3dff 100644 --- a/apps/dashboard/src/lib/state/admin.svelte.ts +++ b/apps/dashboard/src/lib/state/admin.svelte.ts @@ -95,15 +95,19 @@ export interface PveImage { volid: string; } -export type ImageDialogMode = 'create' | 'addVersions' | 'edit' | 'editGroup'; - export interface ImageVersionDraft { + error: string; filePath: string; + /** Existing base image id, or null for a version that hasn't been created yet. */ + id: string | null; key: number; + removed: boolean; secureBoot: boolean; version: string; } +export type ImageVersionStatus = 'new' | 'changed' | 'removed' | 'unchanged'; + export interface ImageGroup { name: string; representative: BaseImage; @@ -260,10 +264,9 @@ export class AdminState { importError = $state(''); importTasks = $state([]); imgDialogOpen = $state(false); - imgMode = $state('create'); - imgEditing = $state(null); - /** Name of the image family being edited or extended (as it was when the dialog opened). */ + /** Saved name of the image family being managed, or null when creating a new one. */ imgGroupName = $state(null); + imgDetailsExpanded = $state(true); imgSaving = $state(false); imgError = $state(''); imgName = $state(''); @@ -804,7 +807,25 @@ export class AdminState { private imgNewRow(partial: Partial = {}): ImageVersionDraft { this.imgRowSeq += 1; - return { key: this.imgRowSeq, version: '', filePath: '', secureBoot: true, ...partial }; + return { + key: this.imgRowSeq, + id: null, + version: '', + filePath: '', + secureBoot: true, + removed: false, + error: '', + ...partial + }; + } + + private imgRowFromImage(image: BaseImage) { + return this.imgNewRow({ + id: image.id, + version: image.version, + filePath: image.filePath, + secureBoot: image.secureBoot + }); } private imgResetShared(source?: BaseImage) { @@ -818,43 +839,46 @@ export class AdminState { this.imgIsa = 'x86'; } - private imgShow() { - this.imgError = ''; - this.imgSaving = false; - this.imgDialogOpen = true; - if (this.pveImages.length === 0) { - this.loadPveImages(); - } + private imgLoadGroup(group: ImageGroup) { + this.imgGroupName = group.name; + this.imgResetShared(group.representative); + this.imgDetailsExpanded = false; } - /** Brand new image family with one or more versions. */ + /** Start a brand new image family. */ imgOpenCreate(filePath = '') { - this.imgMode = 'create'; - this.imgEditing = null; this.imgGroupName = null; this.imgResetShared(); + this.imgDetailsExpanded = true; this.imgRows = [this.imgNewRow({ filePath, version: guessVersionFromFilename(filePath) })]; - this.imgShow(); + this.imgError = ''; + this.imgSaving = false; + this.imgDialogOpen = true; + if (this.pveImages.length === 0) { + this.loadPveImages(); + } } - /** Add versions to an existing family, reusing its shared details. */ - imgOpenAddVersions(groupName: string) { + /** Manage an existing family: shared details plus every version, saved in one go. */ + imgOpenManage(groupName: string) { const group = this.imageGroups.find((g) => g.name === groupName); if (!group) { this.imgOpenCreate(); return; } - this.imgMode = 'addVersions'; - this.imgEditing = null; - this.imgGroupName = group.name; - this.imgResetShared(group.representative); - this.imgRows = [this.imgNewRow({ secureBoot: group.representative.secureBoot })]; - this.imgShow(); + this.imgLoadGroup(group); + this.imgRows = group.versions.map((image) => this.imgRowFromImage(image)); + this.imgError = ''; + this.imgSaving = false; + this.imgDialogOpen = true; + if (this.pveImages.length === 0) { + this.loadPveImages(); + } } - /** While creating, typing an existing family name switches to "add versions" for it. */ + /** While creating, typing an existing family name pulls that family in (keeping new rows). */ imgAdoptExistingName() { - if (this.imgMode !== 'create') { + if (this.imgGroupName !== null) { return; } const name = this.imgName.trim(); @@ -862,54 +886,36 @@ export class AdminState { if (!group) { return; } - this.imgMode = 'addVersions'; - this.imgGroupName = group.name; - this.imgResetShared(group.representative); - } - - /** Back out of "add versions" into creating a new family (keeps the rows). */ - imgDetachFromGroup() { - this.imgMode = 'create'; - this.imgGroupName = null; - this.imgName = ''; - } - - /** Edit shared details (name, logo, ...) for every version in a family. */ - imgOpenEditGroup(groupName: string) { - const group = this.imageGroups.find((g) => g.name === groupName); - if (!group) { - return; - } - this.imgMode = 'editGroup'; - this.imgEditing = null; - this.imgGroupName = group.name; - this.imgResetShared(group.representative); - this.imgRows = []; - this.imgShow(); - } - - /** Edit a single version (version label, Proxmox file, secure boot). */ - imgOpenEdit(img: BaseImage) { - this.imgMode = 'edit'; - this.imgEditing = img; - this.imgGroupName = img.name; - this.imgResetShared(img); + const pending = this.imgRows.filter((row) => row.version.trim() || row.filePath); + this.imgLoadGroup(group); this.imgRows = [ - this.imgNewRow({ version: img.version, filePath: img.filePath, secureBoot: img.secureBoot }) + ...group.versions.map((image) => this.imgRowFromImage(image)), + ...(pending.length > 0 + ? pending + : [this.imgNewRow({ secureBoot: group.representative.secureBoot })]) ]; - this.imgShow(); } imgAddRow() { - const last = this.imgRows.at(-1); + const last = this.imgRows.findLast((row) => !row.removed); this.imgRows.push(this.imgNewRow({ secureBoot: last?.secureBoot ?? true })); } - imgRemoveRow(key: number) { - if (this.imgRows.length <= 1) { + /** New rows are dropped outright; existing versions are marked for deletion on save. */ + imgToggleRemoveRow(row: ImageVersionDraft) { + if (row.id === null) { + this.imgRows = this.imgRows.filter((r) => r.key !== row.key); return; } - this.imgRows = this.imgRows.filter((row) => row.key !== key); + row.removed = !row.removed; + } + + /** Mark every existing version for deletion and discard unsaved ones. */ + imgRemoveAll() { + this.imgRows = this.imgRows.filter((row) => row.id !== null); + for (const row of this.imgRows) { + row.removed = true; + } } imgRowFileChanged(row: ImageVersionDraft) { @@ -918,41 +924,101 @@ export class AdminState { } } - /** Volids that are already referenced by a configured image, with a label for display. */ - get imgUsedFiles(): Map { - return new Map( - this.images - .filter((image) => image.id !== this.imgEditing?.id) - .map((image) => [image.filePath, `${image.name} ${image.version}`.trim()]) - ); + imgRowStatus(row: ImageVersionDraft): ImageVersionStatus { + if (row.id === null) { + return 'new'; + } + if (row.removed) { + return 'removed'; + } + const original = this.images.find((image) => image.id === row.id); + if ( + !original || + original.version !== row.version.trim() || + original.filePath !== row.filePath || + original.secureBoot !== row.secureBoot + ) { + return 'changed'; + } + return 'unchanged'; } - get imgRowsValid() { - if (this.imgMode === 'editGroup') { + /** Label of another configured image already using this Proxmox file, if any. */ + imgFileUsedBy(volid: string, rowId: string | null) { + const other = this.images.find((image) => image.filePath === volid && image.id !== rowId); + return other ? `${other.name} ${other.version}`.trim() : null; + } + + private imgSharedData() { + return { + name: this.imgName.trim(), + description: this.imgDescription.trim(), + color: this.imgColor, + isOfficial: this.imgIsOfficial, + accentColor: this.imgAccentColor.trim() || '#6b7280', + icon: this.imgIcon.trim(), + logoSvg: this.imgIsOfficial ? this.imgLogoSvg.trim() : '' + }; + } + + get imgSharedChanged() { + if (this.imgGroupName === null) { + return false; + } + const rep = this.imageGroups.find((g) => g.name === this.imgGroupName)?.representative; + if (!rep) { return true; } - const volids = new Set(this.pveImages.map((image) => image.volid)); + const shared = this.imgSharedData(); return ( - this.imgRows.length > 0 && - this.imgRows.every((row) => row.version.trim() && volids.has(row.filePath)) + shared.name !== rep.name || + shared.description !== rep.description || + shared.color !== rep.color || + shared.isOfficial !== rep.isOfficial || + shared.accentColor !== rep.accentColor || + shared.icon !== (rep.icon ?? '') || + shared.logoSvg !== (rep.isOfficial ? (rep.logoSvg ?? '') : '') ); } + get imgPending() { + const counts = { new: 0, changed: 0, removed: 0 }; + for (const row of this.imgRows) { + const status = this.imgRowStatus(row); + if (status !== 'unchanged') { + counts[status] += 1; + } + } + return { ...counts, details: this.imgSharedChanged }; + } + + get imgHasChanges() { + const pending = this.imgPending; + return pending.details || pending.new + pending.changed + pending.removed > 0; + } + + get imgActiveRows() { + return this.imgRows.filter((row) => !row.removed); + } + + get imgRowsValid() { + const volids = new Set(this.pveImages.map((image) => image.volid)); + const active = this.imgActiveRows; + if (active.length === 0 && this.imgGroupName === null) { + return false; + } + return active.every((row) => row.version.trim() && volids.has(row.filePath)); + } + get imgDuplicateVersions(): string[] { - const group = this.imageGroups.find((g) => g.name === this.imgName.trim()); - const taken = new Set( - (group?.versions ?? []) - .filter((image) => image.id !== this.imgEditing?.id) - .map((image) => image.version.trim().toLowerCase()) - ); const seen = new Set(); const dupes = new Set(); - for (const row of this.imgRows) { + for (const row of this.imgActiveRows) { const version = row.version.trim().toLowerCase(); if (!version) { continue; } - if (taken.has(version) || seen.has(version)) { + if (seen.has(version)) { dupes.add(row.version.trim()); } seen.add(version); @@ -960,35 +1026,36 @@ export class AdminState { return [...dupes]; } - private imgSharedData() { - const icon = this.imgIcon.trim(); - const logoSvg = this.imgLogoSvg.trim(); - return { - name: this.imgName.trim(), - description: this.imgDescription.trim(), - color: this.imgColor, - isOfficial: this.imgIsOfficial, - accentColor: this.imgAccentColor.trim() || '#6b7280', - isa: 'x86' as const, - icon, - logoSvg: this.imgIsOfficial ? logoSvg : '' - }; + get imgCanSave() { + if (this.imgSaving || !this.imgHasChanges) { + return false; + } + if (this.imgActiveRows.length === 0) { + // Deleting the whole family doesn't need valid details. + return this.imgGroupName !== null; + } + return Boolean(this.imgName.trim()) && this.imgRowsValid; } async imgSave() { - if (!(this.imgName.trim() && this.imgRowsValid)) { + if (!this.imgCanSave) { return; } this.imgSaving = true; this.imgError = ''; + for (const row of this.imgRows) { + row.error = ''; + } + const shared = this.imgSharedData(); + const sharedChanged = this.imgSharedChanged; const remoteShared = { name: shared.name, description: shared.description, color: shared.color, isOfficial: shared.isOfficial, accentColor: shared.accentColor, - isa: shared.isa, + isa: 'x86' as const, ...(shared.icon ? { icon: shared.icon } : {}) }; const localShared = { @@ -996,56 +1063,80 @@ export class AdminState { icon: shared.icon || null, logoSvg: shared.logoSvg || null }; + const versionData = (row: ImageVersionDraft) => ({ + version: row.version.trim(), + filePath: row.filePath, + secureBoot: row.secureBoot + }); + const formatOf = (volid: string) => + this.pveImages.find((image) => image.volid === volid)?.format || 'qcow2'; + let failed = false; + try { - if (this.imgMode === 'editGroup') { - const members = this.images.filter((image) => image.name === this.imgGroupName); - await Promise.all( - members.map((image) => - updateImage({ imageId: image.id, ...remoteShared, logoSvg: shared.logoSvg }) - ) - ); - this.images = this.images.map((image) => - image.name === this.imgGroupName ? { ...image, ...localShared } : image - ); - } else if (this.imgMode === 'edit' && this.imgEditing) { - const [row] = this.imgRows; - const file = this.pveImages.find((image) => image.volid === row.filePath); - const versionData = { - version: row.version.trim(), - filePath: row.filePath, - secureBoot: row.secureBoot - }; - const { id } = this.imgEditing; - await updateImage({ imageId: id, ...versionData }); - this.images = this.images.map((image) => - image.id === id ? { ...image, ...versionData, imageType: file?.format || 'qcow2' } : image - ); - } else { - // Sequential so sort order follows the row order. - for (const row of this.imgRows) { - const file = this.pveImages.find((image) => image.volid === row.filePath); - const versionData = { - version: row.version.trim(), - filePath: row.filePath, - secureBoot: row.secureBoot - }; + // Deletions and updates can go in parallel. + const existingWork = this.imgRows + .filter((row) => row.id !== null) + .map(async (row) => { + const id = row.id as string; + const status = this.imgRowStatus(row); + try { + if (status === 'removed') { + await deleteImage({ imageId: id }); + this.images = this.images.filter((image) => image.id !== id); + this.imgRows = this.imgRows.filter((r) => r.key !== row.key); + } else if (status === 'changed' || sharedChanged) { + const data = { + ...(sharedChanged ? { ...remoteShared, logoSvg: shared.logoSvg } : {}), + ...versionData(row) + }; + await updateImage({ imageId: id, ...data }); + this.images = this.images.map((image) => + image.id === id + ? { + ...image, + ...(sharedChanged ? localShared : {}), + ...versionData(row), + imageType: formatOf(row.filePath) + } + : image + ); + } + } catch (err) { + failed = true; + row.error = getErrorMessage(err, 'Failed to save'); + } + }); + await Promise.all(existingWork); + + // Creations run sequentially so sort order follows the row order. + for (const row of this.imgRows.filter((r) => r.id === null)) { + try { // biome-ignore lint/performance/noAwaitInLoops: preserve insertion order for sortOrder const result = await createImage({ ...remoteShared, ...(shared.logoSvg ? { logoSvg: shared.logoSvg } : {}), - ...versionData + ...versionData(row) }); this.images.push({ id: result.id, ...localShared, - ...versionData, - imageType: file?.format || 'qcow2' + ...versionData(row), + imageType: formatOf(row.filePath) }); - // Drop saved rows so a partial failure can be retried without duplicates. - this.imgRows = this.imgRows.filter((r) => r.key !== row.key); + row.id = result.id; + } catch (err) { + failed = true; + row.error = getErrorMessage(err, 'Failed to create'); } } + if (failed) { + this.imgError = 'Some changes failed to save. Fix the highlighted rows and try again.'; + if (this.images.some((image) => image.name === shared.name)) { + this.imgGroupName = shared.name; + } + return; + } this.imgDialogOpen = false; } catch (err) { this.imgError = getErrorMessage(err, 'Failed to save'); @@ -1076,13 +1167,4 @@ export class AdminState { toast.error(getErrorMessage(err, 'Failed to reorder images')); } } - - async imgRemove(id: string) { - try { - await deleteImage({ imageId: id }); - this.images = this.images.filter((image) => image.id !== id); - } catch (err) { - toast.error(getErrorMessage(err, 'Failed to delete image')); - } - } } diff --git a/apps/dashboard/src/routes/(app)/admin/images/+page.svelte b/apps/dashboard/src/routes/(app)/admin/images/+page.svelte index 42851b83..e39f77fd 100644 --- a/apps/dashboard/src/routes/(app)/admin/images/+page.svelte +++ b/apps/dashboard/src/routes/(app)/admin/images/+page.svelte @@ -10,6 +10,7 @@ import GripVertical from '~icons/lucide/grip-vertical'; import Loader2 from '~icons/lucide/loader-2'; import Plus from '~icons/lucide/plus'; + import Undo from '~icons/lucide/undo-2'; import X from '~icons/lucide/x'; import AlertTriangle from '~icons/nucleo/alert-triangle'; import Disc from '~icons/nucleo/disc'; @@ -85,30 +86,40 @@ admin.imgReorderGroups(index, event.key === 'ArrowUp' ? index - 1 : index + 1); } - const dialogTitles = { - create: 'Add Image', - addVersions: 'Add Versions', - edit: 'Edit Version', - editGroup: 'Edit Image Details' - } as const; - - let showSharedFields = $derived(admin.imgMode === 'create' || admin.imgMode === 'editGroup'); - let showVersionRows = $derived(admin.imgMode !== 'editGroup'); - let groupVersionCount = $derived( - admin.imgGroupName - ? (admin.imageGroups.find((group) => group.name === admin.imgGroupName)?.versions.length ?? 0) - : 0 - ); + let isNewImage = $derived(admin.imgGroupName === null); + let pendingSummary = $derived.by(() => { + const { new: added, changed, removed, details } = admin.imgPending; + const parts: string[] = []; + if (details) { + parts.push('details'); + } + if (added) { + parts.push(`${added} new`); + } + if (changed) { + parts.push(`${changed} changed`); + } + if (removed) { + parts.push(`${removed} removed`); + } + return parts.join(', '); + }); let saveLabel = $derived.by(() => { - if (admin.imgMode === 'edit' || admin.imgMode === 'editGroup') { - return 'Save'; + if (isNewImage) { + return 'Create image'; } - const count = admin.imgRows.length; - if (count > 1) { - return `Add ${count} versions`; + if (admin.imgActiveRows.length === 0) { + return 'Delete image'; } - return admin.imgMode === 'create' ? 'Create' : 'Add version'; + return 'Save changes'; }); + + const statusStyles = { + new: 'text-emerald-700 dark:text-emerald-400', + changed: 'text-amber-700 dark:text-amber-400', + removed: 'text-red-600 dark:text-red-400', + unchanged: 'text-muted-foreground' + } as const; let localImportTargets = $derived( admin.pveImageImportTargets.filter((target) => target.storage === admin.importStorage) ); @@ -217,24 +228,15 @@
-
- - -
+ {#each group.versions as img (img.id)} @@ -256,26 +258,7 @@ {img.secureBoot ? 'On' : 'Off'} - -
- - -
- + {/each} @@ -424,25 +407,20 @@ - + - {dialogTitles[admin.imgMode]} + {isNewImage ? 'Add Image' : `Manage ${admin.imgGroupName}`} - {#if admin.imgMode === 'editGroup'} - Changes apply to all {groupVersionCount} version{groupVersionCount === 1 ? '' : 's'} - of this image. - {:else if admin.imgMode === 'edit'} - Edit this version. Name, logo and description are shared, so edit them from the image row. - {:else} - Configure a base image and one or more versions for VM provisioning. - {/if} + {isNewImage + ? 'Configure a base image and its versions for VM provisioning.' + : 'Edit details and versions. Nothing is saved until you hit Save.'} -
+
{#if admin.imgError}
{/if} - {#if !showSharedFields} - + +
- {admin.imgName} - - {groupVersionCount} - existing version{groupVersionCount === 1 ? '' : 's'}{admin.imgDescription - ? ` · ${admin.imgDescription}` - : ''} + + {admin.imgName || 'New image'} + {#if admin.imgIsOfficial} + Official + {/if} + {#if admin.imgDescription} + {admin.imgDescription} + {/if}
- {#if admin.imgMode === 'addVersions'} - - {/if} -
- {/if} - - {#if showSharedFields} -
- - admin.imgAdoptExistingName()} - /> - - {#each admin.imageGroups as group (group.name)} - - {/each} - - {#if admin.imgMode === 'create'} -

- Pick an existing name to add versions to it instead of re-entering details. -

- {/if} +
-
-
- -
- x86 + {#if admin.imgDetailsExpanded} +
+
+
+ + admin.imgAdoptExistingName()} + /> + {#if isNewImage} + + {#each admin.imageGroups as group (group.name)} + + {/each} + + {/if} +
+
+ + +
-
-
- - -
-
- -
- - -
+ {#if isNewImage} +

+ Picking an existing name opens that image so you can add versions to it. +

+ {/if} - {#if admin.imgIsOfficial} -
- +
-
- -
- {#if admin.imgLogoSvg.trim()} - - {:else} - - {/if} + + {#if admin.imgIsOfficial} +
+
+ + +
+
+ +
+ {#if admin.imgLogoSvg.trim()} + + {:else} + + {/if} +
+
-
-
-
- - +
+ + +
+ {/if} + + {#if !isNewImage} +

+ Detail changes apply to every version of this image. +

+ {/if}
{/if} - {/if} +
- {#if showVersionRows} -
-
- - -
+ +
+
+ + +
-
-
- Version - Proxmox image - Secure Boot - Remove -
- {#each admin.imgRows as row, rowIndex (row.key)} -
+
+
+ Version + Proxmox image + Secure Boot + Status + Remove +
+ {#each admin.imgRows as row, rowIndex (row.key)} + {@const status = admin.imgRowStatus(row)} +
+
- {#if admin.imgRows.length > 1} - - {:else} - - {/if} + + {status === 'unchanged' ? '' : status} + +
- {/each} -
+ {#if row.error} +

{row.error}

+ {/if} +
+ {:else} +

No versions yet.

+ {/each} +
- {#if admin.imgMode !== 'edit'} - - {/if} + - {#if admin.isoError} -

{admin.isoError}

- {:else if admin.pveImages.length === 0 && !admin.isoLoading} -

- No imported images found. Use Upload/Import Image first. -

- {/if} - {#if admin.imgDuplicateVersions.length > 0} -

- Heads up: {admin.imgDuplicateVersions.join(', ')} already exists for this image. -

- {/if} -
- {/if} + {#if admin.isoError} +

{admin.isoError}

+ {:else if admin.pveImages.length === 0 && !admin.isoLoading} +

+ No imported images found. Use Upload/Import Image first. +

+ {/if} + {#if admin.imgDuplicateVersions.length > 0} +

+ Heads up: {admin.imgDuplicateVersions.join(', ')} is listed more than once. +

+ {/if} + {#if !isNewImage && admin.imgActiveRows.length === 0} +

+ Every version is marked for removal. Saving will delete this image entirely. +

+ {/if} +
- - - {/if} - {saveLabel} - + {#if pendingSummary && !isNewImage} + Pending: {pendingSummary} + {/if} +
+
+ + +
From 586a97926ff813d19928d375a50f0672d7fc3c78 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Tue, 27 Oct 2026 17:47:31 -0600 Subject: [PATCH 4/4] formatting --- apps/dashboard/src/lib/state/admin.svelte.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/dashboard/src/lib/state/admin.svelte.ts b/apps/dashboard/src/lib/state/admin.svelte.ts index 1dea3dff..ffddce90 100644 --- a/apps/dashboard/src/lib/state/admin.svelte.ts +++ b/apps/dashboard/src/lib/state/admin.svelte.ts @@ -98,7 +98,6 @@ export interface PveImage { export interface ImageVersionDraft { error: string; filePath: string; - /** Existing base image id, or null for a version that hasn't been created yet. */ id: string | null; key: number; removed: boolean; @@ -264,7 +263,6 @@ export class AdminState { importError = $state(''); importTasks = $state([]); imgDialogOpen = $state(false); - /** Saved name of the image family being managed, or null when creating a new one. */ imgGroupName = $state(null); imgDetailsExpanded = $state(true); imgSaving = $state(false); @@ -280,7 +278,6 @@ export class AdminState { imgRows = $state([]); private imgRowSeq = 0; - /** Images grouped by name, in display order (by first appearance in sortOrder). */ get imageGroups(): ImageGroup[] { const groups = new Map(); for (const image of this.images) { @@ -845,7 +842,6 @@ export class AdminState { this.imgDetailsExpanded = false; } - /** Start a brand new image family. */ imgOpenCreate(filePath = '') { this.imgGroupName = null; this.imgResetShared(); @@ -859,7 +855,6 @@ export class AdminState { } } - /** Manage an existing family: shared details plus every version, saved in one go. */ imgOpenManage(groupName: string) { const group = this.imageGroups.find((g) => g.name === groupName); if (!group) { @@ -876,7 +871,6 @@ export class AdminState { } } - /** While creating, typing an existing family name pulls that family in (keeping new rows). */ imgAdoptExistingName() { if (this.imgGroupName !== null) { return; @@ -901,7 +895,6 @@ export class AdminState { this.imgRows.push(this.imgNewRow({ secureBoot: last?.secureBoot ?? true })); } - /** New rows are dropped outright; existing versions are marked for deletion on save. */ imgToggleRemoveRow(row: ImageVersionDraft) { if (row.id === null) { this.imgRows = this.imgRows.filter((r) => r.key !== row.key); @@ -910,7 +903,6 @@ export class AdminState { row.removed = !row.removed; } - /** Mark every existing version for deletion and discard unsaved ones. */ imgRemoveAll() { this.imgRows = this.imgRows.filter((row) => row.id !== null); for (const row of this.imgRows) { @@ -943,7 +935,6 @@ export class AdminState { return 'unchanged'; } - /** Label of another configured image already using this Proxmox file, if any. */ imgFileUsedBy(volid: string, rowId: string | null) { const other = this.images.find((image) => image.filePath === volid && image.id !== rowId); return other ? `${other.name} ${other.version}`.trim() : null; @@ -1145,7 +1136,6 @@ export class AdminState { } } - /** Reorder whole image families; versions inside a family are sorted by version. */ async imgReorderGroups(fromIndex: number, toIndex: number) { const groups = this.imageGroups; if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) {