Files
cameleer-server/ui/src/pages/AppsTab/AppsTab.tsx
hsiegeln 18ffbea9db
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m25s
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled
fix: use visually-hidden clip pattern for file inputs
The opacity:0 approach caused the native "Choose File" button to
appear in the accessibility tree and compete for clicks. The clip
pattern properly hides the input while keeping it functional for
programmatic .click().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:23:05 +02:00

1086 lines
56 KiB
TypeScript

import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router';
import {
Badge,
Button,
ConfirmDialog,
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, useProcessorRouteMapping } from '../../api/queries/commands';
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands';
import { useRouteCatalog } from '../../api/queries/catalog';
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
import { DeploymentProgress } from '../../components/DeploymentProgress';
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<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
DEGRADED: 'warning', STOPPING: 'auto',
};
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 100);
}
export default function AppsTab() {
const { appId } = useParams<{ appId?: string }>();
const location = useLocation();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: environments = [] } = useEnvironments();
if (location.pathname.endsWith('/apps/new')) return <CreateAppView environments={environments} selectedEnv={selectedEnv} />;
if (appId) return <AppDetailView appId={appId} environments={environments} selectedEnv={selectedEnv} />;
return <AppListView selectedEnv={selectedEnv} environments={environments} />;
}
// ═══════════════════════════════════════════════════════════════════
// LIST VIEW
// ═══════════════════════════════════════════════════════════════════
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
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 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<AppRow>[] = useMemo(() => [
{ key: 'displayName', header: 'Name', sortable: true,
render: (_v: unknown, row: AppRow) => (
<div><div className={styles.cellName}>{row.displayName}</div><div className={styles.cellMeta}>{row.slug}</div></div>
),
},
...(!selectedEnv ? [{ key: 'envName', header: 'Environment', sortable: true,
render: (_v: unknown, row: AppRow) => <Badge label={row.envName} color={'auto' as const} />,
}] : []),
{ key: 'updatedAt', header: 'Updated', sortable: true,
render: (_v: unknown, row: AppRow) => <span className={styles.cellMeta}>{timeAgo(row.updatedAt)}</span>,
},
{ key: 'createdAt', header: 'Created', sortable: true,
render: (_v: unknown, row: AppRow) => <span className={styles.cellMeta}>{new Date(row.createdAt).toLocaleDateString()}</span>,
},
], [selectedEnv]);
if (isLoading) return <Spinner size="md" />;
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<Button size="sm" variant="primary" onClick={() => navigate('/apps/new')}>+ Create App</Button>
</div>
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// CREATE APP PAGE
// ═══════════════════════════════════════════════════════════════════
function CreateAppView({ environments, selectedEnv }: { environments: Environment[]; selectedEnv: string | undefined }) {
const { toast } = useToast();
const navigate = useNavigate();
const createApp = useCreateApp();
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const updateAgentConfig = useUpdateApplicationConfig();
const updateContainerConfig = useUpdateContainerConfig();
const defaultEnvId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id ?? (environments.length > 0 ? environments[0].id : ''), [environments, selectedEnv]);
// Identity
const [name, setName] = useState('');
const [slugEdited, setSlugEdited] = useState(false);
const [slug, setSlug] = useState('');
const [envId, setEnvId] = useState(defaultEnvId);
const [file, setFile] = useState<File | null>(null);
const [deploy, setDeploy] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
// Monitoring
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 [compressSuccess, setCompressSuccess] = useState(false);
const [replayEnabled, setReplayEnabled] = useState(true);
const [routeControlEnabled, setRouteControlEnabled] = useState(true);
// Resources
const env = useMemo(() => environments.find((e) => e.id === envId), [environments, envId]);
const isProd = env?.production ?? false;
const defaults = env?.defaultContainerConfig ?? {};
const [memoryLimit, setMemoryLimit] = useState(String(defaults.memoryLimitMb ?? 512));
const [memoryReserve, setMemoryReserve] = useState(String(defaults.memoryReserveMb ?? ''));
const [cpuShares, setCpuShares] = useState(String(defaults.cpuShares ?? 512));
const [cpuLimit, setCpuLimit] = useState(String(defaults.cpuLimit ?? ''));
const [ports, setPorts] = useState<number[]>(Array.isArray(defaults.exposedPorts) ? defaults.exposedPorts as number[] : []);
const [newPort, setNewPort] = useState('');
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
const [appPort, setAppPort] = useState('8080');
const [replicas, setReplicas] = useState('1');
const [deployStrategy, setDeployStrategy] = useState('blue-green');
const [stripPrefix, setStripPrefix] = useState(true);
const [sslOffloading, setSslOffloading] = useState(true);
const [configTab, setConfigTab] = useState<'variables' | 'monitoring' | 'resources'>('variables');
const [busy, setBusy] = useState(false);
const [step, setStep] = useState('');
// Reset resource defaults when environment changes
useEffect(() => {
const d = environments.find((e) => e.id === envId)?.defaultContainerConfig ?? {};
setMemoryLimit(String(d.memoryLimitMb ?? 512));
setMemoryReserve(String(d.memoryReserveMb ?? ''));
setCpuShares(String(d.cpuShares ?? 512));
setCpuLimit(String(d.cpuLimit ?? ''));
setPorts(Array.isArray(d.exposedPorts) ? d.exposedPorts as number[] : []);
}, [envId, environments]);
useEffect(() => {
if (!slugEdited) setSlug(slugify(name));
}, [name, slugEdited]);
function addPort() {
const p = parseInt(newPort);
if (p && !ports.includes(p)) { setPorts([...ports, p]); setNewPort(''); }
}
const canSubmit = name.trim() && slug.trim() && envId && file;
async function handleSubmit() {
if (!canSubmit) return;
setBusy(true);
try {
// 1. Create app
setStep('Creating app...');
const app = await createApp.mutateAsync({ environmentId: envId, slug: slug.trim(), displayName: name.trim() });
// 2. Upload JAR
setStep('Uploading JAR...');
const version = await uploadJar.mutateAsync({ appId: app.id, file: file! });
// 3. Save container config
setStep('Saving configuration...');
const containerConfig: Record<string, unknown> = {
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])),
appPort: appPort ? parseInt(appPort) : 8080,
replicas: replicas ? parseInt(replicas) : 1,
deploymentStrategy: deployStrategy,
stripPathPrefix: stripPrefix,
sslOffloading: sslOffloading,
};
await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig });
// 4. Save agent config (will be pushed to agent on first connect)
setStep('Saving monitoring config...');
await updateAgentConfig.mutateAsync({
application: slug.trim(),
version: 0,
engineLevel,
payloadCaptureMode: payloadCapture,
applicationLogLevel: appLogLevel,
agentLogLevel,
metricsEnabled,
samplingRate: parseFloat(samplingRate) || 1.0,
compressSuccess,
tracedProcessors: {},
taps: [],
tapVersion: 0,
routeRecording: {},
});
// 5. Deploy (if requested)
if (deploy) {
setStep('Starting deployment...');
await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId });
}
toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' });
navigate(`/apps/${app.id}`);
} catch (e) {
toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 });
} finally {
setBusy(false);
setStep('');
}
}
return (
<div className={styles.container}>
<div className={styles.detailHeader}>
<div>
<h2 className={styles.detailTitle}>Create Application</h2>
<div className={styles.detailMeta}>Configure and deploy a new application</div>
</div>
<div className={styles.detailActions}>
<Button size="sm" variant="ghost" onClick={() => navigate('/apps')} disabled={busy}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleSubmit} loading={busy} disabled={!canSubmit || busy}>
{deploy ? 'Create & Deploy' : 'Create'}
</Button>
</div>
</div>
{step && <div className={styles.stepIndicator}>{step}</div>}
{/* Identity Section */}
<SectionHeader>Identity & Artifact</SectionHeader>
<div className={styles.configGrid}>
<span className={styles.configLabel}>Application Name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} />
<span className={styles.configLabel}>External URL</span>
<MonoText size="sm">/{env?.slug ?? '...'}/{slug || '...'}/</MonoText>
<span className={styles.configLabel}>Environment</span>
<Select value={envId} onChange={(e) => setEnvId(e.target.value)} disabled={busy}
options={environments.filter((e) => e.enabled).map((e) => ({ value: e.id, label: `${e.displayName} (${e.slug})` }))} />
<span className={styles.configLabel}>Application JAR</span>
<div className={styles.fileRow}>
<input ref={fileInputRef} type="file" accept=".jar"
style={{ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0,0,0,0)', border: 0 }}
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<Button size="sm" variant="secondary" type="button" onClick={() => fileInputRef.current?.click()} disabled={busy}>
{file ? 'Change file' : 'Select JAR'}
</Button>
{file && <span className={styles.fileName}>{file.name} ({formatBytes(file.size)})</span>}
</div>
<span className={styles.configLabel}>Deploy</span>
<div className={styles.configInline}>
<Toggle checked={deploy} onChange={() => setDeploy(!deploy)} disabled={busy} />
<span className={deploy ? styles.toggleEnabled : styles.toggleDisabled}>
{deploy ? 'Deploy immediately after creation' : 'Create only (deploy later)'}
</span>
</div>
</div>
{/* Config Tabs */}
<div className={styles.subTabs}>
<button className={`${styles.subTab} ${configTab === 'variables' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('variables')}>Variables</button>
<button className={`${styles.subTab} ${configTab === 'monitoring' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('monitoring')}>Monitoring</button>
<button className={`${styles.subTab} ${configTab === 'resources' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('resources')}>Resources</button>
</div>
{configTab === 'variables' && (
<>
{envVars.map((v, i) => (
<div key={i} className={styles.envVarRow}>
<Input disabled={busy} value={v.key} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next);
}} className={styles.envVarKey} placeholder="KEY" />
<Input disabled={busy} value={v.value} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
}} className={styles.envVarValue} placeholder="value" />
<button className={styles.envVarDelete} disabled={busy}
onClick={() => !busy && setEnvVars(envVars.filter((_, j) => j !== i))}>&times;</button>
</div>
))}
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
</>
)}
{configTab === 'monitoring' && (
<div className={styles.configGrid}>
<span className={styles.configLabel}>Engine Level</span>
<Select disabled={busy} value={engineLevel} onChange={(e) => setEngineLevel(e.target.value)}
options={[{ value: 'NONE', label: 'NONE' }, { value: 'MINIMAL', label: 'MINIMAL' }, { value: 'REGULAR', label: 'REGULAR' }, { value: 'COMPLETE', label: 'COMPLETE' }]} />
<span className={styles.configLabel}>Payload Capture</span>
<Select disabled={busy} value={payloadCapture} onChange={(e) => setPayloadCapture(e.target.value)}
options={[{ value: 'NONE', label: 'NONE' }, { value: 'INPUT', label: 'INPUT' }, { value: 'OUTPUT', label: 'OUTPUT' }, { value: 'BOTH', label: 'BOTH' }]} />
<span className={styles.configLabel}>Max Payload Size</span>
<div className={styles.configInline}>
<Input disabled={busy} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} style={{ width: 70 }} />
<Select disabled={busy} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} style={{ width: 90 }}
options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} />
</div>
<span className={styles.configLabel}>App Log Level</span>
<Select disabled={busy} value={appLogLevel} onChange={(e) => setAppLogLevel(e.target.value)}
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} />
<span className={styles.configLabel}>Agent Log Level</span>
<Select disabled={busy} value={agentLogLevel} onChange={(e) => setAgentLogLevel(e.target.value)}
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} />
<span className={styles.configLabel}>Metrics</span>
<div className={styles.configInline}>
<Toggle checked={metricsEnabled} onChange={() => !busy && setMetricsEnabled(!metricsEnabled)} disabled={busy} />
<span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span>
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
<Input disabled={busy} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} style={{ width: 50 }} />
<span className={styles.cellMeta}>s</span>
</div>
<span className={styles.configLabel}>Sampling Rate</span>
<Input disabled={busy} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>Compress Success</span>
<div className={styles.configInline}>
<Toggle checked={compressSuccess} onChange={() => !busy && setCompressSuccess(!compressSuccess)} disabled={busy} />
<span className={compressSuccess ? styles.toggleEnabled : styles.toggleDisabled}>{compressSuccess ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>Replay</span>
<div className={styles.configInline}>
<Toggle checked={replayEnabled} onChange={() => !busy && setReplayEnabled(!replayEnabled)} disabled={busy} />
<span className={replayEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{replayEnabled ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>Route Control</span>
<div className={styles.configInline}>
<Toggle checked={routeControlEnabled} onChange={() => !busy && setRouteControlEnabled(!routeControlEnabled)} disabled={busy} />
<span className={routeControlEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{routeControlEnabled ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
)}
{configTab === 'resources' && (
<>
<SectionHeader>Container Resources</SectionHeader>
<div className={styles.configGrid}>
<span className={styles.configLabel}>Memory Limit</span>
<div className={styles.configInline}>
<Input disabled={busy} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} style={{ width: 80 }} />
<span className={styles.cellMeta}>MB</span>
</div>
<span className={styles.configLabel}>Memory Reserve</span>
<div>
<div className={styles.configInline}>
<Input disabled={!isProd || busy} value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" style={{ width: 80 }} />
<span className={styles.cellMeta}>MB</span>
</div>
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
</div>
<span className={styles.configLabel}>CPU Shares</span>
<Input disabled={busy} value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>CPU Limit</span>
<div className={styles.configInline}>
<Input disabled={busy} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 80 }} />
<span className={styles.cellMeta}>cores</span>
</div>
<span className={styles.configLabel}>Exposed Ports</span>
<div className={styles.portPills}>
{ports.map((p) => (
<span key={p} className={styles.portPill}>
{p}
<button className={styles.portPillDelete} disabled={busy}
onClick={() => !busy && setPorts(ports.filter((x) => x !== p))}>&times;</button>
</span>
))}
<input className={styles.portAddInput} disabled={busy} placeholder="+ port" value={newPort}
onChange={(e) => setNewPort(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addPort(); } }} />
</div>
<span className={styles.configLabel}>App Port</span>
<Input disabled={busy} value={appPort} onChange={(e) => setAppPort(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>Replicas</span>
<Input disabled={busy} value={replicas} onChange={(e) => setReplicas(e.target.value)} style={{ width: 60 }} type="number" />
<span className={styles.configLabel}>Deploy Strategy</span>
<Select disabled={busy} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)}
options={[{ value: 'blue-green', label: 'Blue/Green' }, { value: 'rolling', label: 'Rolling' }]} />
<span className={styles.configLabel}>Strip Path Prefix</span>
<div className={styles.configInline}>
<Toggle checked={stripPrefix} onChange={() => !busy && setStripPrefix(!stripPrefix)} disabled={busy} />
<span className={stripPrefix ? styles.toggleEnabled : styles.toggleDisabled}>{stripPrefix ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>SSL Offloading</span>
<div className={styles.configInline}>
<Toggle checked={sslOffloading} onChange={() => !busy && setSslOffloading(!sslOffloading)} disabled={busy} />
<span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
</>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// 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<HTMLInputElement>(null);
const [subTab, setSubTab] = useState<'overview' | 'config'>('overview');
const [deleteConfirm, setDeleteConfirm] = useState(false);
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 <Spinner size="md" />;
const env = envMap.get(app.environmentId);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className={styles.container}>
<div className={styles.detailHeader}>
<div>
<h2 className={styles.detailTitle}>{app.displayName}</h2>
<div className={styles.detailMeta}>
{app.slug} &middot; <Badge label={env?.displayName ?? '?'} color="auto" />
</div>
</div>
<div className={styles.detailActions}>
<input ref={fileInputRef} type="file" accept=".jar"
style={{ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0,0,0,0)', border: 0 }}
onChange={handleUpload} />
<Button size="sm" variant="primary" type="button" onClick={() => fileInputRef.current?.click()} loading={uploadJar.isPending}>Upload JAR</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>Delete App</Button>
</div>
</div>
<div className={styles.subTabs}>
<button className={`${styles.subTab} ${subTab === 'overview' ? styles.subTabActive : ''}`} onClick={() => setSubTab('overview')}>Overview</button>
<button className={`${styles.subTab} ${subTab === 'config' ? styles.subTabActive : ''}`} onClick={() => setSubTab('config')}>Configuration</button>
</div>
{subTab === 'overview' && (
<OverviewSubTab
app={app} deployments={deployments} versions={sortedVersions}
environments={environments} envMap={envMap} selectedEnv={selectedEnv}
onDeploy={handleDeploy} onStop={handleStop}
/>
)}
{subTab === 'config' && (
<ConfigSubTab app={app} environment={env} />
)}
<ConfirmDialog
open={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
message={`Delete app "${app.displayName}"? All versions and deployments will be removed. This cannot be undone.`}
confirmText={app.slug}
loading={deleteApp.isPending}
/>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// OVERVIEW SUB-TAB
// ═══════════════════════════════════════════════════════════════════
function OverviewSubTab({ app, deployments, versions, environments, envMap, selectedEnv, onDeploy, onStop }: {
app: App; deployments: Deployment[]; versions: AppVersion[];
environments: Environment[]; envMap: Map<string, Environment>;
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 (
<>
<SectionHeader>Deployments</SectionHeader>
{deployments.length === 0 && <p className={styles.emptyNote}>No deployments yet.</p>}
{deployments.length > 0 && (
<table className={styles.table}>
<thead>
<tr>
<th>Environment</th>
<th>Version</th>
<th>Status</th>
<th>Replicas</th>
<th>URL</th>
<th>Deployed</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={d.id} className={!isSelectedEnv ? styles.mutedRow : undefined}>
<td>
<Badge label={dEnv?.displayName ?? '?'} color={dEnv?.production ? 'error' : 'auto'} />
</td>
<td><Badge label={version ? `v${version.version}` : '?'} color="auto" /></td>
<td><Badge label={d.status} color={STATUS_COLORS[d.status] ?? 'auto'} /></td>
<td>
{d.replicaStates && d.replicaStates.length > 0 ? (
<span className={styles.cellMeta}>
{d.replicaStates.filter((r) => r.status === 'RUNNING').length}/{d.replicaStates.length}
</span>
) : '—'}
</td>
<td>
{d.status === 'RUNNING' ? (
<MonoText size="xs">{url}</MonoText>
) : (
<span className={styles.mutedMono}>{url}</span>
)}
</td>
<td><span className={styles.cellMeta}>{d.deployedAt ? timeAgo(d.deployedAt) : '—'}</span></td>
<td style={{ textAlign: 'right' }}>
{canAct && <Button size="sm" variant="danger" onClick={() => onStop(d.id)}>Stop</Button>}
{canStart && <Button size="sm" variant="secondary" onClick={() => onDeploy(d.appVersionId, d.environmentId)}>Start</Button>}
{!isSelectedEnv && <span className={styles.envHint}>switch env to manage</span>}
</td>
</tr>
);
})}
</tbody>
</table>
)}
{deployments.filter((d) => d.deployStage).map((d) => (
<div key={`progress-${d.id}`} style={{ marginBottom: 8 }}>
<span className={styles.cellMeta}>{d.containerName}</span>
<DeploymentProgress currentStage={d.deployStage} failed={d.status === 'FAILED'} />
</div>
))}
<SectionHeader>Versions ({versions.length})</SectionHeader>
{versions.length === 0 && <p className={styles.emptyNote}>No versions uploaded yet.</p>}
{versions.map((v) => (
<VersionRow key={v.id} version={v} environments={environments} onDeploy={(envId) => onDeploy(v.id, envId)} />
))}
</>
);
}
function VersionRow({ version, environments, onDeploy }: { version: AppVersion; environments: Environment[]; onDeploy: (envId: string) => void }) {
const [deployEnv, setDeployEnv] = useState('');
return (
<div className={styles.row}>
<Badge label={`v${version.version}`} color="auto" />
<span className={styles.rowText}>{version.jarFilename} ({formatBytes(version.jarSizeBytes)})</span>
<MonoText size="xs">{version.jarChecksum.substring(0, 8)}</MonoText>
<span className={styles.cellMeta}>{timeAgo(version.uploadedAt)}</span>
<select className={styles.nativeSelect} value={deployEnv} onChange={(e) => setDeployEnv(e.target.value)}>
<option value="">Deploy to...</option>
{environments.filter((e) => e.enabled).map((e) => <option key={e.id} value={e.id}>{e.displayName}</option>)}
</select>
<Button size="sm" variant="secondary" disabled={!deployEnv} onClick={() => { onDeploy(deployEnv); setDeployEnv(''); }}>Deploy</Button>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// CONFIGURATION SUB-TAB
// ═══════════════════════════════════════════════════════════════════
interface TracedTapRow { id: string; processorId: string; captureMode: string | null; taps: TapDefinition[]; }
interface RouteRecordingRow { id: string; routeId: string; recording: boolean; }
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
const { toast } = useToast();
const navigate = useNavigate();
const { data: agentConfig } = useApplicationConfig(app.slug);
const updateAgentConfig = useUpdateApplicationConfig();
const updateContainerConfig = useUpdateContainerConfig();
const { data: catalog } = useRouteCatalog();
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
const isProd = environment?.production ?? false;
const [editing, setEditing] = useState(false);
const [configTab, setConfigTab] = useState<'variables' | 'monitoring' | 'traces' | 'recording' | 'resources'>('variables');
const appRoutes: RouteSummary[] = useMemo(() => {
if (!catalog) return [];
const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === app.slug);
return entry?.routes ?? [];
}, [catalog, app.slug]);
// 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);
const [compressSuccess, setCompressSuccess] = useState(false);
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
// 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<number[]>([]);
const [newPort, setNewPort] = useState('');
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
const [appPort, setAppPort] = useState('8080');
const [replicas, setReplicas] = useState('1');
const [deployStrategy, setDeployStrategy] = useState('blue-green');
const [stripPrefix, setStripPrefix] = useState(true);
const [sslOffloading, setSslOffloading] = useState(true);
// Sync from server data
const syncFromServer = useCallback(() => {
if (agentConfig) {
setEngineLevel(agentConfig.engineLevel ?? 'REGULAR');
setPayloadCapture(agentConfig.payloadCaptureMode ?? 'BOTH');
setPayloadSize('4'); setPayloadUnit('KB');
setAppLogLevel(agentConfig.applicationLogLevel ?? 'INFO');
setAgentLogLevel(agentConfig.agentLogLevel ?? 'INFO');
setMetricsEnabled(agentConfig.metricsEnabled);
setSamplingRate(String(agentConfig.samplingRate));
setCompressSuccess(agentConfig.compressSuccess);
setTracedDraft({ ...agentConfig.tracedProcessors });
setRouteRecordingDraft({ ...agentConfig.routeRecording });
}
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<string, string> | undefined;
setEnvVars(vars ? Object.entries(vars).map(([key, value]) => ({ key, value })) : []);
setAppPort(String(merged.appPort ?? 8080));
setReplicas(String(merged.replicas ?? 1));
setDeployStrategy(String(merged.deploymentStrategy ?? 'blue-green'));
setStripPrefix(merged.stripPathPrefix !== false);
setSslOffloading(merged.sslOffloading !== false);
}, [agentConfig, merged]);
useEffect(() => { syncFromServer(); }, [syncFromServer]);
function handleCancel() {
syncFromServer();
setEditing(false);
}
function updateTracedProcessor(processorId: string, mode: string) {
setTracedDraft((prev) => {
if (mode === 'REMOVE') { const next = { ...prev }; delete next[processorId]; return next; }
return { ...prev, [processorId]: mode };
});
}
function updateRouteRecording(routeId: string, recording: boolean) {
setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording }));
}
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,
compressSuccess,
tracedProcessors: tracedDraft,
routeRecording: routeRecordingDraft,
});
} catch { toast({ title: 'Failed to save agent config', variant: 'error', duration: 86_400_000 }); return; }
}
// Save container config
const containerConfig: Record<string, unknown> = {
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])),
appPort: appPort ? parseInt(appPort) : 8080,
replicas: replicas ? parseInt(replicas) : 1,
deploymentStrategy: deployStrategy,
stripPathPrefix: stripPrefix,
sslOffloading: sslOffloading,
};
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(''); }
}
// Traces & Taps
const tracedTapRows: TracedTapRow[] = useMemo(() => {
const traced = editing ? tracedDraft : (agentConfig?.tracedProcessors ?? {});
const taps = agentConfig?.taps ?? [];
const pids = new Set<string>([...Object.keys(traced), ...taps.map(t => t.processorId)]);
return Array.from(pids).sort().map(pid => ({ id: pid, processorId: pid, captureMode: traced[pid] ?? null, taps: taps.filter(t => t.processorId === pid) }));
}, [editing, tracedDraft, agentConfig?.tracedProcessors, agentConfig?.taps]);
const tracedCount = useMemo(() => Object.keys(editing ? tracedDraft : (agentConfig?.tracedProcessors ?? {})).length, [editing, tracedDraft, agentConfig?.tracedProcessors]);
const tapCount = agentConfig?.taps?.length ?? 0;
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(() => [
{ key: 'route' as any, header: 'Route', render: (_v: unknown, row: TracedTapRow) => {
const routeId = processorToRoute[row.processorId];
return routeId ? <span className={styles.routeLabel}>{routeId}</span> : <span className={styles.hint}>&mdash;</span>;
}},
{ key: 'processorId', header: 'Processor', render: (_v: unknown, row: TracedTapRow) => <MonoText size="xs">{row.processorId}</MonoText> },
{
key: 'captureMode', header: 'Capture',
render: (_v: unknown, row: TracedTapRow) => {
if (row.captureMode === null) return <span className={styles.hint}>&mdash;</span>;
if (editing) return (
<select className={styles.nativeSelect} value={row.captureMode} onChange={(e) => updateTracedProcessor(row.processorId, e.target.value)}>
<option value="NONE">None</option><option value="INPUT">Input</option><option value="OUTPUT">Output</option><option value="BOTH">Both</option>
</select>
);
return <Badge label={row.captureMode} color={row.captureMode === 'BOTH' ? 'running' : row.captureMode === 'NONE' ? 'auto' : 'warning'} variant="filled" />;
},
},
{
key: 'taps', header: 'Taps',
render: (_v: unknown, row: TracedTapRow) => row.taps.length === 0
? <span className={styles.hint}>&mdash;</span>
: <div className={styles.tapBadges}>{row.taps.map(t => (
<button key={t.tapId} className={styles.tapBadgeLink} title={`Manage tap on route page`}>
<Badge label={t.attributeName} color={t.enabled ? 'success' : 'auto'} variant="filled" />
</button>
))}</div>,
},
...(editing ? [{
key: '_remove' as const, header: '', width: '36px',
render: (_v: unknown, row: TracedTapRow) => row.captureMode === null ? null : (
<button className={styles.removeBtn} title="Remove" onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}>&times;</button>
),
}] : []),
], [editing, processorToRoute]);
// Route Recording
const routeRecordingRows: RouteRecordingRow[] = useMemo(() => {
const rec = editing ? routeRecordingDraft : (agentConfig?.routeRecording ?? {});
return appRoutes.map(r => ({ id: r.routeId, routeId: r.routeId, recording: rec[r.routeId] !== false }));
}, [editing, routeRecordingDraft, agentConfig?.routeRecording, appRoutes]);
const recordingCount = routeRecordingRows.filter(r => r.recording).length;
const routeRecordingColumns: Column<RouteRecordingRow>[] = useMemo(() => [
{ key: 'routeId', header: 'Route', render: (_v: unknown, row: RouteRecordingRow) => <MonoText size="xs">{row.routeId}</MonoText> },
{ key: 'recording', header: 'Recording', width: '100px', render: (_v: unknown, row: RouteRecordingRow) => <Toggle checked={row.recording} onChange={() => { if (editing) updateRouteRecording(row.routeId, !row.recording); }} disabled={!editing} /> },
], [editing, routeRecordingDraft]);
return (
<>
{!editing && (
<div className={styles.editBanner}>
<span className={styles.editBannerText}>Configuration is read-only. Enter edit mode to make changes.</span>
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit</Button>
</div>
)}
{editing && (
<div className={`${styles.editBanner} ${styles.editBannerActive}`}>
<span className={styles.editBannerTextWarn}>Editing configuration. Changes are not saved until you click Save.</span>
<div className={styles.editBannerActions}>
<Button size="sm" variant="ghost" onClick={handleCancel}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleSave}
loading={updateAgentConfig.isPending || updateContainerConfig.isPending}>Save Configuration</Button>
</div>
</div>
)}
<div className={styles.subTabs}>
<button className={`${styles.subTab} ${configTab === 'variables' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('variables')}>Variables</button>
<button className={`${styles.subTab} ${configTab === 'monitoring' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('monitoring')}>Monitoring</button>
<button className={`${styles.subTab} ${configTab === 'traces' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('traces')}>Traces & Taps</button>
<button className={`${styles.subTab} ${configTab === 'recording' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('recording')}>Route Recording</button>
<button className={`${styles.subTab} ${configTab === 'resources' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('resources')}>Resources</button>
</div>
{configTab === 'variables' && (
<>
{envVars.map((v, i) => (
<div key={i} className={styles.envVarRow}>
<Input disabled={!editing} value={v.key} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next);
}} className={styles.envVarKey} placeholder="KEY" />
<Input disabled={!editing} value={v.value} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
}} className={styles.envVarValue} placeholder="value" />
<button className={styles.envVarDelete} disabled={!editing}
onClick={() => editing && setEnvVars(envVars.filter((_, j) => j !== i))}>&times;</button>
</div>
))}
{editing && (
<Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
)}
{envVars.length === 0 && !editing && <p className={styles.emptyNote}>No environment variables configured.</p>}
</>
)}
{configTab === 'monitoring' && (
<div className={styles.configGrid}>
<span className={styles.configLabel}>Engine Level</span>
<Select disabled={!editing} value={engineLevel} onChange={(e) => setEngineLevel(e.target.value)}
options={[{ value: 'NONE', label: 'NONE' }, { value: 'MINIMAL', label: 'MINIMAL' }, { value: 'REGULAR', label: 'REGULAR' }, { value: 'COMPLETE', label: 'COMPLETE' }]} />
<span className={styles.configLabel}>Payload Capture</span>
<Select disabled={!editing} value={payloadCapture} onChange={(e) => setPayloadCapture(e.target.value)}
options={[{ value: 'NONE', label: 'NONE' }, { value: 'INPUT', label: 'INPUT' }, { value: 'OUTPUT', label: 'OUTPUT' }, { value: 'BOTH', label: 'BOTH' }]} />
<span className={styles.configLabel}>Max Payload Size</span>
<div className={styles.configInline}>
<Input disabled={!editing} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} style={{ width: 70 }} />
<Select disabled={!editing} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} style={{ width: 90 }}
options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} />
</div>
<span className={styles.configLabel}>App Log Level</span>
<Select disabled={!editing} value={appLogLevel} onChange={(e) => setAppLogLevel(e.target.value)}
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} />
<span className={styles.configLabel}>Agent Log Level</span>
<Select disabled={!editing} value={agentLogLevel} onChange={(e) => setAgentLogLevel(e.target.value)}
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} />
<span className={styles.configLabel}>Metrics</span>
<div className={styles.configInline}>
<Toggle checked={metricsEnabled} onChange={() => editing && setMetricsEnabled(!metricsEnabled)} disabled={!editing} />
<span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span>
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
<Input disabled={!editing} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} style={{ width: 50 }} />
<span className={styles.cellMeta}>s</span>
</div>
<span className={styles.configLabel}>Sampling Rate</span>
<Input disabled={!editing} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>Compress Success</span>
<div className={styles.configInline}>
<Toggle checked={compressSuccess} onChange={() => editing && setCompressSuccess(!compressSuccess)} disabled={!editing} />
<span className={compressSuccess ? styles.toggleEnabled : styles.toggleDisabled}>{compressSuccess ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>Replay</span>
<div className={styles.configInline}>
<Toggle checked={replayEnabled} onChange={() => editing && setReplayEnabled(!replayEnabled)} disabled={!editing} />
<span className={replayEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{replayEnabled ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>Route Control</span>
<div className={styles.configInline}>
<Toggle checked={routeControlEnabled} onChange={() => editing && setRouteControlEnabled(!routeControlEnabled)} disabled={!editing} />
<span className={routeControlEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{routeControlEnabled ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
)}
{configTab === 'traces' && (
<>
<span className={styles.sectionSummary}>{tracedCount} traced &middot; {tapCount} taps</span>
{tracedTapRows.length > 0
? <DataTable<TracedTapRow> columns={tracedTapColumns} data={tracedTapRows} pageSize={20} flush />
: <p className={styles.emptyNote}>No processor traces or taps configured.</p>}
</>
)}
{configTab === 'recording' && (
<>
<span className={styles.sectionSummary}>{recordingCount} of {routeRecordingRows.length} routes recording</span>
{routeRecordingRows.length > 0
? <DataTable<RouteRecordingRow> columns={routeRecordingColumns} data={routeRecordingRows} pageSize={20} flush />
: <p className={styles.emptyNote}>No routes found for this application.</p>}
</>
)}
{configTab === 'resources' && (
<>
{/* Container Resources */}
<SectionHeader>Container Resources</SectionHeader>
<div className={styles.configGrid}>
<span className={styles.configLabel}>Memory Limit</span>
<div className={styles.configInline}>
<Input disabled={!editing} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} style={{ width: 80 }} />
<span className={styles.cellMeta}>MB</span>
</div>
<span className={styles.configLabel}>Memory Reserve</span>
<div>
<div className={styles.configInline}>
<Input disabled value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" style={{ width: 80 }} />
<span className={styles.cellMeta}>MB</span>
</div>
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
</div>
<span className={styles.configLabel}>CPU Shares</span>
<Input disabled={!editing} value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>CPU Limit</span>
<div className={styles.configInline}>
<Input disabled={!editing} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 80 }} />
<span className={styles.cellMeta}>cores</span>
</div>
<span className={styles.configLabel}>Exposed Ports</span>
<div className={styles.portPills}>
{ports.map((p) => (
<span key={p} className={styles.portPill}>
{p}
<button className={styles.portPillDelete} disabled={!editing}
onClick={() => editing && setPorts(ports.filter((x) => x !== p))}>&times;</button>
</span>
))}
<input className={styles.portAddInput} disabled={!editing} placeholder="+ port" value={newPort}
onChange={(e) => setNewPort(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addPort(); } }} />
</div>
<span className={styles.configLabel}>App Port</span>
<Input disabled={!editing} value={appPort} onChange={(e) => setAppPort(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>Replicas</span>
<Input disabled={!editing} value={replicas} onChange={(e) => setReplicas(e.target.value)} style={{ width: 60 }} type="number" />
<span className={styles.configLabel}>Deploy Strategy</span>
<Select disabled={!editing} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)}
options={[{ value: 'blue-green', label: 'Blue/Green' }, { value: 'rolling', label: 'Rolling' }]} />
<span className={styles.configLabel}>Strip Path Prefix</span>
<div className={styles.configInline}>
<Toggle checked={stripPrefix} onChange={() => editing && setStripPrefix(!stripPrefix)} disabled={!editing} />
<span className={stripPrefix ? styles.toggleEnabled : styles.toggleDisabled}>{stripPrefix ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>SSL Offloading</span>
<div className={styles.configInline}>
<Toggle checked={sslOffloading} onChange={() => editing && setSslOffloading(!sslOffloading)} disabled={!editing} />
<span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
</>
)}
</>
);
}