feat: replicas column, deploy progress, and new config fields in Deployments UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 20:33:41 +02:00
parent 977bfc1c6b
commit 96fbca1b35

View File

@@ -34,6 +34,7 @@ import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapp
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 {
@@ -54,6 +55,7 @@ function timeAgo(date: string): string {
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 {
@@ -174,6 +176,11 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
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);
@@ -221,6 +228,11 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
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 });
@@ -430,6 +442,28 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
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>
</>
)}
@@ -567,6 +601,7 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele
<th>Environment</th>
<th>Version</th>
<th>Status</th>
<th>Replicas</th>
<th>URL</th>
<th>Deployed</th>
<th style={{ textAlign: 'right' }}>Actions</th>
@@ -588,6 +623,13 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele
</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>
@@ -607,6 +649,12 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele
</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>}
@@ -685,6 +733,11 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
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(() => {
@@ -707,6 +760,11 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
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]);
@@ -751,6 +809,11 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
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 });
@@ -989,6 +1052,28 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
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>
</>
)}