From de4ca10fa5db279210936829a823a3773ab5e5f1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:23:30 +0200 Subject: [PATCH] feat: move Apps from admin to main tab bar with container config - Apps tab visible to OPERATOR+ (hidden for VIEWER), scoped by sidebar app selection and environment filter - List view: DataTable with name, environment, updated, created columns - Detail view: deployments across all envs, version upload with per-env deploy target, container config form (resources, ports, custom env vars) with explicit Save - Memory reserve field disabled for non-production environments with info hint - Admin sidebar sorted alphabetically, Applications entry removed - Old admin AppsPage deleted Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/queries/admin/apps.ts | 18 + ui/src/api/queries/admin/environments.ts | 1 + ui/src/components/ContentTabs.tsx | 12 +- ui/src/components/sidebar-utils.ts | 11 +- ui/src/hooks/useScope.ts | 4 +- ui/src/pages/Admin/AppsPage.tsx | 405 ------------------ ui/src/pages/AppsTab/AppsTab.module.css | 170 ++++++++ ui/src/pages/AppsTab/AppsTab.tsx | 515 +++++++++++++++++++++++ ui/src/router.tsx | 7 +- 9 files changed, 726 insertions(+), 417 deletions(-) delete mode 100644 ui/src/pages/Admin/AppsPage.tsx create mode 100644 ui/src/pages/AppsTab/AppsTab.module.css create mode 100644 ui/src/pages/AppsTab/AppsTab.tsx diff --git a/ui/src/api/queries/admin/apps.ts b/ui/src/api/queries/admin/apps.ts index 7752a049..6f71803c 100644 --- a/ui/src/api/queries/admin/apps.ts +++ b/ui/src/api/queries/admin/apps.ts @@ -7,7 +7,9 @@ export interface App { environmentId: string; slug: string; displayName: string; + containerConfig: Record; createdAt: string; + updatedAt: string; } export interface AppVersion { @@ -59,6 +61,13 @@ async function appFetch(path: string, options?: RequestInit): Promise { // --- Apps --- +export function useAllApps() { + return useQuery({ + queryKey: ['apps', 'all'], + queryFn: () => appFetch(''), + }); +} + export function useApps(environmentId: string | undefined) { return useQuery({ queryKey: ['apps', environmentId], @@ -85,6 +94,15 @@ export function useDeleteApp() { }); } +export function useUpdateContainerConfig() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ appId, config }: { appId: string; config: Record }) => + appFetch(`/${appId}/container-config`, { method: 'PUT', body: JSON.stringify(config) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }), + }); +} + // --- Versions --- export function useAppVersions(appId: string | undefined) { diff --git a/ui/src/api/queries/admin/environments.ts b/ui/src/api/queries/admin/environments.ts index 7e198aa1..48d0b823 100644 --- a/ui/src/api/queries/admin/environments.ts +++ b/ui/src/api/queries/admin/environments.ts @@ -7,6 +7,7 @@ export interface Environment { displayName: string; production: boolean; enabled: boolean; + defaultContainerConfig: Record; createdAt: string; } diff --git a/ui/src/components/ContentTabs.tsx b/ui/src/components/ContentTabs.tsx index 02b18588..37b5ad5d 100644 --- a/ui/src/components/ContentTabs.tsx +++ b/ui/src/components/ContentTabs.tsx @@ -1,9 +1,11 @@ +import { useMemo } from 'react'; import { Tabs } from '@cameleer/design-system'; import type { TabKey, Scope } from '../hooks/useScope'; import { TabKpis } from './TabKpis'; +import { useCanControl } from '../auth/auth-store'; import styles from './ContentTabs.module.css'; -const TABS = [ +const BASE_TABS = [ { label: 'Exchanges', value: 'exchanges' }, { label: 'Dashboard', value: 'dashboard' }, { label: 'Runtime', value: 'runtime' }, @@ -18,10 +20,16 @@ interface ContentTabsProps { } export function ContentTabs({ active, onChange, scope }: ContentTabsProps) { + const canControl = useCanControl(); + const tabs = useMemo(() => { + if (!canControl) return BASE_TABS; + return [...BASE_TABS, { label: 'Apps', value: 'apps' }]; + }, [canControl]); + return (
onChange(v as TabKey)} /> diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index f0b454f1..49e3cada 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -94,16 +94,15 @@ export function buildAppTreeNodes( } /** - * Admin tree — static nodes. + * Admin tree — static nodes, alphabetically sorted. */ export function buildAdminTreeNodes(): SidebarTreeNode[] { return [ - { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, - { id: 'admin:apps', label: 'Applications', path: '/admin/apps' }, - { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' }, - { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, - { id: 'admin:database', label: 'Database', path: '/admin/database' }, { id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' }, + { id: 'admin:database', label: 'Database', path: '/admin/database' }, + { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, + { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, + { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, ]; } diff --git a/ui/src/hooks/useScope.ts b/ui/src/hooks/useScope.ts index 18cf5418..1700a774 100644 --- a/ui/src/hooks/useScope.ts +++ b/ui/src/hooks/useScope.ts @@ -2,9 +2,9 @@ import { useParams, useNavigate, useLocation } from 'react-router'; import { useCallback } from 'react'; -export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs' | 'config'; +export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs' | 'config' | 'apps'; -const VALID_TABS = new Set(['exchanges', 'dashboard', 'runtime', 'logs', 'config']); +const VALID_TABS = new Set(['exchanges', 'dashboard', 'runtime', 'logs', 'config', 'apps']); export interface Scope { tab: TabKey; diff --git a/ui/src/pages/Admin/AppsPage.tsx b/ui/src/pages/Admin/AppsPage.tsx deleted file mode 100644 index 824aa310..00000000 --- a/ui/src/pages/Admin/AppsPage.tsx +++ /dev/null @@ -1,405 +0,0 @@ -import { useState, useMemo, useRef } from 'react'; -import { - Avatar, - Badge, - Button, - Input, - MonoText, - SectionHeader, - Select, - ConfirmDialog, - SplitPane, - EntityList, - Spinner, - useToast, -} from '@cameleer/design-system'; -import { useEnvironments } from '../../api/queries/admin/environments'; -import { - useApps, - useCreateApp, - useDeleteApp, - useAppVersions, - useUploadJar, - useDeployments, - useCreateDeployment, - useStopDeployment, -} from '../../api/queries/admin/apps'; -import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps'; -import styles from './UserManagement.module.css'; - -function formatBytes(bytes: number): string { - if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${bytes} B`; -} - -const STATUS_COLORS: Record = { - RUNNING: 'running', - STARTING: 'warning', - FAILED: 'error', - STOPPED: 'auto', -}; - -export default function AppsPage() { - const { toast } = useToast(); - const { data: environments = [] } = useEnvironments(); - const [envId, setEnvId] = useState(''); - const { data: apps = [], isLoading } = useApps(envId || undefined); - - const [search, setSearch] = useState(''); - const [selectedId, setSelectedId] = useState(null); - const [creating, setCreating] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - - // Create form - const [newSlug, setNewSlug] = useState(''); - const [newDisplayName, setNewDisplayName] = useState(''); - - // Mutations - const createApp = useCreateApp(); - const deleteApp = useDeleteApp(); - - const selected = useMemo( - () => apps.find((a) => a.id === selectedId) ?? null, - [apps, selectedId], - ); - - const filtered = useMemo(() => { - if (!search) return apps; - const q = search.toLowerCase(); - return apps.filter( - (a) => - a.slug.toLowerCase().includes(q) || - a.displayName.toLowerCase().includes(q), - ); - }, [apps, search]); - - const duplicateSlug = - newSlug.trim() !== '' && - apps.some((a) => a.slug.toLowerCase() === newSlug.trim().toLowerCase()); - - async function handleCreate() { - if (!newSlug.trim() || !newDisplayName.trim() || !envId) return; - try { - await createApp.mutateAsync({ - environmentId: envId, - slug: newSlug.trim(), - displayName: newDisplayName.trim(), - }); - toast({ title: 'App created', description: newSlug.trim(), variant: 'success' }); - setCreating(false); - setNewSlug(''); - setNewDisplayName(''); - } catch { - toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 }); - } - } - - async function handleDelete() { - if (!deleteTarget) return; - try { - await deleteApp.mutateAsync(deleteTarget.id); - toast({ title: 'App deleted', description: deleteTarget.slug, variant: 'warning' }); - if (selectedId === deleteTarget.id) setSelectedId(null); - setDeleteTarget(null); - } catch { - toast({ title: 'Failed to delete app', variant: 'error', duration: 86_400_000 }); - setDeleteTarget(null); - } - } - - if (!environments.length) return ; - - // Auto-select first environment if none selected - if (!envId && environments.length > 0) { - setEnvId(environments[0].id); - return ; - } - - return ( - <> - -
- setNewSlug(e.target.value)} - /> - {duplicateSlug && ( - Slug already exists - )} - setNewDisplayName(e.target.value)} - /> -
- - -
-
- )} - - {isLoading ? ( - - ) : ( - ( - <> - -
-
{app.displayName}
-
{app.slug}
-
- - )} - getItemId={(app) => app.id} - selectedId={selectedId ?? undefined} - onSelect={setSelectedId} - searchPlaceholder="Search apps..." - onSearch={setSearch} - addLabel="+ Add app" - onAdd={() => setCreating(true)} - emptyMessage="No apps in this environment" - /> - )} - - } - detail={ - selected ? ( - setDeleteTarget(selected)} - /> - ) : null - } - emptyMessage="Select an app to view details" - /> - - setDeleteTarget(null)} - onConfirm={handleDelete} - message={`Delete app "${deleteTarget?.displayName}"? All versions and deployments will be removed. This cannot be undone.`} - confirmText={deleteTarget?.slug ?? ''} - loading={deleteApp.isPending} - /> - - ); -} - -// --- App Detail Pane --- - -function AppDetail({ - app, - environments, - onDelete, -}: { - app: App; - environments: { id: string; slug: string; displayName: string }[]; - onDelete: () => void; -}) { - const { toast } = useToast(); - const { data: versions = [] } = useAppVersions(app.id); - const { data: deployments = [] } = useDeployments(app.id); - const uploadJar = useUploadJar(); - const createDeployment = useCreateDeployment(); - const stopDeployment = useStopDeployment(); - const fileInputRef = useRef(null); - - const envName = environments.find((e) => e.id === app.environmentId)?.displayName ?? app.environmentId; - const sortedVersions = useMemo( - () => [...versions].sort((a, b) => b.version - a.version), - [versions], - ); - - async function handleUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - try { - const v = await uploadJar.mutateAsync({ appId: app.id, file }); - toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' }); - } catch { - toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); - } - if (fileInputRef.current) fileInputRef.current.value = ''; - } - - async function handleDeploy(versionId: string) { - try { - await createDeployment.mutateAsync({ - appId: app.id, - appVersionId: versionId, - environmentId: app.environmentId, - }); - toast({ title: 'Deployment started', variant: 'success' }); - } catch { - toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); - } - } - - async function handleStop(deploymentId: string) { - try { - await stopDeployment.mutateAsync({ appId: app.id, deploymentId }); - toast({ title: 'Deployment stopped', variant: 'warning' }); - } catch { - toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); - } - } - - return ( - <> -
- -
-
{app.displayName}
-
{app.slug}
-
- -
- -
- ID - {app.id} - Slug - {app.slug} - Environment - {envName} - Created - - {new Date(app.createdAt).toLocaleDateString()} - -
- - {/* Versions */} - - Versions ({versions.length}) - -
- - -
- {sortedVersions.length === 0 && ( -

No versions uploaded yet.

- )} - {sortedVersions.map((v) => ( - handleDeploy(v.id)} /> - ))} - - {/* Deployments */} - - Deployments ({deployments.length}) - - {deployments.length === 0 && ( -

No deployments yet.

- )} - {deployments.map((d) => ( - handleStop(d.id)} - /> - ))} - - ); -} - -// --- Version Row --- - -function VersionRow({ version, onDeploy }: { version: AppVersion; onDeploy: () => void }) { - return ( -
- - - {version.jarFilename} ({formatBytes(version.jarSizeBytes)}) - - - {new Date(version.uploadedAt).toLocaleDateString()} - - -
- ); -} - -// --- Deployment Row --- - -function DeploymentRow({ - deployment, - versions, - environments, - onStop, -}: { - deployment: Deployment; - versions: AppVersion[]; - environments: { id: string; slug: string; displayName: string }[]; - onStop: () => void; -}) { - const version = versions.find((v) => v.id === deployment.appVersionId); - const env = environments.find((e) => e.id === deployment.environmentId); - const canStop = deployment.status === 'RUNNING' || deployment.status === 'STARTING'; - - return ( -
- - - {version ? `v${version.version}` : '?'} in {env?.displayName ?? '?'} - - {deployment.containerName && ( - {deployment.containerName} - )} - {deployment.errorMessage && ( - {deployment.errorMessage} - )} - {canStop && ( - - )} -
- ); -} diff --git a/ui/src/pages/AppsTab/AppsTab.module.css b/ui/src/pages/AppsTab/AppsTab.module.css new file mode 100644 index 00000000..c62c6a22 --- /dev/null +++ b/ui/src/pages/AppsTab/AppsTab.module.css @@ -0,0 +1,170 @@ +.container { + padding: 16px; + overflow-y: auto; + flex: 1; +} + +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.createForm { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + margin-bottom: 12px; + border: 1px solid var(--border-subtle); + border-radius: 6px; + background: var(--bg-raised); +} + +.createActions { + display: flex; + gap: 8px; +} + +.envSelect { + padding: 6px 8px; + border: 1px solid var(--border-subtle); + border-radius: 4px; + background: var(--bg-surface); + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-body); +} + +.cellName { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.cellMeta { + font-size: 12px; + color: var(--text-muted); +} + +.detailHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; +} + +.detailTitle { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.detailMeta { + font-size: 13px; + color: var(--text-muted); + margin-top: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +.metaGrid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 16px; + font-size: 12px; + font-family: var(--font-body); + margin-bottom: 16px; +} + +.metaLabel { + color: var(--text-muted); + font-weight: 500; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid var(--border-subtle); + font-size: 12px; + font-family: var(--font-body); +} + +.rowText { + flex: 1; + color: var(--text-primary); +} + +.rowMeta { + color: var(--text-muted); + font-size: 11px; +} + +.errorText { + color: var(--error); + font-size: 11px; +} + +.emptyNote { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + margin: 4px 0 8px; +} + +.configForm { + margin-top: 8px; +} + +.configSection { + margin-bottom: 16px; +} + +.configTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px; +} + +.configGrid { + display: grid; + grid-template-columns: 160px 1fr; + gap: 8px 12px; + align-items: start; +} + +.configLabel { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; + padding-top: 8px; +} + +.configField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.configHint { + font-size: 11px; + color: var(--text-muted); + font-style: italic; +} + +.envVarEditor { + width: 100%; + font-family: var(--font-mono); + font-size: 12px; + padding: 8px; + border: 1px solid var(--border-subtle); + border-radius: 4px; + background: var(--bg-surface); + color: var(--text-primary); + resize: vertical; +} diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx new file mode 100644 index 00000000..09e88cb1 --- /dev/null +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -0,0 +1,515 @@ +import { useState, useMemo, useRef } from 'react'; +import { useParams } from 'react-router'; +import { + Badge, + Button, + DataTable, + Input, + MonoText, + SectionHeader, + Spinner, + useToast, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { useEnvironmentStore } from '../../api/environment-store'; +import { useEnvironments } from '../../api/queries/admin/environments'; +import { + useAllApps, + useApps, + useCreateApp, + useDeleteApp, + useAppVersions, + useUploadJar, + useDeployments, + useCreateDeployment, + useStopDeployment, + useUpdateContainerConfig, +} from '../../api/queries/admin/apps'; +import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps'; +import type { Environment } from '../../api/queries/admin/environments'; +import styles from './AppsTab.module.css'; + +function formatBytes(bytes: number): string { + if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +function timeAgo(date: string): string { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +const STATUS_COLORS: Record = { + RUNNING: 'running', + STARTING: 'warning', + FAILED: 'error', + STOPPED: 'auto', +}; + +export default function AppsTab() { + const { appId } = useParams<{ appId?: string }>(); + const selectedEnv = useEnvironmentStore((s) => s.environment); + const { data: environments = [] } = useEnvironments(); + + // If an app is selected via sidebar, show detail view + if (appId) { + return ( + + ); + } + + // Otherwise show list view + return ( + + ); +} + +// --- List View --- + +function AppListView({ + selectedEnv, + environments, +}: { + selectedEnv: string | undefined; + environments: Environment[]; +}) { + const { toast } = useToast(); + const { data: allApps = [], isLoading: allLoading } = useAllApps(); + const { data: envApps = [], isLoading: envLoading } = useApps( + selectedEnv ? environments.find((e) => e.slug === selectedEnv)?.id : undefined, + ); + + const [creating, setCreating] = useState(false); + const [newSlug, setNewSlug] = useState(''); + const [newDisplayName, setNewDisplayName] = useState(''); + const [newEnvId, setNewEnvId] = useState(''); + const createApp = useCreateApp(); + + const apps = selectedEnv ? envApps : allApps; + const isLoading = selectedEnv ? envLoading : allLoading; + + // Build enriched rows with environment name and latest deployment status + const envMap = useMemo(() => { + const m = new Map(); + for (const e of environments) m.set(e.id, e); + return m; + }, [environments]); + + type AppRow = App & { envName: string }; + const rows: AppRow[] = useMemo( + () => apps.map((a) => ({ + ...a, + envName: envMap.get(a.environmentId)?.displayName ?? '?', + })), + [apps, envMap], + ); + + const columns: Column[] = useMemo(() => [ + { + key: 'displayName', + header: 'Name', + render: (_val: unknown, row: AppRow) => ( +
+
{row.displayName}
+
{row.slug}
+
+ ), + sortable: true, + }, + ...(!selectedEnv ? [{ + key: 'envName', + header: 'Environment', + render: (_val: unknown, row: AppRow) => , + sortable: true, + }] : []), + { + key: 'updatedAt', + header: 'Updated', + render: (_val: unknown, row: AppRow) => {timeAgo(row.updatedAt)}, + sortable: true, + }, + { + key: 'createdAt', + header: 'Created', + render: (_val: unknown, row: AppRow) => {new Date(row.createdAt).toLocaleDateString()}, + sortable: true, + }, + ], [selectedEnv]); + + async function handleCreate() { + if (!newSlug.trim() || !newDisplayName.trim() || !newEnvId) return; + try { + await createApp.mutateAsync({ + environmentId: newEnvId, + slug: newSlug.trim(), + displayName: newDisplayName.trim(), + }); + toast({ title: 'App created', description: newSlug.trim(), variant: 'success' }); + setCreating(false); + setNewSlug(''); + setNewDisplayName(''); + setNewEnvId(''); + } catch { + toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 }); + } + } + + if (isLoading) return ; + + return ( +
+
+ +
+ + {creating && ( +
+ + setNewSlug(e.target.value)} /> + setNewDisplayName(e.target.value)} /> +
+ + +
+
+ )} + + { + window.location.href = `${window.location.pathname.replace(/\/apps.*/, '')}/apps/${row.id}`; + }} + /> +
+ ); +} + +// --- Detail View --- + +function AppDetailView({ + appId, + environments, +}: { + appId: string; + environments: Environment[]; +}) { + const { toast } = useToast(); + const { data: allApps = [] } = useAllApps(); + const app = useMemo(() => allApps.find((a) => a.id === appId), [allApps, appId]); + const { data: versions = [] } = useAppVersions(appId); + const { data: deployments = [] } = useDeployments(appId); + const uploadJar = useUploadJar(); + const createDeployment = useCreateDeployment(); + const stopDeployment = useStopDeployment(); + const deleteApp = useDeleteApp(); + const fileInputRef = useRef(null); + + const envMap = useMemo(() => { + const m = new Map(); + for (const e of environments) m.set(e.id, e); + return m; + }, [environments]); + + const sortedVersions = useMemo( + () => [...versions].sort((a, b) => b.version - a.version), + [versions], + ); + + if (!app) return ; + + const envName = envMap.get(app.environmentId)?.displayName ?? '?'; + + async function handleUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + try { + const v = await uploadJar.mutateAsync({ appId, file }); + toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' }); + } catch { + toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); + } + if (fileInputRef.current) fileInputRef.current.value = ''; + } + + async function handleDeploy(versionId: string, environmentId: string) { + try { + await createDeployment.mutateAsync({ appId, appVersionId: versionId, environmentId }); + toast({ title: 'Deployment started', variant: 'success' }); + } catch { + toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); + } + } + + async function handleStop(deploymentId: string) { + try { + await stopDeployment.mutateAsync({ appId, deploymentId }); + toast({ title: 'Deployment stopped', variant: 'warning' }); + } catch { + toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); + } + } + + async function handleDelete() { + try { + await deleteApp.mutateAsync(appId); + toast({ title: 'App deleted', variant: 'warning' }); + window.history.back(); + } catch { + toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); + } + } + + return ( +
+
+
+

