feat: high-availability page (/ha)

Platform-level HA surface: manager enable/disable toggle, status cards
(nodes, health checks, active alerts), manual failover with
confirmation, per-service failover policy CRUD with project/service
picker, active alert resolution, and health check result list.

Verified live: status, enable/disable, policy set/list/get/disable,
manual failover, alerts and health results endpoints. HA state is
in-memory — noted in roadmap alongside autoscaling persistence 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:17:49 +02:00
1 parent 44a1b09dad
commit d5c99acb8c
5 files changed
+496 -3

No files matched your search

+7 -3
View File
@@ -52,7 +52,7 @@ The backend exposes **~95 route registrations**; the frontend consumes
| 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 | 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 | done (beta) | `/ha` page: manager toggle, failover policies, alerts, health results; state is in-memory |
| 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 |
| Proxmox provider (cluster/nodes/VM/LXC CRUD) | written, **routes never registered** — unreachable | none | not documented |
@@ -366,8 +366,12 @@ reaches the upstream container and appears in the analytics tables.
(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.
- [x] **HA / failover UI** (`/ha/*`): `/ha` page with manager enable/disable,
status cards (nodes/health checks/alerts), manual failover with
confirmation, per-service failover policy CRUD (project→service
picker), active alerts with resolve, health check results. Verified
live. Beta: policies/alerts are in-memory, lost on restart.
Health-check and notifier CRUD endpoints remain unexposed.
- [ ] **Security center** (`/security/*`): scan trigger per project, finding
list with severity, compliance report viewer, audit correlation.
Verify the scanner actually runs Trivy/Grype or document the real
+2
View File
@@ -13,6 +13,7 @@ import {
SettingsPage,
UsagePage,
DatabasesPage,
HighAvailabilityPage,
ComponentShowcase,
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
@@ -63,6 +64,7 @@ export default function App() {
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
@@ -15,6 +15,7 @@ import {
RefreshCw,
Search,
Settings,
ShieldCheck,
Sun,
UploadCloud,
Users,
@@ -34,6 +35,7 @@ const navItems = [
{ label: 'Projects', href: '/projects', icon: FolderKanban },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
{ label: 'Databases', href: '/databases', icon: Database },
{ label: 'HA', href: '/ha', icon: ShieldCheck },
{ label: 'Usage', href: '/usage', icon: ChartBar },
{ label: 'People', href: '/people', icon: Users },
{ label: 'Settings', href: '/settings', icon: Settings },
@@ -29,8 +29,18 @@ import {
getGitHubAppInstallUrl,
connectGitHubApp,
updateCurrentUserProfile,
getHAStatus,
setHAEnabled,
triggerFailover,
listFailoverPolicies,
setFailoverPolicy,
deleteFailoverPolicy,
listActiveAlerts,
resolveAlert,
listHealthResults,
type AgentAuthTokenCreated,
type DatabaseEntity,
type FailoverPolicy,
} from '@/lib/api-client';
import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
@@ -73,6 +83,9 @@ import {
Play,
Square,
Archive,
ShieldCheck,
Zap,
HeartPulse,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -1885,3 +1898,408 @@ function BackupScheduleRow({
</div>
);
}
const failoverStrategies = [
{ value: 'active_passive', label: 'Active / passive' },
{ value: 'active_active', label: 'Active / active' },
{ value: 'graceful', label: 'Graceful' },
] as const;
export function HighAvailabilityPage() {
const queryClient = useQueryClient();
const [failoverReason, setFailoverReason] = useState('');
const [policyForm, setPolicyForm] = useState<{
serviceId: string;
strategy: string;
minHealthyNodes: string;
maxFailures: string;
enabled: boolean;
editing: boolean;
} | null>(null);
const [policyProjectId, setPolicyProjectId] = useState('');
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000 });
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects });
const servicesQuery = useQuery({
queryKey: ['services', policyProjectId],
queryFn: () => listServicesByProject(policyProjectId),
enabled: Boolean(policyProjectId),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['ha-status'] });
queryClient.invalidateQueries({ queryKey: ['ha-policies'] });
queryClient.invalidateQueries({ queryKey: ['ha-alerts'] });
};
const toggleMutation = useMutation({
mutationFn: (enabled: boolean) => setHAEnabled(enabled),
onSuccess: invalidate,
});
const failoverMutation = useMutation({
mutationFn: () => triggerFailover(failoverReason.trim()),
onSuccess: () => { setFailoverReason(''); invalidate(); },
});
const savePolicyMutation = useMutation({
mutationFn: async () => {
if (!policyForm) return;
await setFailoverPolicy({
service_id: policyForm.serviceId,
enabled: policyForm.enabled,
failover_strategy: policyForm.strategy,
min_healthy_nodes: Number(policyForm.minHealthyNodes) || 1,
max_failures: Number(policyForm.maxFailures) || 3,
failover_timeout: 30e9,
recovery_timeout: 60e9,
});
},
onSuccess: () => { setPolicyForm(null); invalidate(); },
});
const deletePolicyMutation = useMutation({
mutationFn: (serviceId: string) => deleteFailoverPolicy(serviceId),
onSuccess: invalidate,
});
const resolveMutation = useMutation({
mutationFn: (alertId: string) => resolveAlert(alertId),
onSuccess: invalidate,
});
const status = statusQuery.data;
const pageError =
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ??
deletePolicyMutation.error ?? resolveMutation.error;
return (
<div className="flex flex-col min-h-full">
<SecondaryPageHeader
title="High availability"
description="Failover manager, policies, and active alerts"
/>
<div className="p-8 space-y-8">
{pageError && (
<div className="rounded-[var(--radius-md)] border border-[var(--error)] bg-[var(--error)]/10 px-4 py-3 text-sm text-[var(--error)]">
{pageError instanceof Error ? pageError.message : 'Request failed'}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Manager"
value={status?.enabled ? 'Enabled' : 'Disabled'}
description="HA manager state"
icon={ShieldCheck}
color={status?.enabled ? 'success' : 'default'}
/>
<StatCard
title="Nodes"
value={`${status?.nodes?.healthy ?? 0}/${status?.nodes?.total ?? 0}`}
description={`${status?.nodes?.unhealthy ?? 0} unhealthy`}
icon={Server}
color={status?.nodes?.unhealthy ? 'warning' : 'default'}
/>
<StatCard
title="Health checks"
value={`${status?.health_checks?.healthy ?? 0}/${status?.health_checks?.total ?? 0}`}
description={`${status?.health_checks?.unhealthy ?? 0} unhealthy`}
icon={HeartPulse}
color={status?.health_checks?.unhealthy ? 'warning' : 'default'}
/>
<StatCard
title="Active alerts"
value={String(status?.alerts?.active ?? 0)}
description="Firing right now"
icon={AlertCircle}
color={status?.alerts?.active ? 'error' : 'default'}
/>
</div>
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<Zap size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Manager controls</h2>
</div>
<div className="flex flex-wrap items-end gap-3">
<button
type="button"
disabled={toggleMutation.isPending || statusQuery.isLoading}
onClick={() => toggleMutation.mutate(!status?.enabled)}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
</button>
<div className="flex items-end gap-2">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Failover reason</label>
<input
value={failoverReason}
onChange={(e) => setFailoverReason(e.target.value)}
placeholder="Manual failover"
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] w-56"
/>
</div>
<button
type="button"
disabled={failoverMutation.isPending}
onClick={() => {
if (window.confirm('Trigger a platform failover now? Services may be rescheduled.')) {
failoverMutation.mutate();
}
}}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--warning)] text-sm text-[var(--warning)] disabled:opacity-50"
>
{failoverMutation.isPending ? 'Triggering…' : 'Trigger failover'}
</button>
</div>
</div>
</div>
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<AlertCircle size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
</div>
{alertsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (alertsQuery.data ?? []).length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p>
) : (
<div className="space-y-2">
{(alertsQuery.data ?? []).map((alert) => (
<div
key={alert.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3"
>
<div className="min-w-0">
<p className="text-sm text-[var(--text-primary)] truncate">{alert.message ?? alert.id}</p>
<p className="text-xs text-[var(--text-tertiary)]">
{alert.severity ?? 'info'} · since {alert.starts_at ? formatRelative(alert.starts_at) : '—'}
</p>
</div>
<button
type="button"
disabled={resolveMutation.isPending}
onClick={() => alert.id && resolveMutation.mutate(alert.id)}
className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-primary)] disabled:opacity-50"
>
Resolve
</button>
</div>
))}
</div>
)}
</div>
<div className="panel p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ShieldCheck size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2>
</div>
{policyForm === null && (
<button
type="button"
onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-xs font-medium"
>
New policy
</button>
)}
</div>
{policyForm !== null && (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 mb-4 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
{policyForm.editing ? (
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Service</label>
<p className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] font-mono">
{policyForm.serviceId}
</p>
</div>
) : (
<>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
<select
value={policyProjectId}
onChange={(e) => { setPolicyProjectId(e.target.value); setPolicyForm({ ...policyForm, serviceId: '' }); }}
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)]"
>
<option value="">Select project…</option>
{(projectsQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Service</label>
<select
value={policyForm.serviceId}
onChange={(e) => setPolicyForm({ ...policyForm, serviceId: e.target.value })}
disabled={!policyProjectId}
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)] disabled:opacity-50"
>
<option value="">Select service…</option>
{(servicesQuery.data ?? []).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</>
)}
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Strategy</label>
<select
value={policyForm.strategy}
onChange={(e) => setPolicyForm({ ...policyForm, strategy: 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)]"
>
{failoverStrategies.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Min healthy nodes</label>
<input
type="number" min={1}
value={policyForm.minHealthyNodes}
onChange={(e) => setPolicyForm({ ...policyForm, minHealthyNodes: 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="block text-xs text-[var(--text-tertiary)] mb-1">Max failures</label>
<input
type="number" min={1}
value={policyForm.maxFailures}
onChange={(e) => setPolicyForm({ ...policyForm, maxFailures: 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>
</div>
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={policyForm.enabled}
onChange={(e) => setPolicyForm({ ...policyForm, enabled: e.target.checked })}
/>
Enabled
</label>
<div className="flex items-center gap-3">
<button
type="button"
disabled={savePolicyMutation.isPending || !policyForm.serviceId}
onClick={() => savePolicyMutation.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"
>
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
</button>
<button
type="button"
onClick={() => setPolicyForm(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>
)}
{policiesQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (policiesQuery.data ?? []).length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p>
) : (
<div className="space-y-2">
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => (
<div
key={policy.service_id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3"
>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)] font-mono truncate">{policy.service_id}</p>
<p className="text-xs text-[var(--text-tertiary)]">
{policy.enabled ? 'enabled' : 'disabled'} · {policy.failover_strategy ?? 'default'} ·
min nodes {policy.min_healthy_nodes ?? '—'} · max failures {policy.max_failures ?? '—'}
</p>
</div>
<div className="ml-4 flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => setPolicyForm({
serviceId: policy.service_id ?? '',
strategy: policy.failover_strategy ?? 'active_passive',
minHealthyNodes: String(policy.min_healthy_nodes ?? 1),
maxFailures: String(policy.max_failures ?? 3),
enabled: policy.enabled ?? true,
editing: true,
})}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-primary)]"
>
Edit
</button>
{policy.enabled && (
<button
type="button"
disabled={deletePolicyMutation.isPending}
onClick={() => {
if (window.confirm(`Disable failover for ${policy.service_id}?`)) {
deletePolicyMutation.mutate(policy.service_id ?? '');
}
}}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--error)] text-xs text-[var(--error)] disabled:opacity-50"
>
Disable
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<HeartPulse size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
</div>
{healthQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (healthQuery.data ?? []).length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p>
) : (
<div className="space-y-2">
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => (
<div
key={`${result.check_id}-${i}`}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3"
>
<div className="min-w-0">
<p className="text-sm text-[var(--text-primary)] font-mono truncate">{result.check_id}</p>
<p className="text-xs text-[var(--text-tertiary)]">{result.message || '—'}</p>
</div>
<div className="ml-4 text-right shrink-0">
<p className={`text-xs font-medium ${result.status === 'healthy' ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
{result.status ?? 'unknown'}
</p>
<p className="text-xs text-[var(--text-tertiary)]">
{result.timestamp ? formatRelative(result.timestamp) : ''}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+67
View File
@@ -1535,3 +1535,70 @@ export async function manualScaleService(serviceId: string, replicas: number, re
body: JSON.stringify({ replicas, reason }),
});
}
export type FailoverPolicy = components['schemas']['FailoverPolicy'];
export type HAStatus = {
enabled?: boolean;
nodes?: { total?: number; healthy?: number; unhealthy?: number };
health_checks?: { total?: number; healthy?: number; unhealthy?: number };
alerts?: { active?: number };
};
export type HAAlert = {
id?: string;
rule_id?: string;
status?: string;
severity?: string;
message?: string;
starts_at?: string;
ends_at?: string;
};
export type HAHealthResult = {
check_id?: string;
status?: string;
message?: string;
latency?: number;
timestamp?: string;
};
export async function getHAStatus(): Promise<HAStatus> {
const payload = await requestJson<{ status?: HAStatus }>('/ha/status');
return payload.status ?? {};
}
export async function setHAEnabled(enabled: boolean): Promise<void> {
await requestJson(enabled ? '/ha/enable' : '/ha/disable', { method: 'POST' });
}
export async function triggerFailover(reason: string): Promise<void> {
await requestJson('/ha/failover', { method: 'POST', body: JSON.stringify({ reason }) });
}
export async function listFailoverPolicies(): Promise<FailoverPolicy[]> {
const payload = await requestJson<{ policies?: FailoverPolicy[] }>('/ha/failover/policies');
return payload.policies ?? [];
}
export async function setFailoverPolicy(policy: FailoverPolicy): Promise<void> {
await requestJson('/ha/failover/policies', { method: 'POST', body: JSON.stringify(policy) });
}
export async function deleteFailoverPolicy(serviceId: string): Promise<void> {
await requestJson(`/ha/failover/policies/${encodeURIComponent(serviceId)}`, { method: 'DELETE' });
}
export async function listActiveAlerts(): Promise<HAAlert[]> {
const payload = await requestJson<{ alerts?: HAAlert[] }>('/ha/alerts/active');
return payload.alerts ?? [];
}
export async function resolveAlert(alertId: string): Promise<void> {
await requestJson(`/ha/alerts/${encodeURIComponent(alertId)}/resolve`, { method: 'POST' });
}
export async function listHealthResults(): Promise<HAHealthResult[]> {
const payload = await requestJson<{ results?: HAHealthResult[] }>('/ha/health/results');
return payload.results ?? [];
}