feat: migrate traces/taps and route recording into Deployments config
ConfigSubTab now uses inner tabs (Agent / Infrastructure): - Agent: observability settings, compress success, traces & taps table, route recording toggles - Infrastructure: container resources, exposed ports, environment variables This completes the Config tab consolidation — all features from the standalone Config page now live in the Deployments tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,10 @@ 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 { 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 styles from './AppsTab.module.css';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -456,13 +458,26 @@ function VersionRow({ version, environments, onDeploy }: { version: AppVersion;
|
||||
// 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<'agent' | 'infra'>('agent');
|
||||
|
||||
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');
|
||||
@@ -476,6 +491,9 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
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 ?? {};
|
||||
@@ -493,12 +511,14 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
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));
|
||||
setCompressSuccess(agentConfig.compressSuccess);
|
||||
setTracedDraft({ ...agentConfig.tracedProcessors });
|
||||
setRouteRecordingDraft({ ...agentConfig.routeRecording });
|
||||
}
|
||||
setMemoryLimit(String(merged.memoryLimitMb ?? 512));
|
||||
setMemoryReserve(String(merged.memoryReserveMb ?? ''));
|
||||
@@ -516,11 +536,15 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function payloadSizeToBytes(): number {
|
||||
const val = parseFloat(payloadSize) || 0;
|
||||
if (payloadUnit === 'KB') return val * 1024;
|
||||
if (payloadUnit === 'MB') return val * 1048576;
|
||||
return val;
|
||||
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() {
|
||||
@@ -532,6 +556,9 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
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; }
|
||||
}
|
||||
@@ -557,6 +584,66 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
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}>—</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}>—</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}>—</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')}>×</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 && (
|
||||
@@ -576,115 +663,148 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
</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 className={styles.subTabs}>
|
||||
<button className={`${styles.subTab} ${configTab === 'agent' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('agent')}>Agent</button>
|
||||
<button className={`${styles.subTab} ${configTab === 'infra' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('infra')}>Infrastructure</button>
|
||||
</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>
|
||||
{configTab === 'agent' && (
|
||||
<>
|
||||
{/* Observability Settings */}
|
||||
<SectionHeader>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}>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>
|
||||
<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>
|
||||
{!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 }} />
|
||||
{/* Traces & Taps */}
|
||||
<SectionHeader>Traces & Taps</SectionHeader>
|
||||
<span className={styles.sectionSummary}>{tracedCount} traced · {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>}
|
||||
|
||||
<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>
|
||||
{/* Route Recording */}
|
||||
<SectionHeader>Route Recording</SectionHeader>
|
||||
<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>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<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))}>×</button>
|
||||
</span>
|
||||
{configTab === 'infra' && (
|
||||
<>
|
||||
{/* 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))}>×</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))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<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))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
{editing && (
|
||||
<Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
|
||||
{editing && (
|
||||
<Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user