{app.displayName}

+
+ {app.slug} · +
+
+ +
+ +
+ ID + {app.id} + Environment + {envName} + Created + {new Date(app.createdAt).toLocaleDateString()} + Updated + {timeAgo(app.updatedAt)} +
+ + {/* Deployments across all environments */} + Deployments ({deployments.length}) + {deployments.length === 0 &&

No deployments yet.

} + {deployments.map((d) => ( + handleStop(d.id)} + /> + ))} + + {/* Versions */} + Versions ({versions.length}) +
+ + +
+ {sortedVersions.length === 0 &&

No versions uploaded yet.

} + {sortedVersions.map((v) => ( + handleDeploy(v.id, envId)} + /> + ))} + + {/* Container Config */} + Container Configuration + +
+ ); +} + +// --- Deployment Row --- + +function DeploymentRow({ + deployment, + versions, + envMap, + onStop, +}: { + deployment: Deployment; + versions: AppVersion[]; + envMap: Map; + onStop: () => void; +}) { + const version = versions.find((v) => v.id === deployment.appVersionId); + const env = envMap.get(deployment.environmentId); + const canStop = deployment.status === 'RUNNING' || deployment.status === 'STARTING'; + + return ( +
+ + + {version ? `v${version.version}` : '?'} + {deployment.containerName && {deployment.containerName}} + {deployment.errorMessage && {deployment.errorMessage}} + {deployment.deployedAt ? timeAgo(deployment.deployedAt) : ''} + {canStop && } +
+ ); +} + +// --- Version Row --- + +function VersionRow({ + version, + environments, + onDeploy, +}: { + version: AppVersion; + environments: Environment[]; + onDeploy: (environmentId: string) => void; +}) { + const [deployEnv, setDeployEnv] = useState(''); + + return ( +
+ + + {version.jarFilename} ({formatBytes(version.jarSizeBytes)}) + + {timeAgo(version.uploadedAt)} + + +
+ ); +} + +// --- Container Config Form --- + +function ContainerConfigForm({ app, environment }: { app: App; environment?: Environment }) { + const { toast } = useToast(); + const updateConfig = useUpdateContainerConfig(); + const isProd = environment?.production ?? false; + + const defaults = environment?.defaultContainerConfig ?? {}; + const merged = { ...defaults, ...app.containerConfig }; + + const [memoryLimitMb, setMemoryLimitMb] = useState(String(merged.memoryLimitMb ?? '512')); + const [memoryReserveMb, setMemoryReserveMb] = useState(String(merged.memoryReserveMb ?? '')); + const [cpuShares, setCpuShares] = useState(String(merged.cpuShares ?? '512')); + const [cpuLimit, setCpuLimit] = useState(String(merged.cpuLimit ?? '')); + const [exposedPorts, setExposedPorts] = useState( + Array.isArray(merged.exposedPorts) ? (merged.exposedPorts as number[]).join(', ') : '', + ); + const [customEnvVars, setCustomEnvVars] = useState( + merged.customEnvVars ? JSON.stringify(merged.customEnvVars, null, 2) : '{}', + ); + + async function handleSave() { + const config: Record = { + memoryLimitMb: memoryLimitMb ? parseInt(memoryLimitMb) : null, + memoryReserveMb: memoryReserveMb ? parseInt(memoryReserveMb) : null, + cpuShares: cpuShares ? parseInt(cpuShares) : null, + cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null, + exposedPorts: exposedPorts ? exposedPorts.split(',').map((p) => parseInt(p.trim())).filter((p) => !isNaN(p)) : [], + }; + try { + config.customEnvVars = JSON.parse(customEnvVars); + } catch { + toast({ title: 'Invalid JSON in environment variables', variant: 'error' }); + return; + } + try { + await updateConfig.mutateAsync({ appId: app.id, config }); + toast({ title: 'Container config saved', variant: 'success' }); + } catch { + toast({ title: 'Failed to save config', variant: 'error', duration: 86_400_000 }); + } + } + + return ( +
+
+

Resources

+
+ + setMemoryLimitMb(e.target.value)} placeholder="512" /> + +
+ setMemoryReserveMb(e.target.value)} + placeholder="—" + disabled={!isProd} + /> + {!isProd && Available in production environments only} +
+ + setCpuShares(e.target.value)} placeholder="512" /> + + setCpuLimit(e.target.value)} placeholder="e.g. 1.0" /> + + setExposedPorts(e.target.value)} placeholder="e.g. 8080, 9090" /> +
+
+ +
+

Custom Environment Variables

+