diff --git a/prisma/migrations/20251219105325_add_storage_class_to_app_volume/migration.sql b/prisma/migrations/20251219105325_add_storage_class_to_app_volume/migration.sql new file mode 100644 index 00000000..b0543aa3 --- /dev/null +++ b/prisma/migrations/20251219105325_add_storage_class_to_app_volume/migration.sql @@ -0,0 +1,20 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AppVolume" ( + "id" TEXT NOT NULL PRIMARY KEY, + "containerMountPath" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "accessMode" TEXT NOT NULL DEFAULT 'rwo', + "storageClassName" TEXT NOT NULL DEFAULT 'longhorn', + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppVolume_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_AppVolume" ("accessMode", "appId", "containerMountPath", "createdAt", "id", "size", "updatedAt") SELECT "accessMode", "appId", "containerMountPath", "createdAt", "id", "size", "updatedAt" FROM "AppVolume"; +DROP TABLE "AppVolume"; +ALTER TABLE "new_AppVolume" RENAME TO "AppVolume"; +CREATE UNIQUE INDEX "AppVolume_appId_containerMountPath_key" ON "AppVolume"("appId", "containerMountPath"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f440deab..e3a1264b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -245,6 +245,7 @@ model AppVolume { containerMountPath String size Int accessMode String @default("rwo") + storageClassName String @default("longhorn") appId String app App @relation(fields: [appId], references: [id], onDelete: Cascade) volumeBackups VolumeBackup[] diff --git a/src/app/project/[projectId]/project-overview.tsx b/src/app/project/[projectId]/project-overview.tsx index d9e4d9f1..ebe745da 100644 --- a/src/app/project/[projectId]/project-overview.tsx +++ b/src/app/project/[projectId]/project-overview.tsx @@ -6,6 +6,7 @@ import ProjectNetworkGraph from "./project-network-graph"; import { App } from "@prisma/client"; import { UserSession } from "@/shared/model/sim-session.model"; import { useRouter, useSearchParams } from "next/navigation"; +import { Table, Network } from "lucide-react"; interface ProjectOverviewProps { apps: any[]; // Using any to avoid complex type imports, as we know the data structure is correct @@ -25,8 +26,8 @@ export default function ProjectOverview({ apps, session, projectId }: ProjectOve return ( - Table View - Network Graph + Table View + Network Graph diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx index 7bab8465..4ab0d0dc 100644 --- a/src/app/project/app/[appId]/app-tabs.tsx +++ b/src/app/project/app/[appId]/app-tabs.tsx @@ -23,19 +23,23 @@ import NetworkPolicy from "./advanced/network-policy"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import DbToolsCard from "./credentials/db-tools"; import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"; +import { NodeInfoModel } from "@/shared/model/node-info.model"; +import { Eye, Key, Settings, Zap, Globe, HardDrive, Cog } from "lucide-react"; export default function AppTabs({ app, role, tabName, s3Targets, - volumeBackups + volumeBackups, + nodesInfo }: { app: AppExtendedModel; role: RolePermissionEnum; tabName: string; - s3Targets: S3Target[], - volumeBackups: VolumeBackupExtendedModel[] + s3Targets: S3Target[]; + volumeBackups: VolumeBackupExtendedModel[]; + nodesInfo: NodeInfoModel[]; }) { const router = useRouter(); const readonly = role !== RolePermissionEnum.READWRITE; @@ -47,13 +51,13 @@ export default function AppTabs({ openTab(newTab)} className="space-y-4"> - Overview - {app.appType !== 'APP' && Credentials} - General - Environment - Domains - Storage - Advanced + Overview + {app.appType !== 'APP' && Credentials} + General + Environment + Domains + Storage + Advanced @@ -79,7 +83,7 @@ export default function AppTabs({ - + @@ -31,6 +33,7 @@ export default async function AppPage({ volumeBackups={volumeBackups} s3Targets={s3Targets} app={app} + nodesInfo={nodesInfo} tabName={searchParams?.tabName ?? 'overview'} /> diff --git a/src/app/project/app/[appId]/volumes/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts index fb26084d..22ffbfe9 100644 --- a/src/app/project/app/[appId]/volumes/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -47,13 +47,20 @@ export const saveVolume = async (prevState: any, inputData: z.infer validatedData.size) { throw new ServiceException('Volume size cannot be decreased'); } + if (existingVolume && existingVolume.storageClassName !== validatedData.storageClassName) { + throw new ServiceException('Storage class cannot be changed for existing volumes'); + } if (existingApp.replicas > 1 && validatedData.accessMode === 'ReadWriteOnce') { throw new ServiceException('Volume access mode must be ReadWriteMany because your app has more than one replica configured.'); } + if (validatedData.accessMode === 'ReadWriteMany' && validatedData.storageClassName === 'local-path') { + throw new ServiceException('The Local Path storage class does not support ReadWriteMany access mode. Please choose another storage class / access mode.'); + } await appService.saveVolume({ ...validatedData, id: validatedData.id ?? undefined, - accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string + accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string, + storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName }); }); @@ -208,4 +215,4 @@ async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) { } }); await isAuthorizedWriteForApp(volumeAppId?.volume.appId); -} \ No newline at end of file +} 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 b4c948e2..c06843d7 100644 --- a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx @@ -39,13 +39,24 @@ import { toast } from "sonner" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { QuestionMarkCircledIcon } from "@radix-ui/react-icons" import { AppExtendedModel } from "@/shared/model/app-extended.model" +import { NodeInfoModel } from "@/shared/model/node-info.model" const accessModes = [ { label: "ReadWriteOnce", value: "ReadWriteOnce" }, { label: "ReadWriteMany", value: "ReadWriteMany" }, ] as const -export default function DialogEditDialog({ children, volume, app }: { children: React.ReactNode; volume?: AppVolume; app: AppExtendedModel; }) { +const storageClasses = [ + { label: "Longhorn (Default)", value: "longhorn", description: "Distributed, replicated storage recommended workloads in a cluster of multiple nodes." }, + { label: "Local Path", value: "local-path", description: "Node-local volumes, no replication. Data is stored on the master node. Only works in a single node setup." } +] as const + +export default function DialogEditDialog({ children, volume, app, nodesInfo }: { + children: React.ReactNode; + volume?: AppVolume; + app: AppExtendedModel; + nodesInfo: NodeInfoModel[]; +}) { const [isOpen, setIsOpen] = useState(false); @@ -54,7 +65,8 @@ export default function DialogEditDialog({ children, volume, app }: { children: resolver: zodResolver(appVolumeEditZodModel), defaultValues: { ...volume, - accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce") + accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), + storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', } }); @@ -77,7 +89,11 @@ export default function DialogEditDialog({ children, volume, app }: { children: }, [state]); useEffect(() => { - form.reset(volume); + form.reset({ + ...volume, + accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), + storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path', + }); }, [volume]); return ( @@ -207,6 +223,88 @@ export default function DialogEditDialog({ children, volume, app }: { children: )} /> + {nodesInfo.length === 1 && + ( + + +
Storage Class
+
+ + + + +

+ Choose where the volume is provisioned.

+ Longhorn keeps data replicated across nodes.
+ Local Path stores data on a the master node and works only in single-node clusters. +

+
+
+
+
+
+ + + + + + + + + + + {storageClasses.map((storageClass) => ( + { + form.setValue("storageClassName", storageClass.value); + }} + > +
+ {storageClass.label} + {storageClass.description} +
+ +
+ ))} +
+
+
+
+
+ + This cannot be changed after creation. + + +
+ )} + />}

