import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router'; import { Badge, Button, DataTable, Input, MonoText, SectionHeader, Select, Spinner, Toggle, 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 { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import type { ApplicationConfig } from '../../api/queries/commands'; 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`; return `${Math.floor(hours / 24)}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 (appId) return ; return ; } // ═══════════════════════════════════════════════════════════════════ // LIST VIEW // ═══════════════════════════════════════════════════════════════════ function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) { const { toast } = useToast(); const navigate = useNavigate(); const { data: allApps = [], isLoading: allLoading } = useAllApps(); const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]); const { data: envApps = [], isLoading: envLoading } = useApps(envId); 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; const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); type AppRow = App & { id: string; 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', sortable: true, render: (_v: unknown, row: AppRow) => (
{row.displayName}
{row.slug}
), }, ...(!selectedEnv ? [{ key: 'envName', header: 'Environment', sortable: true, render: (_v: unknown, row: AppRow) => , }] : []), { key: 'updatedAt', header: 'Updated', sortable: true, render: (_v: unknown, row: AppRow) => {timeAgo(row.updatedAt)}, }, { key: 'createdAt', header: 'Created', sortable: true, render: (_v: unknown, row: AppRow) => {new Date(row.createdAt).toLocaleDateString()}, }, ], [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)} />
)} navigate(`/apps/${row.id}`)} />
); } // ═══════════════════════════════════════════════════════════════════ // DETAIL VIEW // ═══════════════════════════════════════════════════════════════════ function AppDetailView({ appId, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) { const { toast } = useToast(); const navigate = useNavigate(); 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 [subTab, setSubTab] = useState<'overview' | 'config'>('overview'); const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]); if (!app) return ; const env = envMap.get(app.environmentId); 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' }); navigate('/apps'); } catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); } } return (

{app.displayName}

{app.slug} ·
{subTab === 'overview' && ( )} {subTab === 'config' && ( )}
); } // ═══════════════════════════════════════════════════════════════════ // OVERVIEW SUB-TAB // ═══════════════════════════════════════════════════════════════════ function OverviewSubTab({ app, deployments, versions, environments, envMap, selectedEnv, onDeploy, onStop }: { app: App; deployments: Deployment[]; versions: AppVersion[]; environments: Environment[]; envMap: Map; selectedEnv: string | undefined; onDeploy: (versionId: string, envId: string) => void; onStop: (deploymentId: string) => void; }) { // Determine which env slug is selected const selectedEnvId = useMemo( () => selectedEnv ? environments.find((e) => e.slug === selectedEnv)?.id : undefined, [environments, selectedEnv], ); return ( <> Deployments {deployments.length === 0 &&

