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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
5 changes: 3 additions & 2 deletions src/app/project/[projectId]/project-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,8 +26,8 @@ export default function ProjectOverview({ apps, session, projectId }: ProjectOve
return (
<Tabs value={currentTab} onValueChange={handleTabChange} className="w-full">
<TabsList>
<TabsTrigger value="table">Table View</TabsTrigger>
<TabsTrigger value="graph">Network Graph</TabsTrigger>
<TabsTrigger value="table"><Table className="mr-2 h-4 w-4" />Table View</TabsTrigger>
<TabsTrigger value="graph"><Network className="mr-2 h-4 w-4" />Network Graph</TabsTrigger>
</TabsList>
<TabsContent value="table">
<AppTable session={session} app={apps} projectId={projectId} />
Expand Down
26 changes: 15 additions & 11 deletions src/app/project/app/[appId]/app-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,13 +51,13 @@ export default function AppTabs({
<Tabs defaultValue="general" value={tabName} onValueChange={(newTab) => openTab(newTab)} className="space-y-4">
<ScrollArea>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
{app.appType !== 'APP' && <TabsTrigger value="credentials">Credentials</TabsTrigger>}
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="storage">Storage</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
<TabsTrigger value="overview"><Eye className="mr-2 h-4 w-4" />Overview</TabsTrigger>
{app.appType !== 'APP' && <TabsTrigger value="credentials"><Key className="mr-2 h-4 w-4" />Credentials</TabsTrigger>}
<TabsTrigger value="general"><Settings className="mr-2 h-4 w-4" />General</TabsTrigger>
<TabsTrigger value="environment"><Zap className="mr-2 h-4 w-4" />Environment</TabsTrigger>
<TabsTrigger value="domains"><Globe className="mr-2 h-4 w-4" />Domains</TabsTrigger>
<TabsTrigger value="storage"><HardDrive className="mr-2 h-4 w-4" />Storage</TabsTrigger>
<TabsTrigger value="advanced"><Cog className="mr-2 h-4 w-4" />Advanced</TabsTrigger>
</TabsList>
<ScrollBar orientation="horizontal" />
</ScrollArea>
Expand All @@ -79,7 +83,7 @@ export default function AppTabs({
<InternalHostnames readonly={readonly} app={app} />
</TabsContent>
<TabsContent value="storage" className="space-y-4">
<StorageList readonly={readonly} app={app} />
<StorageList readonly={readonly} app={app} nodesInfo={nodesInfo} />
<FileMount readonly={readonly} app={app} />
<VolumeBackupList
readonly={readonly}
Expand Down
7 changes: 5 additions & 2 deletions src/app/project/app/[appId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import AppBreadcrumbs from "./app-breadcrumbs";
import s3TargetService from "@/server/services/s3-target.service";
import volumeBackupService from "@/server/services/volume-backup.service";
import { UserGroupUtils } from "@/shared/utils/role.utils";
import clusterService from "@/server/services/node.service";

export default async function AppPage({
searchParams,
Expand All @@ -19,10 +20,11 @@ export default async function AppPage({
}
const session = await isAuthorizedReadForApp(appId);
const role = UserGroupUtils.getRolePermissionForApp(session, appId);
const [app, s3Targets, volumeBackups] = await Promise.all([
const [app, s3Targets, volumeBackups, nodesInfo] = await Promise.all([
appService.getExtendedById(appId),
s3TargetService.getAll(),
volumeBackupService.getForApp(appId)
volumeBackupService.getForApp(appId),
clusterService.getNodeInfo()
]);

return (<>
Expand All @@ -31,6 +33,7 @@ export default async function AppPage({
volumeBackups={volumeBackups}
s3Targets={s3Targets}
app={app}
nodesInfo={nodesInfo}
tabName={searchParams?.tabName ?? 'overview'} />
<AppBreadcrumbs app={app} />
</>
Expand Down
11 changes: 9 additions & 2 deletions src/app/project/app/[appId]/volumes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,20 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
if (existingVolume && existingVolume.size > 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
});
});

Expand Down Expand Up @@ -208,4 +215,4 @@ async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) {
}
});
await isAuthorizedWriteForApp(volumeAppId?.volume.appId);
}
}
109 changes: 102 additions & 7 deletions src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(false);

Expand All @@ -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',
}
});

Expand All @@ -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 (
Expand Down Expand Up @@ -207,6 +223,88 @@ export default function DialogEditDialog({ children, volume, app }: { children:
</FormItem>
)}
/>
{nodesInfo.length === 1 &&
<FormField
control={form.control}
name="storageClassName"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="flex gap-2">
<div>Storage Class</div>
<div className="self-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild><QuestionMarkCircledIcon /></TooltipTrigger>
<TooltipContent>
<p className="max-w-[350px]">
Choose where the volume is provisioned.<br /><br />
<b>Longhorn</b> keeps data replicated across nodes.<br />
<b>Local Path</b> stores data on a the master node and works only in single-node clusters.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
disabled={!!volume}
>
{field.value
? storageClasses.find(
(storageClass) => storageClass.value === field.value
)?.label
: "Select storage class"}
<ChevronsUpDown className="opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="max-w-[280px] p-0">
<Command>
<CommandList>
<CommandGroup>
{storageClasses.map((storageClass) => (
<CommandItem
value={storageClass.label}
key={storageClass.value}
onSelect={() => {
form.setValue("storageClassName", storageClass.value);
}}
>
<div className="flex flex-col gap-1">
<span>{storageClass.label}</span>
<span className="text-xs text-muted-foreground">{storageClass.description}</span>
</div>
<Check
className={cn(
"ml-auto",
storageClass.value === field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This cannot be changed after creation.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>}
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
Expand All @@ -216,7 +314,4 @@ export default function DialogEditDialog({ children, volume, app }: { children:
</Dialog>
</>
)



}
}
12 changes: 8 additions & 4 deletions src/app/project/app/[appId]/volumes/storages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) {

Expand Down Expand Up @@ -150,6 +152,7 @@ export default function StorageList({ app, readonly }: {
<TableHead>Mount Path</TableHead>
<TableHead>Storage Size</TableHead>
<TableHead>Storage Used</TableHead>
<TableHead>Storage Class</TableHead>
<TableHead>Access Mode</TableHead>
<TableHead className="w-[100px]">Action</TableHead>
</TableRow>
Expand All @@ -168,6 +171,7 @@ export default function StorageList({ app, readonly }: {
</div>
</>}
</TableCell>
<TableCell className="font-medium capitalize">{volume.storageClassName?.replace('-', ' ')}</TableCell>
<TableCell className="font-medium">{volume.accessMode}</TableCell>
<TableCell className="font-medium flex gap-2">
<TooltipProvider>
Expand Down Expand Up @@ -209,7 +213,7 @@ export default function StorageList({ app, readonly }: {
</TooltipProvider>
</StorageRestoreDialog>*/}
{!readonly && <>
<DialogEditDialog app={app} volume={volume}>
<DialogEditDialog app={app} volume={volume} nodesInfo={nodesInfo}>
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger>
Expand Down Expand Up @@ -241,10 +245,10 @@ export default function StorageList({ app, readonly }: {
</Table>
</CardContent>
{!readonly && <CardFooter>
<DialogEditDialog app={app}>
<DialogEditDialog app={app} nodesInfo={nodesInfo}>
<Button>Add Volume</Button>
</DialogEditDialog>
</CardFooter>}
</Card >
</>;
}
}
20 changes: 19 additions & 1 deletion src/app/settings/cluster/actions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
'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 () => {
await getAdminUserSession();
await clusterService.setNodeStatus(nodeName, schedulable);
return new SuccessActionResult(undefined, 'Successfully updated node status.');
});

export const applyTraefikIpPropagation = async (enableIpPreservation: boolean) =>
simpleAction<TraefikIpPropagationStatus, TraefikIpPropagationStatus>(async () => {
await getAdminUserSession();
const updatedStatus = await traefikService.applyExternalTrafficPolicy(enableIpPreservation);
return new SuccessActionResult<TraefikIpPropagationStatus>(
updatedStatus,
`Traefik externalTrafficPolicy set to ${enableIpPreservation ? 'Local' : 'Cluster'}.`,
);
});

export const getTraefikIpPropagationStatus = async () =>
simpleAction<TraefikIpPropagationStatus, TraefikIpPropagationStatus>(async () => {
await getAdminUserSession();
return traefikService.getStatus();
});
Loading