Files
cameleer-server/ui/src/pages/AppsTab/AppsTab.tsx

681 lines
32 KiB
TypeScript
Raw Normal View History

import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
Badge,
Button,
DataTable,
Input,
Modal,
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<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: '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 selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: environments = [] } = useEnvironments();
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 { 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 [createOpen, setCreateOpen] = useState(false);
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={() => setCreateOpen(true)}>+ Create App</Button>
</div>
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
<CreateAppModal
open={createOpen}
onClose={() => setCreateOpen(false)}
environments={environments}
defaultEnvId={envId}
onCreated={(appId) => { setCreateOpen(false); navigate(`/apps/${appId}`); }}
/>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// CREATE APP MODAL
// ═══════════════════════════════════════════════════════════════════
function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }: {
open: boolean;
onClose: () => void;
environments: Environment[];
defaultEnvId: string | undefined;
onCreated: (appId: string) => void;
}) {
const { toast } = useToast();
const createApp = useCreateApp();
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const [name, setName] = useState('');
const [slugEdited, setSlugEdited] = useState(false);
const [slug, setSlug] = useState('');
const [envId, setEnvId] = useState('');
const [file, setFile] = useState<File | null>(null);
const [deploy, setDeploy] = useState(true);
const [busy, setBusy] = useState(false);
const [step, setStep] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Reset on open
useEffect(() => {
if (open) {
setName(''); setSlug(''); setSlugEdited(false); setFile(null);
setDeploy(true); setBusy(false); setStep('');
setEnvId(defaultEnvId || (environments.length > 0 ? environments[0].id : ''));
}
}, [open, defaultEnvId, environments]);
// Auto-compute slug from name
useEffect(() => {
if (!slugEdited) setSlug(slugify(name));
}, [name, slugEdited]);
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. Deploy (if requested)
if (deploy) {
setStep('Starting deployment...');
await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId });
}
toast({ title: 'App created and deployed', description: name.trim(), variant: 'success' });
onCreated(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 (
<Modal open={open} onClose={onClose} title="Create Application" size="md">
<div className={styles.createModal}>
<div className={styles.createField}>
<label className={styles.createLabel}>Application Name</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} />
</div>
<div className={styles.createField}>
<label className={styles.createLabel}>
Slug
<span className={styles.createLabelHint}>(auto-generated, editable)</span>
</label>
<Input value={slug} onChange={(e) => { setSlug(e.target.value); setSlugEdited(true); }} disabled={busy} />
</div>
<div className={styles.createField}>
<label className={styles.createLabel}>Environment</label>
<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})` }))} />
</div>
<div className={styles.createField}>
<label className={styles.createLabel}>Application JAR</label>
<div className={styles.fileRow}>
<input ref={fileInputRef} type="file" accept=".jar" style={{ display: 'none' }}
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={busy}>
{file ? 'Change file' : 'Select JAR'}
</Button>
{file && <span className={styles.fileName}>{file.name} ({formatBytes(file.size)})</span>}
</div>
</div>
<div className={styles.createField}>
<div className={styles.deployToggle}>
<Toggle checked={deploy} onChange={() => setDeploy(!deploy)} disabled={busy} />
<span>{deploy ? 'Deploy immediately after upload' : 'Upload only (deploy later)'}</span>
</div>
</div>
{step && <div className={styles.stepIndicator}>{step}</div>}
<div className={styles.createActions}>
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleSubmit} loading={busy} disabled={!canSubmit || busy}>
{deploy ? 'Create & Deploy' : 'Create & Upload'}
</Button>
</div>
</div>
</Modal>
);
}
// ═══════════════════════════════════════════════════════════════════
// 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 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={{ display: 'none' }} onChange={handleUpload} />
<Button size="sm" variant="primary" onClick={() => fileInputRef.current?.click()} loading={uploadJar.isPending}>Upload JAR</Button>
<Button size="sm" variant="danger" onClick={handleDelete}>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} />
)}
</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>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.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>
)}
<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
// ═══════════════════════════════════════════════════════════════════
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<number[]>([]);
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<string, string> | 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<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])),
};
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 && (
<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>
)}
{/* Agent Observability */}
<SectionHeader>Agent Observability</SectionHeader>
<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}>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>
{/* 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>
</div>
{/* Environment Variables */}
<SectionHeader>Environment Variables</SectionHeader>
{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} />
<Input disabled={!editing} value={v.value} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
}} className={styles.envVarValue} />
<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>
)}
</>
);
}