No deployments yet.

} {deployments.length > 0 && ( {deployments.map((d) => { const dEnv = envMap.get(d.environmentId); const version = versions.find((v) => v.id === d.appVersionId); const isSelectedEnv = !selectedEnvId || d.environmentId === selectedEnvId; const canAct = isSelectedEnv && (d.status === 'RUNNING' || d.status === 'STARTING'); const canStart = isSelectedEnv && d.status === 'STOPPED'; const url = dEnv ? `/${dEnv.slug}/${app.slug}/` : ''; return ( ); })}
Environment Version Status URL Deployed Actions
{d.status === 'RUNNING' ? ( {url} ) : ( {url} )} {d.deployedAt ? timeAgo(d.deployedAt) : '—'} {canAct && } {canStart && } {!isSelectedEnv && switch env to manage}
)} Versions ({versions.length}) {versions.length === 0 &&

No versions uploaded yet.

} {versions.map((v) => ( onDeploy(v.id, envId)} /> ))} ); } function VersionRow({ version, environments, onDeploy }: { version: AppVersion; environments: Environment[]; onDeploy: (envId: string) => void }) { const [deployEnv, setDeployEnv] = useState(''); return (
{version.jarFilename} ({formatBytes(version.jarSizeBytes)}) {version.jarChecksum.substring(0, 8)} {timeAgo(version.uploadedAt)}
); } // ═══════════════════════════════════════════════════════════════════ // CONFIGURATION SUB-TAB // ═══════════════════════════════════════════════════════════════════ function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) { const { toast } = useToast(); const { data: agentConfig } = useApplicationConfig(app.slug); const updateAgentConfig = useUpdateApplicationConfig(); const updateContainerConfig = useUpdateContainerConfig(); const isProd = environment?.production ?? false; const [editing, setEditing] = useState(false); // Agent config state const [engineLevel, setEngineLevel] = useState('REGULAR'); const [payloadCapture, setPayloadCapture] = useState('BOTH'); const [payloadSize, setPayloadSize] = useState('4'); const [payloadUnit, setPayloadUnit] = useState('KB'); const [appLogLevel, setAppLogLevel] = useState('INFO'); const [agentLogLevel, setAgentLogLevel] = useState('INFO'); const [metricsEnabled, setMetricsEnabled] = useState(true); const [metricsInterval, setMetricsInterval] = useState('60'); const [samplingRate, setSamplingRate] = useState('1.0'); const [replayEnabled, setReplayEnabled] = useState(true); const [routeControlEnabled, setRouteControlEnabled] = useState(true); // Container config state const defaults = environment?.defaultContainerConfig ?? {}; const merged = useMemo(() => ({ ...defaults, ...app.containerConfig }), [defaults, app.containerConfig]); const [memoryLimit, setMemoryLimit] = useState('512'); const [memoryReserve, setMemoryReserve] = useState(''); const [cpuShares, setCpuShares] = useState('512'); const [cpuLimit, setCpuLimit] = useState(''); const [ports, setPorts] = useState([]); const [newPort, setNewPort] = useState(''); const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]); // Sync from server data const syncFromServer = useCallback(() => { if (agentConfig) { setEngineLevel(agentConfig.engineLevel ?? 'REGULAR'); setPayloadCapture(agentConfig.payloadCaptureMode ?? 'BOTH'); const raw = agentConfig.payloadCaptureMode !== undefined ? 4096 : 4096; // TODO: read from config when available setPayloadSize('4'); setPayloadUnit('KB'); setAppLogLevel(agentConfig.applicationLogLevel ?? 'INFO'); setAgentLogLevel(agentConfig.agentLogLevel ?? 'INFO'); setMetricsEnabled(agentConfig.metricsEnabled); setSamplingRate(String(agentConfig.samplingRate)); } setMemoryLimit(String(merged.memoryLimitMb ?? 512)); setMemoryReserve(String(merged.memoryReserveMb ?? '')); setCpuShares(String(merged.cpuShares ?? 512)); setCpuLimit(String(merged.cpuLimit ?? '')); setPorts(Array.isArray(merged.exposedPorts) ? merged.exposedPorts as number[] : []); const vars = merged.customEnvVars as Record | undefined; setEnvVars(vars ? Object.entries(vars).map(([key, value]) => ({ key, value })) : []); }, [agentConfig, merged]); useEffect(() => { syncFromServer(); }, [syncFromServer]); function handleCancel() { syncFromServer(); setEditing(false); } function payloadSizeToBytes(): number { const val = parseFloat(payloadSize) || 0; if (payloadUnit === 'KB') return val * 1024; if (payloadUnit === 'MB') return val * 1048576; return val; } async function handleSave() { // Save agent config if (agentConfig) { try { await updateAgentConfig.mutateAsync({ ...agentConfig, engineLevel, payloadCaptureMode: payloadCapture, applicationLogLevel: appLogLevel, agentLogLevel, metricsEnabled, samplingRate: parseFloat(samplingRate) || 1.0, }); } catch { toast({ title: 'Failed to save agent config', variant: 'error', duration: 86_400_000 }); return; } } // Save container config const containerConfig: Record = { memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null, memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null, cpuShares: cpuShares ? parseInt(cpuShares) : null, cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null, exposedPorts: ports, customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])), }; try { await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig }); toast({ title: 'Configuration saved', variant: 'success' }); setEditing(false); } catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); } } function addPort() { const p = parseInt(newPort); if (p && !ports.includes(p)) { setPorts([...ports, p]); setNewPort(''); } } return ( <> {!editing && (
Configuration is read-only. Enter edit mode to make changes.
)} {editing && (
Editing configuration. Changes are not saved until you click Save.
)} {/* Agent Observability */} Agent Observability
Engine Level setPayloadCapture(e.target.value)} options={[{ value: 'NONE', label: 'NONE' }, { value: 'INPUT', label: 'INPUT' }, { value: 'OUTPUT', label: 'OUTPUT' }, { value: 'BOTH', label: 'BOTH' }]} /> Max Payload Size
setPayloadSize(e.target.value)} style={{ width: 70 }} /> setAppLogLevel(e.target.value)} options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} /> Agent Log Level setMetricsInterval(e.target.value)} style={{ width: 50 }} /> s
Sampling Rate setSamplingRate(e.target.value)} style={{ width: 80 }} /> Replay
editing && setReplayEnabled(!replayEnabled)} disabled={!editing} /> {replayEnabled ? 'Enabled' : 'Disabled'}
Route Control
editing && setRouteControlEnabled(!routeControlEnabled)} disabled={!editing} /> {routeControlEnabled ? 'Enabled' : 'Disabled'}
{/* Container Resources */} Container Resources
Memory Limit
setMemoryLimit(e.target.value)} style={{ width: 80 }} /> MB
Memory Reserve
setMemoryReserve(e.target.value)} placeholder="---" style={{ width: 80 }} /> MB
{!isProd && Available in production environments only}
CPU Shares setCpuShares(e.target.value)} style={{ width: 80 }} /> CPU Limit
setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 80 }} /> cores
Exposed Ports
{ports.map((p) => ( {p} ))} setNewPort(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addPort(); } }} />
{/* Environment Variables */} Environment Variables {envVars.map((v, i) => (
{ const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next); }} className={styles.envVarKey} /> { const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next); }} className={styles.envVarValue} />
))} {editing && ( )} ); }