From 44a1b09dad4e8ea5da2cd4f7f172d418ffec24dd Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 18:10:52 +0200 Subject: [PATCH] feat: per-service scaling section on service detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 16 +- .../service/pages/ServiceDetailPage.tsx | 209 +++++++++++++++++- app/frontend/src/lib/api-client.ts | 53 +++++ 3 files changed, 273 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f155c8..1373206 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | | Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it | | 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 | | 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 | @@ -359,9 +359,13 @@ reaches the upstream container and appears in the analytics tables. ### Phase 3 — Advanced operations -- [ ] **Autoscaling UI** (`/scaling/*`): per-service policy editor - (min/max/target CPU/mem), scaling history timeline, manual scale - control on service detail. Mark beta until soak-tested. +- [x] **Autoscaling UI** (`/scaling/*`): per-service policy editor + (min/max/target CPU/mem + enable toggle) and manual scale control + 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 policies, manual failover with confirmation, status banner. Beta. - [ ] **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). Mechanical port to `sqlc/queries/`; the consolidated baseline schema is 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/ Templates but the new Databases page and service Cron tab have no demo fixtures — add them so `?demo=1` doesn't show empty sections. diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 13cd744..fadb41f 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -21,6 +21,11 @@ import { createPreviewEnvironment, deletePreviewEnvironment, promotePreviewEnvironment, + getScalingPolicy, + setScalingPolicy, + deleteScalingPolicy, + getServiceScalingState, + manualScaleService, rollbackDeployment, updateServiceVariables, type CronJobEntity, @@ -57,7 +62,7 @@ import { ExternalLink, } 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 }> = [ { 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: 'cron', label: 'Cron', icon: Clock }, { key: 'previews', label: 'Previews', icon: GitPullRequest }, + { key: 'scaling', label: 'Scaling', icon: Layers }, { key: 'settings', label: 'Settings', icon: Settings }, ]; @@ -234,6 +240,49 @@ export function ServiceDetailPage() { onSuccess: invalidatePreviews, }); 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({ mutationFn: (id: string) => triggerCronJob(id), onSuccess: () => { @@ -1453,6 +1502,164 @@ export function ServiceDetailPage() { )} + {activeSection === 'scaling' && ( +
+
+
+
+ +
+
+

Scaling

+

Replica policy and manual scaling

+
+
+
+ + {scalingPolicyQuery.isLoading ? ( +

Loading…

+ ) : ( + <> +
+
+

Replicas

+

+ {scalingStateQuery.data?.CurrentReplicas ?? '—'} + + {' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired + +

+
+
+

Policy

+

+ {scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'} +

+
+
+

Autoscaling

+

+ {scalingPolicyQuery.data?.enabled ? 'On' : 'Off'} +

+
+
+

Last action

+

+ {scalingStateQuery.data?.LastScaleDirection ?? '—'} +

+
+
+ + {scalingForm === null ? ( +
+
+ + 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" + /> +
+ + + {scalingPolicyQuery.data?.enabled && ( + + )} +
+ ) : ( +
+
+ {([ + ['Min replicas', 'min'], + ['Max replicas', 'max'], + ['Target CPU %', 'targetCpu'], + ['Target memory %', 'targetMemory'], + ] as const).map(([label, field]) => ( +
+ + 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)]" + /> +
+ ))} +
+ +
+ + +
+
+ )} + + {(saveScalingMutation.isError || deleteScalingMutation.isError || manualScaleMutation.isError || scalingPolicyQuery.isError || scalingStateQuery.isError) && ( +

+ {((saveScalingMutation.error ?? deleteScalingMutation.error ?? manualScaleMutation.error ?? scalingPolicyQuery.error ?? scalingStateQuery.error) as Error)?.message ?? 'Operation failed'} +

+ )} + {!scalingStateQuery.data && !scalingStateQuery.isLoading && ( +

+ No scaling state yet — create a policy or scale manually to register this service with the autoscaler. +

+ )} + + )} +
+ )} + {activeSection === 'settings' && (
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 1293a9f..d81825f 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1482,3 +1482,56 @@ export async function promotePreviewEnvironment( 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 { + 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 { + await requestJson('/scaling/policies', { method: 'POST', body: JSON.stringify(policy) }); +} + +export async function deleteScalingPolicy(serviceId: string): Promise { + await requestJson(`/scaling/policies/${encodeURIComponent(serviceId)}`, { method: 'DELETE' }); +} + +export async function getServiceScalingState(serviceId: string): Promise { + 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 { + await requestJson(`/scaling/services/${encodeURIComponent(serviceId)}/scale`, { + method: 'POST', + body: JSON.stringify({ replicas, reason }), + }); +}