mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
44a1b09dad
commit
d5c99acb8c
5 files changed
+496
-3
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
Reference in new issue
Block a user