From 1c57746f9ac3232fec7413ac92645362dc1322cd Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Thu, 8 Oct 2026 14:41:42 +0000 Subject: [PATCH] feat: prioritize longhorn storage class and use it as default Sort available storage classes so longhorn is always listed before local-path, with remaining entries alphabetical. New volume dialogs for apps and agents now default to longhorn when available. --- .../general/agent-volume-edit-overlay.tsx | 3 +- .../[appId]/volumes/storage-edit-overlay.tsx | 3 +- src/server/services/cluster.service.ts | 12 +++--- src/shared/utils/storage-class.utils.ts | 22 ++++++++++ .../utils/storage-class.utils.unit.spec.ts | 41 +++++++++++++++++++ 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 src/shared/utils/storage-class.utils.ts create mode 100644 src/shared/utils/storage-class.utils.unit.spec.ts diff --git a/src/app/project/agent/[agentId]/general/agent-volume-edit-overlay.tsx b/src/app/project/agent/[agentId]/general/agent-volume-edit-overlay.tsx index 2eee185b..8969e16d 100644 --- a/src/app/project/agent/[agentId]/general/agent-volume-edit-overlay.tsx +++ b/src/app/project/agent/[agentId]/general/agent-volume-edit-overlay.tsx @@ -15,6 +15,7 @@ import { ServerActionResult } from "@/shared/model/server-action-error-return.mo import { AgentVolumeEditModel, agentVolumeEditZodModel } from "@/shared/model/volume-edit.model"; import { useDialogContext } from "@/frontend/states/dialog-context"; import { saveAgentVolume } from "./actions"; +import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; export default function AgentVolumeEditOverlay({ existingVolume, @@ -26,7 +27,7 @@ export default function AgentVolumeEditOverlay({ storageClasses: string[]; }) { const { closeDialog } = useDialogContext(); - const defaultStorageClassName = existingVolume?.storageClassName || storageClasses[0] || ''; + const defaultStorageClassName = existingVolume?.storageClassName || StorageClassUtils.getDefaultStorageClass(storageClasses); const form = useForm, unknown, z.output>({ resolver: zodResolver(agentVolumeEditZodModel), diff --git a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx index 39d117f3..28900164 100644 --- a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx @@ -42,6 +42,7 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model" import CheckboxFormField from "@/components/custom/checkbox-form-field" import StorageClassCombobox from "@/components/custom/storage-class-combobox" import { useDialog } from "@/frontend/states/zustand.states"; +import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; const accessModes = [ { label: "ReadWriteOnce", value: "ReadWriteOnce" }, @@ -55,7 +56,7 @@ export default function StorageEditDialog({ volume, app, storageClasses }: { }) { const { closeDialog } = useDialog(); - const defaultStorageClassName = volume?.storageClassName ?? storageClasses[0] ?? ""; + const defaultStorageClassName = volume?.storageClassName ?? StorageClassUtils.getDefaultStorageClass(storageClasses); const form = useForm, unknown, z.output>({ resolver: zodResolver(appVolumeEditZodModel), diff --git a/src/server/services/cluster.service.ts b/src/server/services/cluster.service.ts index bfaf5b02..49e5ca14 100644 --- a/src/server/services/cluster.service.ts +++ b/src/server/services/cluster.service.ts @@ -7,6 +7,7 @@ import { revalidateTag, unstable_cache } from "next/cache"; import longhornApiAdapter from "../adapter/longhorn-api.adapter"; import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils"; import { CatchUtils } from "@/shared/utils/catch.utils"; +import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; class ClusterService { @@ -53,11 +54,12 @@ class ClusterService { async getStorageClasses(): Promise { return await unstable_cache(async () => { const storageClasses = await k3s.storage.listStorageClass(); - return storageClasses.items - .map((storageClass) => storageClass.metadata?.name) - .filter((name): name is string => !!name) - .filter((name) => name !== 'longhorn-static') // filter out longhorn-static, because in longhorn setup only local path or longhorn should be used - .sort((a, b) => a.localeCompare(b)); + return StorageClassUtils.sortStorageClasses( + storageClasses.items + .map((storageClass) => storageClass.metadata?.name) + .filter((name): name is string => !!name) + .filter((name) => name !== 'longhorn-static') // filter out longhorn-static, because in longhorn setup only local path or longhorn should be used + ); }, [Tags.storageClasses()], { revalidate: 60, diff --git a/src/shared/utils/storage-class.utils.ts b/src/shared/utils/storage-class.utils.ts new file mode 100644 index 00000000..b95a5b70 --- /dev/null +++ b/src/shared/utils/storage-class.utils.ts @@ -0,0 +1,22 @@ +export class StorageClassUtils { + static readonly LONGHORN = 'longhorn'; + static readonly LOCAL_PATH = 'local-path'; + + static sortStorageClasses(storageClassNames: string[]): string[] { + return storageClassNames.slice().sort((a, b) => { + const priorityA = a === StorageClassUtils.LONGHORN ? 0 : 1; + const priorityB = b === StorageClassUtils.LONGHORN ? 0 : 1; + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + return a.localeCompare(b); + }); + } + + static getDefaultStorageClass(storageClassNames: string[]): string { + if (storageClassNames.includes(StorageClassUtils.LONGHORN)) { + return StorageClassUtils.LONGHORN; + } + return storageClassNames[0] ?? ''; + } +} diff --git a/src/shared/utils/storage-class.utils.unit.spec.ts b/src/shared/utils/storage-class.utils.unit.spec.ts new file mode 100644 index 00000000..654351eb --- /dev/null +++ b/src/shared/utils/storage-class.utils.unit.spec.ts @@ -0,0 +1,41 @@ +import { StorageClassUtils } from '@/shared/utils/storage-class.utils'; + +describe('StorageClassUtils', () => { + + describe('sortStorageClasses', () => { + it('should place longhorn before local-path', () => { + const result = StorageClassUtils.sortStorageClasses(['local-path', 'longhorn']); + expect(result).toEqual(['longhorn', 'local-path']); + }); + + it('should sort the remaining storage classes alphabetically', () => { + const result = StorageClassUtils.sortStorageClasses(['zfs', 'nfs', 'local-path']); + expect(result).toEqual(['local-path', 'nfs', 'zfs']); + }); + + it('should keep longhorn first and sort all other entries alphabetically', () => { + const result = StorageClassUtils.sortStorageClasses(['zfs', 'local-path', 'nfs', 'longhorn']); + expect(result).toEqual(['longhorn', 'local-path', 'nfs', 'zfs']); + }); + + it('should not mutate the input array', () => { + const input = ['local-path', 'longhorn']; + StorageClassUtils.sortStorageClasses(input); + expect(input).toEqual(['local-path', 'longhorn']); + }); + }); + + describe('getDefaultStorageClass', () => { + it('should return longhorn when available', () => { + expect(StorageClassUtils.getDefaultStorageClass(['local-path', 'longhorn'])).toBe('longhorn'); + }); + + it('should fall back to the first entry when longhorn is not available', () => { + expect(StorageClassUtils.getDefaultStorageClass(['local-path', 'nfs'])).toBe('local-path'); + }); + + it('should return an empty string when no storage classes are available', () => { + expect(StorageClassUtils.getDefaultStorageClass([])).toBe(''); + }); + }); +});