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" + /> +
+ +
+
+
+ +
+
+ +

Active alerts

+
+ {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} +

+
+ ) : ( + <> +
+ + +
+
+ + +
+ + )} +
+ + +
+
+
+ + 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)]" + /> +
+
+ + 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)]" + /> +
+
+
+ +
+ + +
+
+ )} + + {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 ?? []; +}