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/api/build-status/route.ts b/src/app/api/build-status/route.ts new file mode 100644 index 00000000..24e02a2c --- /dev/null +++ b/src/app/api/build-status/route.ts @@ -0,0 +1,103 @@ +import buildLiveStatusService from "@/server/services/build-live-status.service"; +import buildStatusService from "@/server/services/standalone-services/build-status-pub-sub.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; + let heartbeat: ReturnType | 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; + unsubscribe?.(); + unsubscribe = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + 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); + } + + 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"); + shouldStopStreaming = true; + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + if (heartbeat) clearInterval(heartbeat); + heartbeat = 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/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/api/logs-download/route.ts b/src/app/api/logs-download/route.ts deleted file mode 100644 index 54588163..00000000 --- a/src/app/api/logs-download/route.ts +++ /dev/null @@ -1,48 +0,0 @@ - -import { FsUtils } from "@/server/utils/fs.utils"; -import { PathUtils } from "@/server/utils/path.utils"; -import { NextRequest, NextResponse } from "next/server"; -import fs from 'fs/promises'; -import { getAuthUserSession, isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils"; -import { ServiceException } from "@/shared/model/service.exception.model"; -import { z } from "zod"; -import { stringToDate } from "@/shared/utils/zod.utils"; - -// Prevents this route's response from being cached -export const dynamic = "force-dynamic"; - -const zodInputModel = z.object({ - appId: z.string().min(1), - date: stringToDate -}); - -export async function GET(request: NextRequest) { - try { - await getAuthUserSession(); - - const requestUrl = new URL(request.url); - const appId = requestUrl.searchParams.get('appId'); - const date = requestUrl.searchParams.get('date'); - const validatedData = zodInputModel.parse({ appId, date }); - - await isAuthorizedReadForApp(validatedData.appId); - - const logsPath = PathUtils.appLogsFile(validatedData.appId, validatedData.date); - if (!await FsUtils.fileExists(logsPath)) { - throw new ServiceException(`Could not find logs for ${appId}.`); - } - - const buffer = await fs.readFile(logsPath); - - return new NextResponse(buffer, { - headers: { - 'Content-Type': 'application/gzip', - 'Content-Disposition': - `attachment; filename="${appId}-${validatedData.date.toISOString().split('T')[0]}.tar.gz"`, - }, - }); - } catch (error) { - console.error('Error while downloading data:', error); - return new Response((error as Error)?.message ?? 'An unknown error occured.', { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 00000000..dd317ebb --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,28 @@ +'use client'; + +import ErrorState from '@/components/custom/error-state'; +import { CircleAlert } from 'lucide-react'; +import { useEffect } from 'react'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( + } + mediaClassName="bg-destructive/10 text-destructive" + title="Something went wrong" + description="An unexpected error occurred while loading this page. You can try again, or return to the dashboard." + digest={error.digest} + onRetry={reset} + /> + ); +} diff --git a/src/app/error/page.tsx b/src/app/error/page.tsx index 73b3479e..7b06b6a7 100644 --- a/src/app/error/page.tsx +++ b/src/app/error/page.tsx @@ -1,9 +1,13 @@ +import ErrorState from '@/components/custom/error-state'; +import { TriangleAlert } from 'lucide-react'; export default function ErrorPage() { - return ( -
- Error Page -
- ) -} \ No newline at end of file + } + mediaClassName="bg-destructive/10 text-destructive" + title="Something went wrong" + description="An unexpected error occurred. Please try again or return to the dashboard." + /> + ); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 17086361..933b0f0a 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,41 +1,39 @@ -'use client' // Error boundaries must be Client Components +'use client'; -import { cn } from "@/frontend/utils/utils"; -import { AlertCircle } from "lucide-react" -import { Inter } from "next/font/google"; +import ErrorState from '@/components/custom/error-state'; +import { cn } from '@/frontend/utils/utils'; +import { TriangleAlert } from 'lucide-react'; +import { Inter } from 'next/font/google'; const inter = Inter({ - subsets: ["latin"], - variable: "--font-sans", + subsets: ['latin'], + variable: '--font-sans', }); export default function GlobalError({ error, + reset, }: { - error: Error & { digest?: string } - reset: () => void + error: Error & { digest?: string }; + reset: () => void; }) { return ( - - -
-
-
- -
-

Something went wrong!

-

- An unexpected error occurred. Please check if your authorized for this action and try again. -

-

- Digest: {error.digest} -

