diff --git a/ui/src/pages/AppDetailPage.tsx b/ui/src/pages/AppDetailPage.tsx index a5e44ad..b8ead18 100644 --- a/ui/src/pages/AppDetailPage.tsx +++ b/ui/src/pages/AppDetailPage.tsx @@ -1,3 +1,737 @@ -export function AppDetailPage() { - return
App Detail
; +import React, { useRef, useState } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { + Badge, + Button, + Card, + ConfirmDialog, + DataTable, + EmptyState, + FormField, + Input, + LogViewer, + Modal, + Spinner, + StatusDot, + Tabs, + useToast, +} from '@cameleer/design-system'; +import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system'; +import { useAuthStore } from '../auth/auth-store'; +import { + useApp, + useDeployment, + useDeployments, + useDeploy, + useStop, + useRestart, + useDeleteApp, + useUpdateRouting, + useAgentStatus, + useObservabilityStatus, + useLogs, + useCreateApp, +} from '../api/hooks'; +import { RequirePermission } from '../components/RequirePermission'; +import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge'; +import { usePermissions } from '../hooks/usePermissions'; +import type { DeploymentResponse } from '../types/api'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface DeploymentRow { + id: string; + version: number; + observedStatus: string; + desiredStatus: string; + deployedAt: string | null; + stoppedAt: string | null; + errorMessage: string | null; + _raw: DeploymentResponse; +} + +// ─── Deployment history columns ─────────────────────────────────────────────── + +const deploymentColumns: Column[] = [ + { + key: 'version', + header: 'Version', + render: (_val, row) => ( + v{row.version} + ), + }, + { + key: 'observedStatus', + header: 'Status', + render: (_val, row) => , + }, + { + key: 'desiredStatus', + header: 'Desired', + render: (_val, row) => ( + + ), + }, + { + key: 'deployedAt', + header: 'Deployed', + render: (_val, row) => + row.deployedAt + ? new Date(row.deployedAt).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '—', + }, + { + key: 'stoppedAt', + header: 'Stopped', + render: (_val, row) => + row.stoppedAt + ? new Date(row.stoppedAt).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '—', + }, + { + key: 'errorMessage', + header: 'Error', + render: (_val, row) => + row.errorMessage ? ( + {row.errorMessage} + ) : ( + '—' + ), + }, +]; + +// ─── Main page component ────────────────────────────────────────────────────── + +export function AppDetailPage() { + const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>(); + const navigate = useNavigate(); + const { toast } = useToast(); + const tenantId = useAuthStore((s) => s.tenantId); + const { canManageApps, canDeploy } = usePermissions(); + + // Active tab + const [activeTab, setActiveTab] = useState('overview'); + + // App data + const { data: app, isLoading: appLoading } = useApp(envId, appId); + + // Current deployment (auto-polls while BUILDING/STARTING) + const { data: currentDeployment } = useDeployment( + appId, + app?.currentDeploymentId ?? '', + ); + + // Deployment history + const { data: deployments = [] } = useDeployments(appId); + + // Agent and observability status + const { data: agentStatus } = useAgentStatus(appId); + const { data: obsStatus } = useObservabilityStatus(appId); + + // Log stream filter + const [logStream, setLogStream] = useState(undefined); + const { data: logEntries = [] } = useLogs(appId, { + limit: 500, + stream: logStream, + }); + + // Mutations + const deployMutation = useDeploy(appId); + const stopMutation = useStop(appId); + const restartMutation = useRestart(appId); + const deleteMutation = useDeleteApp(envId, appId); + const updateRoutingMutation = useUpdateRouting(envId, appId); + const reuploadMutation = useCreateApp(envId); + + // Dialog / modal state + const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [routingModalOpen, setRoutingModalOpen] = useState(false); + const [reuploadModalOpen, setReuploadModalOpen] = useState(false); + + // Routing form + const [portInput, setPortInput] = useState(''); + + // Re-upload form + const [reuploadFile, setReuploadFile] = useState(null); + const fileInputRef = useRef(null); + + // ─── Handlers ────────────────────────────────────────────────────────────── + + async function handleDeploy() { + try { + await deployMutation.mutateAsync(); + toast({ title: 'Deployment triggered', variant: 'success' }); + } catch { + toast({ title: 'Failed to trigger deployment', variant: 'error' }); + } + } + + async function handleStop() { + try { + await stopMutation.mutateAsync(); + toast({ title: 'App stopped', variant: 'success' }); + setStopConfirmOpen(false); + } catch { + toast({ title: 'Failed to stop app', variant: 'error' }); + } + } + + async function handleRestart() { + try { + await restartMutation.mutateAsync(); + toast({ title: 'App restarting', variant: 'success' }); + } catch { + toast({ title: 'Failed to restart app', variant: 'error' }); + } + } + + async function handleDelete() { + try { + await deleteMutation.mutateAsync(); + toast({ title: 'App deleted', variant: 'success' }); + navigate(`/environments/${envId}`); + } catch { + toast({ title: 'Failed to delete app', variant: 'error' }); + } + } + + async function handleUpdateRouting(e: React.FormEvent) { + e.preventDefault(); + const port = portInput.trim() === '' ? null : parseInt(portInput, 10); + if (port !== null && (isNaN(port) || port < 1 || port > 65535)) { + toast({ title: 'Invalid port number', variant: 'error' }); + return; + } + try { + await updateRoutingMutation.mutateAsync({ exposedPort: port }); + toast({ title: 'Routing updated', variant: 'success' }); + setRoutingModalOpen(false); + } catch { + toast({ title: 'Failed to update routing', variant: 'error' }); + } + } + + function openRoutingModal() { + setPortInput(app?.exposedPort != null ? String(app.exposedPort) : ''); + setRoutingModalOpen(true); + } + + async function handleReupload(e: React.FormEvent) { + e.preventDefault(); + if (!reuploadFile) return; + const formData = new FormData(); + formData.append('jar', reuploadFile); + if (app?.slug) formData.append('slug', app.slug); + if (app?.displayName) formData.append('displayName', app.displayName); + try { + await reuploadMutation.mutateAsync(formData); + toast({ title: 'JAR uploaded', variant: 'success' }); + setReuploadModalOpen(false); + setReuploadFile(null); + } catch { + toast({ title: 'Failed to upload JAR', variant: 'error' }); + } + } + + // ─── Derived data ─────────────────────────────────────────────────────────── + + const deploymentRows: DeploymentRow[] = deployments.map((d) => ({ + id: d.id, + version: d.version, + observedStatus: d.observedStatus, + desiredStatus: d.desiredStatus, + deployedAt: d.deployedAt, + stoppedAt: d.stoppedAt, + errorMessage: d.errorMessage, + _raw: d, + })); + + // Map API LogEntry to design system LogEntry + const dsLogEntries: DSLogEntry[] = logEntries.map((entry) => ({ + timestamp: entry.timestamp, + level: entry.stream === 'stderr' ? ('error' as const) : ('info' as const), + message: entry.message, + })); + + // Agent state → StatusDot variant + function agentDotVariant(): 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running' { + if (!agentStatus?.registered) return 'dead'; + switch (agentStatus.state) { + case 'CONNECTED': return 'live'; + case 'DISCONNECTED': return 'stale'; + default: return 'stale'; + } + } + + // ─── Loading / not-found states ──────────────────────────────────────────── + + if (appLoading) { + return ( +
+ +
+ ); + } + + if (!app) { + return ( + navigate(`/environments/${envId}`)}> + Back to Environment + + } + /> + ); + } + + // ─── Breadcrumb ───────────────────────────────────────────────────────────── + + const breadcrumb = ( + + ); + + // ─── Tabs ─────────────────────────────────────────────────────────────────── + + const tabs = [ + { label: 'Overview', value: 'overview' }, + { label: 'Deployments', value: 'deployments' }, + { label: 'Logs', value: 'logs' }, + ]; + + // ─── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Breadcrumb */} + {breadcrumb} + + {/* Page header */} +
+
+