{state.message}

Save @@ -216,7 +314,4 @@ export default function DialogEditDialog({ children, volume, app }: { children: ) - - - -} \ No newline at end of file +} diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index 96de9e10..581e9fc2 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -22,11 +22,13 @@ import { Code } from "@/components/custom/code"; import { Label } from "@/components/ui/label"; import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils"; import { Progress } from "@/components/ui/progress"; +import { NodeInfoModel } from "@/shared/model/node-info.model"; type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number }); -export default function StorageList({ app, readonly }: { +export default function StorageList({ app, readonly, nodesInfo }: { app: AppExtendedModel; + nodesInfo: NodeInfoModel[]; readonly: boolean; }) { @@ -150,6 +152,7 @@ export default function StorageList({ app, readonly }: { Mount Path Storage Size Storage Used + Storage Class Access Mode Action @@ -168,6 +171,7 @@ export default function StorageList({ app, readonly }: { } + {volume.storageClassName?.replace('-', ' ')} {volume.accessMode} @@ -209,7 +213,7 @@ export default function StorageList({ app, readonly }: { */} {!readonly && <> - + @@ -241,10 +245,10 @@ export default function StorageList({ app, readonly }: {
{!readonly && - + } ; -} \ No newline at end of file +} diff --git a/src/app/settings/cluster/actions.ts b/src/app/settings/cluster/actions.ts index 55ef11f4..7fd5250a 100644 --- a/src/app/settings/cluster/actions.ts +++ b/src/app/settings/cluster/actions.ts @@ -1,8 +1,10 @@ 'use server' -import { getAdminUserSession, getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { getAdminUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import clusterService from "@/server/services/node.service"; +import traefikService from "@/server/services/traefik.service"; +import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; export const setNodeStatus = async (nodeName: string, schedulable: boolean) => simpleAction(async () => { @@ -10,3 +12,19 @@ export const setNodeStatus = async (nodeName: string, schedulable: boolean) => await clusterService.setNodeStatus(nodeName, schedulable); return new SuccessActionResult(undefined, 'Successfully updated node status.'); }); + +export const applyTraefikIpPropagation = async (enableIpPreservation: boolean) => + simpleAction(async () => { + await getAdminUserSession(); + const updatedStatus = await traefikService.applyExternalTrafficPolicy(enableIpPreservation); + return new SuccessActionResult( + updatedStatus, + `Traefik externalTrafficPolicy set to ${enableIpPreservation ? 'Local' : 'Cluster'}.`, + ); + }); + +export const getTraefikIpPropagationStatus = async () => + simpleAction(async () => { + await getAdminUserSession(); + return traefikService.getStatus(); + }); diff --git a/src/app/settings/cluster/page.tsx b/src/app/settings/cluster/page.tsx index d123083b..feaed8fb 100644 --- a/src/app/settings/cluster/page.tsx +++ b/src/app/settings/cluster/page.tsx @@ -14,6 +14,7 @@ export default async function ClusterInfoPage() { const session = await getAdminUserSession(); const nodeInfo = await clusterService.getNodeInfo(); const clusterJoinToken = await paramService.getString(ParamService.K3S_JOIN_TOKEN); + return (
(initialStatus); + const [enabled, setEnabled] = useState((initialStatus.externalTrafficPolicy ?? 'Cluster') === 'Local'); + const [isPending, startTransition] = useTransition(); + + const currentEnabled = (status.externalTrafficPolicy ?? 'Cluster') === 'Local'; + + const handleApply = () => { + startTransition(async () => { + const result = await applyTraefikIpPropagation(enabled); + if (result.status === 'success') { + if (result.data) { + setStatus(result.data); + setEnabled((result.data.externalTrafficPolicy ?? 'Cluster') === 'Local'); + } + toast.success('Traefik updated', { + description: result.message ?? `externalTrafficPolicy set to ${enabled ? 'Local' : 'Cluster'}.` + }); + } else { + toast.error(result.message ?? 'Failed to update Traefik externalTrafficPolicy.'); + } + }); + }; + + const readinessText = `${status.readyReplicas ?? 0}/${status.replicas ?? 0} pods ready`; + const lastRestart = status.restartedAt ? new Date(status.restartedAt).toLocaleString() : 'Not restarted yet'; + + return ( + + + Preserve client IP + + Toggle Traefik externalTrafficPolicy to Local to keep the original client IP on incoming requests. + + + +
+
{readinessText}
+
Last restart: {lastRestart}
+
+ Local policy keeps traffic on a single node; use Cluster if you rely on cross-node load-balancing. +
+
+
+
+ + {enabled ? 'Enable Local policy' : 'Use Cluster policy'} +
+ +
+
+ +

+ Local policy exposes real client IPs but may limit load-balancing flexibility. +

+
+
+ ); +} diff --git a/src/app/settings/maintenance/page.tsx b/src/app/settings/maintenance/page.tsx deleted file mode 100644 index d3bbfffc..00000000 --- a/src/app/settings/maintenance/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use server' - -import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils"; -import PageTitle from "@/components/custom/page-title"; -import paramService, { ParamService } from "@/server/services/param.service"; -import podService from "@/server/services/pod.service"; -import { Constants } from "@/shared/utils/constants"; -import s3TargetService from "@/server/services/s3-target.service"; -import QuickStackVersionInfo from "./qs-version-info"; -import QuickStackMaintenanceSettings from "./qs-maintenance-settings"; -import BreadcrumbSetter from "@/components/breadcrumbs-setter"; -import quickStackService from "@/server/services/qs.service"; - -export default async function MaintenancePage() { - - await getAdminUserSession(); - const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); - const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME); - const qsPodInfo = qsPodInfos.find(p => !!p); - const currentVersion = await quickStackService.getVersionOfCurrentQuickstackInstance(); - - return ( -
- - - -
-
-
-
-
- ) -} diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index d48128fc..9aad2654 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -1,6 +1,6 @@ 'use server' -import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { getAdminUserSession, getAuthUserSession, saveFormAction, simpleAction, fileUploadAction } from "@/server/utils/action-wrapper.utils"; import paramService, { ParamService } from "@/server/services/param.service"; import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/shared/model/qs-settings.model"; import { QsLetsEncryptSettingsModel, qsLetsEncryptSettingsZodModel } from "@/shared/model/qs-letsencrypt-settings.model"; @@ -20,6 +20,12 @@ import appLogsService from "@/server/services/standalone-services/app-logs.servi import systemBackupService from "@/server/services/standalone-services/system-backup.service"; import backupService from "@/server/services/standalone-services/backup.service"; import networkPolicyService from "@/server/services/network-policy.service"; +import traefikService from "@/server/services/traefik.service"; +import { PathUtils } from "@/server/utils/path.utils"; +import { FsUtils } from "@/server/utils/fs.utils"; +import fs from "fs"; +import { z } from "zod"; + export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) => saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => { @@ -204,4 +210,54 @@ export const deleteAllNetworkPolicies = async () => const deletedCount = await networkPolicyService.deleteAllNetworkPolicies(); return new SuccessActionResult(undefined, `Successfully deleted all (${deletedCount}) network policies.`); - }); \ No newline at end of file + }); + +export const uploadAndRestoreSystemBackup = async (formData: FormData) => + fileUploadAction(formData, 'backupFile', async (file: File) => { + await getAdminUserSession(); + + const backupTempDir = PathUtils.tempBackupDataFolder; + await FsUtils.createDirIfNotExistsAsync(backupTempDir, true); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const uploadPath = `${backupTempDir}/uploaded-backup-${timestamp}.tar.gz`; + + // Write uploaded file to disk + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await fs.promises.writeFile(uploadPath, buffer); + + try { + // Restore the backup + await systemBackupService.restoreSystemBackup(uploadPath); + + return new SuccessActionResult(undefined, 'System backup restored successfully. Please restart QuickStack for changes to take effect.'); + } finally { + // Clean up uploaded file + await FsUtils.deleteFileIfExists(uploadPath); + } + }) as Promise>; + +export const downloadSystemBackup = async (backupKey: string) => + simpleAction(async () => { + await getAdminUserSession(); + + const systemBackupLocationId = await paramService.getString(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED); + + if (systemBackupLocationId === Constants.QS_SYSTEM_BACKUP_DEACTIVATED || !systemBackupLocationId) { + throw new Error('System backup is not configured. Please select an S3 storage target first.'); + } + + const fileName = await systemBackupService.downloadSystemBackup(systemBackupLocationId, backupKey); + + return new SuccessActionResult(fileName, 'Starting download...'); + }) as Promise>; + +export const setTraefikIpPropagation = async (prevState: any, inputData: { enableIpPreservation: boolean }) => + saveFormAction(inputData, z.object({ enableIpPreservation: z.boolean() }), async (validatedData) => { + await getAdminUserSession(); + + await traefikService.applyExternalTrafficPolicy(validatedData.enableIpPreservation); + + return new SuccessActionResult(undefined, `Traefik externalTrafficPolicy set to ${validatedData.enableIpPreservation ? 'Local' : 'Cluster'}.`); + }); diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx index 29fc9ba8..5aa9cbab 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -10,9 +10,23 @@ import QuickStackRegistrySettings from "./qs-registry-settings"; import s3TargetService from "@/server/services/s3-target.service"; import QuickStackPublicIpSettings from "./qs-public-ip-settings"; import QuickStackSystemBackupSettings from "./qs-system-backup-settings"; +import QuickStackTraefikSettings from "./qs-traefik-settings"; import BreadcrumbSetter from "@/components/breadcrumbs-setter"; +import traefikService from "@/server/services/traefik.service"; +import { Separator } from "@/components/ui/separator"; +import { TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import QuickStackVersionInfo from "./qs-version-info"; +import QuickStackMaintenanceSettings from "./qs-maintenance-settings"; +import podService from "@/server/services/pod.service"; +import quickStackService from "@/server/services/qs.service"; +import { ServerSettingsTabs } from "./server-settings-tabs"; +import { Settings, Network, HardDrive, Rocket, Wrench } from "lucide-react"; -export default async function ProjectPage() { +export default async function ProjectPage({ + searchParams +}: { + searchParams: { [key: string]: string | string[] | undefined } +}) { const session = await getAdminUserSession(); const serverUrl = await paramService.getString(ParamService.QS_SERVER_HOSTNAME, ''); @@ -22,24 +36,69 @@ export default async function ProjectPage() { const ipv4Address = await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS); const systemBackupLocation = await paramService.getString(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED); const s3Targets = await s3TargetService.getAll(); + const traefikStatus = await traefikService.getStatus(); + const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); + const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME); + const qsPodInfo = qsPodInfos.find(p => !!p); + const currentVersion = await quickStackService.getVersionOfCurrentQuickstackInstance(); + const defaultTab = typeof searchParams?.tab === 'string' ? searchParams.tab : 'general'; return ( -
- - +
+
+ + +
-
-
-
-
-
-
-
+ + + + + + General + Networking / Traefik + Storage & Backups + Updates + Maintenance + + + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+
+ +
+ +
+
+
) } diff --git a/src/app/settings/maintenance/qs-maintenance-settings.tsx b/src/app/settings/server/qs-maintenance-settings.tsx similarity index 98% rename from src/app/settings/maintenance/qs-maintenance-settings.tsx rename to src/app/settings/server/qs-maintenance-settings.tsx index 477d8ed4..82eb5577 100644 --- a/src/app/settings/maintenance/qs-maintenance-settings.tsx +++ b/src/app/settings/server/qs-maintenance-settings.tsx @@ -1,7 +1,7 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteAllNetworkPolicies, deleteOldAppLogs, purgeRegistryImages, updateRegistry } from "../server/actions"; +import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteAllNetworkPolicies, deleteOldAppLogs, purgeRegistryImages, updateRegistry } from "./actions"; import { Button } from "@/components/ui/button"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; diff --git a/src/app/settings/server/qs-system-backup-settings.tsx b/src/app/settings/server/qs-system-backup-settings.tsx index 9f8bc431..8136b14f 100644 --- a/src/app/settings/server/qs-system-backup-settings.tsx +++ b/src/app/settings/server/qs-system-backup-settings.tsx @@ -10,7 +10,7 @@ import { useFormState } from "react-dom"; import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { listSystemBackups, runSystemBackupNow, setSystemBackupLocation } from "./actions"; +import { listSystemBackups, runSystemBackupNow, setSystemBackupLocation, uploadAndRestoreSystemBackup, downloadSystemBackup } from "./actions"; import { S3Target } from "@prisma/client"; import { SystemBackupLocationSettingsModel, systemBackupLocationSettingsZodModel } from "@/shared/model/system-backup-location-settings.model"; import SelectFormField from "@/components/custom/select-form-field"; @@ -18,10 +18,14 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { FileArchive, Loader2, Play } from "lucide-react"; +import { FileArchive, Loader2, Play, Upload, Download, AlertTriangle } from "lucide-react"; import { formatBytes, formatDate, formatDateTime } from "@/frontend/utils/format.utils"; import { Toast } from "@/frontend/utils/toast.utils"; import { Constants } from "@/shared/utils/constants"; +import { Input } from "@/components/ui/input"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { Separator } from "@/components/ui/separator"; const DEACTIVATED_VALUE = Constants.QS_SYSTEM_BACKUP_DEACTIVATED; @@ -36,6 +40,9 @@ export default function QuickStackSystemBackupSettings({ const [backups, setBackups] = useState([]); const [loadingBackups, setLoadingBackups] = useState(false); const [runningBackup, setRunningBackup] = useState(false); + const [uploadingBackup, setUploadingBackup] = useState(false); + const [downloadingBackup, setDownloadingBackup] = useState(null); + const confirmDialog = useConfirmDialog(); const form = useForm({ resolver: zodResolver(systemBackupLocationSettingsZodModel), @@ -82,6 +89,56 @@ export default function QuickStackSystemBackupSettings({ } }; + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const confirmed = await confirmDialog.openConfirmDialog({ + title: 'Restore System Backup', + description: 'This will replace your current database with the one from the backup file. This action cannot be undone. Make sure you have a recent backup before proceeding. You will need to restart QuickStack after restoration.', + okButton: 'Restore Backup', + cancelButton: 'Cancel' + }); + + if (!confirmed) { + event.target.value = ''; // Reset file input + return; + } + + setUploadingBackup(true); + try { + const formData = new FormData(); + formData.append('backupFile', file); + + const result = await uploadAndRestoreSystemBackup(formData); + + if (result.status === 'success') { + toast.success(result.message || 'Backup restored successfully. Please restart QuickStack.'); + setShowBackupsDialog(false); + } else { + toast.error(result.message || 'Failed to restore backup'); + } + } catch (error) { + toast.error('Failed to restore backup'); + } finally { + setUploadingBackup(false); + event.target.value = ''; // Reset file input + } + }; + + const handleDownloadBackup = async (backupKey: string) => { + setDownloadingBackup(backupKey); + try { + await Toast.fromAction(() => downloadSystemBackup(backupKey)).then(x => { + if (x.status === 'success' && x.data) { + window.open('/api/volume-data-download?fileName=' + x.data); + } + }); + } finally { + setDownloadingBackup(null); + } + }; + return <> @@ -91,59 +148,64 @@ export default function QuickStackSystemBackupSettings({ Select an S3 storage target to enable automatic system backups, or deactivate to disable system backups. -
- form.handleSubmit((data) => { - return formAction(data); - })()}> - - - - S3 Storage Locations can be configured here. - } - values={[ - [DEACTIVATED_VALUE, Constants.QS_SYSTEM_BACKUP_DEACTIVATED], - ...s3Targets.map((target) => - [target.id, `S3: ${target.name}`]) - ] as [string, string][]} - /> - -
- - + + + form.handleSubmit((data) => { + return formAction(data); + })()}> +
+ + S3 Storage Locations can be configured here. + } + values={[ + [DEACTIVATED_VALUE, Constants.QS_SYSTEM_BACKUP_DEACTIVATED], + ...s3Targets.map((target) => + [target.id, `S3: ${target.name}`]) + ] as [string, string][]} + /> +
+ Save Settings + {state?.message &&

{state.message}

} +
+ + + + -
- - Save -

{state?.message}

-
- - - +
+

Backup Operations

+
+ + +
+
+ + @@ -154,6 +216,34 @@ export default function QuickStackSystemBackupSettings({ + + + +
+

Upload and Restore Backup

+

+ You can upload a system backup file (.tar.gz) to restore your QuickStack instance. + The system will automatically extract and replace the database. You will need to restart QuickStack after restoration. +

+
+ + {uploadingBackup && ( +
+ + Restoring backup... +
+ )} +
+
+
+
+ {loadingBackups ? (
@@ -169,6 +259,7 @@ export default function QuickStackSystemBackupSettings({ Backup Date Size S3 Key + Actions @@ -177,6 +268,20 @@ export default function QuickStackSystemBackupSettings({ {formatDateTime(backup.date)} {formatBytes(backup.sizeBytes)} {backup.key} + + + ))} diff --git a/src/app/settings/server/qs-traefik-settings.tsx b/src/app/settings/server/qs-traefik-settings.tsx new file mode 100644 index 00000000..6714a4d7 --- /dev/null +++ b/src/app/settings/server/qs-traefik-settings.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { SubmitButton } from "@/components/custom/submit-button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Form } from "@/components/ui/form"; +import { FormUtils } from "@/frontend/utils/form.utilts"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useFormState } from "react-dom"; +import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; +import { useEffect } from "react"; +import { toast } from "sonner"; +import { setTraefikIpPropagation } from "./actions"; +import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; +import { z } from "zod"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; + +const traefikSettingsZodModel = z.object({ + enableIpPreservation: z.boolean() +}); + +type TraefikSettingsModel = z.infer; + +export default function QuickStackTraefikSettings({ + initialStatus +}: { + initialStatus: TraefikIpPropagationStatus; +}) { + const currentEnabled = (initialStatus.externalTrafficPolicy ?? 'Cluster') === 'Local'; + + const form = useForm({ + resolver: zodResolver(traefikSettingsZodModel), + defaultValues: { + enableIpPreservation: currentEnabled, + } + }); + + const [state, formAction] = useFormState((state: ServerActionResult, + payload: TraefikSettingsModel) => + setTraefikIpPropagation(state, payload), + FormUtils.getInitialFormState()); + + useEffect(() => { + if (state.status === 'success') { + toast.success('Traefik settings updated successfully.'); + } + FormUtils.mapValidationErrorsToForm(state, form) + }, [state]); + + const readinessText = `${initialStatus.readyReplicas ?? 0}/${initialStatus.replicas ?? 0} pods ready`; + const lastRestart = initialStatus.restartedAt ? new Date(initialStatus.restartedAt).toLocaleString() : 'Not restarted yet'; + + return ( + + + Preserve Client IP + + Configure how Traefik handles incoming traffic and client IP preservation. + + +
+ form.handleSubmit((data) => { + return formAction(data); + })()}> + +
+
+ +
{readinessText}
+
Last restart: {lastRestart}
+
+ form.setValue('enableIpPreservation', checked)} + /> +
+ +
+

+ Setting externalTrafficPolicy to Local preserves the original client IP but may limit load-balancing flexibility. + Only activate this on a single-node cluster. +

+

+ For further details, refer to the Kubernetes documentation. +

+
+
+ + Save + {state.status !== 'success' &&

{state?.message}

} +
+
+ +
+ ); +} diff --git a/src/app/settings/maintenance/qs-version-info.tsx b/src/app/settings/server/qs-version-info.tsx similarity index 98% rename from src/app/settings/maintenance/qs-version-info.tsx rename to src/app/settings/server/qs-version-info.tsx index 2cd23a55..948bdb18 100644 --- a/src/app/settings/maintenance/qs-version-info.tsx +++ b/src/app/settings/server/qs-version-info.tsx @@ -1,7 +1,7 @@ 'use client'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { purgeRegistryImages, setCanaryChannel, updateQuickstack, updateRegistry } from "../server/actions"; +import { purgeRegistryImages, setCanaryChannel, updateQuickstack, updateRegistry } from "./actions"; import { Button } from "@/components/ui/button"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; diff --git a/src/app/settings/server/server-settings-tabs.tsx b/src/app/settings/server/server-settings-tabs.tsx new file mode 100644 index 00000000..4842b784 --- /dev/null +++ b/src/app/settings/server/server-settings-tabs.tsx @@ -0,0 +1,28 @@ +"use client" + +import { Tabs } from "@/components/ui/tabs" +import { useRouter, usePathname, useSearchParams } from "next/navigation" +import { ReactNode } from "react" + +interface ServerSettingsTabsProps { + children: ReactNode + defaultTab: string +} + +export function ServerSettingsTabs({ children, defaultTab }: ServerSettingsTabsProps) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const onTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()) + params.set("tab", value) + router.replace(`${pathname}?${params.toString()}`, { scroll: false }) + } + + return ( + + {children} + + ) +} diff --git a/src/app/sidebar-client.tsx b/src/app/sidebar-client.tsx index f9e20700..9188ff25 100644 --- a/src/app/sidebar-client.tsx +++ b/src/app/sidebar-client.tsx @@ -52,19 +52,14 @@ const settingsMenu = [ icon: Settings, adminOnly: true, }, - { - title: "QuickStack Settings", - url: "/settings/server", - adminOnly: true, - }, { title: "Cluster", url: "/settings/cluster", adminOnly: true, }, { - title: "Maintenance", - url: "/settings/maintenance", + title: "QuickStack Settings", + url: "/settings/server", adminOnly: true, }, ] diff --git a/src/server/services/pvc.service.ts b/src/server/services/pvc.service.ts index 0f751653..92b0ca81 100644 --- a/src/server/services/pvc.service.ts +++ b/src/server/services/pvc.service.ts @@ -115,9 +115,13 @@ class PvcService { for (const appVolume of app.appVolumes) { const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id); const pvcDefinition = this.mapVolumeToPvcDefinition(app.projectId, appVolume); + const desiredStorageClassName = appVolume.storageClassName ?? 'longhorn'; const existingPvc = existingPvcs.find(pvc => pvc.metadata?.name === pvcName); if (existingPvc) { + if (existingPvc.spec?.storageClassName && existingPvc.spec.storageClassName !== desiredStorageClassName) { + console.warn(`PVC ${pvcName} storageClassName differs from requested value (${existingPvc.spec.storageClassName} vs ${desiredStorageClassName}). Storage class changes are not applied automatically.`); + } if (existingPvc.spec!.resources!.requests!.storage === KubeSizeConverter.megabytesToKubeFormat(appVolume.size)) { console.log(`PVC ${pvcName} for app ${app.id} already exists with the same size`); continue; @@ -159,6 +163,7 @@ class PvcService { } private mapVolumeToPvcDefinition(projectId: string, appVolume: AppVolume): V1PersistentVolumeClaim { + const storageClassName = appVolume.storageClassName ?? 'longhorn'; return { apiVersion: 'v1', kind: 'PersistentVolumeClaim', @@ -173,7 +178,7 @@ class PvcService { }, spec: { accessModes: [appVolume.accessMode], - storageClassName: 'longhorn', + storageClassName, resources: { requests: { storage: KubeSizeConverter.megabytesToKubeFormat(appVolume.size), diff --git a/src/server/services/qs.service.ts b/src/server/services/qs.service.ts index ca47ae92..05b3b1ab 100644 --- a/src/server/services/qs.service.ts +++ b/src/server/services/qs.service.ts @@ -232,6 +232,11 @@ class QuickStackService { private async createOrUpdatePvc() { const pvcName = KubeObjectNameUtils.toPvcName(this.QUICKSTACK_DEPLOYMENT_NAME); + const allPvcs = await k3s.core.listNamespacedPersistentVolumeClaim(this.QUICKSTACK_NAMESPACE); + const existingPvc = allPvcs.body.items.find(p => p.metadata!.name === pvcName); + + const storageClassName = existingPvc?.spec?.storageClassName || 'longhorn'; + const pvc = { apiVersion: 'v1', kind: 'PersistentVolumeClaim', @@ -241,7 +246,7 @@ class QuickStackService { }, spec: { accessModes: ['ReadWriteOnce'], - storageClassName: 'longhorn', + storageClassName, resources: { requests: { storage: '1Gi' @@ -249,8 +254,6 @@ class QuickStackService { } } }; - const allPvcs = await k3s.core.listNamespacedPersistentVolumeClaim(this.QUICKSTACK_NAMESPACE); - const existingPvc = allPvcs.body.items.find(p => p.metadata!.name === pvcName); if (existingPvc) { if (existingPvc.spec!.resources!.requests!.storage === pvc.spec!.resources!.requests!.storage) { console.log(`PVC already exists with the same size, no changes`); diff --git a/src/server/services/secret.service.ts b/src/server/services/secret.service.ts index 81b3c017..0088efea 100644 --- a/src/server/services/secret.service.ts +++ b/src/server/services/secret.service.ts @@ -58,7 +58,7 @@ class SecretService { } } - private appNeedsNoSecret(app: { id: string; name: string; appType: string; projectId: string; sourceType: string; dockerfilePath: string; replicas: number; envVars: string; createdAt: Date; updatedAt: Date; project: { id: string; name: string; createdAt: Date; updatedAt: Date; }; appDomains: { id: string; createdAt: Date; updatedAt: Date; hostname: string; port: number; useSsl: boolean; redirectHttps: boolean; appId: string; }[]; appVolumes: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; size: number; accessMode: string; }[]; appPorts: { id: string; createdAt: Date; updatedAt: Date; port: number; appId: string; }[]; appFileMounts: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; content: string; }[]; containerImageSource?: string | null | undefined; containerRegistryUsername?: string | null | undefined; containerRegistryPassword?: string | null | undefined; gitUrl?: string | null | undefined; gitBranch?: string | null | undefined; gitUsername?: string | null | undefined; gitToken?: string | null | undefined; memoryReservation?: number | null | undefined; memoryLimit?: number | null | undefined; cpuReservation?: number | null | undefined; cpuLimit?: number | null | undefined; }) { + private appNeedsNoSecret(app: { id: string; name: string; appType: string; projectId: string; sourceType: string; dockerfilePath: string; replicas: number; envVars: string; createdAt: Date; updatedAt: Date; project: { id: string; name: string; createdAt: Date; updatedAt: Date; }; appDomains: { id: string; createdAt: Date; updatedAt: Date; hostname: string; port: number; useSsl: boolean; redirectHttps: boolean; appId: string; }[]; appVolumes: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; size: number; accessMode: string; storageClassName: string; }[]; appPorts: { id: string; createdAt: Date; updatedAt: Date; port: number; appId: string; }[]; appFileMounts: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; content: string; }[]; containerImageSource?: string | null | undefined; containerRegistryUsername?: string | null | undefined; containerRegistryPassword?: string | null | undefined; gitUrl?: string | null | undefined; gitBranch?: string | null | undefined; gitUsername?: string | null | undefined; gitToken?: string | null | undefined; memoryReservation?: number | null | undefined; memoryLimit?: number | null | undefined; cpuReservation?: number | null | undefined; cpuLimit?: number | null | undefined; }) { return app.sourceType === 'GIT' || !app.containerImageSource || !app.containerRegistryUsername || !app.containerRegistryPassword; } @@ -81,4 +81,4 @@ class SecretService { } const secretService = new SecretService(); -export default secretService; \ No newline at end of file +export default secretService; diff --git a/src/server/services/standalone-services/system-backup.service.ts b/src/server/services/standalone-services/system-backup.service.ts index a0ff552a..00e5728a 100644 --- a/src/server/services/standalone-services/system-backup.service.ts +++ b/src/server/services/standalone-services/system-backup.service.ts @@ -6,6 +6,9 @@ import s3Service from "../aws-s3.service"; import dataAccess from "@/server/adapter/db.client"; import { CommandExecutorUtils } from "@/server/utils/command-executor.utils"; import { Constants } from "@/shared/utils/constants"; +import util from 'util'; +import child_process from 'child_process'; +import { ServiceException } from "@/shared/model/service.exception.model"; const QS_SYSTEM_BACKUP_PREFIX = 'quickstack-system-backup'; const QS_DEFAULT_RETENTION_DAYS = 30; @@ -162,6 +165,91 @@ class SystemBackupService { return systemBackups; } + + /** + * Download a system backup from S3 to temp volume download path + * Returns just the filename for use with the volume-data-download route + */ + async downloadSystemBackup(s3TargetId: string, backupKey: string): Promise { + const s3Target = await dataAccess.client.s3Target.findFirstOrThrow({ + where: { + id: s3TargetId + } + }); + + const fileName = backupKey.split('/').join('-'); + const downloadPath = PathUtils.volumeDownloadZipPath(fileName); + + await FsUtils.createDirIfNotExistsAsync(PathUtils.tempVolumeDownloadPath, true); + await FsUtils.deleteDirIfExistsAsync(downloadPath, true); + + console.log(`Downloading system backup from S3: ${backupKey} to ${downloadPath}...`); + await s3Service.downloadFile(s3Target, backupKey, downloadPath); + console.log(`System backup downloaded successfully`); + + return PathUtils.splitPath(downloadPath).filePath; + } + + /** + * Restore system backup from an uploaded tar.gz file + * Searches for data.db in the archive and replaces the current database + */ + async restoreSystemBackup(backupFilePath: string): Promise { + const restoreDir = PathUtils.tempBackupResotreFolder; + await FsUtils.createDirIfNotExistsAsync(restoreDir, true); + + const extractPath = `${restoreDir}/extract-${Date.now()}`; + await FsUtils.createDirIfNotExistsAsync(extractPath, true); + + try { + console.log(`Extracting backup archive to ${extractPath}...`); + + // Extract the tar.gz archive + await CommandExecutorUtils.runCommand( + `tar -xzf "${backupFilePath}" -C "${extractPath}"` + ); + + // Search for data.db file in the extracted content + const exec = util.promisify(child_process.exec); + const { stdout } = await exec(`find "${extractPath}" -name "data.db" -type f`); + + const dataDbPath = stdout.trim().split('\n')[0]; + + if (!dataDbPath || !(await FsUtils.fileExists(dataDbPath))) { + throw new ServiceException('data.db file not found in the backup archive. Cannot restore backup.'); + } + + console.log(`Found data.db at: ${dataDbPath}`); + + // Determine the current database path + const dbPath = process.env.DATABASE_URL?.replace('file:', ''); + + // Create backup of current database before replacing + const backupCurrentDb = `${dbPath}.backup-${Date.now()}`; + console.log(`Creating backup of current database: ${backupCurrentDb}`); + await CommandExecutorUtils.runCommand( + `cp "${dbPath}" "${backupCurrentDb}"` + ); + + // Replace the current database with the one from the backup + console.log(`Replacing database with backup...`); + await CommandExecutorUtils.runCommand( + `cp "${dataDbPath}" "${dbPath}"` + ); + + console.log(`Database restored successfully from backup.`); + console.log(`Previous database backed up to: ${backupCurrentDb}`); + + } finally { + // Clean up extracted files + if (await FsUtils.fileExists(extractPath)) { + await CommandExecutorUtils.runCommand( + `rm -rf "${extractPath}"` + ); + console.log(`Cleaned up temporary extraction directory: ${extractPath}`); + } + } + } } const systemBackupService = new SystemBackupService(); diff --git a/src/server/services/traefik.service.ts b/src/server/services/traefik.service.ts new file mode 100644 index 00000000..2c7b4314 --- /dev/null +++ b/src/server/services/traefik.service.ts @@ -0,0 +1,93 @@ +import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; +import { ServiceException } from "@/shared/model/service.exception.model"; +import k3s from "../adapter/kubernetes-api.adapter"; + +class TraefikService { + private readonly TRAEFIK_NAMESPACE = 'kube-system'; + private readonly TRAEFIK_NAME = 'traefik'; + + async getStatus(): Promise { + const [serviceRes, deploymentRes] = await Promise.all([ + k3s.core.readNamespacedService(this.TRAEFIK_NAME, this.TRAEFIK_NAMESPACE), + k3s.apps.readNamespacedDeployment(this.TRAEFIK_NAME, this.TRAEFIK_NAMESPACE), + ]); + + const deployment = deploymentRes.body; + const restartedAt = deployment.spec?.template?.metadata?.annotations?.['kubectl.kubernetes.io/restartedAt']; + + return { + externalTrafficPolicy: serviceRes.body.spec?.externalTrafficPolicy as TraefikIpPropagationStatus['externalTrafficPolicy'], + readyReplicas: deployment.status?.readyReplicas ?? 0, + replicas: deployment.status?.replicas ?? deployment.spec?.replicas ?? 0, + restartedAt, + }; + } + + async applyExternalTrafficPolicy(useLocal: boolean): Promise { + await this.patchServicePolicy(useLocal ? 'Local' : 'Cluster'); + await this.restartDeployment(); + await this.waitUntilDeploymentReady(); + return this.getStatus(); + } + + private async patchServicePolicy(policy: 'Local' | 'Cluster') { + await k3s.core.patchNamespacedService( + this.TRAEFIK_NAME, + this.TRAEFIK_NAMESPACE, + { spec: { externalTrafficPolicy: policy } }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/merge-patch+json' } }, + ); + } + + private async restartDeployment() { + const now = new Date().toISOString(); + await k3s.apps.patchNamespacedDeployment( + this.TRAEFIK_NAME, + this.TRAEFIK_NAMESPACE, + { + spec: { + template: { + metadata: { + annotations: { + 'kubectl.kubernetes.io/restartedAt': now, + }, + }, + }, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/merge-patch+json' } }, + ); + } + + private async waitUntilDeploymentReady(timeoutMs = 120000) { + const pollIntervalMs = 3000; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const deployment = await k3s.apps.readNamespacedDeployment(this.TRAEFIK_NAME, this.TRAEFIK_NAMESPACE); + const desiredReplicas = deployment.body.status?.replicas ?? deployment.body.spec?.replicas ?? 0; + const readyReplicas = deployment.body.status?.readyReplicas ?? 0; + + if (desiredReplicas === 0 || readyReplicas >= desiredReplicas) { + return; + } + + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + throw new ServiceException('Timeout while waiting for Traefik pods to become ready after restart.'); + } +} + +const traefikService = new TraefikService(); +export default traefikService; diff --git a/src/server/utils/action-wrapper.utils.ts b/src/server/utils/action-wrapper.utils.ts index 19dcd19b..17cd831d 100644 --- a/src/server/utils/action-wrapper.utils.ts +++ b/src/server/utils/action-wrapper.utils.ts @@ -169,6 +169,53 @@ export async function simpleAction( } as ServerActionResult; } +/** + * Wrapper for server actions that handle file uploads via FormData + * Extracts file from FormData and passes it to the handler function + */ +export async function fileUploadAction( + formData: FormData, + fileFieldName: string, + func: (file: File) => Promise, + redirectOnSuccessPath?: string) { + let funcResult: ReturnType; + try { + const file = formData.get(fileFieldName) as File; + if (!file || !file.size) { + throw new ServiceException('No file uploaded or file is empty.'); + } + funcResult = await func(file); + } catch (ex) { + if (ex instanceof ServiceException) { + return { + status: 'error', + message: ex.message + } as ServerActionResult; + } else { + console.error(ex); + return { + status: 'error', + message: 'An unknown error occurred during file upload.' + } as ServerActionResult; + } + } + if (redirectOnSuccessPath) redirect(redirectOnSuccessPath); + + if (funcResult instanceof ServerActionResult) { + return { + status: funcResult.status, + message: funcResult.message, + errors: funcResult.errors, + data: funcResult.data + } as ServerActionResult; + } + return { + status: 'success', + data: funcResult ?? undefined + } as ServerActionResult; +} + + export async function simpleRoute( func: () => Promise) { diff --git a/src/shared/model/app-template.model.ts b/src/shared/model/app-template.model.ts index 9a926a14..63cda266 100644 --- a/src/shared/model/app-template.model.ts +++ b/src/shared/model/app-template.model.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, RelatedAppDomainModel, RelatedAppPortModel, RelatedAppVolumeModel } from "./generated-zod"; import { appSourceTypeZodModel, appTypeZodModel } from "./app-source-info.model"; -import { appVolumeTypeZodModel } from "./volume-edit.model"; +import { appVolumeTypeZodModel, storageClassNameZodModel } from "./volume-edit.model"; const appModelWithRelations = z.lazy(() => AppModel.extend({ projectId: z.undefined(), @@ -28,6 +28,7 @@ export const appTemplateContentZodModel = z.object({ appDomains: AppDomainModel.array(), appVolumes: AppVolumeModel.extend({ accessMode: appVolumeTypeZodModel, + storageClassName: storageClassNameZodModel.default('longhorn'), id: z.undefined(), appId: z.undefined(), createdAt: z.undefined(), @@ -54,4 +55,4 @@ export const appTemplateZodModel = z.object({ templates: appTemplateContentZodModel.array(), }); -export type AppTemplateModel = z.infer; \ No newline at end of file +export type AppTemplateModel = z.infer; diff --git a/src/shared/model/generated-zod/appvolume.ts b/src/shared/model/generated-zod/appvolume.ts index 42e5d5c3..f85522ae 100644 --- a/src/shared/model/generated-zod/appvolume.ts +++ b/src/shared/model/generated-zod/appvolume.ts @@ -7,6 +7,7 @@ export const AppVolumeModel = z.object({ containerMountPath: z.string(), size: z.number().int(), accessMode: z.string(), + storageClassName: z.string(), appId: z.string(), createdAt: z.date(), updatedAt: z.date(), diff --git a/src/shared/model/traefik-ip-propagation.model.ts b/src/shared/model/traefik-ip-propagation.model.ts new file mode 100644 index 00000000..58f1b8c2 --- /dev/null +++ b/src/shared/model/traefik-ip-propagation.model.ts @@ -0,0 +1,6 @@ +export type TraefikIpPropagationStatus = { + externalTrafficPolicy?: 'Local' | 'Cluster'; + readyReplicas: number; + replicas: number; + restartedAt?: string | null; +}; diff --git a/src/shared/model/volume-edit.model.ts b/src/shared/model/volume-edit.model.ts index 7245477e..0c23dc36 100644 --- a/src/shared/model/volume-edit.model.ts +++ b/src/shared/model/volume-edit.model.ts @@ -2,11 +2,13 @@ import { stringToNumber } from "@/shared/utils/zod.utils"; import { z } from "zod"; export const appVolumeTypeZodModel = z.enum(["ReadWriteOnce", "ReadWriteMany"]); +export const storageClassNameZodModel = z.enum(["longhorn", "local-path"]); export const appVolumeEditZodModel = z.object({ containerMountPath: z.string().trim().min(1), size: stringToNumber, - accessMode: appVolumeTypeZodModel.nullish().or(z.string().nullish()), -}) + accessMode: appVolumeTypeZodModel.nullish().or(z.string().nullish()), + storageClassName: storageClassNameZodModel.default("longhorn"), +}); -export type AppVolumeEditModel = z.infer; \ No newline at end of file +export type AppVolumeEditModel = z.infer;