-
-
+ + + } + mediaClassName="bg-destructive/10 text-destructive" + title="Something went wrong" + description="A critical error occurred. Please try again, or reload the page to get back to the dashboard." + digest={error.digest} + onRetry={reset} + /> - ) + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f8496cc4..ff7e1f6c 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({ @@ -50,8 +51,8 @@ export default async function RootLayout({
-
-
+
+
{userIsLoggedIn && } }> {children} @@ -66,6 +67,7 @@ export default async function RootLayout({ {userIsLoggedIn && } + {userIsLoggedIn && } ); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 00000000..258bb721 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,12 @@ +import ErrorState from '@/components/custom/error-state'; +import { FileQuestion } from 'lucide-react'; + +export default function NotFound() { + return ( + } + title="Page not found" + description="The page you are looking for doesn't exist or may have been moved." + /> + ); +} 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 099cc308..146ea23f 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -1,9 +1,8 @@ 'use client'; -import type { CSSProperties } from 'react'; -import { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { useRouter } from 'next/navigation'; +import type { CSSProperties, ReactNode } from 'react'; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Background, BackgroundVariant, @@ -12,25 +11,39 @@ import { MarkerType, Position, ReactFlow, + SmoothStepEdge, useNodesState, type Node, type NodeProps, type NodeTypes, + type EdgeProps, type Connection, type ReactFlowInstance, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { Bot, Boxes, Cloud, Database, Edit2, Globe2, Info, RotateCcw, Trash2 } from 'lucide-react'; +import { Blocks, Bot, Boxes, Cloud, Database, File, Info, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; +import { Card, CardFooter } from '@/components/ui/card'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; 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'; import { UserGroupUtils } from '@/shared/utils/role.utils'; -import { InternalHostnameUtils } from '@/server/utils/internal-hostname.utils'; -import { NodeDetailsDrawer, type PanelConnection } from './project-network-graph/node-details-drawer'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model.ts'; +import { NodeDetailsDrawer } from './project-network-graph/node-details-drawer'; +import { + ProjectNetworkGraphAppContextMenu, + type ProjectNetworkGraphAppContextMenuProps, +} from './project-network-graph/project-network-graph-app-context-menu'; +import { ProjectNetworkGraphConnectionContextMenu } from './project-network-graph/project-network-graph-connection-context-menu'; import { connectionDeletionProvenance, NetworkGraphNode } from './project-network-graph/project-network-graph-projection'; import { useProjectNetworkGraph } from './project-network-graph/use-project-network-graph'; import { graphEdgePresentation, graphLegendItems, NETWORK_GRAPH_COLORS } from './project-network-graph/project-network-graph-visual-semantics'; @@ -43,26 +56,37 @@ import { AppNetworkPolicyDraft, AppNetworkPolicyDraftUtils } from '@/shared/util import AppNetworkPolicyRuleDialog from '@/app/project/app/[appId]/advanced/app-network-policy-rule-dialog'; import { saveAppNetworkPolicyConfiguration } from '@/app/project/app/[appId]/advanced/actions'; import { deleteApp } from '@/app/project/[projectId]/actions'; -import { EditAppDialog } from './edit-app-dialog'; +import { EditAppDialog } from '@/app/project/[projectId]/app-components/edit-app-dialog'; +import ChooseTemplateDialog from '@/app/project/[projectId]/choose-template-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'; +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 = '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!'; -const connectionTargetHandleClassName = 'size-3! border-2! border-background! bg-qs-400! opacity-0! shadow-md! transition-all duration-150 [&.connectingto]:opacity-100! hover:bg-qs-500!'; +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!'; +const connectionTargetHandleClassName = 'z-20! size-4! border-2! border-background! bg-qs-400! opacity-0! shadow-md! transition-all duration-150 [&.connectingto]:opacity-100! hover:bg-qs-500!'; -type EdgeMenu = { edgeId: string; x: number; y: number }; -type NodeMenu = { appId: string; x: number; y: number }; type WorkloadNodeData = NetworkGraphNode & { connectionInProgress?: boolean; connectionTarget?: boolean; selected?: boolean; connectedToSelection?: boolean; + contextMenu?: Omit; }; +type ConnectionEdgeData = { onDelete: () => void }; type ProjectNetworkGraphProps = { apps: AppExtendedModel[]; projectId: string; session: UserSession; savedPositions: ProjectNetworkGraphPositions; + s3Targets: S3Target[]; + storageClasses: string[]; + volumeBackupsByApp: Record; + gitSshPublicKeysByApp: Record; }; const WorkloadNode = memo(function WorkloadNode({ @@ -70,32 +94,39 @@ const WorkloadNode = memo(function WorkloadNode({ }: NodeProps>) { const database = !!data.appType && data.appType.toUpperCase() !== 'APP'; const Icon = data.kind === 'AGENT' ? Bot : database ? Database : Boxes; - return ( -
-
- -
-
-
-

{data.name}

- {data.kind === 'APP' &&
- -
} + const node = ( +
+
+
+ +
+
+
+

{data.name}

+ {data.kind === 'APP' &&
+ +
} +
+

{data.caption ?? (database ? data.appType : data.kind === 'AGENT' ? 'Agent sandbox' : 'App')}

-

{data.caption ?? (database ? data.appType : data.kind === 'AGENT' ? 'Agent sandbox' : 'App')}

- + {data.kind === 'APP' &&
+ +
} + - +
); + return data.contextMenu + ? {node} + : node; }); const InternetNode = memo(function InternetNode({ data, @@ -112,6 +143,61 @@ const InternetNode = memo(function InternetNode({ ); }); const nodeTypes = { workload: WorkloadNode, internet: InternetNode } satisfies NodeTypes; +function ConnectionEdge(props: EdgeProps) { + const data = props.data as ConnectionEdgeData | undefined; + if (!data) return ; + return ( + + + + ); +} +const edgeTypes = { connection: ConnectionEdge }; + +function ProjectNetworkGraphCanvasContextMenu({ + projectId, + canCreateApps, + children, +}: { + projectId: string; + canCreateApps: boolean; + children: ReactNode; +}) { + const { openDialog } = useDialog(); + + const openTemplateDialog = (templateType: 'database' | 'template') => { + openDialog( + , + { maxWidth: '1000px' }, + ); + }; + + if (!canCreateApps) return children; + + return ( + + + {children} + + + + + + Create Empty App + + + openTemplateDialog('template')}> + + Create App from Template + + openTemplateDialog('database')}> + + Create Database + + + + ); +} function Legend() { return ( @@ -134,7 +220,9 @@ function Legend() { } export default function ProjectNetworkGraph(props: ProjectNetworkGraphProps) { - return ; + return ; } function ProjectNetworkGraphEditor({ @@ -142,32 +230,57 @@ function ProjectNetworkGraphEditor({ projectId, session, savedPositions, + s3Targets, + storageClasses, + volumeBackupsByApp, + gitSshPublicKeysByApp, }: ProjectNetworkGraphProps) { const router = useRouter(); + const searchParams = useSearchParams(); const { openDialog } = useDialog(); const { openConfirmDialog } = useConfirmDialog(); const [drafts, setDrafts] = useState>(() => AppNetworkPolicyDraftUtils.collectionFromApps(apps)); const [baseline, setBaseline] = useState>(() => AppNetworkPolicyDraftUtils.collectionFromApps(apps)); - const [edgeMenu, setEdgeMenu] = useState(); - const [nodeMenu, setNodeMenu] = useState(); const [saving, setSaving] = useState(false); - const [selectedNodeId, setSelectedNodeId] = useState(); - const [isNodeDrawerOpen, setIsNodeDrawerOpen] = 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); const connectionTargetLeaveTimer = useRef | undefined>(undefined); const graphApps = useMemo(() => apps.map(app => AppNetworkPolicyDraftUtils.applyToApp(app, drafts[app.id])), [apps, drafts]); const canEditLayout = UserGroupUtils.sessionHasWriteAccessToProject(session, projectId); + const canCreateApps = UserGroupUtils.sessionCanCreateNewAppsForProject(session, projectId); 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 writable = (appId: string) => UserGroupUtils.sessionHasWriteAccessForApp(session, appId); - const writableAppIds = new Set(apps.filter(app => writable(app.id)).map(app => app.id)); - const canRenameApps = UserGroupUtils.sessionCanCreateNewAppsForProject(session, projectId); - const canDeleteApps = UserGroupUtils.sessionCanDeleteAppsForProject(session, projectId); + const drawerSession = useProjectNetworkGraphDrawerSession({ + searchParams, + appIds: localAppIds, + }); + const { selectedNodeId } = drawerSession; + const writable = useCallback( + (appId: string) => UserGroupUtils.sessionHasWriteAccessForApp(session, appId), + [session], + ); + const writableAppIds = useMemo( + () => 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); }; @@ -179,9 +292,9 @@ function ProjectNetworkGraphEditor({ project: { id: app.projectId, name: app.project.name }, })); - const updateDraft = (appId: string, update: (draft: AppNetworkPolicyDraft) => AppNetworkPolicyDraft) => { + const updateDraft = useCallback((appId: string, update: (draft: AppNetworkPolicyDraft) => AppNetworkPolicyDraft) => { setDrafts(current => ({ ...current, [appId]: update(current[appId]) })); - }; + }, []); const openConnectionDialog = (sourceAppId: string, targetAppId: string) => { const source = apps.find(app => app.id === sourceAppId); const target = selectableTargets.find(item => item.id === targetAppId); @@ -200,28 +313,25 @@ function ProjectNetworkGraphEditor({ )} />, { maxWidth: 'max-w-md' }); }; - const deleteConnection = (edgeId: string) => { + const deleteConnection = useCallback((edgeId: string) => { const edge = layout?.edges.find(item => item.id === edgeId); const provenance = connectionDeletionProvenance(edge, writableAppIds); if (!provenance) { return; } setDrafts(current => AppNetworkPolicyDraftUtils.removeProvenance(current, provenance)); - setEdgeMenu(undefined); - }; - const deleteLocalApp = async (appId: string) => { - setNodeMenu(undefined); + }, [layout?.edges, writableAppIds]); + const deleteLocalApp = useCallback(async (appId: string) => { if (!await openConfirmDialog({ title: 'Delete App', description: 'Are you sure you want to delete this app? All data will be lost and this action cannot be undone.', })) return; await Toast.fromAction(() => deleteApp(appId)); - }; - const toggleInternetAccess = (appId: string) => { + }, [openConfirmDialog]); + const toggleInternetAccess = useCallback((appId: string) => { updateDraft(appId, draft => ({ ...draft, allowInternetAccess: !draft.allowInternetAccess, })); - setNodeMenu(undefined); - }; + }, [updateDraft]); const saveChanges = async () => { const changed = Object.values(drafts).filter(draft => !AppNetworkPolicyDraftUtils.equals(draft, baseline[draft.appId])); if (!changed.length) { return; } @@ -248,11 +358,17 @@ function ProjectNetworkGraphEditor({ }; const discardChanges = () => { setDrafts(baseline); - setEdgeMenu(undefined); setConnectionSourceNodeId(undefined); setConnectionTargetNodeId(undefined); }; - const projectedNodes: Node[] = useMemo(() => (layout?.nodes ?? []).map(node => ({ + const projectedNodes: Node[] = useMemo(() => (layout?.nodes ?? []).map(node => { + const appId = node.kind === 'APP' ? node.id.replace('APP:', '') : undefined; + const app = appId ? apps.find(item => item.id === appId) : undefined; + const draft = app ? drafts[app.id] : undefined; + const role = app + ? UserGroupUtils.getRolePermissionForApp(session, app.id) ?? undefined + : undefined; + return { id: node.id, type: node.kind === 'INTERNET' ? 'internet' : 'workload', position: node.position, @@ -261,12 +377,23 @@ function ProjectNetworkGraphEditor({ connectionInProgress: !!connectionSourceNodeId, connectionTarget: node.id === connectionTargetNodeId, selected: node.id === selectedNodeId, + contextMenu: app && draft && role === RolePermissionEnum.READWRITE ? { + app, + role, + allowInternetAccess: draft.allowInternetAccess, + onToggleInternetAccess: () => toggleInternetAccess(app.id), + onOpenDrawerTab: (tab: DrawerTab) => { + drawerSession.openAppTab(app.id, tab); + }, + onDelete: () => void deleteLocalApp(app.id), + } : undefined, connectedToSelection: !selectedNodeId || (layout?.edges ?? []).some(edge => (edge.source === selectedNodeId && edge.target === node.id) || (edge.target === selectedNodeId && edge.source === node.id), ), }, - })), [connectionSourceNodeId, connectionTargetNodeId, layout?.edges, layout?.nodes, selectedNodeId]); + }; + }), [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 => { @@ -277,7 +404,10 @@ function ProjectNetworkGraphEditor({ target: edge.target, sourceHandle: presentation.sourceHandle, targetHandle: presentation.targetHandle, - type: 'smoothstep' as const, + type: connectionDeletionProvenance(edge, writableAppIds) ? 'connection' : 'smoothstep', + data: connectionDeletionProvenance(edge, writableAppIds) + ? { onDelete: () => deleteConnection(edge.id) } + : undefined, pathOptions: { offset: 20 }, markerStart: edge.internetIngress ? { type: MarkerType.ArrowClosed, color: presentation.color, width: 16, height: 16 } : undefined, markerEnd: edge.direction === 'INTERNET_CONNECTION' @@ -295,34 +425,13 @@ function ProjectNetworkGraphEditor({ labelBgPadding: [6, 3] as [number, number], labelBgBorderRadius: 6, }; - }), [layout?.edges, selectedNodeId]); + }), [deleteConnection, layout?.edges, selectedNodeId, writableAppIds]); 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; - const selectedConnections = useMemo(() => (layout?.edges ?? []) - .filter(edge => edge.source === selectedNodeId || edge.target === selectedNodeId) - .map(edge => { - const otherNode = (layout?.nodes ?? []).find(node => node.id === (edge.source === selectedNodeId ? edge.target : edge.source)); - const direction = edge.source === selectedNodeId ? 'Egress' : 'Ingress'; - const port = Number.parseInt(edge.labels[0] ?? '', 10); - const copyValue = direction === 'Ingress' && otherNode?.kind === 'APP' && otherNode.projectId - ? InternalHostnameUtils.getInternalBaseUrlForApp({ id: otherNode.id.replace('APP:', ''), projectId: otherNode.projectId }, Number.isNaN(port) ? undefined : port) - : undefined; - return { - id: edge.id, - name: otherNode?.name ?? 'Unknown workload', - direction, - label: graphEdgePresentation(edge).label, - copyValue - } satisfies PanelConnection; - }), [layout, selectedNodeId]); - - useEffect(() => { - if (selectedNodeId) setIsNodeDrawerOpen(true); - }, [selectedNodeId]); - useEffect(() => { if (!selectedNodeId || selectedNode?.kind !== 'APP') return; + if (!window.matchMedia('(min-width: 1024px)').matches) return; const animationFrame = requestAnimationFrame(() => { const reactFlow = reactFlowRef.current; @@ -357,14 +466,15 @@ function ProjectNetworkGraphEditor({
- - Legend - } /> + + Legend + } /> @@ -374,20 +484,25 @@ function ProjectNetworkGraphEditor({ Reset }
- + { reactFlowRef.current = instance; }} nodes={nodes} edges={edges} onNodesChange={onNodesChange} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} fitView fitViewOptions={{ padding: 0.2, maxZoom: 1.1 }} minZoom={0.3} maxZoom={1.5} - zoomOnScroll={false} + zoomOnScroll zoomOnPinch={false} zoomOnDoubleClick={false} - preventScrolling={false} + preventScrolling nodesDraggable={canEditLayout} nodesConnectable elementsSelectable={false} @@ -419,28 +534,7 @@ function ProjectNetworkGraphEditor({ setConnectionSourceNodeId(undefined); setConnectionTargetNodeId(undefined); }} - onEdgeContextMenu={(event, edge) => { - event.preventDefault(); - setNodeMenu(undefined); - const graphEdge = layout?.edges.find(item => item.id === edge.id); - if (connectionDeletionProvenance(graphEdge, writableAppIds)) { - setEdgeMenu({ edgeId: edge.id, x: event.clientX, y: event.clientY }); - } - }} - onNodeContextMenu={(event, node) => { - event.preventDefault(); - setEdgeMenu(undefined); - const data = node.data as NetworkGraphNode; - const appId = data.kind === 'APP' ? data.id.replace('APP:', '') : undefined; - if (appId && localAppIds.has(appId) && (canRenameApps || canDeleteApps || writable(appId))) { - setNodeMenu({ appId, x: event.clientX, y: event.clientY }); - } else { - setNodeMenu(undefined); - } - }} onPaneClick={() => { - setEdgeMenu(undefined); - setNodeMenu(undefined); }} onNodeMouseEnter={(_event, node) => { cancelConnectionTargetLeave(); @@ -467,64 +561,37 @@ function ProjectNetworkGraphEditor({ onNodeClick={(_event, node) => { const data = node.data as NetworkGraphNode; if (data.kind !== 'INTERNET') { - setSelectedNodeId(node.id); + drawerSession.selectNode(data); } }} > - + + {dirty && ( - - + + + + )} - {edgeMenu && createPortal( -
- -
, - 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.

} @@ -533,13 +600,18 @@ function ProjectNetworkGraphEditor({ node={selectedNode} app={selectedApp} role={selectedAppRole} - connections={selectedConnections} - open={isNodeDrawerOpen} - onOpenChange={setIsNodeDrawerOpen} - onOpenChangeComplete={open => { - if (!open) setSelectedNodeId(undefined); + s3Targets={s3Targets} + storageClasses={storageClasses} + volumeBackups={selectedApp ? (volumeBackupsByApp[selectedApp.id] ?? []) : []} + gitSshPublicKey={selectedApp ? gitSshPublicKeysByApp[selectedApp.id] : undefined} + open={drawerSession.open} + onOpenChange={drawerSession.onOpenChange} + onOpenChangeComplete={drawerSession.onOpenChangeComplete} + requestedTab={drawerSession.requestedTab} + onTabChange={tab => { + if (selectedApp) drawerSession.openAppTab(selectedApp.id, tab); }} - onOpen={() => router.push(`/project/app/${selectedNode.id.replace('APP:', '')}`)} />} + />}
); diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backup-list.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backup-list.tsx new file mode 100644 index 00000000..855de0d1 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/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/drawer-backups-tab.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backups-tab.tsx new file mode 100644 index 00000000..c3dd306d --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/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 './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-deployments-tab.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-deployments-tab.tsx new file mode 100644 index 00000000..b83e1ee6 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-deployments-tab.tsx @@ -0,0 +1,59 @@ +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"; +import { useNestedDrawer } from "./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"; +import WebhookDeploymentInfo from "@/app/project/app/[appId]/overview/webhook-deployment"; +import { Button } from "@/components/ui/button"; +import { Webhook } from "lucide-react"; + + + + +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: ( + + ), + }); + }; + + 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/drawer/drawer-environment.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx new file mode 100644 index 00000000..5744297b --- /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-settings.tsx b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx new file mode 100644 index 00000000..3bbade4a --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { + Boxes, + Globe2, + HardDrive, + Network, + SlidersHorizontal, + Zap, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +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 StorageList from '@/app/project/app/[appId]/volumes/storages'; +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 { SettingsSection } from './settings-section'; +import { DrawerEnvironment } from './drawer-environment'; +import { useNestedDrawer } from './nested-drawer'; + +export function DrawerSettings({ + app, + role, + storageClasses, + gitSshPublicKey, +}: { + app: AppExtendedModel; + role: RolePermissionEnum; + storageClasses: string[]; + gitSshPublicKey?: string; +}) { + const readonly = role !== RolePermissionEnum.READWRITE; + const { openNestedDrawer } = useNestedDrawer(); + + return ( +
+
+ + + + + + + + + + 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). + + + + + +
+
+
+
+ ); +} 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..7966fa4d --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/nested-drawer.tsx @@ -0,0 +1,98 @@ +'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, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/drawer'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +type NestedDrawerOptions = { + title: string; + description?: ReactNode; + 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?.description && ( + + {drawer.description} + + )} + + +
+ {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..8cc73cd3 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/drawer/settings-section.tsx @@ -0,0 +1,31 @@ +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..520c9f08 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,25 +1,22 @@ 'use client'; -import { useEffect, type Ref } from 'react'; +import { type ReactNode, type Ref } from 'react'; import { - ArrowDown, - ArrowUp, BarChart3, Bot, Boxes, - ChevronDown, - Copy, ExternalLink, - Globe2, Hammer, - LayoutDashboard, - Network, + Key, Play, Rocket, - ScrollText, + Logs as LogsIcon, + Pencil, + Settings, Square, X, + RotateCwClock, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -29,39 +26,46 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemGroup, - ItemMedia, - ItemTitle, -} from '@/components/ui/item'; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; import { Drawer, DrawerContent, DrawerDescription, - DrawerFooter, DrawerHeader, DrawerTitle, } from '@/components/ui/drawer'; 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 { AppLifecycleUtils } from '@/frontend/utils/app-lifecycle.utils'; import { Toast } from '@/frontend/utils/toast.utils'; -import { toast } from 'sonner'; 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'; 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 { DrawerSettings } from './drawer/drawer-settings'; +import { NestedDrawerProvider } from './drawer/nested-drawer'; +import { DrawerBackupsTab } from './drawer/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'; +import { + DrawerSessionUtils, + type DrawerTab, +} from './project-network-graph-drawer-session'; +import DrawerDeploymentsTab from './drawer/drawer-deployments-tab'; export type PanelConnection = { id: string; @@ -71,6 +75,14 @@ export type PanelConnection = { copyValue?: string; }; +function DrawerTabScrollArea({ children }: { children: ReactNode }) { + return ( + +
{children}
+
+ ); +} + function AppStatusActions({ app, role, @@ -81,83 +93,143 @@ 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'; - return ( -
+ window.open(`${protocol}://${domain.hostname}`, '_blank'); + }; - {canManage ? ( - - -
- - -
-
- - - void Toast.fromAction(() => deploy(app.id)) - } - > - - Deploy - - {app.appType === 'APP' && - (app.sourceType === 'GIT' || - app.sourceType === 'GIT_SSH') && ( - - void Toast.fromAction(() => - deploy(app.id, true), - ) + return ( +
+ + {(lifecycle.canManage || app.appDomains.length > 0) && ( + +
+ {lifecycle.canManage && ( + <> + + void Toast.fromAction(() => deploy(app.id))} + > + + Deploy + + } + /> + Deploy + + {lifecycle.supportsRebuild && ( + + void Toast.fromAction(() => deploy(app.id, true))} + > + + Rebuild + + } + /> + Rebuild + + )} + + void Toast.fromAction(() => startApp(app.id))} + > + + Start + + } + /> + Start + + + void Toast.fromAction(() => stopApp(app.id))} + > + + Stop + + } + /> + Stop + + + )} + {app.appDomains.length === 1 && ( + + openDomain(app.appDomains[0])} + > + + Open domain + } - > - - Rebuild - - )} - - void Toast.fromAction(() => startApp(app.id)) - } - > - - Start - - - void Toast.fromAction(() => stopApp(app.id)) - } - > - - Stop - - - - ) : } + /> + Open domain + + )} + {app.appDomains.length > 1 && ( + + + + + } + /> + + {app.appDomains.map((domain) => ( + openDomain(domain)} + > + + {domain.hostname} + + ))} + + + )} +
+
+ )}
); } @@ -167,108 +239,47 @@ export function NodeDetailsDrawer({ node, app, role, - connections, + s3Targets, + storageClasses, + volumeBackups, + gitSshPublicKey, open, onOpenChange, onOpenChangeComplete, - onOpen, + requestedTab, + onTabChange, }: { contentRef?: Ref; node: NetworkGraphNode; 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; + requestedTab?: string | null; + onTabChange: (tab: DrawerTab) => void; }) { - const isDialogOpen = useDialog((state) => state.isDialogOpen); const isApp = node.kind === 'APP'; const needsSourceConfiguration = app && role === RolePermissionEnum.READWRITE && !AppSourceUtils.isConfiguredSource(app); - const connectionsContent = ( -
- {connections.length === 0 ? ( -

- No active connections. -

- ) : ( - - {connections.map((connection) => { - const isInternet = connection.name === 'Internet'; - const DirectionIcon = connection.direction === 'Ingress' - ? ArrowDown - : ArrowUp; + const hasVolumes = (app?.appVolumes.length ?? 0) > 0; - return ( - - - {isInternet ? ( - - ) : ( - - )} - - - {connection.name} - - {connection.direction} - {connection.label - ? ` · ${connection.label}` - : ''} - - - {connection.copyValue && ( - - - - )} - - ); - })} - - )} -
+ const activeTab = DrawerSessionUtils.resolveTab( + app?.appType, + requestedTab, + hasVolumes, ); - const externalDomain = app?.appDomains[0]; - const externalUrl = externalDomain - ? `${externalDomain.useSsl ? 'https' : 'http'}://${externalDomain.hostname}` - : undefined; - useEffect(() => { - if (open && isDialogOpen) { - onOpenChange(false); - } - }, [isDialogOpen, onOpenChange, open]); + const handleTabChange = (tab: string) => { + const nextTab = DrawerSessionUtils.resolveTab(app?.appType, tab, hasVolumes); + onTabChange(nextTab); + }; return ( - - - -
-
- {isApp ? ( - - ) : ( - + + + + +
+
+ {isApp ? ( + + ) : ( + + )} +
+
+ + {node.name} + {app && role === RolePermissionEnum.READWRITE && ( + + + + )} + + + {node.caption ?? + (isApp ? 'App' : 'Agent sandbox')} + +
+ {app && !needsSourceConfiguration && (<> + +
+ )}
-
- - {node.name} - - - {node.caption ?? - (isApp ? 'App' : 'Agent sandbox')} - -
- {app && !needsSourceConfiguration && ( - + {app && role && !needsSourceConfiguration ? ( + + + + + Deployments + + {app.appType !== 'APP' && ( + + + DB Access + + )} + + + Logs + + + + Stats + + {hasVolumes && ( + + + Backups + + )} + + + Settings + + + + ) : ( +
)} -
- {app && role && !needsSourceConfiguration ? ( - - - - - Overview - - - - Logs - - - - Deployments - - - - Stats - - - - ) : ( -
- )} - - + {needsSourceConfiguration ? ( - <> + - - ) : app && role ? ( + + ) : app && role && ( <> - - - - - - 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} -
+ + + + - - + + - - + + + + - - + {hasVolumes && ( + + +
+ +
+
+
+ )} + {app.appType !== 'APP' && ( + + +
+ {role === RolePermissionEnum.READWRITE && ( + + )} + +
+
+
+ )} + + +
+ +
+
- ) : ( -
-
- - Network Policies ({connections.length}) -
- {connectionsContent} -
)} -
- - {isApp && ( - - - - )} + + ); 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 new file mode 100644 index 00000000..7b5d2d02 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx @@ -0,0 +1,119 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Box, Globe2, Hammer, Logs, Play, Rocket, RotateCwClock, Settings, Square, Trash2 } from 'lucide-react'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; +import { deploy, startApp, stopApp } from '@/app/project/app/[appId]/actions'; +import { usePodsStatus } from '@/frontend/states/zustand.states'; +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'; + +export type ProjectNetworkGraphAppContextMenuProps = { + app: AppExtendedModel; + role?: RolePermissionEnum; + allowInternetAccess: boolean; + onToggleInternetAccess: () => void; + onOpenDrawerTab: (tab: 'deployments' | 'logs' | 'backups' | 'settings') => void; + onDelete: () => void; + children: ReactNode; +}; + +export function ProjectNetworkGraphAppContextMenu({ + app, + role, + allowInternetAccess, + onToggleInternetAccess, + onDelete, + children, + onOpenDrawerTab +}: ProjectNetworkGraphAppContextMenuProps) { + const deploymentStatus = usePodsStatus( + (state) => state.podsStatus.get(app.id)?.deploymentStatus ?? 'UNKNOWN', + ); + const lifecycle = AppLifecycleUtils.availability(app, role, deploymentStatus); + + return ( + + {children} + event.stopPropagation()}> + {lifecycle.canManage && <> + + + + Deploy + + event.stopPropagation()}> + void Toast.fromAction(() => deploy(app.id))} + > + + Deploy + + {lifecycle.supportsRebuild && void Toast.fromAction(() => deploy(app.id, true))} + > + + Rebuild + } + void Toast.fromAction(() => startApp(app.id))} + > + + Start + + void Toast.fromAction(() => stopApp(app.id))} + > + + Stop + + + + + onOpenDrawerTab('deployments')}> + + View Deployments + + onOpenDrawerTab('logs')}> + + View Logs + + {app.appVolumes.length > 0 && ( + onOpenDrawerTab('backups')}> + + View Backups + + )} + onOpenDrawerTab('settings')}> + + View Settings + + + + + {allowInternetAccess ? 'Disable' : 'Enable'} Egress Internet Access + + } + {lifecycle.canManage && + + Delete App + } + + + ); +} diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-connection-context-menu.tsx b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-connection-context-menu.tsx new file mode 100644 index 00000000..c9026dc0 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-connection-context-menu.tsx @@ -0,0 +1,30 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Trash2 } from 'lucide-react'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; + +export function ProjectNetworkGraphConnectionContextMenu({ + onDelete, + children, +}: { + onDelete: () => void; + children: ReactNode; +}) { + return ( + + }>{children} + event.stopPropagation()}> + + + Delete connection + + + + ); +} 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..1adc2a47 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.spec.ts @@ -0,0 +1,68 @@ +import { act, 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', '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', () => { + 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); + }); + + 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(() => + 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).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', () => { + 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 new file mode 100644 index 00000000..e598b9f5 --- /dev/null +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts @@ -0,0 +1,117 @@ +'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', + 'backups', + 'settings', +] as const; + +export type DrawerTab = (typeof drawerTabValues)[number]; + +export class DrawerSessionUtils { + 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; + } +} + +type QueryParams = Pick; + +export function useProjectNetworkGraphDrawerSession({ + searchParams, + appIds, +}: { + searchParams: QueryParams; + appIds: Set; +}) { + const [selectedNodeId, setSelectedNodeId] = useState(); + const [open, setOpen] = useState(false); + const requestedTab = searchParams.get('drawerTab'); + + const updateQuery = useCallback((appId?: string, tab?: DrawerTab) => { + 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(() => { + 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); + if (selectedNodeId === node.id) setOpen(true); + updateQuery(node.kind === 'APP' ? node.id.replace('APP:', '') : undefined); + }, [selectedNodeId, updateQuery]); + + const openAppTab = useCallback((appId: string, tab: DrawerTab) => { + const nodeId = `APP:${appId}`; + setSelectedNodeId(nodeId); + if (selectedNodeId === nodeId) setOpen(true); + updateQuery(appId, tab); + }, [selectedNodeId, updateQuery]); + + const onOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + updateQuery(); + } + }, [updateQuery]); + + 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, + requestedTab, + selectNode, + openAppTab, + onOpenChange, + onOpenChangeComplete, + }), [ + onOpenChange, + onOpenChangeComplete, + openAppTab, + open, + requestedTab, + selectNode, + selectedNodeId, + ]); +} diff --git a/src/app/project/[projectId]/app-components/project-overview.tsx b/src/app/project/[projectId]/app-components/project-overview.tsx index fe8d4450..f0cda138 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,10 +49,15 @@ export default function AppProjectOverview({ projectName, networkGraphPositions, showNewNetworkPolicyExplanation, + s3Targets, + storageClasses, + volumeBackupsByApp, + gitSshPublicKeysByApp, }: ProjectOverviewProps) { const searchParams = useSearchParams(); const { openDialog } = useDialog(); const requestedTab = searchParams.get('tab'); + const requestedDrawerAppId = searchParams.get('drawerAppId'); const [currentTab, setCurrentTab] = useState('graph'); useEffect(() => { @@ -61,13 +72,17 @@ export default function AppProjectOverview({ }, [openDialog, showNewNetworkPolicyExplanation, apps.length]); useEffect(() => { + if (requestedDrawerAppId) { + setCurrentTab('graph'); + return; + } if (isProjectOverviewTab(requestedTab)) { setCurrentTab(requestedTab); return; } const savedTab = window.localStorage.getItem(tabStorageKey()); setCurrentTab(isProjectOverviewTab(savedTab) ? savedTab : 'graph'); - }, [projectId, requestedTab]); + }, [projectId, requestedDrawerAppId, requestedTab]); const handleTabChange = (value: string) => { if (!isProjectOverviewTab(value)) return; @@ -138,12 +153,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/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-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..611c8382 100644 --- a/src/app/project/app/[appId]/advanced/basic-auth.tsx +++ b/src/app/project/app/[appId]/advanced/basic-auth.tsx @@ -4,24 +4,25 @@ 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, Eye, TrashIcon } from "lucide-react"; +import { EditIcon, Eye, 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"; 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,26 +31,27 @@ 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 Username Password - Action + {!readonly && } {app.appBasicAuths.map(basicAuth => ( - + {basicAuth.username} @@ -65,24 +67,39 @@ export default function BasicAuth({ app, readonly }: { - {!readonly && - - - - + {!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]/credentials/db-crendentials.tsx b/src/app/project/app/[appId]/credentials/db-crendentials.tsx index 8ddd6a88..5990698d 100644 --- a/src/app/project/app/[appId]/credentials/db-crendentials.tsx +++ b/src/app/project/app/[appId]/credentials/db-crendentials.tsx @@ -36,7 +36,7 @@ export default function DbCredentials({ {!databaseCredentials ? : <> -
+
{!!databaseCredentials?.databaseName && <> 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..50b792ea 100644 --- a/src/app/project/app/[appId]/domains/node-ports.tsx +++ b/src/app/project/app/[appId]/domains/node-ports.tsx @@ -8,16 +8,19 @@ 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"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -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,54 +29,68 @@ 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' : ''} Container Port Node Port - Protocol - {!readonly && Actions} + Protocol + {!readonly && } {app.appNodePorts.map((np) => ( - + {np.port} {np.nodePort} - {np.protocol} + {np.protocol} {!readonly && ( - - - - - + +
+ + + void openDialog(, { maxWidth: '425px' })}>} /> + Edit node port + + + + + asyncDeleteNodePort(np.id)}>} /> + Delete node port + + +
)}
))}
-
+
} {!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..290aad00 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"; @@ -21,9 +22,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 +60,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); })()}> - +
-
+
( - Memory Limit (MB) + Memory Limit - + + + MB + @@ -106,20 +113,23 @@ export default function GeneralAppRateLimits({ app, readonly }: { name="memoryReservation" render={({ field }) => ( - Memory Reservation (MB) + Memory Reservation - + + + MB + {!readonly && suggestedMemoryMb !== undefined && ( form.setValue('memoryReservation', suggestedMemoryMb)} - > - ~ {suggestedMemoryMb} MB - } /> + className="inline-flex w-fit 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

@@ -135,9 +145,12 @@ export default function GeneralAppRateLimits({ app, readonly }: { name="cpuLimit" render={({ field }) => ( - CPU Limit (m) + CPU Limit - + + + mCPU + @@ -149,20 +162,23 @@ export default function GeneralAppRateLimits({ app, readonly }: { name="cpuReservation" render={({ field }) => ( - CPU Reservation (m) + CPU Reservation - + + + mCPU + {!readonly && suggestedCpuMillicores !== undefined && ( form.setValue('cpuReservation', suggestedCpuMillicores)} - > - ~ {suggestedCpuMillicores} m - } /> + className="inline-flex w-fit 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 +190,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..a86de24f 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]/overview/actions.ts b/src/app/project/app/[appId]/overview/actions.ts index 0be50a6e..6d5957b8 100644 --- a/src/app/project/app/[appId]/overview/actions.ts +++ b/src/app/project/app/[appId]/overview/actions.ts @@ -6,9 +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 appLogsService from "@/server/services/standalone-services/app-logs.service"; -import { ServiceException } from "@/shared/model/service.exception.model"; +import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, simpleAction } from "@/server/utils/action-wrapper.utils"; export const getDeploymentsAndBuildsForApp = async (appId: string) => simpleAction(async () => { @@ -46,22 +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); - }); - -export const getDownloadableLogs = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - return new SuccessActionResult(await appLogsService.getAvailableLogsForApp(appId)); + await isAuthorizedWriteForWorkload(appId); + return await appService.regenerateWebhookId(appId); }); - -export const exportLogsToFileForToday = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const result = await appLogsService.writeAppLogsToDiskForApp(appId); - if (!result) { - throw new ServiceException('There are no logs available for today.'); - } - return new SuccessActionResult(result); - }); \ No newline at end of file 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..2ed0c6e4 100644 --- a/src/app/project/app/[appId]/overview/build-logs-overlay.tsx +++ b/src/app/project/app/[appId]/overview/build-logs-overlay.tsx @@ -8,15 +8,18 @@ import { DeploymentInfoModel } from "@/shared/model/deployment-info.model"; import { WorkloadType } from "@/shared/model/runtime-type.model"; import { formatDateTime } from "@/frontend/utils/format.utils"; import BuildLogsStreamed from "@/components/custom/build-logs-streamed"; +import { cn } from "@/frontend/utils/utils"; export function BuildLogsDialogContent({ deploymentInfo, workloadId, workloadType, + hideHeader = false, }: { deploymentInfo?: DeploymentInfoModel; workloadId?: string; workloadType?: WorkloadType; + hideHeader?: boolean; }) { if (!deploymentInfo) { @@ -25,15 +28,22 @@ 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 && } + {deploymentInfo.deploymentId && workloadId && workloadType && }
) diff --git a/src/app/project/app/[appId]/overview/deployments-grid-view.tsx b/src/app/project/app/[appId]/overview/deployments-grid-view.tsx index 6d6f2d42..31ebdfc6 100644 --- a/src/app/project/app/[appId]/overview/deployments-grid-view.tsx +++ b/src/app/project/app/[appId]/overview/deployments-grid-view.tsx @@ -38,7 +38,7 @@ export function DeploymentsGridView({
{deployments.map(deployment => ( diff --git a/src/app/project/app/[appId]/overview/deployments.tsx b/src/app/project/app/[appId]/overview/deployments.tsx index 39c7a6ce..4585367b 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'; @@ -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 @@ -98,6 +100,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/app/project/app/[appId]/overview/logs-download-overlay.tsx b/src/app/project/app/[appId]/overview/logs-download-overlay.tsx deleted file mode 100644 index aa7b8d20..00000000 --- a/src/app/project/app/[appId]/overview/logs-download-overlay.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import React, { useCallback, useEffect } from "react"; -import { formatDate } from "@/frontend/utils/format.utils"; -import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model"; -import { toast } from "sonner"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { exportLogsToFileForToday, getDownloadableLogs } from "./actions"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Download } from "lucide-react"; -import FullLoadingSpinner from "@/components/ui/full-loading-spinnter"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { DateUtils } from "@/shared/utils/date.utils"; - -export function LogsDownloadOverlay({ - children, - appId, - onClose -}: { - children: React.ReactNode; - appId: string; - onClose?: () => void; -}) { - - const [logs, setLogs] = React.useState(undefined); - const [isLoading, setIsLoading] = React.useState(false); - - const getLogsListAsync = useCallback(async () => { - setIsLoading(true); - try { - let logs = await Actions.run(() => getDownloadableLogs(appId)); - const today = new Date(); - logs = logs.filter(log => !DateUtils.isSameDay(today, log.date)); - logs.unshift({ - appId: appId, - date: new Date() - }); - setLogs(logs); - } catch { - toast.error('Error while loading log files'); - } finally { - setIsLoading(false); - } - }, [appId]) - - const downloadLogFile = async (item: DownloadableAppLogsModel) => { - try { - setIsLoading(true); - // check if item.date is today - const today = new Date(); - if (DateUtils.isSameDay(today, item.date)) { - const logsToOpen = await Toast.fromAction(() => exportLogsToFileForToday(appId)); - if (!logsToOpen.data) { - throw new Error('No logs available for today'); - } - item = logsToOpen.data; - } - window.open(`/api/logs-download?appId=${appId}&date=${item.date.toISOString()}`, '_blank'); - } finally { - setIsLoading(false); - } - } - - useEffect(() => { - getLogsListAsync(); - }, [appId, getLogsListAsync]); - - return ( - { - if (!isO) { - onClose?.(); - } - }}> - - - - Logs Download - - Every day a new export of the logs is created. You can download the logs of the running pod(s) or the logs from the past. - - - - {logs ? - {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..96599212 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"; @@ -21,10 +20,12 @@ export default function Logs({ app, role, hideCard = false, + useFullHeight = false, }: { app: AppExtendedModel; role: RolePermissionEnum; hideCard?: boolean; + useFullHeight?: boolean; }) { const [selectedPod, setSelectedPod] = useState(undefined); const [appPods, setAppPods] = useState(undefined); @@ -111,72 +112,74 @@ export default function Logs({ }, [appPods, selectedPod]); const ContentWrapper = hideCard ? Fragment : Card; + const content = <> + {!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 &&
+ +
} +
+ ; + + 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 &&
- - - -
} -
- - - - - - - - -

Download Logs

-
-
-
-
-
- - - - - -

Fullscreen Logs

-
-
-
-
} - {app.projectId && selectedPod && } -
+ {content}
; } 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/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/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/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/shared-storage-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx index f2f9305a..22203239 100644 --- a/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/shared-storage-edit-overlay.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, @@ -25,6 +25,7 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model" import SelectFormField from "@/components/custom/select-form-field" import { Alert, AlertDescription } from "@/components/ui/alert" import { Info } from "lucide-react" +import { useDialog } from "@/frontend/states/zustand.states"; type ShareableVolume = { id: string; @@ -35,12 +36,11 @@ type ShareableVolume = { app: { name: string }; }; -export default function SharedStorageEditDialog({ children, app }: { - children: React.ReactNode; +export default function SharedStorageEditDialog({ app }: { app: AppExtendedModel; }) { - const [isOpen, setIsOpen] = useState(false); + const { closeDialog } = useDialog(); const [shareableVolumes, setShareableVolumes] = useState([]); const [isLoadingVolumes, setIsLoadingVolumes] = useState(false); @@ -64,22 +64,20 @@ export default function SharedStorageEditDialog({ children, app }: { // Fetch shareable volumes when dialog opens useEffect(() => { - if (isOpen) { - setIsLoadingVolumes(true); - getShareableVolumes(app.id).then(result => { - if (result.status === 'success' && result.data) { - const alreadyAddedSharedVolumes = app.appVolumes - .filter(v => !!v.sharedVolumeId) - .map(v => v.sharedVolumeId); - setShareableVolumes(result.data.filter(v => !alreadyAddedSharedVolumes.includes(v.id))); - } else { - setShareableVolumes([]); - toast.error('An error occurred while fetching shareable volumes'); - } - setIsLoadingVolumes(false); - }); - } - }, [isOpen, app.id, app.appVolumes]); + setIsLoadingVolumes(true); + getShareableVolumes(app.id).then(result => { + if (result.status === 'success' && result.data) { + const alreadyAddedSharedVolumes = app.appVolumes + .filter(v => !!v.sharedVolumeId) + .map(v => v.sharedVolumeId); + setShareableVolumes(result.data.filter(v => !alreadyAddedSharedVolumes.includes(v.id))); + } else { + setShareableVolumes([]); + toast.error('An error occurred while fetching shareable volumes'); + } + setIsLoadingVolumes(false); + }); + }, [app.id, app.appVolumes]); // Watch selected volume and auto-fill fields const watchedSharedVolumeId = form.watch("sharedVolumeId"); @@ -100,18 +98,13 @@ export default function SharedStorageEditDialog({ children, app }: { toast.success('Shared volume mounted successfully', { description: "Click \"deploy\" to apply the changes to your app.", }); - setIsOpen(false); + closeDialog(); } FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); + }, [closeDialog, form, state]); return ( <> -
setIsOpen(true)}> - {children} -
- setIsOpen(false)}> - Mount Shared Volume @@ -171,8 +164,6 @@ export default function SharedStorageEditDialog({ children, app }: {
- - ) } diff --git a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx index 1d3df79f..39d117f3 100644 --- a/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/storage-edit-overlay.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, @@ -29,7 +29,7 @@ import { Check, ChevronsUpDown, CircleHelp } from "lucide-react" 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 { AppVolume } from "@prisma/client" @@ -41,20 +41,20 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { AppExtendedModel } from "@/shared/model/app-extended.model" import CheckboxFormField from "@/components/custom/checkbox-form-field" import StorageClassCombobox from "@/components/custom/storage-class-combobox" +import { useDialog } from "@/frontend/states/zustand.states"; const accessModes = [ { label: "ReadWriteOnce", value: "ReadWriteOnce" }, { label: "ReadWriteMany", value: "ReadWriteMany" }, ] as const -export default function StorageEditDialog({ children, volume, app, storageClasses }: { - children: React.ReactNode; +export default function StorageEditDialog({ volume, app, storageClasses }: { volume?: AppVolume; app: AppExtendedModel; storageClasses: string[]; }) { - const [isOpen, setIsOpen] = useState(false); + const { closeDialog } = useDialog(); const defaultStorageClassName = volume?.storageClassName ?? storageClasses[0] ?? ""; const form = useForm, unknown, z.output>({ @@ -89,10 +89,10 @@ export default function StorageEditDialog({ children, volume, app, storageClasse toast.success('Volume 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({ @@ -109,11 +109,6 @@ export default function StorageEditDialog({ children, volume, app, storageClasse return ( <> -
setIsOpen(true)}> - {children} -
- setIsOpen(false)}> - Edit Volume @@ -287,8 +282,6 @@ export default function StorageEditDialog({ children, volume, app, storageClasse
- - ) } diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index 1b5df83f..3ea0698d 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -4,12 +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 { Download, EditIcon, Folder, TrashIcon, Share2, Unlink } from "lucide-react"; +import { Download, EditIcon, Folder, TrashIcon, Share2, Unlink, Plus, FolderPlus } from "lucide-react"; import DialogEditDialog from "./storage-edit-overlay"; import SharedStorageEditDialog from "./shared-storage-edit-overlay"; import { Toast } from "@/frontend/utils/toast.utils"; import { deleteVolume, downloadPvcData, getPvcUsage, openFileBrowserForVolume } from "./actions"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; import { AppVolume } from "@prisma/client"; import React from "react"; import { KubeObjectNameUtils } from "@/server/utils/kube-object-name.utils"; @@ -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[]); @@ -64,6 +65,7 @@ export default function StorageList({ app, readonly, storageClasses }: { }, [loadAndMapStorageData]); const { openConfirmDialog: openDialog } = useConfirmDialog(); + const { openDialog: openGenericDialog } = useDialog(); const asyncDeleteVolume = async (volumeId: string, isBaseVolume: boolean) => { try { @@ -143,31 +145,33 @@ 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 Mount Path - Storage Size + Storage Size Storage Used - Storage Class - Access Mode - Shared - Action + Storage Class + Access Mode + Shared + {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 && ( @@ -211,34 +215,35 @@ export default function StorageList({ app, readonly, storageClasses }: { )} - - {!volume.sharedVolumeId && <> - - - - - - -

Download volume content

-
-
-
- {!readonly && - - - - - -

View content of Volume

-
-
-
} - } - {/* + +
+ {!volume.sharedVolumeId && <> + + + + + + +

Download volume content

+
+
+
+ {!readonly && + + + + + +

View content of Volume

+
+
+
} + } + {/* @@ -252,59 +257,77 @@ export default function StorageList({ app, readonly, storageClasses }: { */} - {!readonly && <> - {volume.sharedVolumeId ? ( - - - - - - -

Shared volumes cannot be edited (size and storage class are inherited)

-
-
-
- ) : ( - + {!readonly && <> + {volume.sharedVolumeId ? ( - + + + +

Shared volumes cannot be edited (size and storage class are inherited)

+
+
+
+ ) : ( + + + +

Edit volume settings

-
- )} - - - - - - -

{volume.sharedVolumeId ? 'Detach Volume' : 'Delete Volume'}

-
-
-
- } + )} + + + + + + +

{volume.sharedVolumeId ? 'Detach Volume' : 'Delete Volume'}

+
+
+
+ } +
))}
-
- {!readonly && - - - - - - +
} + {!readonly && + + } -
+ ; } diff --git a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx index 3b56c4a6..741e2462 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.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 { 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 { AppVolume, S3Target, VolumeBackup } from "@prisma/client" @@ -27,22 +27,21 @@ import SelectFormField from "@/components/custom/select-form-field" import Link from "next/link" import { Checkbox } from "@/components/ui/checkbox" import { AppExtendedModel } from "@/shared/model/app-extended.model" +import { useDialog } from "@/frontend/states/zustand.states"; export default function VolumeBackupEditDialog({ - children, volumeBackup, s3Targets, volumes, app }: { - children: React.ReactNode; volumeBackup?: VolumeBackup; s3Targets: S3Target[]; volumes: AppVolume[]; app: AppExtendedModel; }) { - const [isOpen, setIsOpen] = useState(false); + const { closeDialog } = useDialog(); const isDatabaseApp = app.appType !== 'APP'; const isDatabaseBackupSupported = [ @@ -75,10 +74,10 @@ export default function VolumeBackupEditDialog({ toast.success('Volume Backup saved successfully', { description: "From now on the volume will be backed up according to the new settings.", }); - setIsOpen(false); + closeDialog(); } FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); + }, [closeDialog, form, state]); useEffect(() => { form.reset(volumeBackup); @@ -86,11 +85,6 @@ export default function VolumeBackupEditDialog({ return ( <> -
setIsOpen(true)}> - {children} -
- setIsOpen(false)}> - Edit Backup Configuration @@ -188,8 +182,6 @@ export default function VolumeBackupEditDialog({
- - ) diff --git a/src/app/project/app/[appId]/volumes/volume-backup.tsx b/src/app/project/app/[appId]/volumes/volume-backup.tsx index 4432525b..8d6d6287 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, 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 } from "@/frontend/states/zustand.states"; +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"; @@ -19,15 +18,20 @@ export default function VolumeBackupList({ app, volumeBackups, s3Targets, - readonly + readonly, + hideCard = false, + onBackupScheduleClick, }: { app: AppExtendedModel, s3Targets: S3Target[], volumeBackups: VolumeBackupExtendedModel[]; readonly: boolean; + hideCard?: boolean; + onBackupScheduleClick?: (volumeBackup: VolumeBackupExtendedModel) => void; }) { const { openConfirmDialog: openDialog } = useConfirmDialog(); + const { openDialog: openGenericDialog } = useDialog(); const [isLoading, setIsLoading] = React.useState(false); // Filter out shared volumes (volumes that are mounted from other apps) @@ -60,59 +64,93 @@ 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 Cron Expression Retention - Backup Method - Backup Location - Created At - Action + Backup Method + Backup Location + {(onBackupScheduleClick || !readonly) && } {volumeBackups.map(volumeBackup => ( - + {volumeBackup.cron} {volumeBackup.retention} - + {app.appType !== 'APP' && volumeBackup.useDatabaseBackup ? `Database (${app.appType.toLocaleLowerCase()})` : 'Archive of Volume'} - {volumeBackup.target.name} - {formatDateTime(volumeBackup.createdAt)} - {!readonly && - } + {!readonly && - - - - } + {!readonly && } + {!readonly && + } + } ))}
-
- {!readonly && - - - +
} + {!readonly && + } -
+ ; -} \ No newline at end of file +} diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index be4f258e..5b0ffad3 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -16,7 +16,6 @@ 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"; @@ -249,11 +248,8 @@ export const purgeRegistryImages = async () => export const deleteOldAppLogs = async () => simpleAction(async () => { await getAdminUserSession(); - await Promise.all([ - appLogsService.deleteOldAppLogs(), - deploymentLogService.deleteOldDeploymentLogs(), - ]); - return new SuccessActionResult(undefined, `Successfully deleted old app and deployment 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