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