diff --git a/ROADMAP.md b/ROADMAP.md
index 1373206..854aa65 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx
index 995d8f8..24e83a9 100644
--- a/app/frontend/src/app/App.tsx
+++ b/app/frontend/src/app/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx
index a9f4980..07e7c83 100644
--- a/app/frontend/src/app/layout/PlatformShell.tsx
+++ b/app/frontend/src/app/layout/PlatformShell.tsx
@@ -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 },
diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx
index 76c7f6b..a4dcdfe 100644
--- a/app/frontend/src/features/secondary/pages.tsx
+++ b/app/frontend/src/features/secondary/pages.tsx
@@ -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({
);
}
+
+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 (
+
+
+
+ {pageError && (
+
+ {pageError instanceof Error ? pageError.message : 'Request failed'}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
Manager controls
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+ {alertsQuery.isLoading ? (
+
Loading…
+ ) : (alertsQuery.data ?? []).length === 0 ? (
+
No active alerts.
+ ) : (
+
+ {(alertsQuery.data ?? []).map((alert) => (
+
+
+
{alert.message ?? alert.id}
+
+ {alert.severity ?? 'info'} · since {alert.starts_at ? formatRelative(alert.starts_at) : '—'}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
Failover policies
+
+ {policyForm === null && (
+
+ )}
+
+
+ {policyForm !== null && (
+
+
+ {policyForm.editing ? (
+
+
+
+ {policyForm.serviceId}
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {policiesQuery.isLoading ? (
+
Loading…
+ ) : (policiesQuery.data ?? []).length === 0 ? (
+
No failover policies configured.
+ ) : (
+
+ {(policiesQuery.data ?? []).map((policy: FailoverPolicy) => (
+
+
+
{policy.service_id}
+
+ {policy.enabled ? 'enabled' : 'disabled'} · {policy.failover_strategy ?? 'default'} ·
+ min nodes {policy.min_healthy_nodes ?? '—'} · max failures {policy.max_failures ?? '—'}
+
+
+
+
+ {policy.enabled && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
Health check results
+
+ {healthQuery.isLoading ? (
+
Loading…
+ ) : (healthQuery.data ?? []).length === 0 ? (
+
No health checks configured.
+ ) : (
+
+ {(healthQuery.data ?? []).slice(0, 20).map((result, i) => (
+
+
+
{result.check_id}
+
{result.message || '—'}
+
+
+
+ {result.status ?? 'unknown'}
+
+
+ {result.timestamp ? formatRelative(result.timestamp) : ''}
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts
index d81825f..2076f91 100644
--- a/app/frontend/src/lib/api-client.ts
+++ b/app/frontend/src/lib/api-client.ts
@@ -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 {
+ const payload = await requestJson<{ status?: HAStatus }>('/ha/status');
+ return payload.status ?? {};
+}
+
+export async function setHAEnabled(enabled: boolean): Promise {
+ await requestJson(enabled ? '/ha/enable' : '/ha/disable', { method: 'POST' });
+}
+
+export async function triggerFailover(reason: string): Promise {
+ await requestJson('/ha/failover', { method: 'POST', body: JSON.stringify({ reason }) });
+}
+
+export async function listFailoverPolicies(): Promise {
+ const payload = await requestJson<{ policies?: FailoverPolicy[] }>('/ha/failover/policies');
+ return payload.policies ?? [];
+}
+
+export async function setFailoverPolicy(policy: FailoverPolicy): Promise {
+ await requestJson('/ha/failover/policies', { method: 'POST', body: JSON.stringify(policy) });
+}
+
+export async function deleteFailoverPolicy(serviceId: string): Promise {
+ await requestJson(`/ha/failover/policies/${encodeURIComponent(serviceId)}`, { method: 'DELETE' });
+}
+
+export async function listActiveAlerts(): Promise {
+ const payload = await requestJson<{ alerts?: HAAlert[] }>('/ha/alerts/active');
+ return payload.alerts ?? [];
+}
+
+export async function resolveAlert(alertId: string): Promise {
+ await requestJson(`/ha/alerts/${encodeURIComponent(alertId)}/resolve`, { method: 'POST' });
+}
+
+export async function listHealthResults(): Promise {
+ const payload = await requestJson<{ results?: HAHealthResult[] }>('/ha/health/results');
+ return payload.results ?? [];
+}