diff --git a/ui/src/components/ContentTabs.tsx b/ui/src/components/ContentTabs.tsx index 37b5ad5d..6444cbe3 100644 --- a/ui/src/components/ContentTabs.tsx +++ b/ui/src/components/ContentTabs.tsx @@ -23,7 +23,7 @@ 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' }]; + return [...BASE_TABS, { label: 'Deployments', value: 'apps' }]; }, [canControl]); return ( diff --git a/ui/src/pages/AppsTab/AppsTab.module.css b/ui/src/pages/AppsTab/AppsTab.module.css index c62c6a22..337d4440 100644 --- a/ui/src/pages/AppsTab/AppsTab.module.css +++ b/ui/src/pages/AppsTab/AppsTab.module.css @@ -26,7 +26,7 @@ gap: 8px; } -.envSelect { +.nativeSelect { padding: 6px 8px; border: 1px solid var(--border-subtle); border-radius: 4px; @@ -47,6 +47,7 @@ color: var(--text-muted); } +/* Detail header */ .detailHeader { display: flex; justify-content: space-between; @@ -70,25 +71,91 @@ gap: 6px; } -.metaGrid { - display: grid; - grid-template-columns: auto 1fr; - gap: 6px 16px; - font-size: 12px; - font-family: var(--font-body); +.detailActions { + display: flex; + gap: 8px; +} + +/* Sub tabs */ +.subTabs { + display: flex; + border-bottom: 1px solid var(--border-subtle); margin-bottom: 16px; } -.metaLabel { - color: var(--text-muted); +.subTab { + padding: 8px 16px; + font-size: 13px; font-weight: 500; + color: var(--text-muted); + cursor: pointer; + border: none; + border-bottom: 2px solid transparent; + background: none; + font-family: var(--font-body); } +.subTab:hover { + color: var(--text-primary); +} + +.subTabActive { + color: var(--accent, #6c7aff); + border-bottom-color: var(--accent, #6c7aff); +} + +/* Table */ +.table { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; +} + +.table th { + text-align: left; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border-subtle); +} + +.table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-subtle); + font-size: 13px; + vertical-align: middle; +} + +.table tr:hover td { + background: var(--bg-hover); +} + +.mutedRow td { + opacity: 0.45; +} + +.mutedMono { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + opacity: 0.5; +} + +.envHint { + font-size: 11px; + color: var(--text-muted); + font-style: italic; +} + +/* Version rows */ .row { display: flex; align-items: center; gap: 8px; - padding: 6px 0; + padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: 12px; font-family: var(--font-body); @@ -99,72 +166,174 @@ 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; + margin: 4px 0 12px; } -.configForm { - margin-top: 8px; -} - -.configSection { +/* Edit mode banner */ +.editBanner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--bg-raised); + border: 1px solid var(--border-subtle); + border-radius: 6px; margin-bottom: 16px; } -.configTitle { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; +.editBannerActive { + border-color: var(--warning); + background: rgba(251, 191, 36, 0.06); } +.editBannerText { + font-size: 12px; + color: var(--text-muted); +} + +.editBannerTextWarn { + font-size: 12px; + color: var(--warning); +} + +.editBannerActions { + display: flex; + gap: 8px; +} + +/* Config grid */ .configGrid { display: grid; grid-template-columns: 160px 1fr; - gap: 8px 12px; - align-items: start; + gap: 8px 16px; + align-items: center; + margin-bottom: 16px; } .configLabel { font-size: 12px; color: var(--text-muted); font-weight: 500; - padding-top: 8px; } -.configField { +.configInline { display: flex; - flex-direction: column; - gap: 4px; + align-items: center; + gap: 6px; } .configHint { font-size: 11px; color: var(--text-muted); font-style: italic; + margin-top: 2px; } -.envVarEditor { - width: 100%; - font-family: var(--font-mono); +.toggleEnabled { font-size: 12px; - padding: 8px; - border: 1px solid var(--border-subtle); - border-radius: 4px; - background: var(--bg-surface); - color: var(--text-primary); - resize: vertical; + color: var(--success); +} + +.toggleDisabled { + font-size: 12px; + color: var(--text-muted); +} + +/* Port pills */ +.portPills { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.portPill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 12px; + font-size: 12px; + font-family: var(--font-mono); + background: var(--bg-raised); + color: var(--text-primary); + border: 1px solid var(--border-subtle); +} + +.portPillDelete { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 0; +} + +.portPillDelete:hover { + color: var(--error); +} + +.portPillDelete:disabled { + opacity: 0.3; + cursor: default; +} + +.portAddInput { + width: 70px; + padding: 3px 6px; + border: 1px dashed var(--border-subtle); + border-radius: 12px; + background: transparent; + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-mono); + text-align: center; +} + +.portAddInput::placeholder { + color: var(--text-muted); +} + +.portAddInput:disabled { + opacity: 0.3; + cursor: default; +} + +/* Env var editor */ +.envVarRow { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.envVarKey { + width: 180px; +} + +.envVarValue { + flex: 1; +} + +.envVarDelete { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 4px; +} + +.envVarDelete:hover { + color: var(--error); +} + +.envVarDelete:disabled { + opacity: 0.3; + cursor: default; } diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 09e88cb1..5efa92ec 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo, useRef } from 'react'; -import { useParams } from 'react-router'; +import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router'; import { Badge, Button, @@ -7,7 +7,9 @@ import { Input, MonoText, SectionHeader, + Select, Spinner, + Toggle, useToast, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; @@ -27,6 +29,8 @@ import { } 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 { @@ -42,15 +46,11 @@ function timeAgo(date: string): string { 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`; + return `${Math.floor(hours / 24)}d ago`; } const STATUS_COLORS: Record = { - RUNNING: 'running', - STARTING: 'warning', - FAILED: 'error', - STOPPED: 'auto', + RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto', }; export default function AppsTab() { @@ -58,39 +58,20 @@ export default function AppsTab() { 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 ( - - ); + if (appId) return ; + return ; } -// --- List View --- +// ═══════════════════════════════════════════════════════════════════ +// LIST VIEW +// ═══════════════════════════════════════════════════════════════════ -function AppListView({ - selectedEnv, - environments, -}: { - selectedEnv: string | undefined; - environments: Environment[]; -}) { +function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) { const { toast } = useToast(); + const navigate = useNavigate(); const { data: allApps = [], isLoading: allLoading } = useAllApps(); - const { data: envApps = [], isLoading: envLoading } = useApps( - selectedEnv ? environments.find((e) => e.slug === selectedEnv)?.id : undefined, - ); + 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(''); @@ -101,70 +82,38 @@ function AppListView({ 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]); + const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); - type AppRow = App & { envName: string }; + type AppRow = App & { id: string; envName: string }; const rows: AppRow[] = useMemo( - () => apps.map((a) => ({ - ...a, - envName: envMap.get(a.environmentId)?.displayName ?? '?', - })), + () => 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}
-
+ { key: 'displayName', header: 'Name', sortable: true, + render: (_v: unknown, row: AppRow) => ( +
{row.displayName}
{row.slug}
), - sortable: true, }, - ...(!selectedEnv ? [{ - key: 'envName', - header: 'Environment', - render: (_val: unknown, row: AppRow) => , - sortable: true, + ...(!selectedEnv ? [{ key: 'envName', header: 'Environment', sortable: true, + render: (_v: unknown, row: AppRow) => , }] : []), - { - key: 'updatedAt', - header: 'Updated', - render: (_val: unknown, row: AppRow) => {timeAgo(row.updatedAt)}, - sortable: true, + { key: 'updatedAt', header: 'Updated', sortable: true, + render: (_v: unknown, row: AppRow) => {timeAgo(row.updatedAt)}, }, - { - key: 'createdAt', - header: 'Created', - render: (_val: unknown, row: AppRow) => {new Date(row.createdAt).toLocaleDateString()}, - sortable: true, + { 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(), - }); + 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 }); - } + setCreating(false); setNewSlug(''); setNewDisplayName(''); setNewEnvId(''); + } catch { toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 }); } } if (isLoading) return ; @@ -175,60 +124,34 @@ function AppListView({ + }}>+ Create App - {creating && (
- setNewEnvId(e.target.value)}> + {environments.map((env) => )} setNewSlug(e.target.value)} /> setNewDisplayName(e.target.value)} />
- +
)} - - { - window.location.href = `${window.location.pathname.replace(/\/apps.*/, '')}/apps/${row.id}`; - }} - /> + navigate(`/apps/${row.id}`)} /> ); } -// --- Detail View --- +// ═══════════════════════════════════════════════════════════════════ +// DETAIL VIEW +// ═══════════════════════════════════════════════════════════════════ -function AppDetailView({ - appId, - environments, -}: { - appId: string; - environments: Environment[]; -}) { +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); @@ -238,21 +161,14 @@ function AppDetailView({ const stopDeployment = useStopDeployment(); const deleteApp = useDeleteApp(); const fileInputRef = useRef(null); + const [subTab, setSubTab] = useState<'overview' | 'config'>('overview'); - 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], - ); + 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 envName = envMap.get(app.environmentId)?.displayName ?? '?'; + const env = envMap.get(app.environmentId); async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -260,9 +176,7 @@ function AppDetailView({ 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 }); - } + } catch { toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); } if (fileInputRef.current) fileInputRef.current.value = ''; } @@ -270,28 +184,22 @@ function AppDetailView({ 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 }); - } + } 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 }); - } + } 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 }); - } + navigate('/apps'); + } catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); } } return ( @@ -300,216 +208,364 @@ function AppDetailView({

{app.displayName}

- {app.slug} · + {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)} + {subTab === 'overview' && ( + - ))} - - {/* Versions */} - Versions ({versions.length}) -
- - -
- {sortedVersions.length === 0 &&

No versions uploaded yet.

} - {sortedVersions.map((v) => ( - handleDeploy(v.id, envId)} - /> - ))} - - {/* Container Config */} - Container Configuration - + )} + {subTab === 'config' && ( + + )}
); } -// --- Deployment Row --- +// ═══════════════════════════════════════════════════════════════════ +// OVERVIEW SUB-TAB +// ═══════════════════════════════════════════════════════════════════ -function DeploymentRow({ - deployment, - versions, - envMap, - onStop, -}: { - deployment: Deployment; - versions: AppVersion[]; - envMap: Map; - onStop: () => void; +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; }) { - const version = versions.find((v) => v.id === deployment.appVersionId); - const env = envMap.get(deployment.environmentId); - const canStop = deployment.status === 'RUNNING' || deployment.status === 'STARTING'; + // Determine which env slug is selected + const selectedEnvId = useMemo( + () => selectedEnv ? environments.find((e) => e.slug === selectedEnv)?.id : undefined, + [environments, selectedEnv], + ); return ( -
- - - {version ? `v${version.version}` : '?'} - {deployment.containerName && {deployment.containerName}} - {deployment.errorMessage && {deployment.errorMessage}} - {deployment.deployedAt ? timeAgo(deployment.deployedAt) : ''} - {canStop && } -
+ <> + 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 ( + + + + + + + + + ); + })} + +
EnvironmentVersionStatusURLDeployedActions
+ + + {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)} /> + ))} + ); } -// --- Version Row --- - -function VersionRow({ - version, - environments, - onDeploy, -}: { - version: AppVersion; - environments: Environment[]; - onDeploy: (environmentId: string) => void; -}) { +function VersionRow({ version, environments, onDeploy }: { version: AppVersion; environments: Environment[]; onDeploy: (envId: string) => void }) { const [deployEnv, setDeployEnv] = useState(''); - return (
- - {version.jarFilename} ({formatBytes(version.jarSizeBytes)}) - - {timeAgo(version.uploadedAt)} - setDeployEnv(e.target.value)}> - {environments.filter((e) => e.enabled).map((e) => ( - - ))} + {environments.filter((e) => e.enabled).map((e) => )} - +
); } -// --- Container Config Form --- +// ═══════════════════════════════════════════════════════════════════ +// CONFIGURATION SUB-TAB +// ═══════════════════════════════════════════════════════════════════ -function ContainerConfigForm({ app, environment }: { app: App; environment?: Environment }) { +function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) { const { toast } = useToast(); - const updateConfig = useUpdateContainerConfig(); + 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 = { ...defaults, ...app.containerConfig }; + 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 }[]>([]); - 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) : '{}', - ); + // 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() { - const config: Record = { - memoryLimitMb: memoryLimitMb ? parseInt(memoryLimitMb) : null, - memoryReserveMb: memoryReserveMb ? parseInt(memoryReserveMb) : null, + // 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: exposedPorts ? exposedPorts.split(',').map((p) => parseInt(p.trim())).filter((p) => !isNaN(p)) : [], + exposedPorts: ports, + customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])), }; 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 }); - } + 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 ( -
-
-

Resources

-
- - setMemoryLimitMb(e.target.value)} placeholder="512" /> - -
- setMemoryReserveMb(e.target.value)} - placeholder="—" - disabled={!isProd} - /> - {!isProd && Available in production environments only} + <> + {!editing && ( +
+ Configuration is read-only. Enter edit mode to make changes. + +
+ )} + {editing && ( +
+ Editing configuration. Changes are not saved until you click Save. +
+ +
- - setCpuShares(e.target.value)} placeholder="512" /> - - setCpuLimit(e.target.value)} placeholder="e.g. 1.0" /> - - setExposedPorts(e.target.value)} placeholder="e.g. 8080, 9090" /> +
+ )} + + {/* 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'}
-
-

Custom Environment Variables

-