diff --git a/src/app/monitoring/app-monitoring.tsx b/src/app/monitoring/app-monitoring.tsx index 7cb75598..a9af74a5 100644 --- a/src/app/monitoring/app-monitoring.tsx +++ b/src/app/monitoring/app-monitoring.tsx @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'; import { AppMonitoringUsageModel } from '@/shared/model/app-monitoring-usage.model'; import PodStatusIndicator from '@/components/custom/pod-status-indicator'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; export default function AppRessourceMonitoring({ appsRessourceUsage @@ -74,7 +75,7 @@ export default function AppRessourceMonitoring({ {KubeSizeConverter.convertBytesToReadableSize(item.ramUsageBytes)} - + diff --git a/src/app/monitoring/app-volumes-monitoring.tsx b/src/app/monitoring/app-volumes-monitoring.tsx index 8a552eaf..3466850d 100644 --- a/src/app/monitoring/app-volumes-monitoring.tsx +++ b/src/app/monitoring/app-volumes-monitoring.tsx @@ -16,6 +16,7 @@ import { ExternalLink } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Progress } from "@/components/ui/progress" +import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; type AppVolumeMonitoringUsageExtendedModel = AppVolumeMonitoringUsageModel & { usedPercentage: number; @@ -92,7 +93,7 @@ export default function AppVolumeMonitoring({
{KubeSizeConverter.convertBytesToReadableSize(item.usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(item.capacityBytes)}
- + diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backup-list.tsx b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-backup-list.tsx similarity index 62% rename from src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backup-list.tsx rename to src/app/project/[projectId]/app-components/app-drawer-components/app-details-backup-list.tsx index 855de0d1..509bb987 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backup-list.tsx +++ b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-backup-list.tsx @@ -1,17 +1,22 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Archive } from 'lucide-react'; +import { Archive, Download, Loader2 } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; 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 { Toast } from '@/frontend/utils/toast.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'; +import { + downloadBackupForVolumeSchedule, + getBackupsForVolumeSchedule, +} from '@/app/project/app/[appId]/volumes/actions'; export function DrawerBackupList({ volumeBackup, @@ -20,6 +25,7 @@ export function DrawerBackupList({ }) { const [backups, setBackups] = useState(); const [hasLoadError, setHasLoadError] = useState(false); + const [downloadingBackupKey, setDownloadingBackupKey] = useState(); useEffect(() => { setBackups(undefined); @@ -29,6 +35,21 @@ export function DrawerBackupList({ .catch(() => setHasLoadError(true)); }, [volumeBackup.id]); + const downloadBackup = async (backupKey: string) => { + try { + setDownloadingBackupKey(backupKey); + const result = await Toast.fromAction(() => + downloadBackupForVolumeSchedule(volumeBackup.id, backupKey), + ); + if (result.status === 'success' && result.data) { + const params = new URLSearchParams({ fileName: result.data }); + window.open(`/api/volume-data-download?${params.toString()}`); + } + } finally { + setDownloadingBackupKey(undefined); + } + }; + if (hasLoadError) { return ( @@ -70,6 +91,7 @@ export function DrawerBackupList({ Created Size + Actions @@ -81,6 +103,22 @@ export function DrawerBackupList({ ? 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/app-drawer-components/app-details-backups-tab.tsx similarity index 95% rename from src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backups-tab.tsx rename to src/app/project/[projectId]/app-components/app-drawer-components/app-details-backups-tab.tsx index c3dd306d..af935512 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-backups-tab.tsx +++ b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-backups-tab.tsx @@ -6,7 +6,7 @@ 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'; +import { DrawerBackupList } from './app-details-backup-list'; export function DrawerBackupsTab({ app, diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-deployments-tab.tsx b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-deployments-tab.tsx similarity index 99% rename from src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-deployments-tab.tsx rename to src/app/project/[projectId]/app-components/app-drawer-components/app-details-deployments-tab.tsx index b83e1ee6..c77fd889 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-deployments-tab.tsx +++ b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-deployments-tab.tsx @@ -9,9 +9,6 @@ import WebhookDeploymentInfo from "@/app/project/app/[appId]/overview/webhook-de import { Button } from "@/components/ui/button"; import { Webhook } from "lucide-react"; - - - export default function DrawerDeploymentsTab({ app, role, diff --git a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-drawer.tsx similarity index 91% rename from src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx rename to src/app/project/[projectId]/app-components/app-drawer-components/app-details-drawer.tsx index 520c9f08..0f4f7d37 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/node-details-drawer.tsx +++ b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-drawer.tsx @@ -40,6 +40,13 @@ import { DrawerTitle, } from '@/components/ui/drawer'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; import PodStatusIndicator from '@/components/custom/pod-status-indicator'; import { deploy, startApp, stopApp } from '@/app/project/app/[appId]/actions'; import { usePodsStatus } from '@/frontend/states/zustand.states'; @@ -51,28 +58,27 @@ import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import Logs from '@/app/project/app/[appId]/overview/logs'; 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 { DrawerSettings } from './app-details-settings'; +import { NestedDrawerProvider } from './nested-drawer'; +import { DrawerBackupsTab } from './app-details-backups-tab'; +import { EditAppDialog } from '@/app/project/[projectId]/app-components/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'; + AppDrawerNavigationUtils, + type AppDrawerTab, +} from '@/shared/utils/app-drawer-navigation.utils'; +import DrawerDeploymentsTab from './app-details-deployments-tab'; -export type PanelConnection = { +export type AppDetailsDrawerNode = { id: string; + kind: string; + external?: boolean; name: string; - direction: 'Ingress' | 'Egress'; - label?: string; - copyValue?: string; + caption?: string; }; function DrawerTabScrollArea({ children }: { children: ReactNode }) { @@ -234,7 +240,7 @@ function AppStatusActions({ ); } -export function NodeDetailsDrawer({ +export function AppDetailsDrawer({ contentRef, node, app, @@ -248,9 +254,11 @@ export function NodeDetailsDrawer({ onOpenChangeComplete, requestedTab, onTabChange, + openEnvironment, + onEnvironmentOpened, }: { contentRef?: Ref; - node: NetworkGraphNode; + node: AppDetailsDrawerNode; app?: AppExtendedModel; role?: RolePermissionEnum; s3Targets: S3Target[]; @@ -261,23 +269,26 @@ export function NodeDetailsDrawer({ onOpenChange: (open: boolean) => void; onOpenChangeComplete: (open: boolean) => void; requestedTab?: string | null; - onTabChange: (tab: DrawerTab) => void; + onTabChange: (tab: AppDrawerTab) => void; + openEnvironment?: boolean; + onEnvironmentOpened?: () => void; }) { const isApp = node.kind === 'APP'; + const isExternalApp = isApp && node.external; const needsSourceConfiguration = app && role === RolePermissionEnum.READWRITE && !AppSourceUtils.isConfiguredSource(app); const hasVolumes = (app?.appVolumes.length ?? 0) > 0; - const activeTab = DrawerSessionUtils.resolveTab( + const activeTab = AppDrawerNavigationUtils.resolveTab( app?.appType, requestedTab, hasVolumes, ); const handleTabChange = (tab: string) => { - const nextTab = DrawerSessionUtils.resolveTab(app?.appType, tab, hasVolumes); + const nextTab = AppDrawerNavigationUtils.resolveTab(app?.appType, tab, hasVolumes); onTabChange(nextTab); }; @@ -394,7 +405,19 @@ export function NodeDetailsDrawer({
)} - {needsSourceConfiguration ? ( + {isExternalApp ? ( + + + + + + Settings unavailable + + This app belongs to another project. Its settings can only be edited from that project. + + + + ) : needsSourceConfiguration ? ( diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-environment.tsx similarity index 100% rename from src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-environment.tsx rename to src/app/project/[projectId]/app-components/app-drawer-components/app-details-environment.tsx diff --git a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-settings.tsx similarity index 88% rename from src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx rename to src/app/project/[projectId]/app-components/app-drawer-components/app-details-settings.tsx index 3bbade4a..f73f8d01 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/drawer/drawer-settings.tsx +++ b/src/app/project/[projectId]/app-components/app-drawer-components/app-details-settings.tsx @@ -8,6 +8,7 @@ import { SlidersHorizontal, Zap, } from 'lucide-react'; +import { useCallback, useEffect } from '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'; @@ -25,7 +26,7 @@ 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 { DrawerEnvironment } from './app-details-environment'; import { useNestedDrawer } from './nested-drawer'; export function DrawerSettings({ @@ -33,14 +34,31 @@ export function DrawerSettings({ role, storageClasses, gitSshPublicKey, + openEnvironment = false, + onEnvironmentOpened, }: { app: AppExtendedModel; role: RolePermissionEnum; storageClasses: string[]; gitSshPublicKey?: string; + openEnvironment?: boolean; + onEnvironmentOpened?: () => void; }) { const readonly = role !== RolePermissionEnum.READWRITE; const { openNestedDrawer } = useNestedDrawer(); + const openEnvironmentEditor = useCallback(() => { + openNestedDrawer({ + title: 'Environment variables', + description: <>Values are encrypted at rest and never shown. Names starting with QS_ are reserved by QuickStack., + content: , + }); + }, [app, openNestedDrawer, readonly]); + + useEffect(() => { + if (!openEnvironment) return; + openEnvironmentEditor(); + onEnvironmentOpened?.(); + }, [onEnvironmentOpened, openEnvironment, openEnvironmentEditor]); return (
@@ -88,18 +106,7 @@ export function DrawerSettings({ > - openNestedDrawer({ - title: 'Environment variables', - content: ( - - ), - }) - } + onEdit={openEnvironmentEditor} /> + PathBuilderUtils.projectAppDrawer(projectId, appId, 'deployments', 'table'); return <> {item.name} ], @@ -47,7 +50,7 @@ export default function AppTable({ ['status', 'Status', true, (item) => ], ]} data={apps} - onItemClickLink={(item) => `/project/app/${item.id}`} + onItemClickLink={(item) => drawerHref(item.id)} actionCol={(item) => <>
@@ -61,7 +64,7 @@ export default function AppTable({ Actions - + Show App Details 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 db337dd2..eb4e9973 100644 --- a/src/app/project/[projectId]/app-components/edit-app-dialog.tsx +++ b/src/app/project/[projectId]/app-components/edit-app-dialog.tsx @@ -6,6 +6,7 @@ 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"; +import { PathBuilderUtils } from "@/shared/utils/path-builder.utils"; export function EditAppDialog({ children, @@ -32,7 +33,7 @@ export function EditAppDialog({ if (!name) { return; } const result = await Toast.fromAction(() => createApp(name, projectId, existingItem?.id)); if (result.status === "success" && !existingItem && openAppAfterCreate) { - router.push(existingItem ? `/project/app/${result!.data!.id}` : `/project/app/${result!.data!.id}?tabName=general`); + router.push(PathBuilderUtils.projectAppDrawer(projectId, result.data!.id, 'settings')); } }; 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 146ea23f..0ed1efb4 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph.tsx @@ -38,13 +38,12 @@ 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 { 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 { connectionDeletionProvenance, type NetworkGraphEdge, 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'; import { getFocusedNodeViewport } from './project-network-graph/project-network-graph-focus'; @@ -53,6 +52,7 @@ import { Toast } from '@/frontend/utils/toast.utils'; import { AppNetworkPolicyRuleEditModel, NetworkPolicySelectableTarget } from '@/shared/model/app-network-policy-edit.model'; import { NetworkPolicyRuleUtils } from '@/shared/utils/network-policy-rule.utils'; import { AppNetworkPolicyDraft, AppNetworkPolicyDraftUtils } from '@/shared/utils/app-network-policy-draft.utils'; +import { InternalHostnameUtils } from '@/shared/utils/internal-hostname.utils'; 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'; @@ -65,6 +65,7 @@ import { type DrawerTab, useProjectNetworkGraphDrawerSession, } from './project-network-graph/project-network-graph-drawer-session'; +import { AppDetailsDrawer } from './app-drawer-components/app-details-drawer'; const hiddenHandleClassName = 'size-1.5! border-0! bg-transparent! opacity-0! pointer-events-none'; const connectionSourceHandleClassName = 'z-20! size-4! border-2! border-background! bg-qs-500! opacity-0! shadow-md! transition-all duration-150 group-hover:opacity-100! [&.connectingfrom]:opacity-0! hover:bg-qs-600!'; @@ -77,7 +78,12 @@ type WorkloadNodeData = NetworkGraphNode & { connectedToSelection?: boolean; contextMenu?: Omit; }; -type ConnectionEdgeData = { onDelete: () => void }; + +type ConnectionEdgeData = { + onDelete?: () => void; + internalHostnames: { hostname: string; port: number }[]; +}; + type ProjectNetworkGraphProps = { apps: AppExtendedModel[]; projectId: string; @@ -128,6 +134,7 @@ const WorkloadNode = memo(function WorkloadNode({ ? {node} : node; }); + const InternetNode = memo(function InternetNode({ data, }: NodeProps>) { @@ -142,16 +149,46 @@ 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 ( - + ); } + +function getInternalHostnames( + edge: NetworkGraphEdge, + drafts: Record, + apps: AppExtendedModel[], +) { + const hostnames = new Map(); + + for (const provenance of edge.ruleProvenance) { + const rule = drafts[provenance.ownerAppId]?.rules.find(item => item.key === provenance.ruleKey); + if (!rule) continue; + + const target = rule.type === 'INGRESS' + ? apps.find(app => app.id === provenance.ownerAppId) + : rule.targetType === 'APP' + ? { id: rule.targetId, projectId: rule.targetProjectId } + : undefined; + if (!target?.projectId) continue; + + const hostname = InternalHostnameUtils.getInternalBaseUrlForApp(target, rule.port); + hostnames.set(hostname, { hostname, port: rule.port }); + } + + return Array.from(hostnames.values()).sort((left, right) => left.port - right.port); +} const edgeTypes = { connection: ConnectionEdge }; function ProjectNetworkGraphCanvasContextMenu({ @@ -244,6 +281,7 @@ function ProjectNetworkGraphEditor({ const [saving, setSaving] = useState(false); const [connectionSourceNodeId, setConnectionSourceNodeId] = useState(); const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(); + const [environmentAppId, setEnvironmentAppId] = useState(); const [graphHeight, setGraphHeight] = useState(); const graphContainerRef = useRef(null); const drawerContentRef = useRef(null); @@ -385,6 +423,10 @@ function ProjectNetworkGraphEditor({ onOpenDrawerTab: (tab: DrawerTab) => { drawerSession.openAppTab(app.id, tab); }, + onShowEnvironment: () => { + setEnvironmentAppId(app.id); + drawerSession.openAppTab(app.id, 'settings'); + }, onDelete: () => void deleteLocalApp(app.id), } : undefined, connectedToSelection: !selectedNodeId || (layout?.edges ?? []).some(edge => @@ -394,19 +436,28 @@ function ProjectNetworkGraphEditor({ }, }; }), [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 => { const presentation = graphEdgePresentation(edge); + const deletionProvenance = connectionDeletionProvenance(edge, writableAppIds); + const internalHostnames = getInternalHostnames(edge, drafts, apps); return { id: edge.id, source: edge.source, target: edge.target, sourceHandle: presentation.sourceHandle, targetHandle: presentation.targetHandle, - type: connectionDeletionProvenance(edge, writableAppIds) ? 'connection' : 'smoothstep', - data: connectionDeletionProvenance(edge, writableAppIds) - ? { onDelete: () => deleteConnection(edge.id) } + type: edge.direction === 'CONNECTION' && (deletionProvenance || internalHostnames.length > 0) + ? 'connection' + : 'smoothstep', + data: edge.direction === 'CONNECTION' && (deletionProvenance || internalHostnames.length > 0) + ? { + ...(deletionProvenance ? { onDelete: () => deleteConnection(edge.id) } : {}), + internalHostnames, + } : undefined, pathOptions: { offset: 20 }, markerStart: edge.internetIngress ? { type: MarkerType.ArrowClosed, color: presentation.color, width: 16, height: 16 } : undefined, @@ -425,10 +476,12 @@ function ProjectNetworkGraphEditor({ labelBgPadding: [6, 3] as [number, number], labelBgBorderRadius: 6, }; - }), [deleteConnection, layout?.edges, selectedNodeId, writableAppIds]); + }), [apps, deleteConnection, drafts, 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; + useEffect(() => { if (!selectedNodeId || selectedNode?.kind !== 'APP') return; if (!window.matchMedia('(min-width: 1024px)').matches) return; @@ -595,7 +648,7 @@ function ProjectNetworkGraphEditor({ {edges.length === 0 && nodes.length === 0 &&

No active network policy connections yet.

} - {selectedNode && { if (selectedApp) drawerSession.openAppTab(selectedApp.id, tab); }} + openEnvironment={environmentAppId === selectedApp?.id} + onEnvironmentOpened={() => setEnvironmentAppId(undefined)} />}
diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx index 7b5d2d02..a3d73f7c 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-app-context-menu.tsx @@ -1,7 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; -import { Box, Globe2, Hammer, Logs, Play, Rocket, RotateCwClock, Settings, Square, Trash2 } from 'lucide-react'; +import { Box, Globe2, Hammer, Logs, Play, Rocket, RotateCwClock, Settings, Square, Trash2, Zap } from 'lucide-react'; import { ContextMenu, ContextMenuContent, @@ -25,6 +25,7 @@ export type ProjectNetworkGraphAppContextMenuProps = { allowInternetAccess: boolean; onToggleInternetAccess: () => void; onOpenDrawerTab: (tab: 'deployments' | 'logs' | 'backups' | 'settings') => void; + onShowEnvironment: () => void; onDelete: () => void; children: ReactNode; }; @@ -36,7 +37,8 @@ export function ProjectNetworkGraphAppContextMenu({ onToggleInternetAccess, onDelete, children, - onOpenDrawerTab + onOpenDrawerTab, + onShowEnvironment, }: ProjectNetworkGraphAppContextMenuProps) { const deploymentStatus = usePodsStatus( (state) => state.podsStatus.get(app.id)?.deploymentStatus ?? 'UNKNOWN', @@ -99,6 +101,10 @@ export function ProjectNetworkGraphAppContextMenu({ View Backups )} + + + Show Env Variables + onOpenDrawerTab('settings')}> View Settings 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 index c9026dc0..a4c00f96 100644 --- 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 @@ -1,29 +1,43 @@ 'use client'; import type { ReactNode } from 'react'; -import { Trash2 } from 'lucide-react'; +import { Copy, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; export function ProjectNetworkGraphConnectionContextMenu({ onDelete, + internalHostnames, children, }: { - onDelete: () => void; + onDelete?: () => void; + internalHostnames: { hostname: string; port: number }[]; children: ReactNode; }) { return ( }>{children} event.stopPropagation()}> - + {internalHostnames.map(({ hostname, port }) => ( + { + void navigator.clipboard.writeText(hostname); + toast.success(`Internal hostname for port ${port} copied`); + }}> + + Copy internal hostname ({port}) + + ))} + {onDelete && internalHostnames.length > 0 && } + {onDelete && Delete connection - + } ); diff --git a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts index e598b9f5..2ec278ea 100644 --- a/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts +++ b/src/app/project/[projectId]/app-components/project-network-graph/project-network-graph-drawer-session.ts @@ -2,31 +2,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { TabNavigationUtils } from '@/frontend/utils/tab-navigation.utils'; +import { + appDrawerTabValues, + AppDrawerNavigationUtils, + defaultAppDrawerTab, + type AppDrawerTab, +} from '@/shared/utils/app-drawer-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; - } -} +export const drawerTabValues = appDrawerTabValues; +export type DrawerTab = AppDrawerTab; +export const DrawerSessionUtils = AppDrawerNavigationUtils; type QueryParams = Pick; @@ -49,7 +35,7 @@ export function useProjectNetworkGraphDrawerSession({ params.delete('drawerTab'); } else { params.set('drawerAppId', appId); - params.set('drawerTab', tab ?? 'deployments'); + params.set('drawerTab', tab ?? defaultAppDrawerTab); } TabNavigationUtils.replaceQuery(params); diff --git a/src/app/project/[projectId]/app-components/project-overview.tsx b/src/app/project/[projectId]/app-components/project-overview.tsx index f0cda138..8bcc34ec 100644 --- a/src/app/project/[projectId]/app-components/project-overview.tsx +++ b/src/app/project/[projectId]/app-components/project-overview.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import AppTable from "./apps-table"; import ProjectNetworkGraph from "../app-components/project-network-graph"; import { UserSession } from "@/shared/model/sim-session.model"; @@ -57,7 +57,6 @@ export default function AppProjectOverview({ const searchParams = useSearchParams(); const { openDialog } = useDialog(); const requestedTab = searchParams.get('tab'); - const requestedDrawerAppId = searchParams.get('drawerAppId'); const [currentTab, setCurrentTab] = useState('graph'); useEffect(() => { @@ -72,17 +71,13 @@ 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, requestedDrawerAppId, requestedTab]); + }, [projectId, requestedTab]); const handleTabChange = (value: string) => { if (!isProjectOverviewTab(value)) return; @@ -140,7 +135,7 @@ export default function AppProjectOverview({
- + @@ -150,10 +145,10 @@ export default function AppProjectOverview({ {canCreate && }
- +
- - +
+
- +
); } diff --git a/src/app/project/agent/[agentId]/general/agent-network-policy-card.tsx b/src/app/project/agent/[agentId]/general/agent-network-policy-card.tsx index f1aeb498..41932b95 100644 --- a/src/app/project/agent/[agentId]/general/agent-network-policy-card.tsx +++ b/src/app/project/agent/[agentId]/general/agent-network-policy-card.tsx @@ -14,7 +14,7 @@ import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; import { deleteAgentNetworkPolicyEgressRule, saveAgentNetworkPolicySettings } from "./actions"; import AgentNetworkPolicyEgressRuleEditOverlay from "./agent-network-policy-egress-rule-edit-overlay"; import { Tooltip, TooltipProvider, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { InternalHostnameUtils } from "@/server/utils/internal-hostname.utils"; +import { InternalHostnameUtils } from "@/shared/utils/internal-hostname.utils"; type AgentNetworkPolicyRule = NonNullable['rules'][number]; diff --git a/src/app/project/app/[appId]/advanced/app-network-policy-rule-section.tsx b/src/app/project/app/[appId]/advanced/app-network-policy-rule-section.tsx index a64cffcc..a610526c 100644 --- a/src/app/project/app/[appId]/advanced/app-network-policy-rule-section.tsx +++ b/src/app/project/app/[appId]/advanced/app-network-policy-rule-section.tsx @@ -10,7 +10,8 @@ import { Switch } from '@/components/ui/switch'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { toast } from 'sonner'; import { NetworkPolicyDirection, NetworkPolicyTargetProject } from '@/shared/model/app-network-policy-edit.model'; -import { InternalHostnameUtils } from '@/server/utils/internal-hostname.utils'; +import { InternalHostnameUtils } from '@/shared/utils/internal-hostname.utils'; +import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; export type AppNetworkPolicyDirection = NetworkPolicyDirection; @@ -130,7 +131,7 @@ function RuleRow({ rule, readonly, onDelete, currentProjectId, projects }: { {rule.targetType === 'APP' - ? {rule.targetName} + ? {rule.targetName} : rule.targetName} {projectName} ยท {targetTypeLabel} diff --git a/src/app/project/app/[appId]/advanced/network-policy-graph.tsx b/src/app/project/app/[appId]/advanced/network-policy-graph.tsx index 534dac7a..47daa018 100644 --- a/src/app/project/app/[appId]/advanced/network-policy-graph.tsx +++ b/src/app/project/app/[appId]/advanced/network-policy-graph.tsx @@ -20,6 +20,7 @@ import { Bot, Boxes, Cloud, MoveRight } from 'lucide-react'; import { cn } from '@/frontend/utils/utils'; import { AppNetworkPolicyRuleWithTargetAppModel } from '@/shared/model/app-extended.model'; import { NetworkPolicyTargetProject } from '@/shared/model/app-network-policy-edit.model'; +import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; type WorkloadType = 'APP' | 'AGENT'; @@ -86,6 +87,7 @@ function collectNeighbors(rules: AppNetworkPolicyRuleWithTargetAppModel[], appId type PeerNodeData = { type: WorkloadType; + projectId: string; name: string; side: 'left' | 'right'; both: boolean; @@ -248,7 +250,7 @@ export default function NetworkPolicyGraph({ id: `${neighbor.type}:${neighbor.id}`, type: 'peer', position: { x: COLUMN_X_LEFT - PEER_WIDTH / 2, y: y - 28 }, - data: { type: neighbor.type, name: neighbor.name, side: 'left', both: false, externalProject: neighbor.projectId !== appProjectId, caption: captionFor(neighbor) }, + data: { type: neighbor.type, projectId: neighbor.projectId, name: neighbor.name, side: 'left', both: false, externalProject: neighbor.projectId !== appProjectId, caption: captionFor(neighbor) }, }); edges.push({ ...edgeLabelStyle(), @@ -270,7 +272,7 @@ export default function NetworkPolicyGraph({ id: `${neighbor.type}:${neighbor.id}`, type: 'peer', position: { x: COLUMN_X_RIGHT - PEER_WIDTH / 2, y: y - 28 }, - data: { type: neighbor.type, name: neighbor.name, side: 'right', both: hasIngress, externalProject: neighbor.projectId !== appProjectId, caption: captionFor(neighbor) }, + data: { type: neighbor.type, projectId: neighbor.projectId, name: neighbor.name, side: 'right', both: hasIngress, externalProject: neighbor.projectId !== appProjectId, caption: captionFor(neighbor) }, }); edges.push({ ...edgeLabelStyle(), @@ -366,7 +368,10 @@ export default function NetworkPolicyGraph({ onNodeClick={(_event, node) => { if (node.type !== 'peer') return; const [workloadType, workloadId] = String(node.id).split(':'); - router.push(workloadType === 'AGENT' ? `/project/agent/${workloadId}` : `/project/app/${workloadId}`); + const peer = node.data as PeerNodeData; + router.push(workloadType === 'AGENT' + ? `/project/agent/${workloadId}` + : PathBuilderUtils.projectAppDrawer(peer.projectId, workloadId)); }} > diff --git a/src/app/project/app/[appId]/environment/env-edit.tsx b/src/app/project/app/[appId]/environment/env-edit.tsx index 5150153b..65c22920 100644 --- a/src/app/project/app/[appId]/environment/env-edit.tsx +++ b/src/app/project/app/[appId]/environment/env-edit.tsx @@ -97,7 +97,7 @@ export default function EnvEdit({ app, readonly, hideCard = false }: { )} /> - {!readonly && + {!readonly && Save } 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 41f38caf..200f2114 100644 --- a/src/app/project/app/[appId]/overview/deployments-grid-view.tsx +++ b/src/app/project/app/[appId]/overview/deployments-grid-view.tsx @@ -1,4 +1,4 @@ -import { Boxes, EllipsisVertical, File, GitCommit, LucideTerminal, RotateCcw } from 'lucide-react'; +import { Boxes, EllipsisVertical, File, GitCommit, LucideTerminal, Rocket, RotateCcw } from 'lucide-react'; import ShortCommitHash from '@/components/custom/short-commit-hash'; import { Button } from '@/components/ui/button'; import { @@ -12,6 +12,13 @@ import { formatDateTime } from '@/frontend/utils/format.utils'; import type { DeploymentInfoModel } from '@/shared/model/deployment-info.model'; import { appBuildMethodLabels } from '@/shared/model/app-source-info.model'; import DeploymentStatusBadge from './deployment-status-badge'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; type DeploymentsGridViewProps = { deployments: DeploymentInfoModel[]; @@ -31,7 +38,19 @@ export function DeploymentsGridView({ onRollback, }: DeploymentsGridViewProps) { if (deployments.length === 0) { - return

No deployments yet.

; + return ( + + + + + + No deployments available + + This app has no deployments yet. + + + + ); } return ( diff --git a/src/app/project/app/[appId]/overview/logs.tsx b/src/app/project/app/[appId]/overview/logs.tsx index 96599212..d8cd0758 100644 --- a/src/app/project/app/[appId]/overview/logs.tsx +++ b/src/app/project/app/[appId]/overview/logs.tsx @@ -15,6 +15,13 @@ 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"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; export default function Logs({ app, @@ -123,7 +130,19 @@ export default function Logs({ useFullHeight && 'flex min-h-0 flex-1 flex-col', )}> {!appPods && } - {appPods && appPods.length === 0 &&
No running pods found for this app.
} + {appPods && appPods.length === 0 && ( + + + + + + No running pods found + + This app has no running pods with logs available. + + + + )} {selectedPod && appPods &&