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>
368 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|