{app.displayName}

+
+ + {app.jarOriginalFilename && ( + {app.jarOriginalFilename} + )} +
+
+
+ + {/* Tab navigation */} + + + {/* ── Tab: Overview ── */} + {activeTab === 'overview' && ( +
+ {/* Status card */} + + {!app.currentDeploymentId ? ( +
No deployments yet
+ ) : !currentDeployment ? ( +
+ +
+ ) : ( +
+
+
Version
+ + v{currentDeployment.version} + +
+
+
Status
+ +
+
+
Image
+ + {currentDeployment.imageRef} + +
+ {currentDeployment.deployedAt && ( +
+
Deployed
+ + {new Date(currentDeployment.deployedAt).toLocaleString()} + +
+ )} + {currentDeployment.errorMessage && ( +
+
Error
+ + {currentDeployment.errorMessage} + +
+ )} +
+ )} +
+ + {/* Action bar */} + +
+ + + + + + + + + + + + + + + + + + + +
+
+ + {/* Agent status card */} + +
+
+ + + {agentStatus?.registered ? 'Registered' : 'Not registered'} + + {agentStatus?.state && ( + + )} +
+ + {agentStatus?.lastHeartbeat && ( +
+ Last heartbeat: {new Date(agentStatus.lastHeartbeat).toLocaleString()} +
+ )} + + {agentStatus?.routeIds && agentStatus.routeIds.length > 0 && ( +
+
Routes
+
+ {agentStatus.routeIds.map((rid) => ( + + ))} +
+
+ )} + + {obsStatus && ( +
+ + Traces:{' '} + + {obsStatus.hasTraces ? `${obsStatus.traceCount24h} (24h)` : 'none'} + + + + Metrics:{' '} + + {obsStatus.hasMetrics ? 'yes' : 'none'} + + + + Diagrams:{' '} + + {obsStatus.hasDiagrams ? 'yes' : 'none'} + + +
+ )} + +
+ + View in Dashboard → + +
+
+
+ + {/* Routing card */} + +
+
+ {app.exposedPort ? ( + <> +
Port
+ {app.exposedPort} + + ) : ( + No port configured + )} + {app.routeUrl && ( +
+
Route URL
+ + {app.routeUrl} + +
+ )} +
+ + + +
+
+
+ )} + + {/* ── Tab: Deployments ── */} + {activeTab === 'deployments' && ( + + {deploymentRows.length === 0 ? ( + + ) : ( + + columns={deploymentColumns} + data={deploymentRows} + pageSize={20} + rowAccent={(row) => + row.observedStatus === 'FAILED' ? 'error' : undefined + } + flush + /> + )} + + )} + + {/* ── Tab: Logs ── */} + {activeTab === 'logs' && ( + +
+ {/* Stream filter */} +
+ {[ + { label: 'All', value: undefined }, + { label: 'stdout', value: 'stdout' }, + { label: 'stderr', value: 'stderr' }, + ].map((opt) => ( + + ))} +
+ + {dsLogEntries.length === 0 ? ( + + ) : ( + + )} +
+
+ )} + + {/* ── Dialogs / Modals ── */} + + {/* Stop confirmation */} + setStopConfirmOpen(false)} + onConfirm={handleStop} + title="Stop App" + message={`Are you sure you want to stop "${app.displayName}"?`} + confirmText="Stop" + confirmLabel="Stop" + cancelLabel="Cancel" + variant="warning" + loading={stopMutation.isPending} + /> + + {/* Delete confirmation */} + setDeleteConfirmOpen(false)} + onConfirm={handleDelete} + title="Delete App" + message={`Are you sure you want to delete "${app.displayName}"? This action cannot be undone.`} + confirmText="Delete" + confirmLabel="Delete" + cancelLabel="Cancel" + variant="danger" + loading={deleteMutation.isPending} + /> + + {/* Routing modal */} + setRoutingModalOpen(false)} + title="Edit Routing" + size="sm" + > +
+ + setPortInput(e.target.value)} + placeholder="e.g. 8080" + min={1} + max={65535} + /> + +
+ + +
+
+
+ + {/* Re-upload JAR modal */} + setReuploadModalOpen(false)} + title="Re-upload JAR" + size="sm" + > +
+ + setReuploadFile(e.target.files?.[0] ?? null)} + required + /> + +
+ + +
+
+
+
+ ); }