feat: replace create-app modal with full creation page at /apps/new
Full-page creation flow with: - Identity section: name, auto-slug, environment, JAR upload, deploy toggle - Monitoring tab: engine level, payload capture, log levels, metrics, sampling, compress success, replay, route control - Resources tab: memory, CPU, ports, environment variables Environment variables are configurable before first deploy, addressing the need to set app-specific config upfront. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,11 @@
|
|||||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
DataTable,
|
DataTable,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
|
||||||
MonoText,
|
MonoText,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
Select,
|
Select,
|
||||||
@@ -67,9 +66,11 @@ function slugify(name: string): string {
|
|||||||
|
|
||||||
export default function AppsTab() {
|
export default function AppsTab() {
|
||||||
const { appId } = useParams<{ appId?: string }>();
|
const { appId } = useParams<{ appId?: string }>();
|
||||||
|
const location = useLocation();
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
const { data: environments = [] } = useEnvironments();
|
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} />;
|
if (appId) return <AppDetailView appId={appId} environments={environments} selectedEnv={selectedEnv} />;
|
||||||
return <AppListView selectedEnv={selectedEnv} environments={environments} />;
|
return <AppListView selectedEnv={selectedEnv} environments={environments} />;
|
||||||
}
|
}
|
||||||
@@ -79,12 +80,10 @@ export default function AppsTab() {
|
|||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
|
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
|
||||||
const { toast } = useToast();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
||||||
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
|
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
|
||||||
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
|
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
|
|
||||||
const apps = selectedEnv ? envApps : allApps;
|
const apps = selectedEnv ? envApps : allApps;
|
||||||
const isLoading = selectedEnv ? envLoading : allLoading;
|
const isLoading = selectedEnv ? envLoading : allLoading;
|
||||||
@@ -119,60 +118,86 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<Button size="sm" variant="primary" onClick={() => setCreateOpen(true)}>+ Create App</Button>
|
<Button size="sm" variant="primary" onClick={() => navigate('/apps/new')}>+ Create App</Button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// CREATE APP MODAL
|
// CREATE APP PAGE
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }: {
|
function CreateAppView({ environments, selectedEnv }: { environments: Environment[]; selectedEnv: string | undefined }) {
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
environments: Environment[];
|
|
||||||
defaultEnvId: string | undefined;
|
|
||||||
onCreated: (appId: string) => void;
|
|
||||||
}) {
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const createApp = useCreateApp();
|
const createApp = useCreateApp();
|
||||||
const uploadJar = useUploadJar();
|
const uploadJar = useUploadJar();
|
||||||
const createDeployment = useCreateDeployment();
|
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 [name, setName] = useState('');
|
||||||
const [slugEdited, setSlugEdited] = useState(false);
|
const [slugEdited, setSlugEdited] = useState(false);
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
const [envId, setEnvId] = useState('');
|
const [envId, setEnvId] = useState(defaultEnvId);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [deploy, setDeploy] = useState(true);
|
const [deploy, setDeploy] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [step, setStep] = useState('');
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Reset on open
|
// Monitoring
|
||||||
useEffect(() => {
|
const [engineLevel, setEngineLevel] = useState('REGULAR');
|
||||||
if (open) {
|
const [payloadCapture, setPayloadCapture] = useState('BOTH');
|
||||||
setName(''); setSlug(''); setSlugEdited(false); setFile(null);
|
const [payloadSize, setPayloadSize] = useState('4');
|
||||||
setDeploy(true); setBusy(false); setStep('');
|
const [payloadUnit, setPayloadUnit] = useState('KB');
|
||||||
setEnvId(defaultEnvId || (environments.length > 0 ? environments[0].id : ''));
|
const [appLogLevel, setAppLogLevel] = useState('INFO');
|
||||||
}
|
const [agentLogLevel, setAgentLogLevel] = useState('INFO');
|
||||||
}, [open, defaultEnvId, environments]);
|
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 [configTab, setConfigTab] = useState<'monitoring' | 'resources'>('monitoring');
|
||||||
|
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]);
|
||||||
|
|
||||||
// Auto-compute slug from name
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slugEdited) setSlug(slugify(name));
|
if (!slugEdited) setSlug(slugify(name));
|
||||||
}, [name, slugEdited]);
|
}, [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;
|
const canSubmit = name.trim() && slug.trim() && envId && file;
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -187,14 +212,44 @@ function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }
|
|||||||
setStep('Uploading JAR...');
|
setStep('Uploading JAR...');
|
||||||
const version = await uploadJar.mutateAsync({ appId: app.id, file: file! });
|
const version = await uploadJar.mutateAsync({ appId: app.id, file: file! });
|
||||||
|
|
||||||
// 3. Deploy (if requested)
|
// 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])),
|
||||||
|
};
|
||||||
|
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) {
|
if (deploy) {
|
||||||
setStep('Starting deployment...');
|
setStep('Starting deployment...');
|
||||||
await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId });
|
await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId });
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({ title: 'App created and deployed', description: name.trim(), variant: 'success' });
|
toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' });
|
||||||
onCreated(app.id);
|
navigate(`/apps/${app.id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 });
|
toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -204,29 +259,36 @@ function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title="Create Application" size="md">
|
<div className={styles.container}>
|
||||||
<div className={styles.createModal}>
|
<div className={styles.detailHeader}>
|
||||||
<div className={styles.createField}>
|
<div>
|
||||||
<label className={styles.createLabel}>Application Name</label>
|
<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} />
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.createField}>
|
<span className={styles.configLabel}>Slug <span className={styles.configHint}>(auto-generated)</span></span>
|
||||||
<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} />
|
<Input value={slug} onChange={(e) => { setSlug(e.target.value); setSlugEdited(true); }} disabled={busy} />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.createField}>
|
<span className={styles.configLabel}>Environment</span>
|
||||||
<label className={styles.createLabel}>Environment</label>
|
|
||||||
<Select value={envId} onChange={(e) => setEnvId(e.target.value)} disabled={busy}
|
<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})` }))} />
|
options={environments.filter((e) => e.enabled).map((e) => ({ value: e.id, label: `${e.displayName} (${e.slug})` }))} />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.createField}>
|
<span className={styles.configLabel}>Application JAR</span>
|
||||||
<label className={styles.createLabel}>Application JAR</label>
|
|
||||||
<div className={styles.fileRow}>
|
<div className={styles.fileRow}>
|
||||||
<input ref={fileInputRef} type="file" accept=".jar" style={{ display: 'none' }}
|
<input ref={fileInputRef} type="file" accept=".jar" style={{ display: 'none' }}
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||||
@@ -235,25 +297,139 @@ function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }
|
|||||||
</Button>
|
</Button>
|
||||||
{file && <span className={styles.fileName}>{file.name} ({formatBytes(file.size)})</span>}
|
{file && <span className={styles.fileName}>{file.name} ({formatBytes(file.size)})</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.createField}>
|
<span className={styles.configLabel}>Deploy</span>
|
||||||
<div className={styles.deployToggle}>
|
<div className={styles.configInline}>
|
||||||
<Toggle checked={deploy} onChange={() => setDeploy(!deploy)} disabled={busy} />
|
<Toggle checked={deploy} onChange={() => setDeploy(!deploy)} disabled={busy} />
|
||||||
<span>{deploy ? 'Deploy immediately after upload' : 'Upload only (deploy later)'}</span>
|
<span className={deploy ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||||
|
{deploy ? 'Deploy immediately after creation' : 'Create only (deploy later)'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step && <div className={styles.stepIndicator}>{step}</div>}
|
{/* Config Tabs */}
|
||||||
|
<div className={styles.subTabs}>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className={styles.createActions}>
|
{configTab === 'monitoring' && (
|
||||||
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
<div className={styles.configGrid}>
|
||||||
<Button size="sm" variant="primary" onClick={handleSubmit} loading={busy} disabled={!canSubmit || busy}>
|
<span className={styles.configLabel}>Engine Level</span>
|
||||||
{deploy ? 'Create & Deploy' : 'Create & Upload'}
|
<Select disabled={busy} value={engineLevel} onChange={(e) => setEngineLevel(e.target.value)}
|
||||||
</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
)}
|
||||||
|
|
||||||
|
{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))}>×</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Environment Variables</SectionHeader>
|
||||||
|
{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))}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const router = createBrowserRouter([
|
|||||||
|
|
||||||
// Apps tab (OPERATOR+ via UI guard, shows all or single app)
|
// Apps tab (OPERATOR+ via UI guard, shows all or single app)
|
||||||
{ path: 'apps', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
{ path: 'apps', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||||
|
{ path: 'apps/new', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||||
{ path: 'apps/:appId', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
{ path: 'apps/:appId', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||||
|
|
||||||
// Admin (ADMIN role required)
|
// Admin (ADMIN role required)
|
||||||
|
|||||||
Reference in New Issue
Block a user