From 3ec1a5f6f1ba712bb2369a0ddebbfd4552848049 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Fri, 18 Sep 2026 09:51:16 +0000 Subject: [PATCH 01/25] feat: added settings tab in project-overview drawer to edit app settings and migrated components from classic view so that they also work in the drawer view --- src/app/layout.tsx | 2 +- .../app-components/project-network-graph.tsx | 14 + .../drawer/drawer-environment.tsx | 123 +++++++ .../drawer/drawer-overview.tsx | 78 +++++ .../drawer/drawer-settings.tsx | 301 ++++++++++++++++++ .../drawer/nested-drawer.tsx | 88 +++++ .../drawer/settings-section.tsx | 29 ++ .../node-details-drawer.tsx | 130 +++----- .../app-components/project-overview.tsx | 16 +- src/app/project/[projectId]/page.tsx | 27 +- .../advanced/basic-auth-edit-dialog.tsx | 36 +-- .../app/[appId]/advanced/basic-auth.tsx | 50 ++- .../advanced/health-check-settings.tsx | 16 +- .../app/[appId]/advanced/network-policy.tsx | 13 +- .../[appId]/domains/node-port-edit-dialog.tsx | 37 +-- .../app/[appId]/domains/node-ports.tsx | 49 ++- .../app/[appId]/environment/env-edit.tsx | 16 +- .../[appId]/general/app-container-config.tsx | 16 +- .../app/[appId]/general/app-rate-limits.tsx | 37 ++- .../app/[appId]/general/app-source.tsx | 30 +- .../project/app/[appId]/volumes/storages.tsx | 15 +- .../app/[appId]/volumes/volume-backup.tsx | 18 +- src/components/custom/domains-card.tsx | 16 +- src/components/custom/file-mounts-card.tsx | 15 +- src/server/utils/env-var.utils.ts | 22 +- src/shared/utils/env-var.utils.ts | 36 +++ 26 files changed, 958 insertions(+), 272 deletions(-) create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx create mode 100644 src/shared/utils/env-var.utils.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f8496cc4..2f85a2a2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -50,7 +50,7 @@ export default async function RootLayout({
-
+
{userIsLoggedIn && } }> diff --git a/src/app/project/[projectId]/app-components/project-network-graph.tsx b/src/app/project/[projectId]/app-components/project-network-graph.tsx index 099cc308..9294a5d7 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -45,6 +45,8 @@ import { saveAppNetworkPolicyConfiguration } from '@/app/project/app/[appId]/adv import { deleteApp } from '@/app/project/[projectId]/actions'; import { EditAppDialog } from './edit-app-dialog'; import type { ProjectNetworkGraphPositions } from '@/shared/model/project-network-graph-layout.model'; +import type { S3Target } from '@prisma/client'; +import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; const hiddenHandleClassName = 'size-1.5! border-0! bg-transparent! opacity-0! pointer-events-none'; const connectionSourceHandleClassName = 'size-3! border-2! border-background! bg-qs-500! opacity-0! shadow-md! transition-all duration-150 group-hover:opacity-100! [&.connectingfrom]:opacity-0! hover:bg-qs-600!'; @@ -63,6 +65,10 @@ type ProjectNetworkGraphProps = { projectId: string; session: UserSession; savedPositions: ProjectNetworkGraphPositions; + s3Targets: S3Target[]; + storageClasses: string[]; + volumeBackupsByApp: Record; + gitSshPublicKeysByApp: Record; }; const WorkloadNode = memo(function WorkloadNode({ @@ -142,6 +148,10 @@ function ProjectNetworkGraphEditor({ projectId, session, savedPositions, + s3Targets, + storageClasses, + volumeBackupsByApp, + gitSshPublicKeysByApp, }: ProjectNetworkGraphProps) { const router = useRouter(); const { openDialog } = useDialog(); @@ -534,6 +544,10 @@ function ProjectNetworkGraphEditor({ app={selectedApp} role={selectedAppRole} connections={selectedConnections} + s3Targets={s3Targets} + storageClasses={storageClasses} + volumeBackups={selectedApp ? (volumeBackupsByApp[selectedApp.id] ?? []) : []} + gitSshPublicKey={selectedApp ? gitSshPublicKeysByApp[selectedApp.id] : undefined} open={isNodeDrawerOpen} onOpenChange={setIsNodeDrawerOpen} onOpenChangeComplete={open => { diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx new file mode 100644 index 00000000..b472faa8 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { Copy, Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import type { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { EnvVarUtils } from '@/shared/utils/env-var.utils'; +import { toast } from 'sonner'; + +type EnvironmentVariable = { + name: string; + value: string; +}; + +function VariableList({ + title, + variables, +}: { + title: string; + variables: EnvironmentVariable[]; +}) { + const [visibleValues, setVisibleValues] = useState>(new Set()); + + return ( +
+

{title}

+ {variables.length === 0 ? ( +

None configured.

+ ) : ( +
+ {variables.map((variable, index) => { + const variableId = `${variable.name}-${index}`; + const isVisible = visibleValues.has(variableId); + + return ( +
+
+ + {variable.name} + + +
+ + {isVisible ? variable.value : '••••••••'} + + +
+ ); + })} +
+ )} +
+ ); +} + +export function DrawerEnvironment({ + app, + onEdit, +}: { + app: AppExtendedModel; + onEdit: () => void; +}) { + return ( +
+ + + +
+ ); +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx new file mode 100644 index 00000000..314cd193 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { ExternalLink } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Item, ItemActions, ItemContent, ItemGroup, ItemTitle } from '@/components/ui/item'; +import { Separator } from '@/components/ui/separator'; +import { TabsContent } from '@/components/ui/tabs'; +import type { AppExtendedModel } from '@/shared/model/app-extended.model'; + +export function DrawerOverview({ + app, + connectionsContent, + externalUrl, +}: { + app: AppExtendedModel; + connectionsContent: ReactNode; + externalUrl?: string; +}) { + return ( + + + + + + Image + + + + {app.sourceType === 'CONTAINER' + ? (app.containerImageSource ?? 'Not configured') + : (app.gitUrl ?? 'Not configured')} + + + + + + Replicas + + + {app.replicas} + + + + + Project + + + {app.project.name} + + {externalUrl && ( + + + + External URL + + + + + {externalUrl} + + + + + )} + + +
+

Network Policies

+ {connectionsContent} +
+
+ ); +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx new file mode 100644 index 00000000..f908b1ce --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx @@ -0,0 +1,301 @@ +'use client'; + +import { + Boxes, + Globe2, + HardDrive, + Key, + Network, + SlidersHorizontal, + Zap, +} from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/frontend/utils/utils'; +import BasicAuth from '@/app/project/app/[appId]/advanced/basic-auth'; +import { saveHealthCheck } from '@/app/project/app/[appId]/advanced/actions'; +import HealthCheckSettings from '@/app/project/app/[appId]/advanced/health-check-settings'; +import NetworkPolicy from '@/app/project/app/[appId]/advanced/network-policy'; +import NodePortsCard from '@/app/project/app/[appId]/domains/node-ports'; +import EnvEdit from '@/app/project/app/[appId]/environment/env-edit'; +import GeneralAppContainerConfig from '@/app/project/app/[appId]/general/app-container-config'; +import GeneralAppRateLimits from '@/app/project/app/[appId]/general/app-rate-limits'; +import GeneralAppSource from '@/app/project/app/[appId]/general/app-source'; +import DbCredentials from '@/app/project/app/[appId]/credentials/db-crendentials'; +import DbToolsCard from '@/app/project/app/[appId]/credentials/db-tools'; +import StorageList from '@/app/project/app/[appId]/volumes/storages'; +import VolumeBackupList from '@/app/project/app/[appId]/volumes/volume-backup'; +import DomainsCard from '@/components/custom/domains-card'; +import FileMountsCard from '@/components/custom/file-mounts-card'; +import type { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; +import type { S3Target } from '@prisma/client'; +import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; +import { SettingsSection } from './settings-section'; +import { DrawerEnvironment } from './drawer-environment'; +import { useNestedDrawer } from './nested-drawer'; + +export function DrawerSettings({ + app, + role, + s3Targets, + storageClasses, + volumeBackups, + gitSshPublicKey, +}: { + app: AppExtendedModel; + role: RolePermissionEnum; + s3Targets: S3Target[]; + storageClasses: string[]; + volumeBackups: VolumeBackupExtendedModel[]; + gitSshPublicKey?: string; +}) { + const readonly = role !== RolePermissionEnum.READWRITE; + const { openNestedDrawer } = useNestedDrawer(); + const settingsSections = useMemo( + () => [ + ...(app.appType !== 'APP' + ? [{ id: 'credentials', label: 'Credentials' }] + : []), + { id: 'source', label: 'Source' }, + { id: 'deployment', label: 'Deployment' }, + { id: 'environment', label: 'Environment' }, + { id: 'networking', label: 'Networking' }, + { id: 'storage', label: 'Storage' }, + { id: 'advanced', label: 'Advanced' }, + ], + [app.appType], + ); + const [activeSection, setActiveSection] = useState(settingsSections[0].id); + + useEffect(() => { + const sections = settingsSections + .map((section) => document.getElementById(section.id)) + .filter((section): section is HTMLElement => section !== null); + const scrollArea = sections[0]?.closest( + '[data-slot="scroll-area-viewport"]', + ); + const observer = new IntersectionObserver( + (entries) => { + const visibleSection = entries + .filter((entry) => entry.isIntersecting) + .sort( + (left, right) => + left.boundingClientRect.top - + right.boundingClientRect.top, + )[0]; + + if (visibleSection) { + setActiveSection(visibleSection.target.id); + } + }, + { + root: scrollArea, + rootMargin: '-5% 0px -70% 0px', + threshold: 0, + }, + ); + + sections.forEach((section) => observer.observe(section)); + return () => observer.disconnect(); + }, [settingsSections]); + + return ( +
+
+ {app.appType !== 'APP' && ( + + + + )} + + + + + + + + + + openNestedDrawer({ + title: 'Environment variables', + content: ( + + ), + }) + } + /> + + + + + + + + + + + + + + + +
+ +
+ ); +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx new file mode 100644 index 00000000..bad2b064 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { ArrowLeft } from 'lucide-react'; +import { + createContext, + useCallback, + useContext, + useState, + type ReactNode, +} from 'react'; +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/drawer'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +type NestedDrawerOptions = { + title: string; + content: ReactNode; +}; + +type NestedDrawerContextValue = { + openNestedDrawer: (options: NestedDrawerOptions) => void; + closeNestedDrawer: () => void; +}; + +const NestedDrawerContext = createContext(null); + +export function NestedDrawerProvider({ children }: { children: ReactNode }) { + const [drawer, setDrawer] = useState(null); + const closeNestedDrawer = useCallback(() => setDrawer(null), []); + const openNestedDrawer = useCallback( + (options: NestedDrawerOptions) => setDrawer(options), + [], + ); + + return ( + + {children} + !open && closeNestedDrawer()} + > + + + + + {drawer?.title} + + + +
+ {drawer?.content} +
+
+
+
+
+ ); +} + +export function useNestedDrawer() { + const context = useContext(NestedDrawerContext); + + if (!context) { + throw new Error( + 'useNestedDrawer must be used within a NestedDrawerProvider.', + ); + } + + return context; +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx new file mode 100644 index 00000000..9abd128d --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx @@ -0,0 +1,29 @@ +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +export function SettingsSection({ + id, + title, + icon: Icon, + children, +}: { + id: string; + title: string; + icon: LucideIcon; + children: ReactNode; +}) { + return ( +
+
+ +
+

{title}

+
+ {children} +
+
+ ); +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx index 675e3071..11ed73be 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, type Ref } from 'react'; +import { type Ref } from 'react'; import { ArrowDown, @@ -18,6 +18,7 @@ import { Play, Rocket, ScrollText, + Settings, Square, X, } from 'lucide-react'; @@ -38,7 +39,6 @@ import { ItemTitle, } from '@/components/ui/item'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; import { Drawer, DrawerContent, @@ -50,7 +50,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import PodStatusIndicator from '@/components/custom/pod-status-indicator'; import { deploy, startApp, stopApp } from '@/app/project/app/[appId]/actions'; -import { useDialog, usePodsStatus } from '@/frontend/states/zustand.states'; +import { usePodsStatus } from '@/frontend/states/zustand.states'; import { cn } from '@/frontend/utils/utils'; import { AppSourceUtils } from '@/frontend/utils/app-source.utils'; import { Toast } from '@/frontend/utils/toast.utils'; @@ -62,6 +62,11 @@ import MonitoringTab from '@/app/project/app/[appId]/overview/monitoring-app'; import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; import type { NetworkGraphNode } from './project-network-graph-projection'; import GeneralAppSource from '@/app/project/app/[appId]/general/app-source'; +import { DrawerOverview } from './drawer/drawer-overview'; +import { DrawerSettings } from './drawer/drawer-settings'; +import { NestedDrawerProvider } from './drawer/nested-drawer'; +import type { S3Target } from '@prisma/client'; +import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; export type PanelConnection = { id: string; @@ -168,6 +173,10 @@ export function NodeDetailsDrawer({ app, role, connections, + s3Targets, + storageClasses, + volumeBackups, + gitSshPublicKey, open, onOpenChange, onOpenChangeComplete, @@ -178,12 +187,15 @@ export function NodeDetailsDrawer({ app?: AppExtendedModel; role?: RolePermissionEnum; connections: PanelConnection[]; + s3Targets: S3Target[]; + storageClasses: string[]; + volumeBackups: VolumeBackupExtendedModel[]; + gitSshPublicKey?: string; open: boolean; onOpenChange: (open: boolean) => void; onOpenChangeComplete: (open: boolean) => void; onOpen: () => void; }) { - const isDialogOpen = useDialog((state) => state.isDialogOpen); const isApp = node.kind === 'APP'; const needsSourceConfiguration = app && @@ -264,12 +276,6 @@ export function NodeDetailsDrawer({ ? `${externalDomain.useSsl ? 'https' : 'http'}://${externalDomain.hostname}` : undefined; - useEffect(() => { - if (open && isDialogOpen) { - onOpenChange(false); - } - }, [isDialogOpen, onOpenChange, open]); - return ( + )} + ); diff --git a/src/app/project/[projectId]/app-components/project-overview.tsx b/src/app/project/[projectId]/app-components/project-overview.tsx index fe8d4450..f68cf6a9 100644 --- a/src/app/project/[projectId]/app-components/project-overview.tsx +++ b/src/app/project/[projectId]/app-components/project-overview.tsx @@ -16,6 +16,8 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model"; import type { ProjectNetworkGraphPositions } from '@/shared/model/project-network-graph-layout.model'; import { useDialog } from '@/frontend/states/zustand.states'; import NewNetworkPolicyExplanationDialog from './new-network-policy-explanation-dialog'; +import type { S3Target } from '@prisma/client'; +import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; interface ProjectOverviewProps { apps: AppExtendedModel[]; @@ -24,6 +26,10 @@ interface ProjectOverviewProps { projectName: string; networkGraphPositions: ProjectNetworkGraphPositions; showNewNetworkPolicyExplanation: boolean; + s3Targets: S3Target[]; + storageClasses: string[]; + volumeBackupsByApp: Record; + gitSshPublicKeysByApp: Record; } type ProjectOverviewTab = 'table' | 'graph'; @@ -43,6 +49,10 @@ export default function AppProjectOverview({ projectName, networkGraphPositions, showNewNetworkPolicyExplanation, + s3Targets, + storageClasses, + volumeBackupsByApp, + gitSshPublicKeysByApp, }: ProjectOverviewProps) { const searchParams = useSearchParams(); const { openDialog } = useDialog(); @@ -138,12 +148,16 @@ export default function AppProjectOverview({ - + diff --git a/src/app/project/[projectId]/page.tsx b/src/app/project/[projectId]/page.tsx index f40b2109..ed7bdb3b 100644 --- a/src/app/project/[projectId]/page.tsx +++ b/src/app/project/[projectId]/page.tsx @@ -13,6 +13,10 @@ import AgentListClient from "./agent-components/agent-table"; import projectNetworkGraphLayoutService from '@/server/services/project-network-graph-layout.service'; import { ensureReadProject, RequesterIdentity } from '@/server/utils/shared-authorization.utils'; import paramService, { ParamService } from '@/server/services/param.service'; +import s3TargetService from '@/server/services/s3-target.service'; +import volumeBackupService from '@/server/services/volume-backup.service'; +import clusterService from '@/server/services/cluster.service'; +import appGitSshKeyService from '@/server/services/app-git-ssh-key.service'; export default async function AppsPage({ params @@ -53,9 +57,26 @@ export default async function AppsPage({ const data = await appService.getAllAppsByProjectId(projectId); const relevantApps = data.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.id)); - const [networkGraphPositions, hasAcknowledgedNewNetworkPolicyExplanation] = await Promise.all([ + const [ + networkGraphPositions, + hasAcknowledgedNewNetworkPolicyExplanation, + s3Targets, + storageClasses, + volumeBackups, + gitSshPublicKeys, + ] = await Promise.all([ projectNetworkGraphLayoutService.getPositions(projectId), paramService.getBoolean(ParamService.FEATURE_NEW_NETWORK_POLICY_EXPLENATION), + s3TargetService.getAll(), + clusterService.getStorageClasses(), + Promise.all(relevantApps.map(async (app) => [ + app.id, + await volumeBackupService.getForApp(app.id), + ] as const)), + Promise.all(relevantApps.map(async (app) => [ + app.id, + await appGitSshKeyService.getPublicKey(app.id), + ] as const)), ]); return ( @@ -67,6 +88,10 @@ export default async function AppsPage({ projectName={project.name} networkGraphPositions={networkGraphPositions} showNewNetworkPolicyExplanation={!hasAcknowledgedNewNetworkPolicyExplanation} + s3Targets={s3Targets} + storageClasses={storageClasses} + volumeBackupsByApp={Object.fromEntries(volumeBackups)} + gitSshPublicKeysByApp={Object.fromEntries(gitSshPublicKeys)} />
diff --git a/src/app/project/app/[appId]/advanced/basic-auth-edit-dialog.tsx b/src/app/project/app/[appId]/advanced/basic-auth-edit-dialog.tsx index 26507e1b..f6d4a2e2 100644 --- a/src/app/project/app/[appId]/advanced/basic-auth-edit-dialog.tsx +++ b/src/app/project/app/[appId]/advanced/basic-auth-edit-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Form, FormControl, @@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { useActionState, useEffect, useState } from "react"; +import { useActionState, useEffect } from "react"; import { FormUtils } from "@/frontend/utils/form.utilts"; import { SubmitButton } from "@/components/custom/submit-button"; import { AppBasicAuth } from "@prisma/client" @@ -23,19 +23,18 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model" import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model" import { saveBasicAuth } from "./actions" import { z } from "zod" +import { useDialog } from '@/frontend/states/zustand.states'; export default function BasicAuthEditDialog({ - children, basicAuth, app }: { - children: React.ReactNode; basicAuth?: AppBasicAuth; app: AppExtendedModel; }) { - const [isOpen, setIsOpen] = useState(false); + const { closeDialog } = useDialog(); const form = useForm, unknown, z.output>({ resolver: zodResolver(basicAuthEditZodModel), defaultValues: { @@ -57,10 +56,10 @@ export default function BasicAuthEditDialog({ toast.success('Authentication information saved successfully', { description: "Click \"deploy\" to apply the changes to your app.", }); - setIsOpen(false); + closeDialog(); } FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); + }, [closeDialog, form, state]); useEffect(() => { form.reset(basicAuth); @@ -68,18 +67,13 @@ export default function BasicAuthEditDialog({ return ( <> -
setIsOpen(true)}> - {children} -
- setIsOpen(false)}> - - - Basic Authentication - - Configure basic authentication to secure your app. - - -
+ + Basic Authentication + + Configure basic authentication to secure your app. + + + form.handleSubmit((data) => { return formAction(data); }, console.error)()}> @@ -116,9 +110,7 @@ export default function BasicAuthEditDialog({ Save
- - - + ) diff --git a/src/app/project/app/[appId]/advanced/basic-auth.tsx b/src/app/project/app/[appId]/advanced/basic-auth.tsx index 76520bc5..405333fc 100644 --- a/src/app/project/app/[appId]/advanced/basic-auth.tsx +++ b/src/app/project/app/[appId]/advanced/basic-auth.tsx @@ -6,22 +6,23 @@ import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, Tabl import { Button } from "@/components/ui/button"; import { EditIcon, Eye, TrashIcon } from "lucide-react"; import { Toast } from "@/frontend/utils/toast.utils"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; import React from "react"; -import FileMountEditDialog from "./basic-auth-edit-dialog"; import BasicAuthEditDialog from "./basic-auth-edit-dialog"; import { deleteBasicAuth } from "./actions"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -export default function BasicAuth({ app, readonly }: { +export default function BasicAuth({ app, readonly, hideCard = false }: { app: AppExtendedModel; readonly: boolean; + hideCard?: boolean; }) { - const { openConfirmDialog: openDialog } = useConfirmDialog(); + const { openConfirmDialog } = useConfirmDialog(); + const { openDialog } = useDialog(); const asyncDelete = async (volumeId: string) => { - const confirm = await openDialog({ + const confirm = await openConfirmDialog({ title: "Delete Auth Credential", description: "Are you sure you want to remove this auth credential? The changes will take effect, after you deploy the app. ", okButton: "Delete Auth Credential", @@ -30,14 +31,15 @@ export default function BasicAuth({ app, readonly }: { await Toast.fromAction(() => deleteBasicAuth(volumeId)); } }; + const CardWrapper = hideCard ? 'div' : Card; return <> - + Basic Authentication Configure basic authentication for your app. This will add a basic authentication layer in front of your app. - + {app.appBasicAuths.length > 0 && {app.appBasicAuths.length} Auth Credentials @@ -66,9 +68,18 @@ export default function BasicAuth({ app, readonly }: { {!readonly && - - - + @@ -77,12 +88,17 @@ export default function BasicAuth({ app, readonly }: { ))}
-
- {!readonly && - - - +
} + {!readonly && + } -
+ ; -} \ No newline at end of file +} diff --git a/src/app/project/app/[appId]/advanced/health-check-settings.tsx b/src/app/project/app/[appId]/advanced/health-check-settings.tsx index 92137d8e..1aa58280 100644 --- a/src/app/project/app/[appId]/advanced/health-check-settings.tsx +++ b/src/app/project/app/[appId]/advanced/health-check-settings.tsx @@ -19,10 +19,11 @@ import { SubmitButton } from "@/components/custom/submit-button"; import { FormUtils } from "@/frontend/utils/form.utilts"; import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -export default function HealthCheckSettings({ workload, readonly, saveHealthCheck }: { +export default function HealthCheckSettings({ workload, readonly, saveHealthCheck, hideCard = false }: { workload: HealthCheckWorkload; readonly: boolean; saveHealthCheck: (state: ServerActionResult, payload: HealthCheckModel) => Promise>; + hideCard?: boolean; }) { const defaultHeaders = workload.healthCheckHttpHeadersJson @@ -71,20 +72,21 @@ export default function HealthCheckSettings({ workload, readonly, saveHealthChec } FormUtils.mapValidationErrorsToForm(state, form); }, [form, state]); + const CardWrapper = hideCard ? 'div' : Card; return ( - - + + {!hideCard && Health Check Settings Configure healthchecks so that k3s can automatically monitor when your application is fully started up and ready to receive traffic (In kubernetes terms, startup, readiness and liveness probes). - + }
form.handleSubmit((data) => { formAction(data); })()}> - + )} - + Save -
+ ); } diff --git a/src/app/project/app/[appId]/advanced/network-policy.tsx b/src/app/project/app/[appId]/advanced/network-policy.tsx index e660659c..04f01a07 100644 --- a/src/app/project/app/[appId]/advanced/network-policy.tsx +++ b/src/app/project/app/[appId]/advanced/network-policy.tsx @@ -22,7 +22,7 @@ import NetworkPolicyGraph from './network-policy-graph'; type Project = { id: string; name: string; apps: { id: string; name: string; appType: AppExtendedModel['appType'] }[]; agents: { id: string; name: string }[] }; -export default function NetworkPolicy({ app, readonly }: { app: AppExtendedModel; readonly: boolean }) { +export default function NetworkPolicy({ app, readonly, hideCard = false }: { app: AppExtendedModel; readonly: boolean; hideCard?: boolean }) { const router = useRouter(); const [draft, setDraft] = useState(() => AppNetworkPolicyDraftUtils.fromApp(app)); const [baseline, setBaseline] = useState(() => AppNetworkPolicyDraftUtils.fromApp(app)); @@ -94,10 +94,11 @@ export default function NetworkPolicy({ app, readonly }: { app: AppExtendedModel ))} onAdd={addRule} />, { maxWidth: 'max-w-md' }); + const CardWrapper = hideCard ? 'div' : Card; - return - Network PolicyControl which traffic can reach this app and where it can connect. - + return + {!hideCard && Network PolicyControl which traffic can reach this app and where it can connect.} + setDraft(current => ({ ...current, useNetworkPolicy }))} /> @@ -118,10 +119,10 @@ export default function NetworkPolicy({ app, readonly }: { app: AppExtendedModel } - {!readonly && dirty && + {!readonly && dirty && } - ; + ; } function SettingRow({ label, description, checked, disabled, onChange, hint }: { label: string; description: string; checked: boolean; disabled: boolean; onChange: (checked: boolean) => void; hint?: string }) { diff --git a/src/app/project/app/[appId]/domains/node-port-edit-dialog.tsx b/src/app/project/app/[appId]/domains/node-port-edit-dialog.tsx index a24b405c..d9de00b8 100644 --- a/src/app/project/app/[appId]/domains/node-port-edit-dialog.tsx +++ b/src/app/project/app/[appId]/domains/node-port-edit-dialog.tsx @@ -1,7 +1,7 @@ 'use client' import type { z } from "zod"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Form, FormControl, @@ -15,7 +15,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { useActionState, useEffect, useState } from "react"; +import { useActionState, useEffect } from "react"; import { FormUtils } from "@/frontend/utils/form.utilts"; import { SubmitButton } from "@/components/custom/submit-button"; import { AppNodePort } from "@prisma/client" @@ -23,10 +23,10 @@ import { ServerActionResult } from "@/shared/model/server-action-error-return.mo import { saveNodePort } from "./actions" import { toast } from "sonner" import { NodePortEditModel, nodePortEditZodModel } from "@/shared/model/node-port-edit.model" +import { useDialog } from '@/frontend/states/zustand.states'; -export default function NodePortEditDialog({ children, appNodePort, appId }: { children: React.ReactNode; appNodePort?: AppNodePort; appId: string; }) { - - const [isOpen, setIsOpen] = useState(false); +export default function NodePortEditDialog({ appNodePort, appId }: { appNodePort?: AppNodePort; appId: string; }) { + const { closeDialog } = useDialog(); const form = useForm, unknown, z.output>({ resolver: zodResolver(nodePortEditZodModel), @@ -49,10 +49,10 @@ export default function NodePortEditDialog({ children, appNodePort, appId }: { c toast.success('Node port saved successfully.', { description: 'Click "deploy" to apply the changes to your app.', }); - setIsOpen(false); + closeDialog(); } FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); + }, [closeDialog, form, state]); useEffect(() => { if (appNodePort) { @@ -66,18 +66,13 @@ export default function NodePortEditDialog({ children, appNodePort, appId }: { c return ( <> -
setIsOpen(true)}> - {children} -
- setIsOpen(false)}> - - - {appNodePort ? 'Edit' : 'Add'} Node Port - - Expose this app directly on a host/node port. Changes take effect after redeployment. - - -
+ + {appNodePort ? 'Edit' : 'Add'} Node Port + + Expose this app directly on a host/node port. Changes take effect after redeployment. + + + form.handleSubmit((data) => { return formAction(data); })()}> @@ -137,9 +132,7 @@ export default function NodePortEditDialog({ children, appNodePort, appId }: { c Save
- - - + ); } diff --git a/src/app/project/app/[appId]/domains/node-ports.tsx b/src/app/project/app/[appId]/domains/node-ports.tsx index 556b6477..79bec377 100644 --- a/src/app/project/app/[appId]/domains/node-ports.tsx +++ b/src/app/project/app/[appId]/domains/node-ports.tsx @@ -8,16 +8,18 @@ import NodePortEditDialog from "./node-port-edit-dialog"; import { Button } from "@/components/ui/button"; import { EditIcon, Plus, TrashIcon } from "lucide-react"; import { Toast } from "@/frontend/utils/toast.utils"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; -export default function NodePortsCard({ app, readonly }: { +export default function NodePortsCard({ app, readonly, hideCard = false }: { app: AppExtendedModel; readonly: boolean; + hideCard?: boolean; }) { - const { openConfirmDialog: openDialog } = useConfirmDialog(); + const { openConfirmDialog } = useConfirmDialog(); + const { openDialog } = useDialog(); const asyncDeleteNodePort = async (nodePortId: string) => { - const confirm = await openDialog({ + const confirm = await openConfirmDialog({ title: 'Delete Node Port', description: 'The node port will be removed and the changes will take effect after you redeploy the app. Are you sure you want to remove this node port?', okButton: 'Delete Node Port', @@ -26,16 +28,17 @@ export default function NodePortsCard({ app, readonly }: { await Toast.fromAction(() => deleteNodePort(nodePortId)); } }; + const CardWrapper = hideCard ? 'div' : Card; return ( - - + + Node Ports Expose this app directly on a node/host port, bypassing Traefik. Useful for non-HTTP workloads such as SFTP, game servers, or other TCP/UDP services. - + {app.appNodePorts.length > 0 && {app.appNodePorts.length} Node Port{app.appNodePorts.length !== 1 ? 's' : ''} @@ -54,9 +57,18 @@ export default function NodePortsCard({ app, readonly }: { {np.protocol} {!readonly && ( - - - + @@ -66,14 +78,19 @@ export default function NodePortsCard({ app, readonly }: { ))}
-
+
} {!readonly && ( - - - - + + )} -
+ ); } diff --git a/src/app/project/app/[appId]/environment/env-edit.tsx b/src/app/project/app/[appId]/environment/env-edit.tsx index 1d4a4306..5150153b 100644 --- a/src/app/project/app/[appId]/environment/env-edit.tsx +++ b/src/app/project/app/[appId]/environment/env-edit.tsx @@ -16,9 +16,10 @@ import { Textarea } from "@/components/ui/textarea"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; -export default function EnvEdit({ app, readonly }: { +export default function EnvEdit({ app, readonly, hideCard = false }: { app: AppExtendedModel; readonly: boolean; + hideCard?: boolean; }) { const form = useForm, unknown, z.output>({ resolver: zodResolver(appEnvVariablesZodModel), @@ -37,22 +38,23 @@ export default function EnvEdit({ app, readonly }: { }, [form, state]); const buildArgsEnabled = app.appType === 'APP' && app.buildMethod === 'DOCKERFILE'; + const CardWrapper = hideCard ? 'div' : Card; return <> - - + + {!hideCard && Environment Variables Provide optional environment variables for your application. {app.appType !== 'APP' &&
You should not change ENV variables for databases.
}
-
+
}
form.handleSubmit((data) => { return formAction(data); })()}> - + - {!readonly && + {!readonly && Save } -
+ ; } diff --git a/src/app/project/app/[appId]/general/app-container-config.tsx b/src/app/project/app/[appId]/general/app-container-config.tsx index 8bd0915e..6a8deb49 100644 --- a/src/app/project/app/[appId]/general/app-container-config.tsx +++ b/src/app/project/app/[appId]/general/app-container-config.tsx @@ -23,9 +23,10 @@ import { ContainerCommangArgsUtils } from "@/shared/utils/container-command-args export type AppContainerConfigInputModel = z.infer; -export default function GeneralAppContainerConfig({ app, readonly }: { +export default function GeneralAppContainerConfig({ app, readonly, hideCard = false }: { app: AppExtendedModel; readonly: boolean; + hideCard?: boolean; }) { const inputValue = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? value : ''; // Parse containerArgs from JSON string to array @@ -60,21 +61,22 @@ export default function GeneralAppContainerConfig({ app, readonly }: { }, [form, state]); const values = form.watch(); + const CardWrapper = hideCard ? 'div' : Card; return ( - - + + {!hideCard && Container Configuration Override image defaults only when your workload needs custom startup behavior or Linux security settings. - + }
form.handleSubmit((data) => { return formAction(data); })()}> - +

Runtime

@@ -210,7 +212,7 @@ export default function GeneralAppContainerConfig({ app, readonly }: {
{!readonly && ( - + Save

{state?.message}

@@ -218,6 +220,6 @@ export default function GeneralAppContainerConfig({ app, readonly }: { - + ); } diff --git a/src/app/project/app/[appId]/general/app-rate-limits.tsx b/src/app/project/app/[appId]/general/app-rate-limits.tsx index 73180b1d..dbabd78e 100644 --- a/src/app/project/app/[appId]/general/app-rate-limits.tsx +++ b/src/app/project/app/[appId]/general/app-rate-limits.tsx @@ -21,9 +21,10 @@ import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.util import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -export default function GeneralAppRateLimits({ app, readonly }: { +export default function GeneralAppRateLimits({ app, readonly, hideCard = false }: { app: AppExtendedModel; readonly: boolean; + hideCard?: boolean; }) { const form = useForm, unknown, z.output>({ resolver: zodResolver(appRateLimitsZodModel), @@ -58,17 +59,19 @@ export default function GeneralAppRateLimits({ app, readonly }: { FormUtils.mapValidationErrorsToForm(state, form); }, [form, state]); + const CardWrapper = hideCard ? 'div' : Card; + return <> - - + + {!hideCard && Container Rate Limits Provide optional rate Limits per running container instance. - + }
form.handleSubmit((data) => { return formAction(data); })()}> - +
form.setValue('memoryReservation', suggestedMemoryMb)} - > - ~ {suggestedMemoryMb} MB - } /> + className="inline-flex cursor-pointer items-center rounded-full border border-blue-300 bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950 dark:text-blue-300 dark:hover:bg-blue-900" + onClick={() => form.setValue('memoryReservation', suggestedMemoryMb)} + > + ~ {suggestedMemoryMb} MB + } />

Suggestion based on current pod resource usage

@@ -158,11 +161,11 @@ export default function GeneralAppRateLimits({ app, readonly }: { form.setValue('cpuReservation', suggestedCpuMillicores)} - > - ~ {suggestedCpuMillicores} m - } /> + className="inline-flex cursor-pointer items-center rounded-full border border-blue-300 bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950 dark:text-blue-300 dark:hover:bg-blue-900" + onClick={() => form.setValue('cpuReservation', suggestedCpuMillicores)} + > + ~ {suggestedCpuMillicores} m + } />

Suggestion based on current pod resource usage

@@ -174,13 +177,13 @@ export default function GeneralAppRateLimits({ app, readonly }: { />
- {!readonly && + {!readonly && Save

{state?.message}

} -
+ ; } diff --git a/src/app/project/app/[appId]/general/app-source.tsx b/src/app/project/app/[appId]/general/app-source.tsx index 7f8818d5..bdf20fb1 100644 --- a/src/app/project/app/[appId]/general/app-source.tsx +++ b/src/app/project/app/[appId]/general/app-source.tsx @@ -43,27 +43,23 @@ export default function GeneralAppSource({ !configured ? ( ) : ( - + ) ); if (hideCard) { - return
{cardContent}
; + return
+ {cardContent} + {configured && app.buildMethod === 'FRAMEWORK' && } +
; } return ( <> - -
- Source - Connect the source QuickStack should build or run. -
- {!readonly && configured && ( - - )} + + Source + Connect the source QuickStack should build or run. {cardContent} @@ -94,7 +90,7 @@ function EmptySourceState({ readonly, onConnect }: { readonly: boolean; onConnec ); } -function ConfiguredSourceSummary({ app, gitSshPublicKey }: { app: AppExtendedModel; gitSshPublicKey?: string }) { +function ConfiguredSourceSummary({ app, gitSshPublicKey, readonly, onConnect }: { app: AppExtendedModel; gitSshPublicKey?: string; readonly: boolean; onConnect: () => void; }) { const { openDialog } = useDialog(); const sourceType = app.sourceType as SourceType; const isGitSource = sourceType === 'GIT' || sourceType === 'GIT_SSH'; @@ -103,7 +99,7 @@ function ConfiguredSourceSummary({ app, gitSshPublicKey }: { app: AppExtendedMod return (
-
+
@@ -113,6 +109,12 @@ function ConfiguredSourceSummary({ app, gitSshPublicKey }: { app: AppExtendedMod {isGitSource ? app.gitUrl : app.containerImageSource}

+
+ {!readonly && + + }
diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index 1b5df83f..9b55a38a 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -30,10 +30,11 @@ type AppVolumeWithCapacity = (AppVolume & { usedPercentage?: number; }); -export default function StorageList({ app, readonly, storageClasses }: { +export default function StorageList({ app, readonly, storageClasses, hideCard = false }: { app: AppExtendedModel; storageClasses: string[]; readonly: boolean; + hideCard?: boolean; }) { const [volumesWithStorage, setVolumesWithStorage] = React.useState(app.appVolumes as AppVolumeWithCapacity[]); @@ -143,13 +144,15 @@ export default function StorageList({ app, readonly, storageClasses }: { } } + const CardWrapper = hideCard ? 'div' : Card; + return <> - + Volumes Add one or more volumes to to configure persistent storage within your container. - + {volumesWithStorage.length > 0 && {app.appVolumes.length} Storage @@ -296,8 +299,8 @@ export default function StorageList({ app, readonly, storageClasses }: { ))}
-
- {!readonly && +
} + {!readonly && @@ -305,6 +308,6 @@ export default function StorageList({ app, readonly, storageClasses }: { } -
+ ; } diff --git a/src/app/project/app/[appId]/volumes/volume-backup.tsx b/src/app/project/app/[appId]/volumes/volume-backup.tsx index 4432525b..3a22e50f 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup.tsx @@ -19,12 +19,14 @@ export default function VolumeBackupList({ app, volumeBackups, s3Targets, - readonly + readonly, + hideCard = false, }: { app: AppExtendedModel, s3Targets: S3Target[], volumeBackups: VolumeBackupExtendedModel[]; readonly: boolean; + hideCard?: boolean; }) { const { openConfirmDialog: openDialog } = useConfirmDialog(); @@ -60,13 +62,15 @@ export default function VolumeBackupList({ } }; + const CardWrapper = hideCard ? 'div' : Card; + return <> - + Backup Schedules Configure backup schedules for your volumes. Backups can be stored in a S3 bucket. - + {volumeBackups.length > 0 && {volumeBackups.length} Backup Rules @@ -107,12 +111,12 @@ export default function VolumeBackupList({ ))}
-
- {!readonly && +
} + {!readonly && } -
+ ; -} \ No newline at end of file +} diff --git a/src/components/custom/domains-card.tsx b/src/components/custom/domains-card.tsx index 4c5281bf..7344af92 100644 --- a/src/components/custom/domains-card.tsx +++ b/src/components/custom/domains-card.tsx @@ -12,11 +12,12 @@ import { WorkloadType } from "@/shared/model/runtime-type.model"; import DomainEditOverlay from "@/components/custom/domain-edit-overlay"; import { deleteDomain } from "@/app/project/actions"; -export default function DomainsCard({ domains, workloadId, workloadType, readonly }: { +export default function DomainsCard({ domains, workloadId, workloadType, readonly, hideCard = false }: { domains: DomainEditModel[]; workloadId: string; workloadType: WorkloadType; readonly: boolean; + hideCard?: boolean; }) { const { openConfirmDialog } = useConfirmDialog(); const { openDialog } = useDialog(); @@ -40,15 +41,16 @@ export default function DomainsCard({ domains, workloadId, workloadType, readonl maxWidth: 'max-w-2xl', }); } + const CardWrapper = hideCard ? 'div' : Card; return <> - - + + Domains Add custom domains. If a domain is configured, it will be public and accessible via the internet. - + {domains.length > 0 && {domains.length} Domains @@ -82,11 +84,11 @@ export default function DomainsCard({ domains, workloadId, workloadType, readonl ))}
-
- {!readonly && +
} + {!readonly && } -
+ ; } diff --git a/src/components/custom/file-mounts-card.tsx b/src/components/custom/file-mounts-card.tsx index 75bc56e2..51318671 100644 --- a/src/components/custom/file-mounts-card.tsx +++ b/src/components/custom/file-mounts-card.tsx @@ -11,11 +11,12 @@ import { WorkloadType } from "@/shared/model/runtime-type.model"; import FileMountEditOverlay from "@/components/custom/file-mount-edit-overlay"; import { deleteFileMount } from "@/app/project/actions"; -export default function FileMountsCard({ fileMounts, workloadId, workloadType, readonly }: { +export default function FileMountsCard({ fileMounts, workloadId, workloadType, readonly, hideCard = false }: { fileMounts: FileMountEditModel[]; workloadId: string; workloadType: WorkloadType; readonly: boolean; + hideCard?: boolean; }) { const { openConfirmDialog } = useConfirmDialog(); const { openDialog } = useDialog(); @@ -40,13 +41,15 @@ export default function FileMountsCard({ fileMounts, workloadId, workloadType, r }); }; + const CardWrapper = hideCard ? 'div' : Card; + return <> - + File Mounts Create files which are mounted into the container. - + {fileMounts.length > 0 && {fileMounts.length} File Mounts @@ -69,10 +72,10 @@ export default function FileMountsCard({ fileMounts, workloadId, workloadType, r ))}
-
- {!readonly && +
} + {!readonly && } -
+ ; } diff --git a/src/server/utils/env-var.utils.ts b/src/server/utils/env-var.utils.ts index 9cd60bd9..99adac75 100644 --- a/src/server/utils/env-var.utils.ts +++ b/src/server/utils/env-var.utils.ts @@ -1,21 +1 @@ -import { AppExtendedModel } from "@/shared/model/app-extended.model"; - -export class EnvVarUtils { - static parseEnvVariables(app: AppExtendedModel) { - return app.envVars ? app.envVars.split('\n').filter(x => !!x).map(env => { - const [name] = env.split('='); - const value = env.replace(`${name}=`, ''); - return { name, value }; - }) : []; - } - - static parseBuildArgs(app: AppExtendedModel) { - return app.buildArgs ? app.buildArgs.split('\n').filter(x => !!x).map(buildArg => { - const separatorIndex = buildArg.indexOf('='); - if (separatorIndex === -1) { - return { name: buildArg, value: '' }; - } - return { name: buildArg.slice(0, separatorIndex), value: buildArg.slice(separatorIndex + 1) }; - }) : []; - } -} \ No newline at end of file +export { EnvVarUtils } from '@/shared/utils/env-var.utils'; diff --git a/src/shared/utils/env-var.utils.ts b/src/shared/utils/env-var.utils.ts new file mode 100644 index 00000000..96438213 --- /dev/null +++ b/src/shared/utils/env-var.utils.ts @@ -0,0 +1,36 @@ +import type { AppExtendedModel } from '@/shared/model/app-extended.model'; + +type EnvVarSource = Pick; + +export class EnvVarUtils { + static parseEnvVariables(app: EnvVarSource) { + return app.envVars + ? app.envVars + .split('\n') + .filter((value) => !!value) + .map((env) => { + const [name] = env.split('='); + const value = env.replace(`${name}=`, ''); + return { name, value }; + }) + : []; + } + + static parseBuildArgs(app: EnvVarSource) { + return app.buildArgs + ? app.buildArgs + .split('\n') + .filter((value) => !!value) + .map((buildArg) => { + const separatorIndex = buildArg.indexOf('='); + if (separatorIndex === -1) { + return { name: buildArg, value: '' }; + } + return { + name: buildArg.slice(0, separatorIndex), + value: buildArg.slice(separatorIndex + 1), + }; + }) + : []; + } +} From 437c725aa2c00c9d360b833c229ba7008cb8788a Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Fri, 18 Sep 2026 10:36:19 +0000 Subject: [PATCH 02/25] feat: small ui optimizations in card settings tables --- .../drawer/settings-section.tsx | 7 +++- .../[agentId]/general/agent-volumes-card.tsx | 2 +- .../app/[appId]/advanced/basic-auth.tsx | 12 +++--- .../app/[appId]/domains/node-ports.tsx | 38 +++++++++---------- .../app/[appId]/general/app-rate-limits.tsx | 31 ++++++++++----- .../project/app/[appId]/volumes/storages.tsx | 8 ++-- .../app/[appId]/volumes/volume-backup.tsx | 14 ++++--- src/components/custom/domains-card.tsx | 33 ++++++++++------ src/components/custom/file-mounts-card.tsx | 15 ++++---- 9 files changed, 97 insertions(+), 63 deletions(-) diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx index 9abd128d..9bb7c60c 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx @@ -1,5 +1,6 @@ import type { LucideIcon } from 'lucide-react'; import type { ReactNode } from 'react'; +import { Separator } from '@/components/ui/separator'; export function SettingsSection({ id, @@ -15,12 +16,14 @@ export function SettingsSection({ return (
-

{title}

+
+

{title}

+
{children}
diff --git a/src/app/project/agent/[agentId]/general/agent-volumes-card.tsx b/src/app/project/agent/[agentId]/general/agent-volumes-card.tsx index 9e4a4a2c..d235b895 100644 --- a/src/app/project/agent/[agentId]/general/agent-volumes-card.tsx +++ b/src/app/project/agent/[agentId]/general/agent-volumes-card.tsx @@ -61,7 +61,7 @@ export default function AgentVolumesCard({ volumes, projectId, readonly, storage Mount Path Size Storage Class - {!readonly && Actions} + {!readonly && } diff --git a/src/app/project/app/[appId]/advanced/basic-auth.tsx b/src/app/project/app/[appId]/advanced/basic-auth.tsx index 405333fc..e138a70b 100644 --- a/src/app/project/app/[appId]/advanced/basic-auth.tsx +++ b/src/app/project/app/[appId]/advanced/basic-auth.tsx @@ -46,12 +46,12 @@ export default function BasicAuth({ app, readonly, hideCard = false }: { Username Password - Action + {!readonly && } {app.appBasicAuths.map(basicAuth => ( - + {basicAuth.username} @@ -67,8 +67,9 @@ export default function BasicAuth({ app, readonly, hideCard = false }: { - {!readonly && - - +
} ))} diff --git a/src/app/project/app/[appId]/domains/node-ports.tsx b/src/app/project/app/[appId]/domains/node-ports.tsx index 79bec377..fd7b8614 100644 --- a/src/app/project/app/[appId]/domains/node-ports.tsx +++ b/src/app/project/app/[appId]/domains/node-ports.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { EditIcon, Plus, TrashIcon } from "lucide-react"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export default function NodePortsCard({ app, readonly, hideCard = false }: { app: AppExtendedModel; @@ -32,7 +33,7 @@ export default function NodePortsCard({ app, readonly, hideCard = false }: { return ( - + Node Ports Expose this app directly on a node/host port, bypassing Traefik. Useful for non-HTTP workloads such as SFTP, game servers, or other TCP/UDP services. @@ -46,32 +47,31 @@ export default function NodePortsCard({ app, readonly, hideCard = false }: { Container Port Node Port Protocol - {!readonly && Actions} + {!readonly && } {app.appNodePorts.map((np) => ( - + {np.port} {np.nodePort} {np.protocol} {!readonly && ( - - - + +
+ + + void openDialog(, { maxWidth: '425px' })}>} /> + Edit node port + + + + + asyncDeleteNodePort(np.id)}>} /> + Delete node port + + +
)}
diff --git a/src/app/project/app/[appId]/general/app-rate-limits.tsx b/src/app/project/app/[appId]/general/app-rate-limits.tsx index dbabd78e..c5ffa1bb 100644 --- a/src/app/project/app/[appId]/general/app-rate-limits.tsx +++ b/src/app/project/app/[appId]/general/app-rate-limits.tsx @@ -10,6 +10,7 @@ import { useForm } from "react-hook-form"; import { saveGeneralAppRateLimits } from "./actions"; import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; import { AppRateLimitsModel, appRateLimitsZodModel } from "@/shared/model/app-rate-limits.model"; import { useActionState, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -88,16 +89,19 @@ export default function GeneralAppRateLimits({ app, readonly, hideCard = false } )} />
-
+
( - Memory Limit (MB) + Memory Limit - + + + MB + @@ -109,9 +113,12 @@ export default function GeneralAppRateLimits({ app, readonly, hideCard = false } name="memoryReservation" render={({ field }) => ( - Memory Reservation (MB) + Memory Reservation - + + + MB + {!readonly && suggestedMemoryMb !== undefined && ( @@ -138,9 +145,12 @@ export default function GeneralAppRateLimits({ app, readonly, hideCard = false } name="cpuLimit" render={({ field }) => ( - CPU Limit (m) + CPU Limit - + + + mCPU + @@ -152,9 +162,12 @@ export default function GeneralAppRateLimits({ app, readonly, hideCard = false } name="cpuReservation" render={({ field }) => ( - CPU Reservation (m) + CPU Reservation - + + + mCPU + {!readonly && suggestedCpuMillicores !== undefined && ( diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index 9b55a38a..9f8ad9f3 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -163,12 +163,12 @@ export default function StorageList({ app, readonly, storageClasses, hideCard = Storage Class Access Mode Shared - Action + {volumesWithStorage.map(volume => ( - + {volume.containerMountPath} {volume.size} MB @@ -214,7 +214,8 @@ export default function StorageList({ app, readonly, storageClasses, hideCard = )} - + +
{!volume.sharedVolumeId && <> @@ -294,6 +295,7 @@ export default function StorageList({ app, readonly, storageClasses, hideCard = } +
))} diff --git a/src/app/project/app/[appId]/volumes/volume-backup.tsx b/src/app/project/app/[appId]/volumes/volume-backup.tsx index 3a22e50f..0bf3899a 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup.tsx @@ -80,12 +80,12 @@ export default function VolumeBackupList({ Backup Method Backup Location Created At - Action + {!readonly && }
{volumeBackups.map(volumeBackup => ( - + {volumeBackup.cron} {volumeBackup.retention} @@ -95,17 +95,19 @@ export default function VolumeBackupList({ {volumeBackup.target.name} {formatDateTime(volumeBackup.createdAt)} - {!readonly && - - + - +
} ))} diff --git a/src/components/custom/domains-card.tsx b/src/components/custom/domains-card.tsx index 7344af92..ef34cbea 100644 --- a/src/components/custom/domains-card.tsx +++ b/src/components/custom/domains-card.tsx @@ -11,6 +11,7 @@ import { DomainEditModel } from "@/shared/model/domain-edit.model"; import { WorkloadType } from "@/shared/model/runtime-type.model"; import DomainEditOverlay from "@/components/custom/domain-edit-overlay"; import { deleteDomain } from "@/app/project/actions"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export default function DomainsCard({ domains, workloadId, workloadType, readonly, hideCard = false }: { domains: DomainEditModel[]; @@ -38,19 +39,19 @@ export default function DomainsCard({ domains, workloadId, workloadType, readonl existingDomain={domain} workloadId={workloadId} workloadType={workloadType} />, { - maxWidth: 'max-w-2xl', - }); + maxWidth: 'max-w-2xl', + }); } const CardWrapper = hideCard ? 'div' : Card; return <> - + Domains Add custom domains. If a domain is configured, it will be public and accessible via the internet. - {domains.length > 0 && + {domains.length > 0 && {domains.length} Domains @@ -59,12 +60,12 @@ export default function DomainsCard({ domains, workloadId, workloadType, readonl Port SSL Redirect HTTP to HTTPS - Action + {!readonly && } {domains.map(domain => ( - + {domain.hostname}
window.open((domain.useSsl ? 'https://' : 'http://') + domain.hostname, '_blank')}> @@ -74,11 +75,21 @@ export default function DomainsCard({ domains, workloadId, workloadType, readonl {domain.port} {domain.useSsl ? : } {domain.useSsl && domain.redirectHttps ? : } - {!readonly && - - + {!readonly && +
+ + + openEditDomainDialog(domain)}>} /> + Edit domain + + + + + asyncDeleteDomain(domain.id!)}>} /> + Delete domain + + +
} ))} diff --git a/src/components/custom/file-mounts-card.tsx b/src/components/custom/file-mounts-card.tsx index 51318671..6821618e 100644 --- a/src/components/custom/file-mounts-card.tsx +++ b/src/components/custom/file-mounts-card.tsx @@ -10,6 +10,7 @@ import { FileMountEditModel } from "@/shared/model/file-mount-edit.model"; import { WorkloadType } from "@/shared/model/runtime-type.model"; import FileMountEditOverlay from "@/components/custom/file-mount-edit-overlay"; import { deleteFileMount } from "@/app/project/actions"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export default function FileMountsCard({ fileMounts, workloadId, workloadType, readonly, hideCard = false }: { fileMounts: FileMountEditModel[]; @@ -55,18 +56,18 @@ export default function FileMountsCard({ fileMounts, workloadId, workloadType, r Mount Path - {!readonly && Actions} + {!readonly && } {fileMounts.map(fileMount => ( - + {fileMount.containerMountPath} - {!readonly && - - + {!readonly && +
+ openEditFileMountDialog(fileMount)}>} />Edit file mount + asyncDeleteFileMount(fileMount.id!)}>} />Delete file mount +
}
))} From 65a3fd291335c8cbd20bc8c2591b7642bb7346cc Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 20 Sep 2026 08:49:41 +0000 Subject: [PATCH 03/25] fix: resolve linter errors --- .../project-network-graph/drawer/settings-section.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx index 9bb7c60c..7a2b25be 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx @@ -1,6 +1,5 @@ import type { LucideIcon } from 'lucide-react'; import type { ReactNode } from 'react'; -import { Separator } from '@/components/ui/separator'; export function SettingsSection({ id, From 4a7348b940f2a8f5ea9cbeb6d33cd8f7b3113af4 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Sun, 20 Sep 2026 09:01:45 +0000 Subject: [PATCH 04/25] feat: add live per-app build status streaming Track the latest build status per app in a process-wide singleton fed by the build watch, expose it through a new /api/build-status SSE route, and surface it in the deployments overview and the project canvas. - build-status.service: cached latest status per workload with subscriber fan-out, Kubernetes-restart-safe seeding, and deleted-job reconciliation - build-watch feeds job events into the status service - build-live-status.service merges buildable apps and filters by session - new useBuildStatus store, SSE client service, and provider - BuildStatusIndicator shown on canvas nodes while a build is active or failed - app deployments overview refreshes on build status transitions --- src/app/api/build-status/route.ts | 81 ++++++ src/app/layout.tsx | 2 + .../app-components/project-network-graph.tsx | 4 +- .../app/[appId]/overview/deployments.tsx | 13 +- .../custom/build-status-indicator.tsx | 58 +++++ .../build-status-indicator.unit.spec.ts | 78 ++++++ .../custom/build-status-polling-provider.tsx | 20 ++ .../services/build-status-polling.service.ts | 121 +++++++++ src/frontend/states/zustand.states.ts | 60 +++++ .../services/build-live-status.service.ts | 89 +++++++ .../build-live-status.service.unit.spec.ts | 108 ++++++++ .../build-status.service.ts | 238 ++++++++++++++++++ .../build-status.service.unit.spec.ts | 222 ++++++++++++++++ .../build-watch.service.ts | 4 + .../build-watch.service.unit.spec.ts | 33 ++- src/shared/model/app-build-status.model.ts | 29 +++ 16 files changed, 1156 insertions(+), 4 deletions(-) create mode 100644 src/app/api/build-status/route.ts create mode 100644 src/components/custom/build-status-indicator.tsx create mode 100644 src/components/custom/build-status-indicator.unit.spec.ts create mode 100644 src/components/custom/build-status-polling-provider.tsx create mode 100644 src/frontend/services/build-status-polling.service.ts create mode 100644 src/server/services/build-live-status.service.ts create mode 100644 src/server/services/build-live-status.service.unit.spec.ts create mode 100644 src/server/services/standalone-services/build-status.service.ts create mode 100644 src/server/services/standalone-services/build-status.service.unit.spec.ts create mode 100644 src/shared/model/app-build-status.model.ts diff --git a/src/app/api/build-status/route.ts b/src/app/api/build-status/route.ts new file mode 100644 index 00000000..ef4cf808 --- /dev/null +++ b/src/app/api/build-status/route.ts @@ -0,0 +1,81 @@ +import buildLiveStatusService from "@/server/services/build-live-status.service"; +import buildStatusService from "@/server/services/standalone-services/build-status.service"; +import buildWatchService from "@/server/services/standalone-services/build-watch.service"; +import { getAuthUserSession, simpleRoute } from "@/server/utils/action-wrapper.utils"; +import { StreamUtils } from "@/shared/utils/stream.utils"; + +// Prevents this route's response from being cached +export const dynamic = "force-dynamic"; + +export async function POST() { + return simpleRoute(async () => { + const session = await getAuthUserSession(); + + void buildWatchService.startWatch(); + await buildStatusService.ensureSeeded(); + + let appLookup = await buildLiveStatusService.getBuildableAppLookup(session); + + const encoder = new TextEncoder(); + let shouldStopStreaming = false; + let unsubscribe: (() => void) | null = null; + + const customReadable = new ReadableStream({ + async start(controller) { + const sendData = (data: unknown) => { + if (shouldStopStreaming) { + return; + } + try { + controller.enqueue(encoder.encode(StreamUtils.encodeSseData(data))); + } catch (e) { + console.error(`[BUILD STATUS] Error while enqueueing build status data: `, e); + shouldStopStreaming = true; + controller.close(); + } + }; + + unsubscribe = buildStatusService.subscribe(async (status) => { + if (shouldStopStreaming || status.workloadType !== 'app') { + return; + } + + let appInfo = appLookup.get(status.workloadId); + if (!appInfo) { + // A new app might have been created while streaming, refresh the lookup. + appLookup = await buildLiveStatusService.getBuildableAppLookup(session); + appInfo = appLookup.get(status.workloadId); + } + if (!appInfo) { + return; + } + + sendData(buildLiveStatusService.mapBuildToStatus(status, appInfo)); + }); + + try { + sendData(buildLiveStatusService.getInitialStatus(appLookup)); + } catch (e) { + console.error("Error fetching initial build status", e); + } + }, + cancel() { + console.log("[BUILD STATUS] Client left, cancelling build status stream"); + shouldStopStreaming = true; + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + }, + }); + + return new Response(customReadable, { + headers: { + Connection: "keep-alive", + "Content-Encoding": "none", + "Cache-Control": "no-cache, no-transform", + "Content-Type": "text/event-stream; charset=utf-8", + }, + }); + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2f85a2a2..3859a12e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { BreadcrumbsGenerator } from "../components/custom/breadcrumbs-generator import { getUserSession } from "@/server/utils/action-wrapper.utils"; import { InputDialog } from "@/components/custom/input-dialog"; import PodsStatusPollingProvider from "@/components/custom/pods-status-polling-provider"; +import BuildStatusPollingProvider from "@/components/custom/build-status-polling-provider"; import { GenericDialog } from "@/components/custom/generic-dialog"; const inter = Inter({ @@ -66,6 +67,7 @@ export default async function RootLayout({ {userIsLoggedIn && } + {userIsLoggedIn && } ); diff --git a/src/app/project/[projectId]/app-components/project-network-graph.tsx b/src/app/project/[projectId]/app-components/project-network-graph.tsx index 9294a5d7..8435b172 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import PodStatusIndicator from '@/components/custom/pod-status-indicator'; +import BuildStatusIndicator from '@/components/custom/build-status-indicator'; import { cn } from '@/frontend/utils/utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import type { UserSession } from '@/shared/model/sim-session.model'; @@ -89,7 +90,8 @@ const WorkloadNode = memo(function WorkloadNode({

{data.name}

- {data.kind === 'APP' &&
+ {data.kind === 'APP' &&
+
}
diff --git a/src/app/project/app/[appId]/overview/deployments.tsx b/src/app/project/app/[appId]/overview/deployments.tsx index 39c7a6ce..740811e8 100644 --- a/src/app/project/app/[appId]/overview/deployments.tsx +++ b/src/app/project/app/[appId]/overview/deployments.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Card, CardContent, @@ -8,7 +8,7 @@ import { } from '@/components/ui/card'; import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; import { usePolling } from '@/frontend/hooks/use-polling'; -import { useConfirmDialog, useDialog } from '@/frontend/states/zustand.states'; +import { useBuildStatus, useConfirmDialog, useDialog } from '@/frontend/states/zustand.states'; import { Toast } from '@/frontend/utils/toast.utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import type { DeploymentInfoModel } from '@/shared/model/deployment-info.model'; @@ -98,6 +98,15 @@ export default function BuildsTab({ && deployment.status !== 'DEPLOYING' && deployment.status !== 'DEPLOYED'; + const currentBuildStatus = useBuildStatus(state => state.buildStatus.get(app.id)?.status); + + useEffect(() => { + if (currentBuildStatus === undefined) { + return; + } + void updateBuilds(); + }, [currentBuildStatus, updateBuilds]); + usePolling(updateBuilds, { intervalMs: 10000, enabled: app.sourceType !== 'container', diff --git a/src/components/custom/build-status-indicator.tsx b/src/components/custom/build-status-indicator.tsx new file mode 100644 index 00000000..20f12789 --- /dev/null +++ b/src/components/custom/build-status-indicator.tsx @@ -0,0 +1,58 @@ +'use client' + +import { memo } from 'react'; +import { CircleAlert } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useBuildStatus } from '@/frontend/states/zustand.states'; +import { isActiveBuildStatus } from '@/shared/model/app-build-status.model'; + +interface BuildStatusIndicatorProps { + appId: string; + showLabel?: boolean; +} + +function BuildStatusIndicator({ appId, showLabel }: BuildStatusIndicatorProps) { + const buildStatus = useBuildStatus(state => state.buildStatus.get(appId)); + + if (!buildStatus) { + return null; + } + + if (isActiveBuildStatus(buildStatus.status)) { + return ( + + + + {showLabel && {buildStatus.status === 'RUNNING' ? 'Building' : 'Pending'}} +
} /> + +

{buildStatus.status === 'RUNNING' ? 'Build is running' : 'Build is queued'}

+
+ + ); + } + + if (buildStatus.status === 'FAILED') { + return ( + + + + {showLabel && Build failed} +
} /> + +

Last build failed

+ {buildStatus.gitCommitMessage &&

{buildStatus.gitCommitMessage}

} +
+ + ); + } + + return null; +} + +export default memo(BuildStatusIndicator); diff --git a/src/components/custom/build-status-indicator.unit.spec.ts b/src/components/custom/build-status-indicator.unit.spec.ts new file mode 100644 index 00000000..6dcc9d0d --- /dev/null +++ b/src/components/custom/build-status-indicator.unit.spec.ts @@ -0,0 +1,78 @@ +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AppBuildStatusModel } from '@/shared/model/app-build-status.model'; +import { useBuildStatus } from '@/frontend/states/zustand.states'; +import BuildStatusIndicator from './build-status-indicator'; + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, + TooltipTrigger: ({ children, render }: { children?: React.ReactNode; render?: React.ReactNode }) => render ?? children, + TooltipContent: ({ children }: { children: React.ReactNode }) => children, +})); + +function status(workloadId: string, buildStatus: AppBuildStatusModel['status']): AppBuildStatusModel { + return { + workloadId, + workloadType: 'app', + workloadName: workloadId, + projectId: 'project-1', + projectName: 'Project 1', + status: buildStatus, + }; +} + +describe('BuildStatusIndicator', () => { + beforeEach(() => { + useBuildStatus.setState({ + buildStatus: new Map(), + lastUpdate: null, + isLoading: false, + listeners: new Set(), + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('shows a running label while a build is running', () => { + useBuildStatus.setState({ buildStatus: new Map([['app-a', status('app-a', 'RUNNING')]]) }); + + render(React.createElement(BuildStatusIndicator, { appId: 'app-a', showLabel: true })); + + expect(screen.getByText('Building')).toBeTruthy(); + }); + + it('shows a pending label while a build is queued', () => { + useBuildStatus.setState({ buildStatus: new Map([['app-a', status('app-a', 'PENDING')]]) }); + + render(React.createElement(BuildStatusIndicator, { appId: 'app-a', showLabel: true })); + + expect(screen.getByText('Pending')).toBeTruthy(); + }); + + it('shows a failed label when the last build failed', () => { + useBuildStatus.setState({ buildStatus: new Map([['app-a', status('app-a', 'FAILED')]]) }); + + render(React.createElement(BuildStatusIndicator, { appId: 'app-a', showLabel: true })); + + expect(screen.getByText('Build failed')).toBeTruthy(); + }); + + it('renders nothing when the last build succeeded', () => { + useBuildStatus.setState({ buildStatus: new Map([['app-a', status('app-a', 'SUCCEEDED')]]) }); + + const { container } = render(React.createElement(BuildStatusIndicator, { appId: 'app-a', showLabel: true })); + + expect(container.textContent).toBe(''); + }); + + it('renders nothing when there is no build yet', () => { + useBuildStatus.setState({ buildStatus: new Map([['app-a', status('app-a', 'NOT_BUILT')]]) }); + + const { container } = render(React.createElement(BuildStatusIndicator, { appId: 'app-a' })); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/src/components/custom/build-status-polling-provider.tsx b/src/components/custom/build-status-polling-provider.tsx new file mode 100644 index 00000000..c6248021 --- /dev/null +++ b/src/components/custom/build-status-polling-provider.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useEffect } from 'react'; +import { buildStatusPollingService } from '@/frontend/services/build-status-polling.service'; + +/** + * Client component that initializes and manages the build status streaming service. + * Mounted in the root layout so the build status of all apps stays fresh. + */ +export default function BuildStatusPollingProvider() { + useEffect(() => { + buildStatusPollingService.start(); + + return () => { + buildStatusPollingService.stop(); + }; + }, []); + + return null; +} diff --git a/src/frontend/services/build-status-polling.service.ts b/src/frontend/services/build-status-polling.service.ts new file mode 100644 index 00000000..4ae659f9 --- /dev/null +++ b/src/frontend/services/build-status-polling.service.ts @@ -0,0 +1,121 @@ +import { AppBuildStatusModel } from '@/shared/model/app-build-status.model'; +import { StreamUtils } from '@/shared/utils/stream.utils'; +import { useBuildStatus } from '../states/zustand.states'; + +/** + * Singleton service that manages streaming for the build status of all apps. + * This service runs in the browser and updates the Zustand store with fresh data via SSE. + */ +class BuildStatusPollingService { + private static instance: BuildStatusPollingService; + private controller: AbortController | null = null; + private isConnected = false; + private buffer = ''; + + private constructor() { } + + public static getInstance(): BuildStatusPollingService { + if (!BuildStatusPollingService.instance) { + BuildStatusPollingService.instance = new BuildStatusPollingService(); + } + return BuildStatusPollingService.instance; + } + + public start(): void { + if (this.isConnected) { + console.log('[BuildStatusService] Already connected, skipping start'); + return; + } + + console.log('[BuildStatusService] Starting build status stream'); + this.connect(); + } + + public stop(): void { + if (this.controller) { + console.log('[BuildStatusService] Stopping build status stream'); + this.controller.abort(); + this.controller = null; + this.isConnected = false; + this.buffer = ''; + } + } + + private async connect() { + this.controller = new AbortController(); + const signal = this.controller.signal; + this.isConnected = true; + this.buffer = ''; + + try { + const response = await fetch('/api/build-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: signal, + }); + + if (!response.ok || !response.body) { + throw new Error('Failed to connect to build status stream'); + } + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + this.processChunk(value); + } + } + } catch (error: any) { + if (error.name === 'AbortError') { + console.log('[BuildStatusService] Stream aborted'); + } else { + console.error('[BuildStatusService] Stream error:', error); + this.isConnected = false; + setTimeout(() => { + if (!signal.aborted) { + this.connect(); + } + }, 5000); + } + } finally { + this.isConnected = false; + } + } + + private processChunk(chunk: string) { + const { frames, buffer } = StreamUtils.parseSseFrames(this.buffer, chunk); + this.buffer = buffer; + + for (const frame of frames) { + try { + const data = JSON.parse(frame); + const { setBuildStatus, updateBuildStatus } = useBuildStatus.getState(); + + if (Array.isArray(data)) { + setBuildStatus(data as AppBuildStatusModel[]); + } else { + updateBuildStatus(data as AppBuildStatusModel); + } + } catch (e) { + console.error('[BuildStatusService] Error parsing JSON:', e); + } + } + } + + public refresh(): void { + this.stop(); + this.start(); + } + + public isActive(): boolean { + return this.isConnected; + } +} + +export const buildStatusPollingService = BuildStatusPollingService.getInstance(); diff --git a/src/frontend/states/zustand.states.ts b/src/frontend/states/zustand.states.ts index 2a14c5ad..4437d7c8 100644 --- a/src/frontend/states/zustand.states.ts +++ b/src/frontend/states/zustand.states.ts @@ -1,3 +1,4 @@ +import { AppBuildStatusModel } from "@/shared/model/app-build-status.model"; import { AppPodsStatusModel } from "@/shared/model/app-pod-status.model"; import { ReactNode } from "react"; import { create } from "zustand" @@ -164,6 +165,65 @@ export const usePodsStatus = create((set, get) => ({ } })); +/* Build Status Store */ +interface ZustandBuildStatusProps { + buildStatus: Map; + lastUpdate: Date | null; + isLoading: boolean; + listeners: Set<(changedWorkloadIds: string[]) => void>; + setBuildStatus: (data: AppBuildStatusModel[]) => void; + updateBuildStatus: (data: AppBuildStatusModel) => void; + setLoading: (loading: boolean) => void; + getBuildStatus: (workloadId: string) => AppBuildStatusModel | undefined; + subscribeToStatusChanges: (callback: (changedWorkloadIds: string[]) => void) => () => void; +} + +export const useBuildStatus = create((set, get) => ({ + buildStatus: new Map(), + lastUpdate: null, + isLoading: true, + listeners: new Set(), + setBuildStatus: (data) => { + set({ + buildStatus: new Map(data.map(build => [build.workloadId, build])), + lastUpdate: new Date(), + isLoading: false, + }); + get().listeners.forEach(listener => listener(data.map(build => build.workloadId))); + }, + updateBuildStatus: (data) => { + set((state) => { + const newMap = new Map(state.buildStatus); + newMap.set(data.workloadId, data); + return { + buildStatus: newMap, + lastUpdate: new Date(), + }; + }); + get().listeners.forEach(listener => listener([data.workloadId])); + }, + setLoading: (loading) => { + set({ isLoading: loading }); + }, + getBuildStatus: (workloadId) => { + return get().buildStatus.get(workloadId); + }, + subscribeToStatusChanges: (callback) => { + set((state) => { + const newListeners = new Set(state.listeners); + newListeners.add(callback); + return { listeners: newListeners }; + }); + return () => { + set((state) => { + const newListeners = new Set(state.listeners); + newListeners.delete(callback); + return { listeners: newListeners }; + }); + }; + } +})); + /* Generic Dialog */ export interface DialogSizeProps { diff --git a/src/server/services/build-live-status.service.ts b/src/server/services/build-live-status.service.ts new file mode 100644 index 00000000..08bc58eb --- /dev/null +++ b/src/server/services/build-live-status.service.ts @@ -0,0 +1,89 @@ +import projectService from './project.service'; +import buildStatusService from './standalone-services/build-status.service'; +import { AppBuildStatusModel } from '@/shared/model/app-build-status.model'; +import { UserSession } from '@/shared/model/sim-session.model'; +import { UserGroupUtils } from '@/shared/utils/role.utils'; + +export interface BuildableAppInfo { + appId: string; + appName: string; + projectId: string; + projectName: string; +} + +const BUILDABLE_SOURCE_TYPES = ['GIT', 'GIT_SSH']; + +class BuildLiveStatusService { + + /** Lists all apps that are built from source, filtered by the session's read access. */ + async getBuildableAppLookup(session?: UserSession): Promise> { + const projects = await projectService.getAll(); + const appLookup = new Map(); + + for (const project of projects) { + for (const app of project.apps) { + if (!BUILDABLE_SOURCE_TYPES.includes(app.sourceType)) { + continue; + } + if (session && !UserGroupUtils.sessionHasReadAccessForApp(session, app.id)) { + continue; + } + appLookup.set(app.id, { + appId: app.id, + appName: app.name, + projectId: project.id, + projectName: project.name, + }); + } + } + return appLookup; + } + + /** + * Builds the initial SSE payload. Apps without a known build are reported as + * NOT_BUILT so every buildable app always has a status. + */ + getInitialStatus(appLookup: Map): AppBuildStatusModel[] { + const result: AppBuildStatusModel[] = []; + const knownAppIds = new Set(); + + for (const status of buildStatusService.getStatuses()) { + if (status.workloadType !== 'app') { + continue; + } + const appInfo = appLookup.get(status.workloadId); + if (!appInfo) { + continue; + } + knownAppIds.add(status.workloadId); + result.push(this.mapBuildToStatus(status, appInfo)); + } + + for (const [appId, appInfo] of appLookup.entries()) { + if (knownAppIds.has(appId)) { + continue; + } + result.push({ + workloadId: appId, + workloadType: 'app', + workloadName: appInfo.appName, + projectId: appInfo.projectId, + projectName: appInfo.projectName, + status: 'NOT_BUILT', + }); + } + return result; + } + + mapBuildToStatus(status: AppBuildStatusModel, appInfo: BuildableAppInfo): AppBuildStatusModel { + return { + ...status, + workloadName: appInfo.appName, + projectId: appInfo.projectId, + projectName: appInfo.projectName, + }; + } +} + +const buildLiveStatusService = new BuildLiveStatusService(); +export default buildLiveStatusService; diff --git a/src/server/services/build-live-status.service.unit.spec.ts b/src/server/services/build-live-status.service.unit.spec.ts new file mode 100644 index 00000000..1712663b --- /dev/null +++ b/src/server/services/build-live-status.service.unit.spec.ts @@ -0,0 +1,108 @@ +vi.mock('@/server/services/project.service', () => ({ + default: { + getAll: vi.fn(), + }, +})); +vi.mock('@/server/services/standalone-services/build-status.service', () => ({ + default: { + getStatuses: vi.fn(), + }, +})); + +import projectService from '@/server/services/project.service'; +import buildStatusService from '@/server/services/standalone-services/build-status.service'; +import buildLiveStatusService from '@/server/services/build-live-status.service'; +import { UserGroupUtils } from '@/shared/utils/role.utils'; + +function app(id: string, sourceType: string) { + return { id, name: `name-${id}`, sourceType }; +} + +describe('BuildLiveStatusService', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + describe('getBuildableAppLookup', () => { + it('only includes apps that have to be built', async () => { + vi.mocked(projectService.getAll).mockResolvedValue([ + { + id: 'project-1', + name: 'Project', + apps: [app('app-git', 'GIT'), app('app-ssh', 'GIT_SSH'), app('app-container', 'CONTAINER')], + }, + ] as any); + + const lookup = await buildLiveStatusService.getBuildableAppLookup(); + + expect(Array.from(lookup.keys()).sort()).toEqual(['app-git', 'app-ssh']); + expect(lookup.get('app-git')).toEqual({ + appId: 'app-git', + appName: 'name-app-git', + projectId: 'project-1', + projectName: 'Project', + }); + }); + + it('filters out apps the session cannot read', async () => { + vi.mocked(projectService.getAll).mockResolvedValue([ + { + id: 'project-1', + name: 'Project', + apps: [app('app-1', 'GIT'), app('app-2', 'GIT')], + }, + ] as any); + vi.spyOn(UserGroupUtils, 'sessionHasReadAccessForApp').mockImplementation((_session, appId) => appId === 'app-1'); + + const lookup = await buildLiveStatusService.getBuildableAppLookup({} as any); + + expect(Array.from(lookup.keys())).toEqual(['app-1']); + }); + }); + + describe('getInitialStatus', () => { + it('reports NOT_BUILT for buildable apps without a build', () => { + vi.mocked(buildStatusService.getStatuses).mockReturnValue([]); + const appLookup = new Map([['app-1', { appId: 'app-1', appName: 'App', projectId: 'project-1', projectName: 'Project' }]]); + + const statuses = buildLiveStatusService.getInitialStatus(appLookup); + + expect(statuses).toEqual([ + expect.objectContaining({ workloadId: 'app-1', workloadType: 'app', status: 'NOT_BUILT', workloadName: 'App' }), + ]); + }); + + it('merges known build statuses and only returns apps in the lookup', () => { + vi.mocked(buildStatusService.getStatuses).mockReturnValue([ + { workloadId: 'app-1', workloadType: 'app', workloadName: '', projectId: '', projectName: '', status: 'RUNNING' }, + { workloadId: 'app-other', workloadType: 'app', workloadName: '', projectId: '', projectName: '', status: 'FAILED' }, + { workloadId: 'agent-1', workloadType: 'agent', workloadName: '', projectId: '', projectName: '', status: 'RUNNING' }, + ]); + const appLookup = new Map([['app-1', { appId: 'app-1', appName: 'App', projectId: 'project-1', projectName: 'Project' }]]); + + const statuses = buildLiveStatusService.getInitialStatus(appLookup); + + expect(statuses).toEqual([ + expect.objectContaining({ workloadId: 'app-1', status: 'RUNNING', workloadName: 'App', projectId: 'project-1' }), + ]); + }); + }); + + describe('mapBuildToStatus', () => { + it('overrides names and project information from the app lookup', () => { + const mapped = buildLiveStatusService.mapBuildToStatus( + { workloadId: 'app-1', workloadType: 'app', workloadName: 'stale', projectId: '', projectName: '', status: 'SUCCEEDED' }, + { appId: 'app-1', appName: 'Fresh App', projectId: 'project-9', projectName: 'Fresh Project' }, + ); + + expect(mapped).toMatchObject({ + workloadId: 'app-1', + status: 'SUCCEEDED', + workloadName: 'Fresh App', + projectId: 'project-9', + projectName: 'Fresh Project', + }); + }); + }); +}); diff --git a/src/server/services/standalone-services/build-status.service.ts b/src/server/services/standalone-services/build-status.service.ts new file mode 100644 index 00000000..c08e653f --- /dev/null +++ b/src/server/services/standalone-services/build-status.service.ts @@ -0,0 +1,238 @@ +import { V1Job } from '@kubernetes/client-node'; +import { AppBuildMethod } from '../../../shared/model/app-source-info.model'; +import { AppBuildStatusModel, isActiveBuildStatus } from '../../../shared/model/app-build-status.model'; +import { BuildJobModel } from '../../../shared/model/build-job'; +import { GlobalBuildJobModel } from '../../../shared/model/global-build-job.model'; +import { WorkloadType } from '../../../shared/model/runtime-type.model'; +import { Constants } from '../../../shared/utils/constants'; +import buildService from '../build.service'; + +export type BuildStatusListener = (status: AppBuildStatusModel) => void | Promise; + +type ReconcilableBuild = BuildJobModel & Partial>; + +declare global { + var buildStatusServiceInstance: BuildStatusService | undefined; +} + +/** + * In-memory, process-wide cache of the latest build status per workload. + * + * The Kubernetes job watch feeds this service through `applyJobEvent`, and the + * SSE route subscribes to receive status transitions. The cache is not the + * source of truth: `ensureSeeded` builds it from the build service, so a restart + * or reconnect behaves the same as a long-running process. + */ +class BuildStatusService { + private statuses = new Map(); + private subscribers = new Set(); + private seedPromise: Promise | null = null; + + subscribe(listener: BuildStatusListener): () => void { + this.subscribers.add(listener); + let isSubscribed = true; + return () => { + if (!isSubscribed) { + return; + } + isSubscribed = false; + this.subscribers.delete(listener); + }; + } + + getStatuses(): AppBuildStatusModel[] { + return Array.from(this.statuses.values()); + } + + getStatus(workloadType: WorkloadType, workloadId: string): AppBuildStatusModel | undefined { + return this.statuses.get(this.key(workloadType, workloadId)); + } + + /** Rebuilds the cached status for every workload present in the given builds. */ + applyBuildJobs(builds: ReconcilableBuild[]): void { + const groups = new Map(); + for (const build of builds) { + if (!build.workloadId || !build.workloadType) { + continue; + } + const groupKey = this.key(build.workloadType, build.workloadId); + const group = groups.get(groupKey) ?? { workloadType: build.workloadType, workloadId: build.workloadId, builds: [] }; + group.builds.push(build); + groups.set(groupKey, group); + } + + for (const group of groups.values()) { + this.reconcileGroup(group.workloadType, group.workloadId, group.builds); + } + } + + /** + * Applies a single Kubernetes job watch event. + * + * ADDED/MODIFIED upsert the workload status. DELETED re-reads the remaining + * builds for that workload because a single deleted job does not tell us the + * previous build's outcome. + */ + async applyJobEvent(type: string, job: V1Job): Promise { + const annotations = job.metadata?.annotations; + const buildName = job.metadata?.name; + const workloadType = (annotations?.[Constants.QS_ANNOTATION_WORKLOAD_TYPE] as WorkloadType | undefined) + ?? (annotations?.[Constants.QS_ANNOTATION_AGENT_ID] ? 'agent' : 'app'); + const workloadId = annotations?.[Constants.QS_ANNOTATION_WORKLOAD_ID] + ?? annotations?.[workloadType === 'agent' ? Constants.QS_ANNOTATION_AGENT_ID : Constants.QS_ANNOTATION_APP_ID]; + if (!workloadId) { + return; + } + + if (type === 'DELETED') { + const builds = await buildService.getBuildsForWorkload(workloadId); + const remaining = builds.filter(build => build.workloadType === workloadType); + if (remaining.length === 0) { + this.setNotBuilt(workloadType, workloadId); + } else { + this.reconcileGroup(workloadType, workloadId, remaining); + } + return; + } + + const status = buildService.getJobStatusString(job.status); + const existing = this.statuses.get(this.key(workloadType, workloadId)); + const isDifferentBuild = !!existing?.buildName && !!buildName && existing.buildName !== buildName; + if (isDifferentBuild) { + const incomingStart = this.toTime(job.status?.startTime); + const existingStart = this.toTime(existing?.startedAt); + const isOlderBuild = !!incomingStart && !!existingStart && incomingStart < existingStart; + const isStaleTerminalForActiveBuild = isActiveBuildStatus(existing.status) && !isActiveBuildStatus(status); + if (isOlderBuild || isStaleTerminalForActiveBuild) { + return; + } + } + + this.setStatus({ + workloadId, + workloadType, + workloadName: existing?.workloadName ?? workloadId, + projectId: annotations?.[Constants.QS_ANNOTATION_PROJECT_ID] ?? existing?.projectId ?? '', + projectName: existing?.projectName ?? '', + status, + buildName, + gitCommit: annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT] || existing?.gitCommit, + gitCommitMessage: annotations?.[Constants.QS_ANNOTATION_GIT_COMMIT_MESSAGE] || existing?.gitCommitMessage, + deploymentId: annotations?.[Constants.QS_ANNOTATION_DEPLOYMENT_ID] || existing?.deploymentId, + buildMethod: (annotations?.[Constants.QS_ANNOTATION_BUILD_METHOD] as AppBuildMethod) || existing?.buildMethod, + startedAt: job.status?.startTime ?? existing?.startedAt, + completionTime: job.status?.completionTime ?? existing?.completionTime, + }); + } + + /** Seeds the cache from Kubernetes once. Concurrent callers share one request. */ + async ensureSeeded(): Promise { + if (!this.seedPromise) { + this.seedPromise = (async () => { + const builds = await buildService.getAllBuilds(); + this.applyBuildJobs(builds); + })().catch((error) => { + console.error('[BuildStatus] Failed to seed build statuses:', error); + this.seedPromise = null; + }); + } + return this.seedPromise; + } + + reset(): void { + this.statuses.clear(); + this.subscribers.clear(); + this.seedPromise = null; + } + + private reconcileGroup(workloadType: WorkloadType, workloadId: string, builds: ReconcilableBuild[]): void { + const active = builds.find(build => build.status === 'RUNNING') + ?? builds.find(build => build.status === 'PENDING'); + const latestFinished = builds + .filter(build => build.status !== 'RUNNING' && build.status !== 'PENDING') + .sort((a, b) => this.toTime(b.startTime) - this.toTime(a.startTime))[0]; + const chosen = active ?? latestFinished; + if (!chosen) { + this.setNotBuilt(workloadType, workloadId); + return; + } + + const existing = this.statuses.get(this.key(workloadType, workloadId)); + this.setStatus({ + workloadId, + workloadType, + workloadName: chosen.workloadName || existing?.workloadName || workloadId, + projectId: chosen.projectId ?? existing?.projectId ?? '', + projectName: chosen.projectName ?? existing?.projectName ?? '', + status: chosen.status, + buildName: chosen.name, + gitCommit: chosen.gitCommit || undefined, + gitCommitMessage: chosen.gitCommitMessage || undefined, + deploymentId: chosen.deploymentId || undefined, + buildMethod: chosen.buildMethod, + startedAt: chosen.startTime, + completionTime: chosen.completionTime, + }); + } + + private setNotBuilt(workloadType: WorkloadType, workloadId: string): void { + const existing = this.statuses.get(this.key(workloadType, workloadId)); + this.setStatus({ + workloadId, + workloadType, + workloadName: existing?.workloadName ?? workloadId, + projectId: existing?.projectId ?? '', + projectName: existing?.projectName ?? '', + status: 'NOT_BUILT', + }); + } + + private setStatus(entry: AppBuildStatusModel): void { + const entryKey = this.key(entry.workloadType, entry.workloadId); + const existing = this.statuses.get(entryKey); + if (existing && this.signature(existing) === this.signature(entry)) { + return; + } + this.statuses.set(entryKey, entry); + this.notify(entry); + } + + private notify(status: AppBuildStatusModel): void { + for (const subscriber of this.subscribers) { + try { + const result = subscriber(status); + if (result && typeof result.then === 'function') { + result.catch((error) => console.error('[BuildStatus] Subscriber error:', error)); + } + } catch (error) { + console.error('[BuildStatus] Subscriber error:', error); + } + } + } + + private signature(status: AppBuildStatusModel): string { + return [ + status.status, + status.buildName ?? '', + status.gitCommit ?? '', + status.deploymentId ?? '', + status.startedAt ? this.toTime(status.startedAt) : '', + status.completionTime ? this.toTime(status.completionTime) : '', + ].join('|'); + } + + private toTime(value?: Date | string): number { + if (!value) { + return 0; + } + return value instanceof Date ? value.getTime() : new Date(value).getTime(); + } + + private key(workloadType: WorkloadType, workloadId: string): string { + return `${workloadType}:${workloadId}`; + } +} + +const buildStatusService = globalThis.buildStatusServiceInstance ?? new BuildStatusService(); +globalThis.buildStatusServiceInstance = buildStatusService; +export default buildStatusService; diff --git a/src/server/services/standalone-services/build-status.service.unit.spec.ts b/src/server/services/standalone-services/build-status.service.unit.spec.ts new file mode 100644 index 00000000..85638f1f --- /dev/null +++ b/src/server/services/standalone-services/build-status.service.unit.spec.ts @@ -0,0 +1,222 @@ +vi.mock('@/server/services/build.service', () => ({ + default: { + getAllBuilds: vi.fn(), + getBuildsForWorkload: vi.fn(), + getJobStatusString: vi.fn(), + }, +})); + +import buildService from '@/server/services/build.service'; +import buildStatusService from '@/server/services/standalone-services/build-status.service'; +import type { GlobalBuildJobModel } from '@/shared/model/global-build-job.model'; + +function makeBuild(overrides: Partial & Pick): GlobalBuildJobModel { + return { + name: `build-${overrides.workloadId}`, + startTime: new Date('2024-01-01T00:00:00Z'), + workloadType: 'app', + gitCommit: 'abc123', + gitCommitMessage: 'feat: build', + deploymentId: 'deployment-1', + projectId: 'project-1', + workloadName: 'My App', + projectName: 'My Project', + ...overrides, + } as GlobalBuildJobModel; +} + +describe('BuildStatusService', () => { + beforeEach(() => { + vi.clearAllMocks(); + buildStatusService.reset(); + }); + + describe('applyBuildJobs', () => { + it('uses the active build over a finished build for the same workload', () => { + buildStatusService.applyBuildJobs([ + makeBuild({ workloadId: 'app-1', status: 'SUCCEEDED', startTime: new Date('2024-01-01T00:00:00Z') }), + makeBuild({ workloadId: 'app-1', status: 'RUNNING', name: 'build-active', startTime: new Date('2024-01-02T00:00:00Z') }), + ]); + + const status = buildStatusService.getStatus('app', 'app-1'); + expect(status?.status).toBe('RUNNING'); + expect(status?.buildName).toBe('build-active'); + }); + + it('uses the most recent finished build when no build is active', () => { + buildStatusService.applyBuildJobs([ + makeBuild({ workloadId: 'app-1', status: 'FAILED', name: 'build-old', startTime: new Date('2024-01-01T00:00:00Z') }), + makeBuild({ workloadId: 'app-1', status: 'SUCCEEDED', name: 'build-new', startTime: new Date('2024-01-02T00:00:00Z') }), + ]); + + const status = buildStatusService.getStatus('app', 'app-1'); + expect(status?.status).toBe('SUCCEEDED'); + expect(status?.buildName).toBe('build-new'); + }); + + it('keeps builds of different workloads separate', () => { + buildStatusService.applyBuildJobs([ + makeBuild({ workloadId: 'app-1', status: 'SUCCEEDED' }), + makeBuild({ workloadId: 'app-2', status: 'FAILED' }), + ]); + + expect(buildStatusService.getStatus('app', 'app-1')?.status).toBe('SUCCEEDED'); + expect(buildStatusService.getStatus('app', 'app-2')?.status).toBe('FAILED'); + }); + }); + + describe('applyJobEvent', () => { + it('creates a RUNNING status from a watch event', async () => { + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { + name: 'build-1', + annotations: { + 'qs-workload-type': 'app', + 'qs-app-id': 'app-1', + 'qs-project-id': 'project-1', + 'qs-git-commit': 'abc123', + }, + }, + } as any); + + const status = buildStatusService.getStatus('app', 'app-1'); + expect(status?.status).toBe('RUNNING'); + expect(status?.buildName).toBe('build-1'); + }); + + it('notifies subscribers when a status changes', async () => { + const listener = vi.fn(); + buildStatusService.subscribe(listener); + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-1', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ workloadId: 'app-1', status: 'RUNNING' })); + }); + + it('does not notify subscribers when the status did not change', async () => { + const listener = vi.fn(); + buildStatusService.subscribe(listener); + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + const job = { metadata: { name: 'build-1', annotations: { 'qs-app-id': 'app-1' } } } as any; + + await buildStatusService.applyJobEvent('MODIFIED', job); + await buildStatusService.applyJobEvent('MODIFIED', job); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not overwrite a newer finished build with an older finished build', async () => { + vi.mocked(buildService.getJobStatusString).mockReturnValue('SUCCEEDED'); + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-new', annotations: { 'qs-app-id': 'app-1' } }, + status: { startTime: new Date('2024-01-02T00:00:00Z') }, + } as any); + + vi.mocked(buildService.getJobStatusString).mockReturnValue('FAILED'); + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-old', annotations: { 'qs-app-id': 'app-1' } }, + status: { startTime: new Date('2024-01-01T00:00:00Z') }, + } as any); + + expect(buildStatusService.getStatus('app', 'app-1')?.buildName).toBe('build-new'); + expect(buildStatusService.getStatus('app', 'app-1')?.status).toBe('SUCCEEDED'); + }); + + it('ignores a terminal event of an older build while a newer build is active', async () => { + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-active', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + + vi.mocked(buildService.getJobStatusString).mockReturnValue('SUCCEEDED'); + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-old', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + + expect(buildStatusService.getStatus('app', 'app-1')?.buildName).toBe('build-active'); + expect(buildStatusService.getStatus('app', 'app-1')?.status).toBe('RUNNING'); + }); + + it('falls back to NOT_BUILT when the tracked build is deleted and no builds remain', async () => { + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-1', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + vi.mocked(buildService.getBuildsForWorkload).mockResolvedValue([] as any); + + await buildStatusService.applyJobEvent('DELETED', { + metadata: { name: 'build-1', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + + expect(buildStatusService.getStatus('app', 'app-1')?.status).toBe('NOT_BUILT'); + }); + + it('recomputes the status from remaining builds when the tracked build is deleted', async () => { + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-active', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + vi.mocked(buildService.getBuildsForWorkload).mockResolvedValue([ + makeBuild({ workloadId: 'app-1', status: 'FAILED', name: 'build-older', startTime: new Date('2024-01-01T00:00:00Z') }), + ] as any); + + await buildStatusService.applyJobEvent('DELETED', { + metadata: { name: 'build-active', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + + expect(buildStatusService.getStatus('app', 'app-1')?.status).toBe('FAILED'); + expect(buildStatusService.getStatus('app', 'app-1')?.buildName).toBe('build-older'); + }); + }); + + describe('ensureSeeded', () => { + it('seeds from the build service and only lists once for concurrent calls', async () => { + vi.mocked(buildService.getAllBuilds).mockResolvedValue([ + makeBuild({ workloadId: 'app-1', status: 'SUCCEEDED' }), + ] as any); + + await Promise.all([ + buildStatusService.ensureSeeded(), + buildStatusService.ensureSeeded(), + ]); + + expect(buildService.getAllBuilds).toHaveBeenCalledTimes(1); + expect(buildStatusService.getStatus('app', 'app-1')?.status).toBe('SUCCEEDED'); + }); + }); + + describe('subscribe', () => { + it('isolates a throwing subscriber from other subscribers', async () => { + const bad = vi.fn(() => { throw new Error('boom'); }); + const good = vi.fn(); + buildStatusService.subscribe(bad); + buildStatusService.subscribe(good); + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-1', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + + expect(good).toHaveBeenCalledTimes(1); + }); + + it('stops notifying after unsubscribe', async () => { + const listener = vi.fn(); + const unsubscribe = buildStatusService.subscribe(listener); + unsubscribe(); + vi.mocked(buildService.getJobStatusString).mockReturnValue('RUNNING'); + + await buildStatusService.applyJobEvent('MODIFIED', { + metadata: { name: 'build-1', annotations: { 'qs-app-id': 'app-1' } }, + } as any); + + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/services/standalone-services/build-watch.service.ts b/src/server/services/standalone-services/build-watch.service.ts index 5c2a2e01..09ce94b4 100644 --- a/src/server/services/standalone-services/build-watch.service.ts +++ b/src/server/services/standalone-services/build-watch.service.ts @@ -12,6 +12,7 @@ import { AppBuildMethod } from '@/shared/model/app-source-info.model'; import appGitSshKeyService from '../app-git-ssh-key.service'; import { RollbackAnnotationUtils } from '@/shared/utils/rollback-annotation.utils'; import { AppBuildMethodUtils } from '@/shared/utils/app-build-method.utils'; +import buildStatusService from './build-status.service'; declare global { var buildWatchServiceInstance: BuildWatchService | undefined; @@ -29,6 +30,8 @@ class BuildWatchService { this.isWatchRunning = true; console.log('[BuildWatch] Starting build job watch...'); + await buildStatusService.ensureSeeded(); + const kc = k3s.getKubeConfig(); const watch = new k8s.Watch(kc); @@ -38,6 +41,7 @@ class BuildWatchService { async (type: string, apiObj: unknown) => { try { const job = apiObj as V1Job; + await buildStatusService.applyJobEvent(type, job); await this.handleJobEvent(job); } catch (e) { console.error('[BuildWatch] Error handling job event:', e); diff --git a/src/server/services/standalone-services/build-watch.service.unit.spec.ts b/src/server/services/standalone-services/build-watch.service.unit.spec.ts index d1deb5c7..33cb0f3c 100644 --- a/src/server/services/standalone-services/build-watch.service.unit.spec.ts +++ b/src/server/services/standalone-services/build-watch.service.unit.spec.ts @@ -1,7 +1,12 @@ +const k8sMocks = vi.hoisted(() => ({ + watch: vi.fn(), + abort: vi.fn(), +})); + vi.mock('@kubernetes/client-node', async () => { const actual = await vi.importActual('@kubernetes/client-node'); class WatchMock { - watch = vi.fn().mockResolvedValue({ abort: vi.fn() }); + watch = k8sMocks.watch; } return { ...actual, @@ -20,6 +25,8 @@ vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ vi.mock('@/server/services/build.service', () => ({ default: { getJobStatusString: vi.fn(), + getAllBuilds: vi.fn().mockResolvedValue([]), + getBuildsForWorkload: vi.fn().mockResolvedValue([]), }, })); vi.mock('@/server/services/deployment.service', () => ({ @@ -44,9 +51,16 @@ vi.mock('@/server/services/app-git-ssh-key.service', () => ({ deleteTemporaryBuildSecret: vi.fn(), }, })); +vi.mock('@/server/services/standalone-services/build-status.service', () => ({ + default: { + ensureSeeded: vi.fn().mockResolvedValue(undefined), + applyJobEvent: vi.fn().mockResolvedValue(undefined), + }, +})); import buildService from '@/server/services/build.service'; import buildWatchService from '@/server/services/standalone-services/build-watch.service'; +import buildStatusService from '@/server/services/standalone-services/build-status.service'; import deploymentService from '@/server/services/deployment.service'; import appService from '@/server/services/app.service'; import appGitSshKeyService from '@/server/services/app-git-ssh-key.service'; @@ -55,6 +69,23 @@ describe('BuildWatchService', () => { beforeEach(() => { vi.clearAllMocks(); (buildWatchService as any).processedJobs.clear(); + (buildWatchService as any).isWatchRunning = false; + k8sMocks.watch.mockResolvedValue({ abort: k8sMocks.abort }); + }); + + it('seeds the build status service and forwards job events to it', async () => { + vi.mocked(buildService.getJobStatusString).mockReturnValue('PENDING'); + + await buildWatchService.startWatch(); + + expect(buildStatusService.ensureSeeded).toHaveBeenCalledTimes(1); + expect(k8sMocks.watch).toHaveBeenCalledTimes(1); + + const eventHandler = k8sMocks.watch.mock.calls[0][2] as (type: string, job: unknown) => Promise; + const job = { metadata: { name: 'build-1', annotations: { 'qs-app-id': 'app-1' } } }; + await eventHandler('MODIFIED', job); + + expect(buildStatusService.applyJobEvent).toHaveBeenCalledWith('MODIFIED', job); }); it('ignores pending jobs and does not trigger deployment work', async () => { diff --git a/src/shared/model/app-build-status.model.ts b/src/shared/model/app-build-status.model.ts new file mode 100644 index 00000000..d036acb4 --- /dev/null +++ b/src/shared/model/app-build-status.model.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { appBuildMethodZodModel } from "./app-source-info.model"; +import { buildJobStatusEnumZod } from "./build-job"; +import { zodWorkloadType } from "./runtime-type.model"; + +export const appBuildStatusEnumZod = z.union([buildJobStatusEnumZod, z.literal('NOT_BUILT')]); +export type AppBuildStatus = z.infer; + +export const appBuildStatusZodModel = z.object({ + workloadId: z.string(), + workloadType: zodWorkloadType, + workloadName: z.string(), + projectId: z.string(), + projectName: z.string(), + status: appBuildStatusEnumZod, + buildName: z.string().optional(), + gitCommit: z.string().optional(), + gitCommitMessage: z.string().optional(), + deploymentId: z.string().optional(), + buildMethod: appBuildMethodZodModel.optional(), + startedAt: z.date().optional(), + completionTime: z.date().optional(), +}); + +export type AppBuildStatusModel = z.infer; + +export function isActiveBuildStatus(status: AppBuildStatus): boolean { + return status === 'RUNNING' || status === 'PENDING'; +} From d849a581a07542189a7ae92cd28ffebb04f17320 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 20 Sep 2026 09:05:35 +0000 Subject: [PATCH 05/25] feat: enhance DrawerOverview and DrawerSettings components with improved layout and external URL handling --- .../drawer/drawer-overview.tsx | 109 +++--- .../drawer/drawer-settings.tsx | 6 +- .../drawer/settings-section.tsx | 2 +- .../node-details-drawer.tsx | 318 +++++++----------- 4 files changed, 177 insertions(+), 258 deletions(-) diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx index 314cd193..1f4b5515 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-overview.tsx @@ -4,75 +4,76 @@ import { ExternalLink } from 'lucide-react'; import type { ReactNode } from 'react'; import { Item, ItemActions, ItemContent, ItemGroup, ItemTitle } from '@/components/ui/item'; import { Separator } from '@/components/ui/separator'; -import { TabsContent } from '@/components/ui/tabs'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; +import BuildsTab from '@/app/project/app/[appId]/overview/deployments'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; export function DrawerOverview({ app, - connectionsContent, externalUrl, + role }: { app: AppExtendedModel; - connectionsContent: ReactNode; externalUrl?: string; + role: RolePermissionEnum }) { - return ( - - + return (<> + + + + + Image + + + + {app.sourceType === 'CONTAINER' + ? (app.containerImageSource ?? 'Not configured') + : (app.gitUrl ?? 'Not configured')} + + + + + + Replicas + + + {app.replicas} + + + + + Project + + + {app.project.name} + + {externalUrl && ( - Image + External URL - {app.sourceType === 'CONTAINER' - ? (app.containerImageSource ?? 'Not configured') - : (app.gitUrl ?? 'Not configured')} + + {externalUrl} + + - - - - Replicas - - - {app.replicas} - - - - - Project - - - {app.project.name} - - {externalUrl && ( - - - - External URL - - - - - {externalUrl} - - - - - )} - - -
-

Network Policies

- {connectionsContent} -
-
- ); + )} + + + + ); } diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx index f908b1ce..84947264 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx @@ -101,8 +101,8 @@ export function DrawerSettings({ }, [settingsSections]); return ( -
-
+
+
{app.appType !== 'APP' && (
-
- {logs.length} logs - - - Date - - - - - {logs.map((item, index) => ( - - {formatDate(item.date)} - - - - - ))} - -
: } - - - - ) -} diff --git a/src/app/project/app/[appId]/overview/logs.tsx b/src/app/project/app/[appId]/overview/logs.tsx index 2a6e5879..cfbea4f8 100644 --- a/src/app/project/app/[appId]/overview/logs.tsx +++ b/src/app/project/app/[appId]/overview/logs.tsx @@ -9,10 +9,9 @@ import FullLoadingSpinner from "@/components/ui/full-loading-spinnter"; import { toast } from "sonner"; import { LogsDialogContent } from "@/components/custom/logs-overlay"; import { Button } from "@/components/ui/button"; -import { Download, Expand, Terminal } from "lucide-react"; +import { Expand, Terminal } from "lucide-react"; import { TerminalDialog } from "./terminal-overlay"; -import { LogsDownloadOverlay } from "./logs-download-overlay"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"; import { useDialog, usePodsStatus } from "@/frontend/states/zustand.states"; import { cn } from "@/frontend/utils/utils"; @@ -146,22 +145,6 @@ export default function Logs({
} -
- - - - - - - - -

Download Logs

-
-
-
-
diff --git a/src/app/project/app/[appId]/overview/logs.unit.spec.ts b/src/app/project/app/[appId]/overview/logs.unit.spec.ts index 12563626..f7fac074 100644 --- a/src/app/project/app/[appId]/overview/logs.unit.spec.ts +++ b/src/app/project/app/[appId]/overview/logs.unit.spec.ts @@ -12,7 +12,6 @@ vi.mock('./actions', () => ({ vi.mock('../../../../../components/custom/logs-streamed', () => ({ default: () => null })); vi.mock('./terminal-overlay', () => ({ TerminalDialog: () => null })); -vi.mock('./logs-download-overlay', () => ({ LogsDownloadOverlay: () => null })); vi.mock('@/components/custom/logs-overlay', () => ({ LogsDialogContent: () => null })); const mockedGetPodsForApp = vi.mocked(getPodsForApp); diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 0fe0a1bb..5b0ffad3 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -16,7 +16,7 @@ import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.util import buildService from "@/server/services/build.service"; import standalonePodService from "@/server/services/standalone-services/standalone-pod.service"; import maintenanceService from "@/server/services/standalone-services/maintenance.service"; -import appLogsService from "@/server/services/standalone-services/app-logs.service"; +import deploymentLogService from "@/server/services/deployment-logs.service"; 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"; @@ -248,8 +248,8 @@ export const purgeRegistryImages = async () => export const deleteOldAppLogs = async () => simpleAction(async () => { await getAdminUserSession(); - await appLogsService.deleteOldAppLogs(); - return new SuccessActionResult(undefined, `Successfully deletes old app logs.`); + await deploymentLogService.deleteAllLogs(); + return new SuccessActionResult(undefined, 'Successfully deleted all deployment logs.'); }); export const setCanaryChannel = async (useCanaryChannel: boolean) => diff --git a/src/app/settings/platform/operations/qs-maintenance-settings.tsx b/src/app/settings/platform/operations/qs-maintenance-settings.tsx index 0d258d2d..ca10dea5 100644 --- a/src/app/settings/platform/operations/qs-maintenance-settings.tsx +++ b/src/app/settings/platform/operations/qs-maintenance-settings.tsx @@ -57,13 +57,13 @@ export default function QuickStackMaintenanceSettings({ + }}> Delete Deployment Logs -
, - document.body, - )} - {nodeMenu && (() => { - const app = apps.find(item => item.id === nodeMenu.appId); - const draft = drafts[nodeMenu.appId]; - if (!app || !draft) return null; - return createPortal( -
- {writable(app.id) && } - {canRenameApps && - - } - {canDeleteApps && } -
, - document.body, - ); - })()} {edges.length === 0 && nodes.length === 0 &&

No active network policy connections yet.

} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx index 84947264..9e929c23 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; +import { CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from '@/frontend/utils/utils'; import BasicAuth from '@/app/project/app/[appId]/advanced/basic-auth'; import { saveHealthCheck } from '@/app/project/app/[appId]/advanced/actions'; @@ -103,165 +104,175 @@ export function DrawerSettings({ return (
- {app.appType !== 'APP' && ( - + {app.appType !== 'APP' && ( + + + + )} + + + + + + + + + openNestedDrawer({ + title: 'Environment variables', + content: ( + ), }) } - > - Manage database credentials + /> + + + + + - )} - - - - - - - - - - openNestedDrawer({ - title: 'Environment variables', - content: ( - - ), - }) - } - /> - - - - - - - - - - - - - - - + +
+ + Health Checks + + Configure healthchecks so that k3s can automatically monitor when your application is fully started up and ready to receive traffic (In kubernetes terms, startup, readiness and liveness probes). + + + + + +
+
- {domain.port} - {domain.useSsl ? : } - {domain.useSsl && domain.redirectHttps ? : } + {!hideCard && <> + {domain.port} + {domain.useSsl ? : } + {domain.useSsl && domain.redirectHttps ? : } + } {!readonly &&
@@ -99,7 +103,7 @@ export default function DomainsCard({ domains, workloadId, workloadType, readonl {!readonly && } - + ; } From 50b1fa5dcb963762c596df49dc4d16dc8a0f87de Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 20 Sep 2026 14:12:20 +0000 Subject: [PATCH 14/25] feat: integrate app lifecycle management and enhance project network graph with drawer session handling --- .../app-components/project-network-graph.tsx | 72 ++--------- .../node-details-drawer.tsx | 53 +++----- ...project-network-graph-app-context-menu.tsx | 28 ++-- ...oject-network-graph-drawer-session.spec.ts | 32 +++++ .../project-network-graph-drawer-session.ts | 120 ++++++++++++++++++ src/frontend/utils/app-lifecycle.utils.ts | 37 ++++++ .../utils/app-lifecycle.utils.unit.spec.ts | 43 +++++++ 7 files changed, 273 insertions(+), 112 deletions(-) create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts create mode 100644 src/frontend/utils/app-lifecycle.utils.ts create mode 100644 src/frontend/utils/app-lifecycle.utils.unit.spec.ts diff --git a/src/app/project/[projectId]/app-components/project-network-graph.tsx b/src/app/project/[projectId]/app-components/project-network-graph.tsx index 71ca497c..3a3615da 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -53,7 +53,7 @@ import { deleteApp } from '@/app/project/[projectId]/actions'; import type { ProjectNetworkGraphPositions } from '@/shared/model/project-network-graph-layout.model'; import type { S3Target } from '@prisma/client'; import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; -import { TabNavigationUtils } from '@/frontend/utils/tab-navigation.utils'; +import { useProjectNetworkGraphDrawerSession } from './project-network-graph/project-network-graph-drawer-session'; const hiddenHandleClassName = 'size-1.5! border-0! bg-transparent! opacity-0! pointer-events-none'; const connectionSourceHandleClassName = 'z-20! size-4! border-2! border-background! bg-qs-500! opacity-0! shadow-md! transition-all duration-150 group-hover:opacity-100! [&.connectingfrom]:opacity-0! hover:bg-qs-600!'; @@ -186,9 +186,6 @@ function ProjectNetworkGraphEditor({ const [drafts, setDrafts] = useState>(() => AppNetworkPolicyDraftUtils.collectionFromApps(apps)); const [baseline, setBaseline] = useState>(() => AppNetworkPolicyDraftUtils.collectionFromApps(apps)); const [saving, setSaving] = useState(false); - const [selectedNodeId, setSelectedNodeId] = useState(); - const [isNodeDrawerOpen, setIsNodeDrawerOpen] = useState(false); - const [drawerTab, setDrawerTab] = useState(() => searchParams.get('drawerTab')); const [connectionSourceNodeId, setConnectionSourceNodeId] = useState(); const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(); const graphContainerRef = useRef(null); @@ -200,37 +197,11 @@ function ProjectNetworkGraphEditor({ const { layout, saveNodePosition, resetLayout } = useProjectNetworkGraph(graphApps, projectId, savedPositions); const dirty = Object.keys(drafts).some(appId => !AppNetworkPolicyDraftUtils.equals(drafts[appId], baseline[appId])); const localAppIds = useMemo(() => new Set(apps.map(app => app.id)), [apps]); - - const updateDrawerQuery = useCallback((appId?: string, tab?: string) => { - setDrawerTab(appId ? (tab ?? 'deployments') : null); - const params = new URLSearchParams(searchParams.toString()); - - if (!appId) { - params.delete('drawerAppId'); - params.delete('drawerTab'); - } else { - params.set('drawerAppId', appId); - params.set('drawerTab', tab ?? 'deployments'); - } - - TabNavigationUtils.replaceQuery(params); - }, [searchParams]); - - useEffect(() => { - setDrawerTab(searchParams.get('drawerTab')); - }, [searchParams]); - - useEffect(() => { - const requestedAppId = searchParams.get('drawerAppId'); - - if (!requestedAppId) return; - if (!localAppIds.has(requestedAppId)) { - updateDrawerQuery(); - return; - } - - setSelectedNodeId(`APP:${requestedAppId}`); - }, [localAppIds, searchParams, updateDrawerQuery]); + const drawerSession = useProjectNetworkGraphDrawerSession({ + searchParams, + appIds: localAppIds, + }); + const { selectedNodeId } = drawerSession; const writable = useCallback( (appId: string) => UserGroupUtils.sessionHasWriteAccessForApp(session, appId), [session], @@ -342,8 +313,7 @@ function ProjectNetworkGraphEditor({ allowInternetAccess: draft.allowInternetAccess, onToggleInternetAccess: () => toggleInternetAccess(app.id), onOpenDrawerTab: (tab: string) => { - setSelectedNodeId(node.id); - updateDrawerQuery(app.id, tab); + drawerSession.openAppTab(app.id, tab); }, onDelete: () => void deleteLocalApp(app.id), } : undefined, @@ -353,7 +323,7 @@ function ProjectNetworkGraphEditor({ ), }, }; - }), [apps, connectionSourceNodeId, connectionTargetNodeId, deleteLocalApp, drafts, layout?.edges, layout?.nodes, projectId, selectedNodeId, session, toggleInternetAccess, updateDrawerQuery]); + }), [apps, connectionSourceNodeId, connectionTargetNodeId, deleteLocalApp, drafts, drawerSession, layout?.edges, layout?.nodes, projectId, selectedNodeId, session, toggleInternetAccess]); const [nodes, setNodes, onNodesChange] = useNodesState(projectedNodes); useEffect(() => setNodes(projectedNodes), [projectedNodes, setNodes]); const edges = useMemo(() => (layout?.edges ?? []).map(edge => { @@ -389,10 +359,6 @@ function ProjectNetworkGraphEditor({ const selectedNode = nodes.find(node => node.id === selectedNodeId)?.data as NetworkGraphNode | undefined; const selectedApp = selectedNode?.kind === 'APP' ? apps.find(app => app.id === selectedNode.id.replace('APP:', '')) : undefined; const selectedAppRole = selectedApp ? UserGroupUtils.getRolePermissionForApp(session, selectedApp.id) ?? undefined : undefined; - useEffect(() => { - if (selectedNodeId) setIsNodeDrawerOpen(true); - }, [selectedNodeId]); - useEffect(() => { if (!selectedNodeId || selectedNode?.kind !== 'APP') return; @@ -519,12 +485,7 @@ function ProjectNetworkGraphEditor({ onNodeClick={(_event, node) => { const data = node.data as NetworkGraphNode; if (data.kind !== 'INTERNET') { - setSelectedNodeId(node.id); - if (data.kind === 'APP') { - updateDrawerQuery(node.id.replace('APP:', '')); - } else { - updateDrawerQuery(); - } + drawerSession.selectNode(data); } }} > @@ -566,17 +527,12 @@ function ProjectNetworkGraphEditor({ storageClasses={storageClasses} volumeBackups={selectedApp ? (volumeBackupsByApp[selectedApp.id] ?? []) : []} gitSshPublicKey={selectedApp ? gitSshPublicKeysByApp[selectedApp.id] : undefined} - open={isNodeDrawerOpen} - onOpenChange={open => { - setIsNodeDrawerOpen(open); - if (!open) updateDrawerQuery(); - }} - onOpenChangeComplete={open => { - if (!open) setSelectedNodeId(undefined); - }} - requestedTab={drawerTab} + open={drawerSession.open} + onOpenChange={drawerSession.onOpenChange} + onOpenChangeComplete={drawerSession.onOpenChangeComplete} + requestedTab={drawerSession.requestedTab} onTabChange={tab => { - if (selectedApp) updateDrawerQuery(selectedApp.id, tab); + if (selectedApp) drawerSession.openAppTab(selectedApp.id, tab); }} />}
diff --git a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx index 118dc640..b0250461 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx @@ -43,6 +43,7 @@ import { deploy, startApp, stopApp } from '@/app/project/app/[appId]/actions'; import { usePodsStatus } from '@/frontend/states/zustand.states'; import { cn } from '@/frontend/utils/utils'; import { AppSourceUtils } from '@/frontend/utils/app-source.utils'; +import { AppLifecycleUtils } from '@/frontend/utils/app-lifecycle.utils'; import { Toast } from '@/frontend/utils/toast.utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import Logs from '@/app/project/app/[appId]/overview/logs'; @@ -57,6 +58,10 @@ import { DrawerSettings } from './drawer/drawer-settings'; import { NestedDrawerProvider } from './drawer/nested-drawer'; import type { S3Target } from '@prisma/client'; import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; +import { + DrawerSessionUtils, + type DrawerTab, +} from './project-network-graph-drawer-session'; export type PanelConnection = { id: string; @@ -66,13 +71,6 @@ export type PanelConnection = { copyValue?: string; }; -const drawerTabValues = ['deployments', 'credentials', 'logs', 'stats', 'settings'] as const; -type DrawerTab = (typeof drawerTabValues)[number]; - -function isDrawerTab(value: string | null | undefined): value is DrawerTab { - return drawerTabValues.includes(value as DrawerTab); -} - function AppStatusActions({ app, role, @@ -83,18 +81,7 @@ function AppStatusActions({ const deploymentStatus = usePodsStatus( (state) => state.podsStatus.get(app.id)?.deploymentStatus ?? 'UNKNOWN', ); - const canManage = role === RolePermissionEnum.READWRITE; - const appSourceIsConfigured = AppSourceUtils.isConfiguredSource(app); - const canStart = ['ERROR', 'UNKNOWN', 'SHUTDOWN', 'SHUTTING_DOWN'].includes( - deploymentStatus, - ); - const canStop = [ - 'BUILDING', - 'DEPLOYED', - 'ERROR', - 'UNKNOWN', - 'DEPLOYING', - ].includes(deploymentStatus); + const lifecycle = AppLifecycleUtils.availability(app, role, deploymentStatus); const openDomain = (domain: AppExtendedModel['appDomains'][number]) => { const protocol = domain.useSsl ? 'https' : 'http'; @@ -104,10 +91,10 @@ function AppStatusActions({ return (
- {(canManage || app.appDomains.length > 0) && ( + {(lifecycle.canManage || app.appDomains.length > 0) && (
- {canManage && ( + {lifecycle.canManage && ( <> void Toast.fromAction(() => deploy(app.id))} > @@ -126,8 +113,7 @@ function AppStatusActions({ /> Deploy - {app.appType === 'APP' && - (app.sourceType === 'GIT' || app.sourceType === 'GIT_SSH') && ( + {lifecycle.supportsRebuild && ( void Toast.fromAction(() => deploy(app.id, true))} > @@ -153,7 +139,7 @@ function AppStatusActions({ type="button" variant="ghost" size="icon-sm" - disabled={!canStart || !appSourceIsConfigured} + disabled={!lifecycle.canStart} onClick={() => void Toast.fromAction(() => startApp(app.id))} > @@ -171,7 +157,7 @@ function AppStatusActions({ variant="ghost" size="icon-sm" className="hover:bg-destructive/10 hover:text-destructive" - disabled={!canStop || !appSourceIsConfigured} + disabled={!lifecycle.canStop} onClick={() => void Toast.fromAction(() => stopApp(app.id))} > @@ -271,12 +257,7 @@ export function NodeDetailsDrawer({ role === RolePermissionEnum.READWRITE && !AppSourceUtils.isConfiguredSource(app); - const defaultTab: DrawerTab = - app?.appType !== 'APP' && requestedTab === 'credentials' - ? 'credentials' - : isDrawerTab(requestedTab) - ? requestedTab - : 'deployments'; + const defaultTab = DrawerSessionUtils.resolveTab(app?.appType, requestedTab); const [activeTab, setActiveTab] = useState(defaultTab); useEffect(() => { @@ -284,9 +265,9 @@ export function NodeDetailsDrawer({ }, [app?.id, defaultTab]); const handleTabChange = (tab: string) => { - if (!isDrawerTab(tab)) return; - setActiveTab(tab); - onTabChange(tab); + const nextTab = DrawerSessionUtils.resolveTab(app?.appType, tab); + setActiveTab(nextTab); + onTabChange(nextTab); }; return ( diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx index e0e89161..9c71033d 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx @@ -15,7 +15,7 @@ import { import { deploy, startApp, stopApp } from '@/app/project/app/[appId]/actions'; import { EditAppDialog } from '../edit-app-dialog'; import { usePodsStatus } from '@/frontend/states/zustand.states'; -import { AppSourceUtils } from '@/frontend/utils/app-source.utils'; +import { AppLifecycleUtils } from '@/frontend/utils/app-lifecycle.utils'; import { Toast } from '@/frontend/utils/toast.utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; @@ -41,23 +41,16 @@ export function ProjectNetworkGraphAppContextMenu({ children, onOpenDrawerTab }: ProjectNetworkGraphAppContextMenuProps) { - const canWrite = role === RolePermissionEnum.READWRITE; const deploymentStatus = usePodsStatus( (state) => state.podsStatus.get(app.id)?.deploymentStatus ?? 'UNKNOWN', ); - const appSourceIsConfigured = AppSourceUtils.isConfiguredSource(app); - const canStart = ['ERROR', 'UNKNOWN', 'SHUTDOWN', 'SHUTTING_DOWN'].includes( - deploymentStatus, - ); - const canStop = ['BUILDING', 'DEPLOYED', 'ERROR', 'UNKNOWN', 'DEPLOYING'].includes( - deploymentStatus, - ); + const lifecycle = AppLifecycleUtils.availability(app, role, deploymentStatus); return ( {children} event.stopPropagation()}> - {canWrite && <> + {lifecycle.canManage && <> @@ -65,29 +58,28 @@ export function ProjectNetworkGraphAppContextMenu({ event.stopPropagation()}> void Toast.fromAction(() => deploy(app.id))} > Deploy - {app.appType === 'APP' - && (app.sourceType === 'GIT' || app.sourceType === 'GIT_SSH') && void Toast.fromAction(() => deploy(app.id, true))} > Rebuild } void Toast.fromAction(() => startApp(app.id))} > Start void Toast.fromAction(() => stopApp(app.id))} > @@ -114,13 +106,13 @@ export function ProjectNetworkGraphAppContextMenu({ {allowInternetAccess ? 'Disable' : 'Enable'} Egress Internet Access } - {canWrite && + {lifecycle.canManage && Edit App Name } - {canWrite && + {lifecycle.canManage && Delete App } diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts new file mode 100644 index 00000000..a900d453 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react'; +import { + DrawerSessionUtils, + useProjectNetworkGraphDrawerSession, +} from './project-network-graph-drawer-session'; + +describe('DrawerSessionUtils.resolveTab', () => { + test.each([ + ['APP', 'credentials', 'deployments'], + ['POSTGRES', 'credentials', 'credentials'], + ['APP', 'logs', 'logs'], + ['APP', 'unknown', 'deployments'], + ['APP', null, 'deployments'], + ] as const)('normalizes %s requested tab %s to %s', (appType, requestedTab, expected) => { + expect(DrawerSessionUtils.resolveTab(appType, requestedTab)).toBe(expected); + }); +}); + +describe('useProjectNetworkGraphDrawerSession', () => { + test('keeps its interface stable across an unrelated parent render', () => { + const searchParams = new URLSearchParams('drawerTab=deployments'); + const appIds = new Set(['app-1']); + const { result, rerender } = renderHook(() => + useProjectNetworkGraphDrawerSession({ searchParams, appIds }), + ); + + const session = result.current; + rerender(); + + expect(result.current).toBe(session); + }); +}); diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts new file mode 100644 index 00000000..e4f9dbff --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts @@ -0,0 +1,120 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { TabNavigationUtils } from '@/frontend/utils/tab-navigation.utils'; +import type { NetworkGraphNode } from './project-network-graph-projection'; + +export const drawerTabValues = [ + 'deployments', + 'credentials', + 'logs', + 'stats', + 'settings', +] as const; + +export type DrawerTab = (typeof drawerTabValues)[number]; + +export class DrawerSessionUtils { + static resolveTab(appType: string | undefined, requestedTab: string | null | undefined): DrawerTab { + if (requestedTab === 'credentials' && appType !== 'APP') { + return 'credentials'; + } + + if ( + requestedTab !== 'credentials' + && drawerTabValues.includes(requestedTab as DrawerTab) + ) { + return requestedTab as DrawerTab; + } + + return 'deployments'; + } +} + +type QueryParams = Pick; + +export function useProjectNetworkGraphDrawerSession({ + searchParams, + appIds, +}: { + searchParams: QueryParams; + appIds: Set; +}) { + const [selectedNodeId, setSelectedNodeId] = useState(); + const [open, setOpen] = useState(false); + const [requestedTab, setRequestedTab] = useState( + () => searchParams.get('drawerTab'), + ); + + const updateQuery = useCallback((appId?: string, tab?: DrawerTab) => { + setRequestedTab(appId ? (tab ?? 'deployments') : null); + const params = new URLSearchParams(searchParams.toString()); + + if (!appId) { + params.delete('drawerAppId'); + params.delete('drawerTab'); + } else { + params.set('drawerAppId', appId); + params.set('drawerTab', tab ?? 'deployments'); + } + + TabNavigationUtils.replaceQuery(params); + }, [searchParams]); + + useEffect(() => { + setRequestedTab(searchParams.get('drawerTab')); + }, [searchParams]); + + useEffect(() => { + const requestedAppId = searchParams.get('drawerAppId'); + + if (!requestedAppId) return; + if (!appIds.has(requestedAppId)) { + updateQuery(); + return; + } + + setSelectedNodeId(`APP:${requestedAppId}`); + }, [appIds, searchParams, updateQuery]); + + useEffect(() => { + if (selectedNodeId) setOpen(true); + }, [selectedNodeId]); + + const selectNode = useCallback((node: NetworkGraphNode) => { + setSelectedNodeId(node.id); + updateQuery(node.kind === 'APP' ? node.id.replace('APP:', '') : undefined); + }, [updateQuery]); + + const openAppTab = useCallback((appId: string, tab: DrawerTab) => { + setSelectedNodeId(`APP:${appId}`); + updateQuery(appId, tab); + }, [updateQuery]); + + const onOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) updateQuery(); + }, [updateQuery]); + + const onOpenChangeComplete = useCallback((nextOpen: boolean) => { + if (!nextOpen) setSelectedNodeId(undefined); + }, []); + + return useMemo(() => ({ + selectedNodeId, + open, + requestedTab, + selectNode, + openAppTab, + onOpenChange, + onOpenChangeComplete, + }), [ + onOpenChange, + onOpenChangeComplete, + open, + openAppTab, + requestedTab, + selectNode, + selectedNodeId, + ]); +} diff --git a/src/frontend/utils/app-lifecycle.utils.ts b/src/frontend/utils/app-lifecycle.utils.ts new file mode 100644 index 00000000..ac397ca3 --- /dev/null +++ b/src/frontend/utils/app-lifecycle.utils.ts @@ -0,0 +1,37 @@ +import type { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; +import { AppSourceUtils } from './app-source.utils'; + +export class AppLifecycleUtils { + static availability( + app: AppExtendedModel, + role: RolePermissionEnum | undefined, + deploymentStatus: string, + ) { + const canManage = role === RolePermissionEnum.READWRITE; + const sourceConfigured = AppSourceUtils.isConfiguredSource(app); + const canDeploy = canManage && sourceConfigured; + + return { + canManage, + canDeploy, + supportsRebuild: + app.appType === 'APP' + && (app.sourceType === 'GIT' || app.sourceType === 'GIT_SSH'), + canRebuild: + canDeploy + && app.appType === 'APP' + && (app.sourceType === 'GIT' || app.sourceType === 'GIT_SSH'), + canStart: + canDeploy + && ['ERROR', 'UNKNOWN', 'SHUTDOWN', 'SHUTTING_DOWN'].includes( + deploymentStatus, + ), + canStop: + canDeploy + && ['BUILDING', 'DEPLOYED', 'ERROR', 'UNKNOWN', 'DEPLOYING'].includes( + deploymentStatus, + ), + }; + } +} diff --git a/src/frontend/utils/app-lifecycle.utils.unit.spec.ts b/src/frontend/utils/app-lifecycle.utils.unit.spec.ts new file mode 100644 index 00000000..135a8069 --- /dev/null +++ b/src/frontend/utils/app-lifecycle.utils.unit.spec.ts @@ -0,0 +1,43 @@ +import { AppLifecycleUtils } from './app-lifecycle.utils'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; +import type { AppExtendedModel } from '@/shared/model/app-extended.model'; + +const app = { + appType: 'APP', + sourceType: 'GIT', + gitUrl: 'https://github.com/quickstack/app.git', + gitBranch: 'main', +} as AppExtendedModel; + +describe('AppLifecycleUtils.availability', () => { + test.each([ + ['DEPLOYED', true, true, false], + ['SHUTDOWN', true, false, true], + ['BUILDING', true, true, false], + ['UNKNOWN', true, true, true], + ])('maps %s to the permitted App lifecycle actions', (status, canDeploy, canStop, canStart) => { + expect(AppLifecycleUtils.availability(app, RolePermissionEnum.READWRITE, status)).toMatchObject({ + canDeploy, + supportsRebuild: true, + canRebuild: true, + canStart, + canStop, + }); + }); + + test('denies every App lifecycle action without a configured Source or write permission', () => { + expect(AppLifecycleUtils.availability({ ...app, gitBranch: '' }, RolePermissionEnum.READWRITE, 'UNKNOWN')).toMatchObject({ + canDeploy: false, + supportsRebuild: true, + canRebuild: false, + canStart: false, + canStop: false, + }); + expect(AppLifecycleUtils.availability(app, undefined, 'UNKNOWN')).toMatchObject({ + canManage: false, + canDeploy: false, + canStart: false, + canStop: false, + }); + }); +}); From e4708deaf86322cb5ea9246e84cc34c8a2c306b1 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 20 Sep 2026 14:25:08 +0000 Subject: [PATCH 15/25] feat: moved app rename dialog to drawer --- src/app/project/[projectId]/actions.ts | 2 +- .../app-components/edit-app-dialog.tsx | 12 ++++++-- .../app-components/project-network-graph.tsx | 29 +++++++++++++++---- .../node-details-drawer.tsx | 22 ++++++++++++-- ...project-network-graph-app-context-menu.tsx | 11 +------ 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/app/project/[projectId]/actions.ts b/src/app/project/[projectId]/actions.ts index 02463b3d..9167d058 100644 --- a/src/app/project/[projectId]/actions.ts +++ b/src/app/project/[projectId]/actions.ts @@ -56,7 +56,7 @@ export const createApp = async (appName: string, projectId: string, appId?: stri projectId }); - return new SuccessActionResult(returnData, "App created successfully."); + return new SuccessActionResult(returnData, "Saved successfully."); }); export const createAppFromTemplate = async (prevState: any, inputData: AppTemplateModel, projectId: string) => diff --git a/src/app/project/[projectId]/app-components/edit-app-dialog.tsx b/src/app/project/[projectId]/app-components/edit-app-dialog.tsx index 81a04f67..db337dd2 100644 --- a/src/app/project/[projectId]/app-components/edit-app-dialog.tsx +++ b/src/app/project/[projectId]/app-components/edit-app-dialog.tsx @@ -3,6 +3,7 @@ import { Toast } from "@/frontend/utils/toast.utils"; import { createApp } from "../actions"; import { useRouter } from "next/navigation"; +import { cloneElement, type MouseEvent, type ReactElement } from "react"; import type { App } from "@prisma/client"; import { useInputDialog } from "@/frontend/states/zustand.states"; @@ -12,7 +13,7 @@ export function EditAppDialog({ existingItem, openAppAfterCreate = true }: { - children?: React.ReactNode, + children?: ReactElement<{ onClick?: (event: MouseEvent) => void }>; projectId: string; existingItem?: Pick; openAppAfterCreate?: boolean; @@ -35,5 +36,12 @@ export function EditAppDialog({ } }; - return
createAppFunc()}>{children}
+ if (!children) return null; + + return cloneElement(children, { + onClick: (event) => { + children.props.onClick?.(event); + void createAppFunc(); + }, + }); } diff --git a/src/app/project/[projectId]/app-components/project-network-graph.tsx b/src/app/project/[projectId]/app-components/project-network-graph.tsx index 3a3615da..e3fcaa45 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -1,7 +1,7 @@ 'use client'; import type { CSSProperties } from 'react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Background, @@ -53,7 +53,10 @@ import { deleteApp } from '@/app/project/[projectId]/actions'; import type { ProjectNetworkGraphPositions } from '@/shared/model/project-network-graph-layout.model'; import type { S3Target } from '@prisma/client'; import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; -import { useProjectNetworkGraphDrawerSession } from './project-network-graph/project-network-graph-drawer-session'; +import { + type DrawerTab, + useProjectNetworkGraphDrawerSession, +} from './project-network-graph/project-network-graph-drawer-session'; const hiddenHandleClassName = 'size-1.5! border-0! bg-transparent! opacity-0! pointer-events-none'; const connectionSourceHandleClassName = 'z-20! size-4! border-2! border-background! bg-qs-500! opacity-0! shadow-md! transition-all duration-150 group-hover:opacity-100! [&.connectingfrom]:opacity-0! hover:bg-qs-600!'; @@ -188,6 +191,7 @@ function ProjectNetworkGraphEditor({ const [saving, setSaving] = useState(false); const [connectionSourceNodeId, setConnectionSourceNodeId] = useState(); const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(); + const [graphHeight, setGraphHeight] = useState(); const graphContainerRef = useRef(null); const drawerContentRef = useRef(null); const reactFlowRef = useRef>(null); @@ -210,6 +214,19 @@ function ProjectNetworkGraphEditor({ () => new Set(apps.filter(app => writable(app.id)).map(app => app.id)), [apps, writable], ); + + useLayoutEffect(() => { + const updateGraphHeight = () => { + const graphContainer = graphContainerRef.current; + if (!graphContainer) return; + + setGraphHeight(Math.max(0, window.innerHeight - graphContainer.getBoundingClientRect().top)); + }; + + updateGraphHeight(); + window.addEventListener('resize', updateGraphHeight); + return () => window.removeEventListener('resize', updateGraphHeight); + }, []); const cancelConnectionTargetLeave = () => { if (connectionTargetLeaveTimer.current) clearTimeout(connectionTargetLeaveTimer.current); }; @@ -308,11 +325,10 @@ function ProjectNetworkGraphEditor({ selected: node.id === selectedNodeId, contextMenu: app && draft && role === RolePermissionEnum.READWRITE ? { app, - projectId, role, allowInternetAccess: draft.allowInternetAccess, onToggleInternetAccess: () => toggleInternetAccess(app.id), - onOpenDrawerTab: (tab: string) => { + onOpenDrawerTab: (tab: DrawerTab) => { drawerSession.openAppTab(app.id, tab); }, onDelete: () => void deleteLocalApp(app.id), @@ -323,7 +339,7 @@ function ProjectNetworkGraphEditor({ ), }, }; - }), [apps, connectionSourceNodeId, connectionTargetNodeId, deleteLocalApp, drafts, drawerSession, layout?.edges, layout?.nodes, projectId, selectedNodeId, session, toggleInternetAccess]); + }), [apps, connectionSourceNodeId, connectionTargetNodeId, deleteLocalApp, drafts, drawerSession, layout?.edges, layout?.nodes, selectedNodeId, session, toggleInternetAccess]); const [nodes, setNodes, onNodesChange] = useNodesState(projectedNodes); useEffect(() => setNodes(projectedNodes), [projectedNodes, setNodes]); const edges = useMemo(() => (layout?.edges ?? []).map(edge => { @@ -395,7 +411,8 @@ function ProjectNetworkGraphEditor({
diff --git a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx index b0250461..69a9fa89 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx @@ -12,6 +12,7 @@ import { Play, Rocket, Logs as LogsIcon, + Pencil, Settings, Square, X, @@ -56,6 +57,7 @@ import DbCredentials from '@/app/project/app/[appId]/credentials/db-crendentials import DbToolsCard from '@/app/project/app/[appId]/credentials/db-tools'; import { DrawerSettings } from './drawer/drawer-settings'; import { NestedDrawerProvider } from './drawer/nested-drawer'; +import { EditAppDialog } from '../edit-app-dialog'; import type { S3Target } from '@prisma/client'; import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; import { @@ -316,8 +318,24 @@ export function NodeDetailsDrawer({ )}
- - {node.name} + + {node.name} + {app && role === RolePermissionEnum.READWRITE && ( + + + + )} {node.caption ?? diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx index 9c71033d..297bfc10 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx @@ -1,7 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; -import { Box, Edit2, Globe2, Hammer, Logs, Play, Rocket, Settings, Square, Trash2 } from 'lucide-react'; +import { Box, Globe2, Hammer, Logs, Play, Rocket, Settings, Square, Trash2 } from 'lucide-react'; import { ContextMenu, ContextMenuContent, @@ -13,7 +13,6 @@ import { ContextMenuTrigger, } from '@/components/ui/context-menu'; import { deploy, startApp, stopApp } from '@/app/project/app/[appId]/actions'; -import { EditAppDialog } from '../edit-app-dialog'; import { usePodsStatus } from '@/frontend/states/zustand.states'; import { AppLifecycleUtils } from '@/frontend/utils/app-lifecycle.utils'; import { Toast } from '@/frontend/utils/toast.utils'; @@ -22,7 +21,6 @@ import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; export type ProjectNetworkGraphAppContextMenuProps = { app: AppExtendedModel; - projectId: string; role?: RolePermissionEnum; allowInternetAccess: boolean; onToggleInternetAccess: () => void; @@ -33,7 +31,6 @@ export type ProjectNetworkGraphAppContextMenuProps = { export function ProjectNetworkGraphAppContextMenu({ app, - projectId, role, allowInternetAccess, onToggleInternetAccess, @@ -106,12 +103,6 @@ export function ProjectNetworkGraphAppContextMenu({ {allowInternetAccess ? 'Disable' : 'Enable'} Egress Internet Access } - {lifecycle.canManage && - - - Edit App Name - - } {lifecycle.canManage && Delete App From 9eee28f31108e4dbb56563368ee37444c2c5afdf Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 21 Sep 2026 05:40:47 +0000 Subject: [PATCH 16/25] feat: enhance nested drawer with deployment logs functionality and improve BuildsTab integration --- .../drawer-deployments-tab.tsx | 37 ++++++++++++++++ .../drawer/nested-drawer.tsx | 7 +++ .../node-details-drawer.tsx | 43 +++++++++---------- .../[appId]/overview/build-logs-overlay.tsx | 16 ++++--- .../app/[appId]/overview/deployments.tsx | 6 ++- 5 files changed, 79 insertions(+), 30 deletions(-) create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx new file mode 100644 index 00000000..08a35771 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx @@ -0,0 +1,37 @@ +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"; +import { useNestedDrawer } from "./drawer/nested-drawer"; +import { DeploymentInfoModel } from "@/shared/model/deployment-info.model"; +import { BuildLogsDialogContent } from "@/app/project/app/[appId]/overview/build-logs-overlay"; +import BuildsTab from "@/app/project/app/[appId]/overview/deployments"; +import { formatDateTime } from "@/frontend/utils/format.utils"; + + + + +export default function DrawerDeploymentsTab({ + app, + role, +}: { + app: AppExtendedModel; + role: RolePermissionEnum; +}) { + const { openNestedDrawer } = useNestedDrawer(); + + const showLogs = (deployment: DeploymentInfoModel) => { + openNestedDrawer({ + title: 'Deployment Logs', + description: `View the logs for the selected deployment ${formatDateTime(deployment.createdAt)}.`, + content: ( + + ), + }); + }; + + return ; +} \ No newline at end of file diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx index bad2b064..2e0d709b 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Drawer, DrawerContent, + DrawerDescription, DrawerHeader, DrawerTitle, } from '@/components/ui/drawer'; @@ -19,6 +20,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; type NestedDrawerOptions = { title: string; + description?: ReactNode; content: ReactNode; }; @@ -63,6 +65,11 @@ export function NestedDrawerProvider({ children }: { children: ReactNode }) { {drawer?.title} + {drawer?.description && ( + + {drawer.description} + + )}
diff --git a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx index 69a9fa89..1acc3df9 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx @@ -48,7 +48,6 @@ import { AppLifecycleUtils } from '@/frontend/utils/app-lifecycle.utils'; import { Toast } from '@/frontend/utils/toast.utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import Logs from '@/app/project/app/[appId]/overview/logs'; -import BuildsTab from '@/app/project/app/[appId]/overview/deployments'; import MonitoringTab from '@/app/project/app/[appId]/overview/monitoring-app'; import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; import type { NetworkGraphNode } from './project-network-graph-projection'; @@ -64,6 +63,7 @@ import { DrawerSessionUtils, type DrawerTab, } from './project-network-graph-drawer-session'; +import DrawerDeploymentsTab from './drawer-deployments-tab'; export type PanelConnection = { id: string; @@ -116,24 +116,24 @@ function AppStatusActions({ Deploy {lifecycle.supportsRebuild && ( - - void Toast.fromAction(() => deploy(app.id, true))} - > - - Rebuild - - } - /> - Rebuild - - )} + + void Toast.fromAction(() => deploy(app.id, true))} + > + + Rebuild + + } + /> + Rebuild + + )} Open domain - + )} {app.appDomains.length > 1 && ( @@ -396,11 +396,10 @@ export function NodeDetailsDrawer({ value="deployments" className="mb-4" > - diff --git a/src/app/project/app/[appId]/overview/build-logs-overlay.tsx b/src/app/project/app/[appId]/overview/build-logs-overlay.tsx index a903498a..2eeb10b7 100644 --- a/src/app/project/app/[appId]/overview/build-logs-overlay.tsx +++ b/src/app/project/app/[appId]/overview/build-logs-overlay.tsx @@ -13,10 +13,12 @@ export function BuildLogsDialogContent({ deploymentInfo, workloadId, workloadType, + hideHeader = false, }: { deploymentInfo?: DeploymentInfoModel; workloadId?: string; workloadType?: WorkloadType; + hideHeader?: boolean; }) { if (!deploymentInfo) { @@ -25,12 +27,14 @@ export function BuildLogsDialogContent({ return ( <> - - Deployment Logs - - View the logs for the selected deployment {formatDateTime(deploymentInfo.createdAt)}. - - + {!hideHeader && ( + + Deployment Logs + + View the logs for the selected deployment {formatDateTime(deploymentInfo.createdAt)}. + + + )}
{(!deploymentInfo.deploymentId || !workloadId || !workloadType) && 'For this build is no log available'} {deploymentInfo.deploymentId && workloadId && workloadType && } diff --git a/src/app/project/app/[appId]/overview/deployments.tsx b/src/app/project/app/[appId]/overview/deployments.tsx index 740811e8..4585367b 100644 --- a/src/app/project/app/[appId]/overview/deployments.tsx +++ b/src/app/project/app/[appId]/overview/deployments.tsx @@ -29,10 +29,12 @@ export default function BuildsTab({ app, role, view = 'default', + onShowLogs: onShowLogsOverride, }: { app: AppExtendedModel; role: RolePermissionEnum; view?: BuildsTabView; + onShowLogs?: (deployment: DeploymentInfoModel) => void; }) { const { openConfirmDialog } = useConfirmDialog(); const { openDialog } = useDialog(); @@ -78,7 +80,7 @@ export default function BuildsTab({ await updateBuilds(); }; - const showLogs = (deployment: DeploymentInfoModel) => + const showLogs = onShowLogsOverride ?? ((deployment: DeploymentInfoModel) => openDialog( , { maxWidth: '1300px' }, - ); + )); const canStopBuild = (deployment: DeploymentInfoModel) => role === RolePermissionEnum.READWRITE && !!deployment.buildJobName From 91ed573d60e00f7851a6bc3f1effbfc56b5dab20 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 21 Sep 2026 06:01:19 +0000 Subject: [PATCH 17/25] feat: implement build and pods status streaming services with SSE and enhance related components --- src/app/api/build-status/route.ts | 22 ++++++++++ src/app/api/deployment-status/route.ts | 22 ++++++++++ src/app/layout.tsx | 2 +- .../app-components/project-network-graph.tsx | 3 +- .../node-details-drawer.tsx | 10 +---- ...oject-network-graph-drawer-session.spec.ts | 29 ++++++++++++- .../project-network-graph-drawer-session.ts | 42 +++++-------------- .../custom/build-status-polling-provider.tsx | 2 +- .../custom/pods-status-polling-provider.tsx | 2 +- ...service.ts => build-status-sse.service.ts} | 32 +++++++++----- ....service.ts => pods-status-sse.service.ts} | 31 +++++++++----- .../build-status-pub-sub.service.ts | 28 +++++++++---- .../build-status.service.unit.spec.ts | 19 +++++++++ .../build-watch.service.ts | 14 +++++-- .../build-watch.service.unit.spec.ts | 20 +++++++++ 15 files changed, 198 insertions(+), 80 deletions(-) rename src/frontend/services/{build-status-polling.service.ts => build-status-sse.service.ts} (84%) rename src/frontend/services/{pods-status-polling.service.ts => pods-status-sse.service.ts} (85%) diff --git a/src/app/api/build-status/route.ts b/src/app/api/build-status/route.ts index 3b7bc800..24e02a2c 100644 --- a/src/app/api/build-status/route.ts +++ b/src/app/api/build-status/route.ts @@ -19,6 +19,7 @@ export async function POST() { const encoder = new TextEncoder(); let shouldStopStreaming = false; let unsubscribe: (() => void) | null = null; + let heartbeat: ReturnType | null = null; const customReadable = new ReadableStream({ async start(controller) { @@ -31,6 +32,10 @@ export async function POST() { } catch (e) { console.error(`[BUILD STATUS] Error while enqueueing build status data: `, e); shouldStopStreaming = true; + unsubscribe?.(); + unsubscribe = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; controller.close(); } }; @@ -58,6 +63,21 @@ export async function POST() { } catch (e) { console.error("Error fetching initial build status", e); } + + heartbeat = setInterval(() => { + if (shouldStopStreaming) return; + try { + controller.enqueue(encoder.encode(': ping\n\n')); + } catch (error) { + console.error('[BUILD STATUS] Error while sending heartbeat:', error); + shouldStopStreaming = true; + unsubscribe?.(); + unsubscribe = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + controller.close(); + } + }, 25_000); }, cancel() { console.log("[BUILD STATUS] Client left, cancelling build status stream"); @@ -66,6 +86,8 @@ export async function POST() { unsubscribe(); unsubscribe = null; } + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; }, }); diff --git a/src/app/api/deployment-status/route.ts b/src/app/api/deployment-status/route.ts index e918059b..fbbb4a44 100644 --- a/src/app/api/deployment-status/route.ts +++ b/src/app/api/deployment-status/route.ts @@ -23,6 +23,7 @@ export async function POST() { const encoder = new TextEncoder(); let shouldStopStreaming = false; let unsubscribe: (() => void) | null = null; + let heartbeat: ReturnType | null = null; // Fetch all projects and apps to build a lookup map let appLookup = await deploymentLiveStatusService.getAppLookup(session); @@ -37,6 +38,10 @@ export async function POST() { } catch (e) { console.error(`[ENQUEUE ERROR] Error while enqueueing Deployment Status data: `, e); shouldStopStreaming = true; + unsubscribe?.(); + unsubscribe = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; controller.close(); } }; @@ -85,6 +90,21 @@ export async function POST() { sendData(status); }); + + heartbeat = setInterval(() => { + if (shouldStopStreaming) return; + try { + controller.enqueue(encoder.encode(': ping\n\n')); + } catch (error) { + console.error('[ENQUEUE ERROR] Error while sending deployment status heartbeat:', error); + shouldStopStreaming = true; + unsubscribe?.(); + unsubscribe = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + controller.close(); + } + }, 25_000); }, cancel() { console.log("[LEAVE] Cancelling deployment status stream"); @@ -93,6 +113,8 @@ export async function POST() { unsubscribe(); unsubscribe = null; } + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; } }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3859a12e..ff7e1f6c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -52,7 +52,7 @@ export default async function RootLayout({
-
+
{userIsLoggedIn && } }> {children} diff --git a/src/app/project/[projectId]/app-components/project-network-graph.tsx b/src/app/project/[projectId]/app-components/project-network-graph.tsx index e3fcaa45..4596671a 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -377,6 +377,7 @@ function ProjectNetworkGraphEditor({ const selectedAppRole = selectedApp ? UserGroupUtils.getRolePermissionForApp(session, selectedApp.id) ?? undefined : undefined; useEffect(() => { if (!selectedNodeId || selectedNode?.kind !== 'APP') return; + if (!window.matchMedia('(min-width: 1024px)').matches) return; const animationFrame = requestAnimationFrame(() => { const reactFlow = reactFlowRef.current; @@ -440,7 +441,7 @@ function ProjectNetworkGraphEditor({ fitViewOptions={{ padding: 0.2, maxZoom: 1.1 }} minZoom={0.3} maxZoom={1.5} - zoomOnScroll={false} + zoomOnScroll zoomOnPinch={false} zoomOnDoubleClick={false} preventScrolling={false} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx index 1acc3df9..72ab7e33 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type Ref, useEffect, useState } from 'react'; +import { type Ref } from 'react'; import { BarChart3, @@ -259,16 +259,10 @@ export function NodeDetailsDrawer({ role === RolePermissionEnum.READWRITE && !AppSourceUtils.isConfiguredSource(app); - const defaultTab = DrawerSessionUtils.resolveTab(app?.appType, requestedTab); - const [activeTab, setActiveTab] = useState(defaultTab); - - useEffect(() => { - setActiveTab(defaultTab); - }, [app?.id, defaultTab]); + const activeTab = DrawerSessionUtils.resolveTab(app?.appType, requestedTab); const handleTabChange = (tab: string) => { const nextTab = DrawerSessionUtils.resolveTab(app?.appType, tab); - setActiveTab(nextTab); onTabChange(nextTab); }; diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts index a900d453..9f2d7f33 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { DrawerSessionUtils, useProjectNetworkGraphDrawerSession, @@ -29,4 +29,31 @@ describe('useProjectNetworkGraphDrawerSession', () => { expect(result.current).toBe(session); }); + + test('opens an app from the URL and closes by clearing the selected node', () => { + const searchParams = new URLSearchParams('drawerAppId=app-1&drawerTab=logs'); + const appIds = new Set(['app-1']); + const { result } = renderHook(() => + useProjectNetworkGraphDrawerSession({ searchParams, appIds }), + ); + + expect(result.current.selectedNodeId).toBe('APP:app-1'); + expect(result.current.open).toBe(true); + expect(result.current.requestedTab).toBe('logs'); + + act(() => result.current.onOpenChange(false)); + + expect(result.current.selectedNodeId).toBeUndefined(); + expect(result.current.open).toBe(false); + }); + + test('clears an unknown drawer app from the URL', () => { + const searchParams = new URLSearchParams('drawerAppId=missing&drawerTab=logs'); + const appIds = new Set(['app-1']); + const { result } = renderHook(() => + useProjectNetworkGraphDrawerSession({ searchParams, appIds }), + ); + + expect(result.current.selectedNodeId).toBeUndefined(); + }); }); diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts index e4f9dbff..51d82b86 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts @@ -16,18 +16,9 @@ export type DrawerTab = (typeof drawerTabValues)[number]; export class DrawerSessionUtils { static resolveTab(appType: string | undefined, requestedTab: string | null | undefined): DrawerTab { - if (requestedTab === 'credentials' && appType !== 'APP') { - return 'credentials'; - } - - if ( - requestedTab !== 'credentials' - && drawerTabValues.includes(requestedTab as DrawerTab) - ) { - return requestedTab as DrawerTab; - } - - return 'deployments'; + if (!drawerTabValues.includes(requestedTab as DrawerTab)) return 'deployments'; + if (requestedTab === 'credentials' && appType === 'APP') return 'deployments'; + return requestedTab as DrawerTab; } } @@ -41,13 +32,9 @@ export function useProjectNetworkGraphDrawerSession({ appIds: Set; }) { const [selectedNodeId, setSelectedNodeId] = useState(); - const [open, setOpen] = useState(false); - const [requestedTab, setRequestedTab] = useState( - () => searchParams.get('drawerTab'), - ); + const requestedTab = searchParams.get('drawerTab'); const updateQuery = useCallback((appId?: string, tab?: DrawerTab) => { - setRequestedTab(appId ? (tab ?? 'deployments') : null); const params = new URLSearchParams(searchParams.toString()); if (!appId) { @@ -61,10 +48,6 @@ export function useProjectNetworkGraphDrawerSession({ TabNavigationUtils.replaceQuery(params); }, [searchParams]); - useEffect(() => { - setRequestedTab(searchParams.get('drawerTab')); - }, [searchParams]); - useEffect(() => { const requestedAppId = searchParams.get('drawerAppId'); @@ -77,10 +60,6 @@ export function useProjectNetworkGraphDrawerSession({ setSelectedNodeId(`APP:${requestedAppId}`); }, [appIds, searchParams, updateQuery]); - useEffect(() => { - if (selectedNodeId) setOpen(true); - }, [selectedNodeId]); - const selectNode = useCallback((node: NetworkGraphNode) => { setSelectedNodeId(node.id); updateQuery(node.kind === 'APP' ? node.id.replace('APP:', '') : undefined); @@ -92,17 +71,17 @@ export function useProjectNetworkGraphDrawerSession({ }, [updateQuery]); const onOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) updateQuery(); + if (!nextOpen) { + setSelectedNodeId(undefined); + updateQuery(); + } }, [updateQuery]); - const onOpenChangeComplete = useCallback((nextOpen: boolean) => { - if (!nextOpen) setSelectedNodeId(undefined); - }, []); + const onOpenChangeComplete = useCallback(() => {}, []); return useMemo(() => ({ selectedNodeId, - open, + open: selectedNodeId !== undefined, requestedTab, selectNode, openAppTab, @@ -111,7 +90,6 @@ export function useProjectNetworkGraphDrawerSession({ }), [ onOpenChange, onOpenChangeComplete, - open, openAppTab, requestedTab, selectNode, diff --git a/src/components/custom/build-status-polling-provider.tsx b/src/components/custom/build-status-polling-provider.tsx index c6248021..6eb1eed5 100644 --- a/src/components/custom/build-status-polling-provider.tsx +++ b/src/components/custom/build-status-polling-provider.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect } from 'react'; -import { buildStatusPollingService } from '@/frontend/services/build-status-polling.service'; +import { buildStatusPollingService } from '@/frontend/services/build-status-sse.service'; /** * Client component that initializes and manages the build status streaming service. diff --git a/src/components/custom/pods-status-polling-provider.tsx b/src/components/custom/pods-status-polling-provider.tsx index 0457da1f..415bc75d 100644 --- a/src/components/custom/pods-status-polling-provider.tsx +++ b/src/components/custom/pods-status-polling-provider.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect } from 'react'; -import { podsStatusPollingService } from '@/frontend/services/pods-status-polling.service'; +import { podsStatusPollingService } from '@/frontend/services/pods-status-sse.service'; /** * Client component that initializes and manages the pods status polling service. diff --git a/src/frontend/services/build-status-polling.service.ts b/src/frontend/services/build-status-sse.service.ts similarity index 84% rename from src/frontend/services/build-status-polling.service.ts rename to src/frontend/services/build-status-sse.service.ts index ea3593c7..775b4b03 100644 --- a/src/frontend/services/build-status-polling.service.ts +++ b/src/frontend/services/build-status-sse.service.ts @@ -42,8 +42,9 @@ class BuildStatusSSEStateService { } private async connect() { - this.controller = new AbortController(); - const signal = this.controller.signal; + const controller = new AbortController(); + this.controller = controller; + const signal = controller.signal; this.isConnected = true; this.buffer = ''; @@ -66,7 +67,10 @@ class BuildStatusSSEStateService { while (true) { const { value, done } = await reader.read(); - if (done) break; + if (done) { + this.reconnect(controller); + break; + } if (value) { this.processChunk(value); } @@ -76,18 +80,24 @@ class BuildStatusSSEStateService { console.log('[BuildStatusService] Stream aborted'); } else { console.error('[BuildStatusService] Stream error:', error); - this.isConnected = false; - setTimeout(() => { - if (!signal.aborted) { - this.connect(); - } - }, 5000); + this.reconnect(controller); } } finally { - this.isConnected = false; + if (this.controller === controller) { + this.isConnected = false; + } } } + private reconnect(controller: AbortController): void { + this.isConnected = false; + setTimeout(() => { + if (this.controller === controller && !controller.signal.aborted) { + this.connect(); + } + }, 5000); + } + private processChunk(chunk: string) { const { frames, buffer } = StreamUtils.parseSseFrames(this.buffer, chunk); this.buffer = buffer; @@ -96,7 +106,7 @@ class BuildStatusSSEStateService { try { const data = JSON.parse(frame); const { setBuildStatus, updateBuildStatus } = useBuildStatus.getState(); -console.log('data', data) + if (Array.isArray(data)) { setBuildStatus(data as AppBuildStatusModel[]); } else { diff --git a/src/frontend/services/pods-status-polling.service.ts b/src/frontend/services/pods-status-sse.service.ts similarity index 85% rename from src/frontend/services/pods-status-polling.service.ts rename to src/frontend/services/pods-status-sse.service.ts index 14e6e557..9ba71c56 100644 --- a/src/frontend/services/pods-status-polling.service.ts +++ b/src/frontend/services/pods-status-sse.service.ts @@ -42,8 +42,9 @@ class PodsStatusPollingService { } private async connect() { - this.controller = new AbortController(); - const signal = this.controller.signal; + const controller = new AbortController(); + this.controller = controller; + const signal = controller.signal; this.isConnected = true; this.buffer = ''; @@ -66,7 +67,10 @@ class PodsStatusPollingService { while (true) { const { value, done } = await reader.read(); - if (done) break; + if (done) { + this.reconnect(controller); + break; + } if (value) { this.processChunk(value); } @@ -76,19 +80,24 @@ class PodsStatusPollingService { console.log('[PodsStatusService] Stream aborted'); } else { console.error('[PodsStatusService] Stream error:', error); - // Retry logic - this.isConnected = false; - setTimeout(() => { - if (!signal.aborted) { - this.connect(); - } - }, 5000); + this.reconnect(controller); } } finally { - this.isConnected = false; + if (this.controller === controller) { + this.isConnected = false; + } } } + private reconnect(controller: AbortController): void { + this.isConnected = false; + setTimeout(() => { + if (this.controller === controller && !controller.signal.aborted) { + this.connect(); + } + }, 5000); + } + private processChunk(chunk: string) { // Frames are buffered so an incomplete frame split across chunks is // carried into the next read instead of being dropped. diff --git a/src/server/services/standalone-services/build-status-pub-sub.service.ts b/src/server/services/standalone-services/build-status-pub-sub.service.ts index bd328118..d5fed709 100644 --- a/src/server/services/standalone-services/build-status-pub-sub.service.ts +++ b/src/server/services/standalone-services/build-status-pub-sub.service.ts @@ -125,18 +125,28 @@ class BuildStatusPubSubService { }); } - /** Seeds the cache from Kubernetes once. Concurrent callers share one request. */ + /** Rebuilds the cache from Kubernetes. Concurrent callers share one request. */ async ensureSeeded(): Promise { - if (!this.seedPromise) { - this.seedPromise = (async () => { - const builds = await buildService.getAllBuilds(); - this.applyBuildJobs(builds); - })().catch((error) => { - console.error('[BuildStatus] Failed to seed build statuses:', error); + if (this.seedPromise) { + return this.seedPromise; + } + + const seedPromise = (async () => { + const builds = await buildService.getAllBuilds(); + // A restarted watch receives ADDED only for jobs that still exist. + // Drop statuses for jobs that disappeared during the restart gap. + this.statuses.clear(); + this.applyBuildJobs(builds); + })(); + this.seedPromise = seedPromise; + + try { + await seedPromise; + } finally { + if (this.seedPromise === seedPromise) { this.seedPromise = null; - }); + } } - return this.seedPromise; } reset(): void { diff --git a/src/server/services/standalone-services/build-status.service.unit.spec.ts b/src/server/services/standalone-services/build-status.service.unit.spec.ts index 8bde7a63..28b988cc 100644 --- a/src/server/services/standalone-services/build-status.service.unit.spec.ts +++ b/src/server/services/standalone-services/build-status.service.unit.spec.ts @@ -189,6 +189,25 @@ describe('BuildStatusService', () => { expect(buildService.getAllBuilds).toHaveBeenCalledTimes(1); expect(buildStatusService.getStatus('app', 'app-1')?.status).toBe('SUCCEEDED'); }); + + it('rebuilds the cache on every completed seed', async () => { + vi.mocked(buildService.getAllBuilds) + .mockResolvedValueOnce([makeBuild({ workloadId: 'app-1', status: 'RUNNING' })] as any) + .mockResolvedValueOnce([makeBuild({ workloadId: 'app-2', status: 'SUCCEEDED' })] as any); + + await buildStatusService.ensureSeeded(); + await buildStatusService.ensureSeeded(); + + expect(buildService.getAllBuilds).toHaveBeenCalledTimes(2); + expect(buildStatusService.getStatus('app', 'app-1')).toBeUndefined(); + expect(buildStatusService.getStatus('app', 'app-2')?.status).toBe('SUCCEEDED'); + }); + + it('propagates seeding errors so callers do not report stale statuses', async () => { + vi.mocked(buildService.getAllBuilds).mockRejectedValueOnce(new Error('Kubernetes unavailable')); + + await expect(buildStatusService.ensureSeeded()).rejects.toThrow('Kubernetes unavailable'); + }); }); describe('subscribe', () => { diff --git a/src/server/services/standalone-services/build-watch.service.ts b/src/server/services/standalone-services/build-watch.service.ts index 66217259..86d21a81 100644 --- a/src/server/services/standalone-services/build-watch.service.ts +++ b/src/server/services/standalone-services/build-watch.service.ts @@ -30,7 +30,13 @@ class BuildWatchService { this.isWatchRunning = true; console.log('[BuildWatch] Starting build job watch...'); - await buildStatusService.ensureSeeded(); + try { + await buildStatusService.ensureSeeded(); + } catch (error) { + // The watch must keep processing build completions when its status + // cache cannot be rebuilt. The SSE route will surface the seed error. + console.error('[BuildWatch] Failed to seed build statuses:', error); + } const kc = k3s.getKubeConfig(); const watch = new k8s.Watch(kc); @@ -39,13 +45,13 @@ class BuildWatchService { `/apis/batch/v1/namespaces/${BUILD_NAMESPACE}/jobs`, {}, async (type: string, apiObj: unknown) => { + const job = apiObj as V1Job; try { - const job = apiObj as V1Job; await buildStatusService.applyJobEvent(type, job); - await this.handleJobEvent(job); } catch (e) { - console.error('[BuildWatch] Error handling job event:', e); + console.error('[BuildWatch] Status update failed:', e); } + await this.handleJobEvent(job); }, (err: unknown) => { if (err) console.error('[BuildWatch] Watch error:', err); diff --git a/src/server/services/standalone-services/build-watch.service.unit.spec.ts b/src/server/services/standalone-services/build-watch.service.unit.spec.ts index 9ed50057..92d346a8 100644 --- a/src/server/services/standalone-services/build-watch.service.unit.spec.ts +++ b/src/server/services/standalone-services/build-watch.service.unit.spec.ts @@ -88,6 +88,26 @@ describe('BuildWatchService', () => { expect(buildStatusService.applyJobEvent).toHaveBeenCalledWith('MODIFIED', job); }); + it('still handles a completed build when status tracking fails', async () => { + vi.mocked(buildStatusService.applyJobEvent).mockRejectedValueOnce(new Error('status unavailable')); + vi.mocked(buildService.getJobStatusString).mockReturnValue('SUCCEEDED'); + vi.mocked(appService.getExtendedById).mockResolvedValue({ buildMethod: 'RAILPACK' } as any); + + await buildWatchService.startWatch(); + const eventHandler = k8sMocks.watch.mock.calls[0][2] as (type: string, job: unknown) => Promise; + await eventHandler('MODIFIED', { + metadata: { + name: 'build-1', + annotations: { + 'qs-deplyoment-id': 'deployment-1', + 'qs-app-id': 'app-1', + }, + }, + }); + + expect(deploymentService.createDeployment).toHaveBeenCalledTimes(1); + }); + it('ignores pending jobs and does not trigger deployment work', async () => { vi.mocked(buildService.getJobStatusString).mockReturnValue('PENDING'); From 18628ff4740d356226758987eaf33940d25c77bd Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 21 Sep 2026 08:40:46 +0000 Subject: [PATCH 18/25] fix: small ui fixes in canva view and added webhook settings --- .../app-components/project-network-graph.tsx | 2 +- .../drawer-deployments-tab.tsx | 24 +++++++++++++++++- ...oject-network-graph-drawer-session.spec.ts | 8 ++++-- .../project-network-graph-drawer-session.ts | 25 ++++++++++++++----- .../project/app/[appId]/overview/actions.ts | 6 ++--- .../[appId]/overview/webhook-deployment.tsx | 15 +++++++---- src/server/services/app.service.ts | 1 + 7 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/app/project/[projectId]/app-components/project-network-graph.tsx b/src/app/project/[projectId]/app-components/project-network-graph.tsx index 4596671a..126c5c16 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -444,7 +444,7 @@ function ProjectNetworkGraphEditor({ zoomOnScroll zoomOnPinch={false} zoomOnDoubleClick={false} - preventScrolling={false} + preventScrolling nodesDraggable={canEditLayout} nodesConnectable elementsSelectable={false} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx index 08a35771..ba23b94c 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer-deployments-tab.tsx @@ -5,6 +5,9 @@ import { DeploymentInfoModel } from "@/shared/model/deployment-info.model"; import { BuildLogsDialogContent } from "@/app/project/app/[appId]/overview/build-logs-overlay"; import BuildsTab from "@/app/project/app/[appId]/overview/deployments"; import { formatDateTime } from "@/frontend/utils/format.utils"; +import WebhookDeploymentInfo from "@/app/project/app/[appId]/overview/webhook-deployment"; +import { Button } from "@/components/ui/button"; +import { Webhook } from "lucide-react"; @@ -33,5 +36,24 @@ export default function DrawerDeploymentsTab({ }); }; - return ; + const showWebhookCard = () => { + openNestedDrawer({ + title: 'Webhook', + description: ``, + content: ( + + ), + }); + } + + return
+
+
Latest Deployments
+ +
+ +
; } \ No newline at end of file diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts index 9f2d7f33..922ffd23 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts @@ -30,7 +30,7 @@ describe('useProjectNetworkGraphDrawerSession', () => { expect(result.current).toBe(session); }); - test('opens an app from the URL and closes by clearing the selected node', () => { + test('keeps the selected node until the drawer closing animation completes', () => { const searchParams = new URLSearchParams('drawerAppId=app-1&drawerTab=logs'); const appIds = new Set(['app-1']); const { result } = renderHook(() => @@ -43,8 +43,12 @@ describe('useProjectNetworkGraphDrawerSession', () => { act(() => result.current.onOpenChange(false)); - expect(result.current.selectedNodeId).toBeUndefined(); + expect(result.current.selectedNodeId).toBe('APP:app-1'); expect(result.current.open).toBe(false); + + act(() => result.current.onOpenChangeComplete(false)); + + expect(result.current.selectedNodeId).toBeUndefined(); }); test('clears an unknown drawer app from the URL', () => { diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts index 51d82b86..838d09da 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts @@ -32,6 +32,7 @@ export function useProjectNetworkGraphDrawerSession({ appIds: Set; }) { const [selectedNodeId, setSelectedNodeId] = useState(); + const [open, setOpen] = useState(false); const requestedTab = searchParams.get('drawerTab'); const updateQuery = useCallback((appId?: string, tab?: DrawerTab) => { @@ -60,28 +61,39 @@ export function useProjectNetworkGraphDrawerSession({ setSelectedNodeId(`APP:${requestedAppId}`); }, [appIds, searchParams, updateQuery]); + useEffect(() => { + if (selectedNodeId) setOpen(true); + }, [selectedNodeId]); + const selectNode = useCallback((node: NetworkGraphNode) => { setSelectedNodeId(node.id); + if (selectedNodeId === node.id) setOpen(true); updateQuery(node.kind === 'APP' ? node.id.replace('APP:', '') : undefined); - }, [updateQuery]); + }, [selectedNodeId, updateQuery]); const openAppTab = useCallback((appId: string, tab: DrawerTab) => { - setSelectedNodeId(`APP:${appId}`); + const nodeId = `APP:${appId}`; + setSelectedNodeId(nodeId); + if (selectedNodeId === nodeId) setOpen(true); updateQuery(appId, tab); - }, [updateQuery]); + }, [selectedNodeId, updateQuery]); const onOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); if (!nextOpen) { - setSelectedNodeId(undefined); updateQuery(); } }, [updateQuery]); - const onOpenChangeComplete = useCallback(() => {}, []); + const onOpenChangeComplete = useCallback((nextOpen: boolean) => { + // Keep the node mounted until Vaul finishes its exit animation. A click + // during that animation reopens it, so do not clear the new selection. + if (!nextOpen && !open) setSelectedNodeId(undefined); + }, [open]); return useMemo(() => ({ selectedNodeId, - open: selectedNodeId !== undefined, + open, requestedTab, selectNode, openAppTab, @@ -91,6 +103,7 @@ export function useProjectNetworkGraphDrawerSession({ onOpenChange, onOpenChangeComplete, openAppTab, + open, requestedTab, selectNode, selectedNodeId, diff --git a/src/app/project/app/[appId]/overview/actions.ts b/src/app/project/app/[appId]/overview/actions.ts index 528cdcdc..6d5957b8 100644 --- a/src/app/project/app/[appId]/overview/actions.ts +++ b/src/app/project/app/[appId]/overview/actions.ts @@ -6,7 +6,7 @@ import buildService from "@/server/services/build.service"; import deploymentService from "@/server/services/deployment.service"; import monitoringService from "@/server/services/monitoring.service"; import podService from "@/server/services/pod.service"; -import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, simpleAction } from "@/server/utils/action-wrapper.utils"; export const getDeploymentsAndBuildsForApp = async (appId: string) => simpleAction(async () => { @@ -44,6 +44,6 @@ export const getRessourceDataApp = async (projectId: string, appId: string) => export const createNewWebhookUrl = async (appId: string) => simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - await appService.regenerateWebhookId(appId); + await isAuthorizedWriteForWorkload(appId); + return await appService.regenerateWebhookId(appId); }); diff --git a/src/app/project/app/[appId]/overview/webhook-deployment.tsx b/src/app/project/app/[appId]/overview/webhook-deployment.tsx index 5169881a..4ef83350 100644 --- a/src/app/project/app/[appId]/overview/webhook-deployment.tsx +++ b/src/app/project/app/[appId]/overview/webhook-deployment.tsx @@ -19,14 +19,18 @@ export default function WebhookDeploymentInfo({ const { openConfirmDialog } = useConfirmDialog(); const [webhookUrl, setWebhookUrl] = useState(undefined); - useEffect(() => { - if (app.webhookId) { + const setWebhookId = (webhookId?: string | null | void) => { + if (webhookId) { const hostname = window.location.hostname; const port = [80, 443].includes(Number(window.location.port)) ? '' : `:${window.location.port}`; const protocol = window.location.protocol; - setWebhookUrl(`${protocol}//${hostname}${port}/api/v1/webhook/deploy?id=${app.webhookId}`); + setWebhookUrl(`${protocol}//${hostname}${port}/api/v1/webhook/deploy?id=${webhookId}`); } - }, [app]); + } + + useEffect(() => { + setWebhookId(app.webhookId); + }, [app, app.webhookId]); const createNewWebhookUrlAsync = async () => { if (!await openConfirmDialog({ @@ -36,7 +40,8 @@ export default function WebhookDeploymentInfo({ })) { return; } - await Toast.fromAction(() => createNewWebhookUrl(app.id), 'Webhook URL has been regenerated.'); + const newWebhookId = await Toast.fromAction(() => createNewWebhookUrl(app.id), 'Webhook URL has been regenerated.'); + setWebhookId(newWebhookId.data); } const copyWebhookUrl = () => { diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index baf1f0f2..777b4234 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -372,6 +372,7 @@ class AppService { ...existingApp, webhookId: randomBytes }); + return randomBytes; } async saveDomain(domainToBeSaved: Prisma.AppDomainUncheckedCreateInput | Prisma.AppDomainUncheckedUpdateInput, tx?: Prisma.TransactionClient) { From 7f59d5a75d79f4bd5def9ac580f42cb5b731090b Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 21 Sep 2026 15:54:12 +0000 Subject: [PATCH 19/25] feat: added backup tab to drawer in project canva view --- package.json | 2 +- .../drawer-backup-list.tsx | 89 +++++++++++++++++ .../drawer-backups-tab.tsx | 40 ++++++++ .../drawer/drawer-environment.tsx | 4 +- .../drawer/drawer-settings.tsx | 97 +------------------ .../drawer/settings-section.tsx | 10 +- .../node-details-drawer.tsx | 31 +++++- ...oject-network-graph-drawer-session.spec.ts | 5 + .../project-network-graph-drawer-session.ts | 8 +- .../app/[appId]/general/app-source.tsx | 2 +- .../project/app/[appId]/volumes/actions.ts | 88 +++++------------ .../app/[appId]/volumes/volume-backup.tsx | 61 +++++++----- src/server/services/app.service.ts | 15 +++ .../standalone-services/backup.service.ts | 13 +++ src/server/services/volume-backup.service.ts | 23 +++++ yarn.lock | 8 +- 16 files changed, 295 insertions(+), 201 deletions(-) create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer-backup-list.tsx create mode 100644 src/app/project/[projectId]/app-components/project-network-graph/drawer-backups-tab.tsx diff --git a/package.json b/package.json index ee2f3f7b..d5efc62f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "elysia": "^1.4.28", "exact-mirror": "^1.0.0", "file-type": "^22.0.1", - "lucide-react": "^0.465.0", + "lucide-react": "^1.47.0", "moment": "^2.30.1", "next": "^15", "next-auth": "^4.24.14", diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer-backup-list.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer-backup-list.tsx new file mode 100644 index 00000000..855de0d1 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer-backup-list.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Archive } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; +import LoadingSpinner from '@/components/ui/loading-spinner'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { formatDateTime } from '@/frontend/utils/format.utils'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; +import type { BackupEntry } from '@/shared/model/backup-info.model'; +import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; +import { getBackupsForVolumeSchedule } from '@/app/project/app/[appId]/volumes/actions'; + +export function DrawerBackupList({ + volumeBackup, +}: { + volumeBackup: VolumeBackupExtendedModel; +}) { + const [backups, setBackups] = useState(); + const [hasLoadError, setHasLoadError] = useState(false); + + useEffect(() => { + setBackups(undefined); + setHasLoadError(false); + void Actions.run(() => getBackupsForVolumeSchedule(volumeBackup.id)) + .then(setBackups) + .catch(() => setHasLoadError(true)); + }, [volumeBackup.id]); + + if (hasLoadError) { + return ( + + Backups could not be loaded + + Check the backup storage connection and try again. + + + ); + } + + if (!backups) { + return ( +
+ +
+ ); + } + + if (backups.length === 0) { + return ( + + + + + + No backups yet + + This backup schedule has not created any backups yet. + + + + ); + } + + return ( + + + + Created + Size + + + + {backups.map((backup) => ( + + {formatDateTime(backup.backupDate, true)} + + {backup.sizeBytes + ? KubeSizeConverter.convertBytesToReadableSize(backup.sizeBytes) + : 'Unknown'} + + + ))} + +
+ ); +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer-backups-tab.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer-backups-tab.tsx new file mode 100644 index 00000000..8f3df7a2 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer-backups-tab.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { S3Target } from '@prisma/client'; +import VolumeBackupList from '@/app/project/app/[appId]/volumes/volume-backup'; +import type { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; +import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; +import { useNestedDrawer } from './drawer/nested-drawer'; +import { DrawerBackupList } from './drawer-backup-list'; + +export function DrawerBackupsTab({ + app, + role, + s3Targets, + volumeBackups, +}: { + app: AppExtendedModel; + role: RolePermissionEnum; + s3Targets: S3Target[]; + volumeBackups: VolumeBackupExtendedModel[]; +}) { + const { openNestedDrawer } = useNestedDrawer(); + + return ( + + openNestedDrawer({ + title: 'Backups', + description: `Backups for the ${volumeBackup.cron} schedule.`, + content: , + }) + } + /> + ); +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx index b472faa8..5744297b 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx @@ -35,7 +35,7 @@ function VariableList({ return (
@@ -45,7 +45,7 @@ function VariableList({ type="button" variant="ghost" size="icon" - className="size-6 shrink-0" + className="pointer-events-none size-6 shrink-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100" title="Copy variable name" onClick={() => { void navigator.clipboard.writeText( diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx index 79780d57..3bbade4a 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx @@ -8,10 +8,8 @@ import { SlidersHorizontal, Zap, } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { cn } from '@/frontend/utils/utils'; import BasicAuth from '@/app/project/app/[appId]/advanced/basic-auth'; import { saveHealthCheck } from '@/app/project/app/[appId]/advanced/actions'; import HealthCheckSettings from '@/app/project/app/[appId]/advanced/health-check-settings'; @@ -22,13 +20,10 @@ import GeneralAppContainerConfig from '@/app/project/app/[appId]/general/app-con import GeneralAppRateLimits from '@/app/project/app/[appId]/general/app-rate-limits'; import GeneralAppSource from '@/app/project/app/[appId]/general/app-source'; import StorageList from '@/app/project/app/[appId]/volumes/storages'; -import VolumeBackupList from '@/app/project/app/[appId]/volumes/volume-backup'; import DomainsCard from '@/components/custom/domains-card'; import FileMountsCard from '@/components/custom/file-mounts-card'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; -import type { S3Target } from '@prisma/client'; -import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; import { SettingsSection } from './settings-section'; import { DrawerEnvironment } from './drawer-environment'; import { useNestedDrawer } from './nested-drawer'; @@ -36,68 +31,20 @@ import { useNestedDrawer } from './nested-drawer'; export function DrawerSettings({ app, role, - s3Targets, storageClasses, - volumeBackups, gitSshPublicKey, }: { app: AppExtendedModel; role: RolePermissionEnum; - s3Targets: S3Target[]; storageClasses: string[]; - volumeBackups: VolumeBackupExtendedModel[]; gitSshPublicKey?: string; }) { const readonly = role !== RolePermissionEnum.READWRITE; const { openNestedDrawer } = useNestedDrawer(); - const settingsSections = useMemo( - () => [ - { id: 'source', label: 'Source' }, - { id: 'deployment', label: 'Deployment' }, - { id: 'environment', label: 'Environment' }, - { id: 'networking', label: 'Networking' }, - { id: 'storage', label: 'Storage' }, - { id: 'advanced', label: 'Advanced' }, - ], - [], - ); - const [activeSection, setActiveSection] = useState(settingsSections[0].id); - - useEffect(() => { - const sections = settingsSections - .map((section) => document.getElementById(section.id)) - .filter((section): section is HTMLElement => section !== null); - const scrollArea = sections[0]?.closest( - '[data-slot="scroll-area-viewport"]', - ); - const observer = new IntersectionObserver( - (entries) => { - const visibleSection = entries - .filter((entry) => entry.isIntersecting) - .sort( - (left, right) => - left.boundingClientRect.top - - right.boundingClientRect.top, - )[0]; - - if (visibleSection) { - setActiveSection(visibleSection.target.id); - } - }, - { - root: scrollArea, - rootMargin: '-5% 0px -70% 0px', - threshold: 0, - }, - ); - - sections.forEach((section) => observer.observe(section)); - return () => observer.disconnect(); - }, [settingsSections]); return ( -
-
+
+
-
-
); } diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx index bd2a19a4..8cc73cd3 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx @@ -15,15 +15,15 @@ export function SettingsSection({ return (
-
+
-
-

{title}

+
+

{title}

-
+
{children}
diff --git a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx index 72ab7e33..d9ed8860 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx @@ -16,6 +16,7 @@ import { Settings, Square, X, + RotateCwClock, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -56,6 +57,7 @@ import DbCredentials from '@/app/project/app/[appId]/credentials/db-crendentials import DbToolsCard from '@/app/project/app/[appId]/credentials/db-tools'; import { DrawerSettings } from './drawer/drawer-settings'; import { NestedDrawerProvider } from './drawer/nested-drawer'; +import { DrawerBackupsTab } from './drawer-backups-tab'; import { EditAppDialog } from '../edit-app-dialog'; import type { S3Target } from '@prisma/client'; import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; @@ -91,7 +93,7 @@ function AppStatusActions({ }; return ( -
+
{(lifecycle.canManage || app.appDomains.length > 0) && ( @@ -258,11 +260,16 @@ export function NodeDetailsDrawer({ app && role === RolePermissionEnum.READWRITE && !AppSourceUtils.isConfiguredSource(app); + const hasVolumes = (app?.appVolumes.length ?? 0) > 0; - const activeTab = DrawerSessionUtils.resolveTab(app?.appType, requestedTab); + const activeTab = DrawerSessionUtils.resolveTab( + app?.appType, + requestedTab, + hasVolumes, + ); const handleTabChange = (tab: string) => { - const nextTab = DrawerSessionUtils.resolveTab(app?.appType, tab); + const nextTab = DrawerSessionUtils.resolveTab(app?.appType, tab, hasVolumes); onTabChange(nextTab); }; @@ -363,6 +370,12 @@ export function NodeDetailsDrawer({ Stats + {hasVolumes && ( + + + Backups + + )} Settings @@ -402,6 +415,16 @@ export function NodeDetailsDrawer({ + {hasVolumes && ( + + + + )} {app.appType !== 'APP' && ( diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts index 922ffd23..1adc2a47 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts @@ -9,11 +9,16 @@ describe('DrawerSessionUtils.resolveTab', () => { ['APP', 'credentials', 'deployments'], ['POSTGRES', 'credentials', 'credentials'], ['APP', 'logs', 'logs'], + ['APP', 'backups', 'backups'], ['APP', 'unknown', 'deployments'], ['APP', null, 'deployments'], ] as const)('normalizes %s requested tab %s to %s', (appType, requestedTab, expected) => { expect(DrawerSessionUtils.resolveTab(appType, requestedTab)).toBe(expected); }); + + it('rejects the backups tab when the app has no volumes', () => { + expect(DrawerSessionUtils.resolveTab('APP', 'backups', false)).toBe('deployments'); + }); }); describe('useProjectNetworkGraphDrawerSession', () => { diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts index 838d09da..e598b9f5 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts @@ -9,15 +9,21 @@ export const drawerTabValues = [ 'credentials', 'logs', 'stats', + 'backups', 'settings', ] as const; export type DrawerTab = (typeof drawerTabValues)[number]; export class DrawerSessionUtils { - static resolveTab(appType: string | undefined, requestedTab: string | null | undefined): DrawerTab { + static resolveTab( + appType: string | undefined, + requestedTab: string | null | undefined, + hasVolumes = true, + ): DrawerTab { if (!drawerTabValues.includes(requestedTab as DrawerTab)) return 'deployments'; if (requestedTab === 'credentials' && appType === 'APP') return 'deployments'; + if (requestedTab === 'backups' && !hasVolumes) return 'deployments'; return requestedTab as DrawerTab; } } diff --git a/src/app/project/app/[appId]/general/app-source.tsx b/src/app/project/app/[appId]/general/app-source.tsx index bdf20fb1..a86de24f 100644 --- a/src/app/project/app/[appId]/general/app-source.tsx +++ b/src/app/project/app/[appId]/general/app-source.tsx @@ -48,7 +48,7 @@ export default function GeneralAppSource({ ); if (hideCard) { - return
+ return
{cardContent} {configured && app.buildMethod === 'FRAMEWORK' && }
; diff --git a/src/app/project/app/[appId]/volumes/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts index e31eac5f..c06a65fb 100644 --- a/src/app/project/app/[appId]/volumes/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -3,7 +3,7 @@ import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model"; import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import appService from "@/server/services/app.service"; -import { isAuthorizedReadForApp, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; import { z } from "zod"; import { ServiceException } from "@/shared/model/service.exception.model"; import pvcService from "@/server/services/pvc.service"; @@ -16,7 +16,7 @@ import { volumeUploadZodModel } from "@/shared/model/volume-upload.model"; import restoreService from "@/server/services/restore.service"; import fileBrowserService from "@/server/services/file-browser-service"; import monitoringService from "@/server/services/monitoring.service"; -import dataAccess from "@/server/adapter/db.client"; +import type { BackupEntry } from "@/shared/model/backup-info.model"; const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({ appId: z.string(), @@ -47,14 +47,7 @@ export const saveVolume = async (prevState: any, inputData: z.infer simpleAction(async () => { await validateBackupVolumeWriteAuthorization(backupVolumeId); - // Get the backup volume with app info to determine backup method - const backupVolume = await dataAccess.client.volumeBackup.findFirstOrThrow({ - where: { - id: backupVolumeId - }, - include: { - volume: { - include: { - app: true - } - } - } - }); + const backupVolume = await volumeBackupService.getWithVolumeAndAppById(backupVolumeId); // Use database-specific backup if it's a database app AND useDatabaseBackup is true if (backupVolume.volume.app.appType !== 'APP' && backupVolume.useDatabaseBackup) { @@ -200,6 +181,14 @@ export const runBackupVolumeSchedule = async (backupVolumeId: string) => return new SuccessActionResult(undefined, 'Backup created and uploaded successfully'); }); +export const getBackupsForVolumeSchedule = async (backupVolumeId: string) => + simpleAction(async () => { + await validateBackupVolumeReadAuthorization(backupVolumeId); + + const backups = await backupService.getBackupsForVolumeSchedule(backupVolumeId); + return new SuccessActionResult(backups); + }); + export const openFileBrowserForVolume = async (volumeId: string) => simpleAction(async () => { await validateVolumeWriteAuthorization(volumeId); @@ -211,53 +200,26 @@ export const openFileBrowserForVolume = async (volumeId: string) => }>>; async function validateVolumeWriteAuthorization(volumeId: string) { - const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({ - where: { - id: volumeId, - }, - select: { - appId: true, - } - }); - await isAuthorizedWriteForApp(volumeAppId?.appId); + const volume = await appService.getVolumeWithAppById(volumeId); + await isAuthorizedWriteForWorkload(volume.appId); } async function validateVolumeReadAuthorization(volumeId: string) { - const volumeAppId = await dataAccess.client.appVolume.findFirstOrThrow({ - where: { - id: volumeId, - }, - select: { - appId: true, - } - }); - await isAuthorizedReadForApp(volumeAppId?.appId); + const volume = await appService.getVolumeWithAppById(volumeId); + await isAuthorizedReadForApp(volume.appId); } async function validateFileMountWriteAuthorization(fileMountId: string) { - const fileMountAppId = await dataAccess.client.appFileMount.findFirstOrThrow({ - where: { - id: fileMountId, - }, - select: { - appId: true, - } - }); - await isAuthorizedWriteForApp(fileMountAppId?.appId); + const appId = await appService.getFileMountAppId(fileMountId); + await isAuthorizedWriteForApp(appId); } async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) { - const volumeAppId = await dataAccess.client.volumeBackup.findFirstOrThrow({ - where: { - id: backupVolumeId, - }, - select: { - volume: { - select: { - appId: true, - } - } - } - }); - await isAuthorizedWriteForApp(volumeAppId?.volume.appId); + const appId = await volumeBackupService.getAppIdById(backupVolumeId); + await isAuthorizedWriteForApp(appId); +} + +async function validateBackupVolumeReadAuthorization(backupVolumeId: string) { + const appId = await volumeBackupService.getAppIdById(backupVolumeId); + await isAuthorizedReadForApp(appId); } diff --git a/src/app/project/app/[appId]/volumes/volume-backup.tsx b/src/app/project/app/[appId]/volumes/volume-backup.tsx index 468d80d0..d27116d9 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup.tsx @@ -4,13 +4,12 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; -import { EditIcon, Play, Plus, TrashIcon } from "lucide-react"; +import { EditIcon, List, Play, Plus, TrashIcon } from "lucide-react"; import { Toast } from "@/frontend/utils/toast.utils"; import { deleteBackupVolume, runBackupVolumeSchedule } from "./actions"; import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; import { S3Target } from "@prisma/client"; import React from "react"; -import { formatDateTime } from "@/frontend/utils/format.utils"; import VolumeBackupEditDialog from "./volume-backup-edit-overlay"; import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model"; import { AppVolume } from "@prisma/client"; @@ -21,12 +20,14 @@ export default function VolumeBackupList({ s3Targets, readonly, hideCard = false, + onBackupScheduleClick, }: { app: AppExtendedModel, s3Targets: S3Target[], volumeBackups: VolumeBackupExtendedModel[]; readonly: boolean; hideCard?: boolean; + onBackupScheduleClick?: (volumeBackup: VolumeBackupExtendedModel) => void; }) { const { openConfirmDialog: openDialog } = useConfirmDialog(); @@ -78,10 +79,9 @@ export default function VolumeBackupList({ Cron Expression Retention - Backup Method - Backup Location - Created At - {!readonly && } + Backup Method + Backup Location + {(onBackupScheduleClick || !readonly) && } @@ -95,31 +95,44 @@ export default function VolumeBackupList({ : 'Archive of Volume'} {volumeBackup.target.name} - {formatDateTime(volumeBackup.createdAt)} - {!readonly && + {(onBackupScheduleClick || !readonly) &&
- } + {!readonly && - - } + {!readonly && + }
} @@ -127,7 +140,7 @@ export default function VolumeBackupList({
} - {!readonly && + {!readonly && }
- + { reactFlowRef.current = instance; }} nodes={nodes} edges={edges} @@ -509,7 +567,8 @@ function ProjectNetworkGraphEditor({ > - + + {dirty && ( diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx index 297bfc10..7b5d2d02 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx @@ -1,7 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; -import { Box, Globe2, Hammer, Logs, Play, Rocket, Settings, Square, Trash2 } from 'lucide-react'; +import { Box, Globe2, Hammer, Logs, Play, Rocket, RotateCwClock, Settings, Square, Trash2 } from 'lucide-react'; import { ContextMenu, ContextMenuContent, @@ -24,7 +24,7 @@ export type ProjectNetworkGraphAppContextMenuProps = { role?: RolePermissionEnum; allowInternetAccess: boolean; onToggleInternetAccess: () => void; - onOpenDrawerTab: (tab: 'deployments' | 'logs' | 'settings') => void; + onOpenDrawerTab: (tab: 'deployments' | 'logs' | 'backups' | 'settings') => void; onDelete: () => void; children: ReactNode; }; @@ -93,6 +93,12 @@ export function ProjectNetworkGraphAppContextMenu({ View Logs + {app.appVolumes.length > 0 && ( + onOpenDrawerTab('backups')}> + + View Backups + + )} onOpenDrawerTab('settings')}> View Settings diff --git a/src/app/project/app/[appId]/domains/node-ports.tsx b/src/app/project/app/[appId]/domains/node-ports.tsx index bf226a98..50b792ea 100644 --- a/src/app/project/app/[appId]/domains/node-ports.tsx +++ b/src/app/project/app/[appId]/domains/node-ports.tsx @@ -46,7 +46,7 @@ export default function NodePortsCard({ app, readonly, hideCard = false }: { Container Port Node Port - Protocol + Protocol {!readonly && } @@ -55,7 +55,7 @@ export default function NodePortsCard({ app, readonly, hideCard = false }: { {np.port} {np.nodePort} - {np.protocol} + {np.protocol} {!readonly && (
diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index f09f2a57..3ea0698d 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -159,11 +159,11 @@ export default function StorageList({ app, readonly, storageClasses, hideCard = Mount Path - Storage Size + Storage Size Storage Used - Storage Class - Access Mode - Shared + Storage Class + Access Mode + Shared @@ -171,7 +171,7 @@ export default function StorageList({ app, readonly, storageClasses, hideCard = {volumesWithStorage.map(volume => ( {volume.containerMountPath} - {volume.size} MB + {volume.size} MB {volume.usedPercentage && <> } - {volume.storageClassName?.replace('-', ' ')} - {volume.accessMode} - + {volume.storageClassName?.replace('-', ' ')} + {volume.accessMode} + {volume.shareWithOtherApps && ( diff --git a/src/app/project/app/[appId]/volumes/volume-backup.tsx b/src/app/project/app/[appId]/volumes/volume-backup.tsx index d27116d9..8d6d6287 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup.tsx @@ -79,8 +79,8 @@ export default function VolumeBackupList({ Cron Expression Retention - Backup Method - Backup Location + Backup Method + Backup Location {(onBackupScheduleClick || !readonly) && } @@ -89,12 +89,12 @@ export default function VolumeBackupList({ {volumeBackup.cron} {volumeBackup.retention} - + {app.appType !== 'APP' && volumeBackup.useDatabaseBackup ? `Database (${app.appType.toLocaleLowerCase()})` : 'Archive of Volume'} - {volumeBackup.target.name} + {volumeBackup.target.name} {(onBackupScheduleClick || !readonly) &&
{onBackupScheduleClick &&
{!hideCard && <> - {domain.port} - {domain.useSsl ? : } - {domain.useSsl && domain.redirectHttps ? : } + {domain.port} + {domain.useSsl ? : } + {domain.useSsl && domain.redirectHttps ? : } } {!readonly &&
From 0fcc4981885c3ce86711c33d19f0c2c2987d7f12 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Tue, 22 Sep 2026 15:51:25 +0000 Subject: [PATCH 21/25] feat: improved logs ui in project drawer --- .../drawer/nested-drawer.tsx | 9 +- .../node-details-drawer.tsx | 35 ++++-- .../[appId]/overview/build-logs-overlay.tsx | 10 +- src/app/project/app/[appId]/overview/logs.tsx | 116 ++++++++++-------- .../app/[appId]/overview/terminal-overlay.tsx | 48 ++++---- src/components/custom/build-logs-streamed.tsx | 11 +- src/components/custom/logs-streamed.tsx | 11 +- src/components/ui/scroll-area.tsx | 4 +- src/components/ui/textarea.tsx | 2 +- 9 files changed, 152 insertions(+), 94 deletions(-) diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx index 2e0d709b..6410a4c7 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx @@ -51,7 +51,7 @@ export function NestedDrawerProvider({ children }: { children: ReactNode }) { open={drawer !== null} onOpenChange={(open) => !open && closeNestedDrawer()} > - + + +
} +
+ + + + + +

Fullscreen Logs

+
+
+
+
} + {app.projectId && selectedPod &&
+ +
} + + ; + + if (hideCard && useFullHeight) { + return
{content}
; + } + return <> - {!hideCard && - Logs - Read logs from all running Containers. - } - - {!appPods && } - {appPods && appPods.length === 0 &&
No running pods found for this app.
} - {selectedPod && appPods &&
-
- -
- {role === RolePermissionEnum.READWRITE &&
- - - -
} -
- - - - - -

Fullscreen Logs

-
-
-
-
} - {app.projectId && selectedPod && } -
+ {content}
; } diff --git a/src/app/project/app/[appId]/overview/terminal-overlay.tsx b/src/app/project/app/[appId]/overview/terminal-overlay.tsx index 144dac83..8b2cc3bd 100644 --- a/src/app/project/app/[appId]/overview/terminal-overlay.tsx +++ b/src/app/project/app/[appId]/overview/terminal-overlay.tsx @@ -1,37 +1,43 @@ import { - Dialog, - DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog" -import React from "react"; +import { useDialog } from "@/frontend/states/zustand.states"; +import { cloneElement, type MouseEvent, type ReactElement } from "react"; import { TerminalSetupInfoModel } from "@/shared/model/terminal-setup-info.model"; import TerminalStreamed from "./terminal-streamed"; +function TerminalDialogContent({ terminalInfo }: { terminalInfo: TerminalSetupInfoModel }) { + return <> + + Terminal + +
+ +
+ ; +} + export function TerminalDialog({ terminalInfo, children }: { terminalInfo: TerminalSetupInfoModel; - children: React.ReactNode; + children: ReactElement<{ onClick?: (event: MouseEvent) => void }>; }) { + const { openDialog } = useDialog(); - const [isOpen, setIsOpen] = React.useState(false); + const openTerminalDialog = () => { + void openDialog( + , + { maxWidth: '1300px' }, + ); + }; - return ( - { - setIsOpen(isO); - }}> - - - - Terminal - -
- {terminalInfo ? : 'Currently there is no Terminal available'} -
-
-
- ) + return cloneElement(children, { + onClick: (event) => { + children.props.onClick?.(event); + openTerminalDialog(); + }, + }); } diff --git a/src/components/custom/build-logs-streamed.tsx b/src/components/custom/build-logs-streamed.tsx index 1961d676..3b41e25c 100644 --- a/src/components/custom/build-logs-streamed.tsx +++ b/src/components/custom/build-logs-streamed.tsx @@ -23,12 +23,14 @@ export default function BuildLogsStreamed({ workloadId, workloadType, fullHeight = false, + useFullHeight = false, maxLines = Constants.DEFAULT_MAX_LOG_LINES, }: { deploymentId?: string; workloadId?: string; workloadType?: string; fullHeight?: boolean; + useFullHeight?: boolean; maxLines?: number; }) { const { logs, isConnected, textAreaRef } = useLogStream( @@ -39,12 +41,15 @@ export default function BuildLogsStreamed({ ); return <> -
+