wulf-pulse/app/admin/integrations/page.tsx
lorentz 370125d249 feat(admin): group integration toggles by category
Bucket the integration list under category headers (PSA, RMM,
Documentation, Security, …) following a canonical order, with
unrecognized categories appended after. Rows within a bucket sort
alphabetically by name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:03:54 -04:00

368 lines
13 KiB
TypeScript

/* /admin/integrations — operator-managed integration toggles.
*
* Joins `integration_settings` (DB-backed disabled state, audit info)
* with `/api/dashboard/integration-health` (live status + categories +
* display names) so the admin sees both halves on one page. Toggling a
* row hits PATCH /api/admin/integrations and forces a health refresh. */
'use client';
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
import { StatusBadge } from '@/components/ui/status-badge';
import { EmptyState } from '@/components/ui/empty-state';
import { RefreshCw, AlertTriangle, Power } from 'lucide-react';
import { toast } from 'sonner';
interface IntegrationSetting {
key: string;
disabled: boolean;
reason: string | null;
disabledBy: string | null;
disabledAt: string | null;
updatedAt: string;
}
interface IntegrationHealthItem {
key: string;
name: string;
category: string;
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown' | 'disabled';
configured: boolean;
latencyMs?: number;
error?: string | null;
tokenExpiry?: { daysRemaining: number } | null;
}
interface MergedRow {
key: string;
name: string;
category: string;
liveStatus: IntegrationHealthItem['status'];
setting: IntegrationSetting;
}
const CATEGORY_LABELS: Record<string, string> = {
psa: 'PSA',
rmm: 'RMM',
docs: 'Documentation',
security: 'Security',
backup: 'Backup',
network: 'Network',
identity: 'Identity',
mdm: 'MDM',
mail: 'Mail',
finance: 'Finance',
productivity: 'Productivity',
llm: 'LLM',
};
const CATEGORY_ORDER = [
'psa', 'rmm', 'docs', 'security', 'backup',
'network', 'identity', 'mdm', 'mail',
'finance', 'productivity', 'llm',
];
function liveStatusLight(s: IntegrationHealthItem['status']): StatusLightState {
if (s === 'ok') return 'ok';
if (s === 'auth_failed' || s === 'unreachable') return 'error';
if (s === 'disabled') return 'idle';
return 'idle';
}
function fmtDate(iso: string | null): string {
if (!iso) return '—';
const ms = Date.now() - new Date(iso).getTime();
if (ms < 60_000) return 'just now';
const min = Math.floor(ms / 60_000);
if (min < 60) return `${min} min ago`;
const hr = Math.floor(min / 60);
if (hr < 48) return `${hr} h ago`;
return `${Math.floor(hr / 24)} d ago`;
}
const ENV_OVERRIDE_NOTE =
'INTEGRATIONS_DISABLED env var is also active — env entries always take precedence and cannot be re-enabled here.';
export default function IntegrationTogglesPage() {
const [rows, setRows] = useState<MergedRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
async function load() {
setLoading(true);
try {
const [sRes, hRes] = await Promise.all([
fetch('/api/admin/integrations', { cache: 'no-store' }),
fetch('/api/dashboard/integration-health', { cache: 'no-store' }),
]);
if (!sRes.ok) throw new Error('Failed to load integration settings');
if (!hRes.ok) throw new Error('Failed to load integration health');
const sBody = (await sRes.json()) as { items: IntegrationSetting[] };
const hBody = (await hRes.json()) as { items: IntegrationHealthItem[] };
const settingByKey = new Map(sBody.items.map((s) => [s.key, s]));
// Source of truth for the row list is the live integration-health
// response (it carries display names + categories). We merge in the
// setting if one exists, otherwise synthesize a default.
const merged: MergedRow[] = hBody.items.map((h) => ({
key: h.key,
name: h.name,
category: h.category,
liveStatus: h.status,
setting:
settingByKey.get(h.key) ??
{
key: h.key,
disabled: h.status === 'disabled',
reason: null,
disabledBy: null,
disabledAt: null,
updatedAt: '',
},
}));
merged.sort((a, b) => a.name.localeCompare(b.name));
setRows(merged);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
async function toggle(row: MergedRow, next: boolean) {
setPending(row.key);
try {
const res = await fetch('/api/admin/integrations', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
key: row.key,
disabled: next,
reason: next ? reasons[row.key] || null : null,
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
toast.success(`${row.name} ${next ? 'disabled' : 'enabled'}`);
// Reload merged view so live status reflects the change after cache flush.
await load();
// Clear the inline reason input on success.
setReasons((prev) => {
const copy = { ...prev };
delete copy[row.key];
return copy;
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Toggle failed');
} finally {
setPending(null);
}
}
const disabledCount = rows?.filter((r) => r.setting.disabled).length ?? 0;
const totalCount = rows?.length ?? 0;
return (
<>
<PageHeader
title="Integrations"
description={
rows
? `${disabledCount} of ${totalCount} disabled`
: 'Toggle integrations on or off without a container restart'
}
breadcrumbs={[
{ label: 'Admin', href: '/admin' },
{ label: 'Integrations' },
]}
accent
actions={
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
}
/>
<div className="container mx-auto px-6 py-6 space-y-6 max-w-4xl">
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to load</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>How this works</AlertTitle>
<AlertDescription className="space-y-1 text-sm">
<p>
Disabling an integration here suppresses it from <code>/status</code> and
the top-bar status light, and excludes it from failure roll-ups. Live
auth checks still run (so the underlying state is logged), but the UI
ignores them.
</p>
<p className="text-muted-foreground">{ENV_OVERRIDE_NOTE}</p>
</AlertDescription>
</Alert>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Power className="h-4 w-4" />
Toggle integrations
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{!rows ? (
<div className="p-6 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-14" />
))}
</div>
) : rows.length === 0 ? (
<div className="p-6">
<EmptyState
icon={Power}
title="No integrations registered"
description="The integration-health service didn't return any items."
size="sm"
/>
</div>
) : (
(() => {
// Bucket rows by category, preserving the canonical order.
const byCategory = new Map<string, MergedRow[]>();
for (const r of rows) {
const arr = byCategory.get(r.category) ?? [];
arr.push(r);
byCategory.set(r.category, arr);
}
const visibleCategories = CATEGORY_ORDER.filter((c) => byCategory.has(c));
// Catch any unexpected category not in the canonical order.
for (const c of byCategory.keys()) {
if (!visibleCategories.includes(c)) visibleCategories.push(c);
}
return (
<div className="divide-y divide-border">
{visibleCategories.map((category) => (
<div key={category}>
<p className="metric-label px-4 pt-3 pb-1">
{CATEGORY_LABELS[category] ?? category}
</p>
<ul className="divide-y divide-border/50">
{byCategory.get(category)!
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((row) => (
<IntegrationRow
key={row.key}
row={row}
pending={pending === row.key}
reasonValue={reasons[row.key] ?? ''}
onReasonChange={(v) =>
setReasons((prev) => ({ ...prev, [row.key]: v }))
}
onToggle={(next) => void toggle(row, next)}
/>
))}
</ul>
</div>
))}
</div>
);
})()
)}
</CardContent>
</Card>
</div>
</>
);
}
function IntegrationRow({
row,
pending,
reasonValue,
onReasonChange,
onToggle,
}: {
row: MergedRow;
pending: boolean;
reasonValue: string;
onReasonChange: (v: string) => void;
onToggle: (next: boolean) => void;
}) {
const isDisabled = row.setting.disabled;
return (
<li className={`grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 px-4 py-3 ${isDisabled ? 'opacity-80' : ''}`}>
<div className="flex items-start gap-3 min-w-0">
<StatusLight state={liveStatusLight(row.liveStatus)} size="md" className="mt-1.5" label={row.liveStatus} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{row.name}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{row.category}
</span>
{isDisabled && <StatusBadge tone="inactive" size="xs">disabled</StatusBadge>}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
<span className="num">{row.key}</span>
{row.setting.disabledBy && (
<>
{' · disabled by '}
<span>{row.setting.disabledBy}</span>
{' '}
<span className="num">{fmtDate(row.setting.disabledAt)}</span>
</>
)}
</p>
{isDisabled && row.setting.reason && (
<p className="text-xs italic text-muted-foreground mt-1">
"{row.setting.reason}"
</p>
)}
{!isDisabled && (
<Input
placeholder="Optional: why are you disabling this?"
value={reasonValue}
onChange={(e) => onReasonChange(e.target.value)}
className="h-7 text-xs mt-2 max-w-md"
disabled={pending}
/>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 md:self-center">
<Switch
checked={!isDisabled}
onCheckedChange={(v) => onToggle(!v)}
disabled={pending}
aria-label={`Toggle ${row.name}`}
/>
<span className="text-xs text-muted-foreground w-14 text-left">
{isDisabled ? 'Disabled' : 'Enabled'}
</span>
</div>
</li>
);
}