feat: per-service scaling section on service detail

Exposes the existing /scaling/* API in the frontend: replica state,
policy create/edit form (min/max, target CPU/memory, enable toggle),
manual scale control, and disable-policy action matching the DELETE
endpoint's disable-not-remove semantics.

Verified live: policy CRUD, state auto-registration on policy create,
replica bounds rejection, scheduler dispatch (fails cleanly with "no
ready nodes" when no agents are registered). Noted in roadmap:
policies are in-memory and lost on restart — tracked as Phase 4 debt.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 18:10:52 +02:00
1 parent 8ecc8dcea5
commit 44a1b09dad
3 files changed
+273 -5

No files matched your search

+12 -4
View File
@@ -51,7 +51,7 @@ The backend exposes **~95 route registrations**; the frontend consumes
| Preview environments (CRUD, promote, cleanup) | done (bookkeeping only — no real container deploy; URL is a placeholder) | done — Previews section on service detail | not documented | | Preview environments (CRUD, promote, cleanup) | done (bookkeeping only — no real container deploy; URL is a placeholder) | done — Previews section on service detail | not documented |
| Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it | | Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it |
| Audit logs | done | done — `/settings/audit-logs` filterable page | done | | Audit logs | done | done — `/settings/audit-logs` filterable page | done |
| Autoscaling policies + manual scale | done | **missing** | `docs/guides/AUTOSCALING.md` describes UI that does not exist | | Autoscaling policies + manual scale | done | done (beta) | per-service Scaling section on service detail; policies in-memory, lost on restart |
| HA / failover policies | done | **missing** | not documented | | HA / failover policies | done | **missing** | not documented |
| Node agents (register additional VPS/LXC/VM hosts, heartbeats) | done | done — token issue/revoke + install command on Usage page | done | | Node agents (register additional VPS/LXC/VM hosts, heartbeats) | done | done — token issue/revoke + install command on Usage page | done |
| API gateway (merged APwhy): upstream services, API keys, rate limits, ops/traffic analytics | management API done — native handlers under `/api/v1/gateway/*`, schema-corrected; **traffic proxy path still not mounted** (Phase 2) | **missing** | not documented | | API gateway (merged APwhy): upstream services, API keys, rate limits, ops/traffic analytics | management API done — native handlers under `/api/v1/gateway/*`, schema-corrected; **traffic proxy path still not mounted** (Phase 2) | **missing** | not documented |
@@ -359,9 +359,13 @@ reaches the upstream container and appears in the analytics tables.
### Phase 3 — Advanced operations ### Phase 3 — Advanced operations
- [ ] **Autoscaling UI** (`/scaling/*`): per-service policy editor - [x] **Autoscaling UI** (`/scaling/*`): per-service policy editor
(min/max/target CPU/mem), scaling history timeline, manual scale (min/max/target CPU/mem + enable toggle) and manual scale control
control on service detail. Mark beta until soak-tested. on service detail Scaling section. Verified live: policy CRUD,
state auto-registration, bounds rejection, scheduler dispatch
(fails "no ready nodes" without agents — error surfaces in UI).
Scaling history timeline not surfaced. Beta: policies are
in-memory — they do not survive a backend restart.
- [ ] **HA / failover UI** (`/ha/*`): enable/disable per project, failover - [ ] **HA / failover UI** (`/ha/*`): enable/disable per project, failover
policies, manual failover with confirmation, status banner. Beta. policies, manual failover with confirmation, status banner. Beta.
- [ ] **Security center** (`/security/*`): scan trigger per project, finding - [ ] **Security center** (`/security/*`): scan trigger per project, finding
@@ -422,6 +426,10 @@ security scans produce real reports, not empty tables.
call sites across ~14 handler files remain (agents + tokens are sqlc). call sites across ~14 handler files remain (agents + tokens are sqlc).
Mechanical port to `sqlc/queries/`; the consolidated baseline schema is Mechanical port to `sqlc/queries/`; the consolidated baseline schema is
now the codegen source of truth. now the codegen source of truth.
- [ ] **Autoscaling persistence**: policies, service states, and scale
events live in process memory — a backend restart wipes them and
reverts replicas to policy minimums on next registration. Persist
to Postgres before leaving beta.
- [ ] **Demo-data parity for new pages**: `demo-data.ts` covers Projects/ - [ ] **Demo-data parity for new pages**: `demo-data.ts` covers Projects/
Templates but the new Databases page and service Cron tab have no demo Templates but the new Databases page and service Cron tab have no demo
fixtures — add them so `?demo=1` doesn't show empty sections. fixtures — add them so `?demo=1` doesn't show empty sections.
@@ -21,6 +21,11 @@ import {
createPreviewEnvironment, createPreviewEnvironment,
deletePreviewEnvironment, deletePreviewEnvironment,
promotePreviewEnvironment, promotePreviewEnvironment,
getScalingPolicy,
setScalingPolicy,
deleteScalingPolicy,
getServiceScalingState,
manualScaleService,
rollbackDeployment, rollbackDeployment,
updateServiceVariables, updateServiceVariables,
type CronJobEntity, type CronJobEntity,
@@ -57,7 +62,7 @@ import {
ExternalLink, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'settings'; type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'settings';
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity }, { key: 'metrics', label: 'Metrics', icon: Activity },
@@ -66,6 +71,7 @@ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Act
{ key: 'variables', label: 'Variables', icon: KeyRound }, { key: 'variables', label: 'Variables', icon: KeyRound },
{ key: 'cron', label: 'Cron', icon: Clock }, { key: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest }, { key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'scaling', label: 'Scaling', icon: Layers },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -234,6 +240,49 @@ export function ServiceDetailPage() {
onSuccess: invalidatePreviews, onSuccess: invalidatePreviews,
}); });
const previews = (previewsQuery.data ?? []).filter((p) => p.service_id === serviceId); const previews = (previewsQuery.data ?? []).filter((p) => p.service_id === serviceId);
// --- Scaling ---
const [scalingForm, setScalingForm] = useState<{
min: string; max: string; targetCpu: string; targetMemory: string; enabled: boolean;
} | null>(null);
const [scaleReplicas, setScaleReplicas] = useState('');
const scalingPolicyQuery = useQuery({
queryKey: ['scaling-policy', serviceId],
queryFn: () => getScalingPolicy(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'scaling',
});
const scalingStateQuery = useQuery({
queryKey: ['scaling-state', serviceId],
queryFn: () => getServiceScalingState(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'scaling',
refetchInterval: 15_000,
});
const invalidateScaling = () => {
queryClient.invalidateQueries({ queryKey: ['scaling-policy', serviceId] });
queryClient.invalidateQueries({ queryKey: ['scaling-state', serviceId] });
};
const saveScalingMutation = useMutation({
mutationFn: async () => {
if (!scalingForm) return;
await setScalingPolicy({
service_id: serviceId,
min_replicas: Number(scalingForm.min) || 1,
max_replicas: Number(scalingForm.max) || 1,
target_cpu: Number(scalingForm.targetCpu) || 70,
target_memory: Number(scalingForm.targetMemory) || 80,
enabled: scalingForm.enabled,
});
},
onSuccess: () => { setScalingForm(null); invalidateScaling(); },
});
const deleteScalingMutation = useMutation({
mutationFn: () => deleteScalingPolicy(serviceId),
onSuccess: invalidateScaling,
});
const manualScaleMutation = useMutation({
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
onSuccess: invalidateScaling,
});
const triggerCronMutation = useMutation({ const triggerCronMutation = useMutation({
mutationFn: (id: string) => triggerCronJob(id), mutationFn: (id: string) => triggerCronJob(id),
onSuccess: () => { onSuccess: () => {
@@ -1453,6 +1502,164 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{activeSection === 'scaling' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Layers size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Scaling</h2>
<p className="text-sm text-[var(--text-secondary)]">Replica policy and manual scaling</p>
</div>
</div>
</div>
{scalingPolicyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingStateQuery.data?.CurrentReplicas ?? '—'}
<span className="text-sm font-normal text-[var(--text-secondary)]">
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired
</span>
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Policy</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Autoscaling</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Last action</p>
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{scalingStateQuery.data?.LastScaleDirection ?? '—'}
</p>
</div>
</div>
{scalingForm === null ? (
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Manual replicas</label>
<input
type="number"
min={1}
max={20}
value={scaleReplicas}
onChange={(e) => setScaleReplicas(e.target.value)}
className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
placeholder="e.g. 3"
/>
</div>
<button
type="button"
disabled={manualScaleMutation.isPending || !scaleReplicas || !scalingStateQuery.data}
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{manualScaleMutation.isPending ? 'Scaling…' : 'Scale'}
</button>
<button
type="button"
onClick={() => setScalingForm({
min: String(scalingPolicyQuery.data?.min_replicas ?? 1),
max: String(scalingPolicyQuery.data?.max_replicas ?? 3),
targetCpu: String(scalingPolicyQuery.data?.target_cpu ?? 70),
targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80),
enabled: scalingPolicyQuery.data?.enabled ?? true,
})}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
{scalingPolicyQuery.data ? 'Edit policy' : 'Create policy'}
</button>
{scalingPolicyQuery.data?.enabled && (
<button
type="button"
disabled={deleteScalingMutation.isPending}
onClick={() => { if (window.confirm('Disable autoscaling for this service?')) deleteScalingMutation.mutate(); }}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error)] text-sm text-[var(--error)] disabled:opacity-50"
>
Disable policy
</button>
)}
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{([
['Min replicas', 'min'],
['Max replicas', 'max'],
['Target CPU %', 'targetCpu'],
['Target memory %', 'targetMemory'],
] as const).map(([label, field]) => (
<div key={field}>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">{label}</label>
<input
type="number"
min={field === 'min' || field === 'max' ? 1 : 0}
value={scalingForm[field]}
onChange={(e) => setScalingForm({ ...scalingForm, [field]: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
</div>
))}
</div>
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={scalingForm.enabled}
onChange={(e) => setScalingForm({ ...scalingForm, enabled: e.target.checked })}
/>
Enable autoscaling
</label>
<div className="flex items-center gap-3">
<button
type="button"
disabled={saveScalingMutation.isPending}
onClick={() => saveScalingMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{saveScalingMutation.isPending ? 'Saving…' : 'Save policy'}
</button>
<button
type="button"
onClick={() => setScalingForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
</div>
</div>
)}
{(saveScalingMutation.isError || deleteScalingMutation.isError || manualScaleMutation.isError || scalingPolicyQuery.isError || scalingStateQuery.isError) && (
<p className="mt-3 text-xs text-[var(--error)]">
{((saveScalingMutation.error ?? deleteScalingMutation.error ?? manualScaleMutation.error ?? scalingPolicyQuery.error ?? scalingStateQuery.error) as Error)?.message ?? 'Operation failed'}
</p>
)}
{!scalingStateQuery.data && !scalingStateQuery.isLoading && (
<p className="mt-3 text-xs text-[var(--text-tertiary)]">
No scaling state yet — create a policy or scale manually to register this service with the autoscaler.
</p>
)}
</>
)}
</div>
)}
{activeSection === 'settings' && ( {activeSection === 'settings' && (
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
+53
View File
@@ -1482,3 +1482,56 @@ export async function promotePreviewEnvironment(
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export type ScalingPolicy = components['schemas']['ScalingPolicy'];
export type ServiceScalingState = {
ServiceID?: string;
CurrentReplicas?: number;
DesiredReplicas?: number;
LastScaleAction?: string;
LastScaleDirection?: string;
};
export async function getScalingPolicy(serviceId: string): Promise<ScalingPolicy | null> {
try {
const payload = await requestJson<{ policy?: ScalingPolicy }>(
`/scaling/policies/${encodeURIComponent(serviceId)}`,
);
return payload.policy ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null;
}
throw error;
}
}
export async function setScalingPolicy(policy: ScalingPolicy): Promise<void> {
await requestJson('/scaling/policies', { method: 'POST', body: JSON.stringify(policy) });
}
export async function deleteScalingPolicy(serviceId: string): Promise<void> {
await requestJson(`/scaling/policies/${encodeURIComponent(serviceId)}`, { method: 'DELETE' });
}
export async function getServiceScalingState(serviceId: string): Promise<ServiceScalingState | null> {
try {
const payload = await requestJson<{ state?: ServiceScalingState }>(
`/scaling/services/${encodeURIComponent(serviceId)}`,
);
return payload.state ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null;
}
throw error;
}
}
export async function manualScaleService(serviceId: string, replicas: number, reason = ''): Promise<void> {
await requestJson(`/scaling/services/${encodeURIComponent(serviceId)}/scale`, {
method: 'POST',
body: JSON.stringify({ replicas, reason }),
});
}