mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
8ecc8dcea5
commit
44a1b09dad
3 files changed
+273
-5
No files matched your search
+12
-4
@@ -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">
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user