feat(admin): DB-backed integration toggles + sticky cols + dark contrast
Builds on the env-var INTEGRATIONS_DISABLED shipped with the nav-design overhaul. Adds a DB-backed admin UI so operators can flip integrations without editing .env and restarting the container, plus the remaining visual cleanup items from the design backlog. Integration toggles - Migration 081 — integration_settings table (key PK, disabled flag, reason, disabled_by audit, disabled_at). Seeded with all 13 known integrations as enabled. - GET / PATCH /api/admin/integrations — gated by requirePermission (admin, access). PATCH clears the in-process integration-health cache so toggles take effect within seconds. - /admin/integrations admin page with a Switch per integration, optional reason input, audit-info subtitle (disabled by, when, why), live status light from /api/dashboard/integration-health. - integration-health service merges env-var disable list with DB rows; degrades gracefully if migration unapplied / DB unreachable. - Wired into the Admin nav dropdown (eight items now). - CLAUDE.md describes both env + DB sources. Sticky first column on tables - Table primitive accepts stickyFirstColumn?: boolean. When true, TH and TD :first-child stay pinned during horizontal scroll, with background inheritance preserving hover and selected row tints. - DataTable exposes the prop too — on by default for paginated tables. - /addigy-devices opts in. Dark-mode contrast - --border lifted from 10% to 14% in .dark; --input from 15% to 18%; --sidebar-border to 14%. - StatusLight outline ring lifted from /10 to /15 (light) and /20 (dark). - DetailModal empty-cell em-dash lifted from /40 to /70 so missing values are legible on dark surfaces. DESIGN.md - Closed sticky-first-column, dark-mode contrast, and palette-audit items (palette deprioritized — most uses are semantic). - Skeleton helpers documented as preferred for new code; existing ad-hoc patterns left in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab78e7bd4f
commit
e1427b62d7
13 changed files with 561 additions and 34 deletions
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -124,13 +124,18 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
|
|||
|
||||
## Operator config
|
||||
|
||||
- `INTEGRATIONS_DISABLED` — comma- or space-separated list of integration
|
||||
keys (or aliases) to suppress from `/status` and the top-bar status light.
|
||||
Disabled entries render muted, don't count toward failure summaries, and
|
||||
don't flag the rollup. Set in `.env` and restart. Aliases:
|
||||
`sentinelone` → `s1`, `datto` → `datto_rmm`, `it-glue` → `itglue`,
|
||||
`ms-graph` → `msgraph`. Live auth checks still run (so logs still
|
||||
surface the underlying state) but the UI ignores the result.
|
||||
- **Integration disable** — two sources, merged:
|
||||
- `INTEGRATIONS_DISABLED` env var (legacy / bootstrap fallback).
|
||||
Comma- or space-separated keys with aliases (`sentinelone` → `s1`,
|
||||
`datto` → `datto_rmm`, `it-glue` → `itglue`, `ms-graph` → `msgraph`).
|
||||
Set in `.env` and restart.
|
||||
- **`/admin/integrations`** UI backed by the `integration_settings`
|
||||
table (migration 081). Toggle without a container restart; takes
|
||||
effect within the 5-minute health cache (PATCH clears the cache
|
||||
immediately). Audit columns capture `disabled_by` (session email),
|
||||
`disabled_at`, and an optional `disabled_reason`.
|
||||
In both cases live auth checks still run (logs surface the underlying
|
||||
state); the UI ignores the result for disabled integrations.
|
||||
|
||||
## Watch out for
|
||||
- A `.env` file is committed to the repo. Treat secrets as potentially real; don't
|
||||
|
|
|
|||
23
DESIGN.md
23
DESIGN.md
|
|
@ -370,25 +370,32 @@ below is the working backlog; expand as we go.
|
|||
via the `bg-{hue}-500/15 text-{hue}-700` recipe documented above).
|
||||
Surveyed and deprioritized — case-by-case cleanup as new work
|
||||
touches a page.
|
||||
- [ ] Verify dark-mode contrast on status badges and chart legends; the 10%-
|
||||
opacity borders in dark mode are subtle and may need lifting.
|
||||
- [x] ~~Verify dark-mode contrast on status badges and chart legends~~ —
|
||||
bumped dark `--border` from 10% to 14%, `--input` from 15% to 18%,
|
||||
`--sidebar-border` to 14%. `<StatusLight>` outline lifted to
|
||||
`ring-foreground/15 dark:ring-foreground/20`. DetailModal empty-cell
|
||||
em-dash lifted from `/40` to `/70` so missing-value placeholders are
|
||||
legible on dark surfaces.
|
||||
|
||||
### Loading & empty
|
||||
- [x] ~~Standardize Skeleton heights~~ — helpers in
|
||||
`components/ui/skeleton-helpers.tsx`: `SkeletonRow`, `SkeletonRows`,
|
||||
`SkeletonCard`, `SkeletonChart`, `SkeletonHeader`, `SkeletonTable`.
|
||||
- [ ] Adopt the helpers across pages (still scattering `h-12` / `h-24` in
|
||||
pages built before the helpers landed).
|
||||
- [ ] Loading shells should match the post-load layout — skeletons inside
|
||||
Cards, not a single full-width bar.
|
||||
Adopted on `/dashboard` and `/status`.
|
||||
- [-] Helpers are **preferred for new code**. Existing ad-hoc
|
||||
`<Skeleton h-NN>` patterns aren't broken (they render the same
|
||||
shape just with arbitrary heights); leave them in place and
|
||||
migrate opportunistically when touching the surrounding code.
|
||||
|
||||
### Mobile
|
||||
- [x] ~~CI filter bar overflows on small viewports~~ — company selector
|
||||
now wraps and shrinks; the stat pill flows below.
|
||||
- [x] ~~Analyzer multi-select dropdowns clip on narrow widths~~ —
|
||||
Popover gets `max-w-[calc(100vw-1rem)]` and `collisionPadding={8}`.
|
||||
- [ ] Tables horizontally scroll without a sticky first column; consider
|
||||
responsive card-list fallbacks for narrow screens.
|
||||
- [x] ~~Tables horizontally scroll without a sticky first column~~ —
|
||||
`Table` primitive accepts `stickyFirstColumn` (also exposed on
|
||||
`DataTable` and on by default for paginated tables). Hover and
|
||||
selected row backgrounds carry through.
|
||||
|
||||
## 11. When in doubt
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export default function AddigyDevicesPage() {
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Table stickyFirstColumn>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Device</TableHead>
|
||||
|
|
|
|||
318
app/admin/integrations/page.tsx
Normal file
318
app/admin/integrations/page.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/* /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;
|
||||
}
|
||||
|
||||
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>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{rows.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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
99
app/api/admin/integrations/route.ts
Normal file
99
app/api/admin/integrations/route.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* GET /api/admin/integrations — list every integration_settings row
|
||||
* PATCH /api/admin/integrations — body: { key, disabled, reason? }
|
||||
*
|
||||
* Backed by the `integration_settings` table (migration 081). Toggling
|
||||
* here takes effect within the 5-minute health cache without restarting
|
||||
* the container. Forces a cache refresh on PATCH so the UI reflects the
|
||||
* change immediately.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { clearIntegrationHealthCache } from '@/lib/services/integration-health';
|
||||
|
||||
interface IntegrationSettingRow {
|
||||
key: string;
|
||||
disabled: boolean;
|
||||
disabled_reason: string | null;
|
||||
disabled_by: string | null;
|
||||
disabled_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface IntegrationSetting {
|
||||
key: string;
|
||||
disabled: boolean;
|
||||
reason: string | null;
|
||||
disabledBy: string | null;
|
||||
disabledAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function rowToDto(r: IntegrationSettingRow): IntegrationSetting {
|
||||
return {
|
||||
key: r.key,
|
||||
disabled: r.disabled,
|
||||
reason: r.disabled_reason,
|
||||
disabledBy: r.disabled_by,
|
||||
disabledAt: r.disabled_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
const res = await postgresClient.query<IntegrationSettingRow>(
|
||||
`SELECT key, disabled, disabled_reason, disabled_by, disabled_at::text,
|
||||
updated_at::text
|
||||
FROM integration_settings
|
||||
ORDER BY key`,
|
||||
);
|
||||
return NextResponse.json({ items: res.rows.map(rowToDto) });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const { session, error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
|
||||
let body: { key?: unknown; disabled?: unknown; reason?: unknown };
|
||||
try {
|
||||
body = (await request.json()) as typeof body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const key = typeof body.key === 'string' ? body.key.trim() : '';
|
||||
const disabled = body.disabled === true;
|
||||
const reason =
|
||||
typeof body.reason === 'string' && body.reason.trim().length > 0
|
||||
? body.reason.trim().slice(0, 500)
|
||||
: null;
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: '`key` is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const actor = (session?.user as { email?: string } | undefined)?.email ?? null;
|
||||
|
||||
const res = await postgresClient.query<IntegrationSettingRow>(
|
||||
`INSERT INTO integration_settings (key, disabled, disabled_reason, disabled_by, disabled_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, CASE WHEN $2 THEN NOW() ELSE NULL END, NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET disabled = EXCLUDED.disabled,
|
||||
disabled_reason = CASE WHEN EXCLUDED.disabled THEN EXCLUDED.disabled_reason ELSE NULL END,
|
||||
disabled_by = CASE WHEN EXCLUDED.disabled THEN EXCLUDED.disabled_by ELSE NULL END,
|
||||
disabled_at = CASE WHEN EXCLUDED.disabled THEN NOW() ELSE NULL END,
|
||||
updated_at = NOW()
|
||||
RETURNING key, disabled, disabled_reason, disabled_by, disabled_at::text, updated_at::text`,
|
||||
[key, disabled, reason, disabled ? actor : null],
|
||||
);
|
||||
|
||||
// Force the next /api/dashboard/integration-health request to re-check.
|
||||
clearIntegrationHealthCache();
|
||||
|
||||
return NextResponse.json({ item: rowToDto(res.rows[0]) });
|
||||
}
|
||||
|
|
@ -95,8 +95,8 @@
|
|||
--accent: oklch(0.62 0.17 220); /* Logo blue */
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--border: oklch(1 0 0 / 14%);
|
||||
--input: oklch(1 0 0 / 18%);
|
||||
--ring: oklch(0.62 0.17 220);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.62 0.17 220); /* Logo blue */
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-border: oklch(1 0 0 / 14%);
|
||||
--sidebar-ring: oklch(0.62 0.17 220);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ export interface DataTableProps<TData = any> {
|
|||
/** Empty-state slot. Defaults to a neutral "No results" message. */
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
/** Pin the first column when the table scrolls horizontally. Default true. */
|
||||
stickyFirstColumn?: boolean;
|
||||
}
|
||||
|
||||
export default function DataTable<TData = any>({
|
||||
|
|
@ -109,6 +111,7 @@ export default function DataTable<TData = any>({
|
|||
renderSubRow,
|
||||
emptyTitle = 'No data found',
|
||||
emptyDescription = 'Try adjusting your search or filters.',
|
||||
stickyFirstColumn = true,
|
||||
}: DataTableProps<TData>) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
|
@ -210,7 +213,7 @@ export default function DataTable<TData = any>({
|
|||
)}
|
||||
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<Table>
|
||||
<Table stickyFirstColumn={stickyFirstColumn}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ const COMPANY_GROUPS: FieldGroup[] = [
|
|||
|
||||
function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { display: React.ReactNode; isEmpty: boolean } {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return { display: <span className="text-muted-foreground/40 italic text-xs">—</span>, isEmpty: true };
|
||||
return { display: <span className="text-muted-foreground/70 italic text-xs">—</span>, isEmpty: true };
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
|
|
|
|||
|
|
@ -167,6 +167,12 @@ const navigationItems: NavItem[] = [
|
|||
icon: Database,
|
||||
description: 'Audit-driven changes pushed back to IT Glue; revert from here',
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
href: '/admin/integrations',
|
||||
icon: Activity,
|
||||
description: 'Toggle integrations on or off — affects /status without a container restart',
|
||||
},
|
||||
{
|
||||
title: 'Device-link conflicts',
|
||||
href: '/admin/device-link-conflicts',
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function StatusLight({
|
|||
role="status"
|
||||
aria-label={label ?? state}
|
||||
className={cn(
|
||||
'inline-block ring-1 ring-foreground/10 align-middle',
|
||||
'inline-block ring-1 ring-foreground/15 dark:ring-foreground/20 align-middle',
|
||||
sizeMap[size],
|
||||
stateMap[state],
|
||||
pulse && state === 'pending' && 'animate-pulse',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import * as React from "react"
|
|||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
interface TableProps extends React.ComponentProps<"table"> {
|
||||
/** Pin the first column when the table scrolls horizontally. Useful on
|
||||
* wide list tables where the first column is an identifier. */
|
||||
stickyFirstColumn?: boolean
|
||||
}
|
||||
|
||||
function Table({ className, stickyFirstColumn, ...props }: TableProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
|
|
@ -12,7 +18,26 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
className={cn(
|
||||
"w-full caption-bottom text-sm",
|
||||
stickyFirstColumn && [
|
||||
// Header cell: sticky against the muted header bg.
|
||||
"[&_thead_tr_th:first-child]:sticky",
|
||||
"[&_thead_tr_th:first-child]:left-0",
|
||||
"[&_thead_tr_th:first-child]:z-20",
|
||||
"[&_thead_tr_th:first-child]:bg-muted",
|
||||
// Body cell: sticky, inherits the row's background so hover +
|
||||
// selected states still read; falls back to card surface.
|
||||
"[&_tbody_tr_td:first-child]:sticky",
|
||||
"[&_tbody_tr_td:first-child]:left-0",
|
||||
"[&_tbody_tr_td:first-child]:z-10",
|
||||
"[&_tbody_tr_td:first-child]:bg-card",
|
||||
// When the parent row has its hover bg, override.
|
||||
"[&_tbody_tr:hover_td:first-child]:bg-muted/50",
|
||||
"[&_tbody_tr[data-state=selected]_td:first-child]:bg-muted",
|
||||
],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -252,12 +252,15 @@ function checkConfigOnly(
|
|||
}
|
||||
|
||||
/**
|
||||
* Operator-side disable list. Set INTEGRATIONS_DISABLED to a comma- or
|
||||
* space-separated list of integration keys (or aliases) to suppress them
|
||||
* from the /status page and the top-bar indicator. Disabled entries
|
||||
* render muted and don't count toward failure summaries.
|
||||
* Operator-side disable list. Two sources, merged:
|
||||
*
|
||||
* Aliases:
|
||||
* 1. INTEGRATIONS_DISABLED env var (legacy / bootstrap fallback) —
|
||||
* comma- or space-separated keys with aliases.
|
||||
* 2. integration_settings table (DB-backed, admin-toggleable at
|
||||
* /admin/integrations) — takes effect within the 5-minute health
|
||||
* cache without requiring a container restart.
|
||||
*
|
||||
* Aliases (env only — DB rows store canonical keys):
|
||||
* sentinelone, s1 → s1
|
||||
* datto, datto-rmm → datto_rmm
|
||||
* itglue, it-glue → itglue
|
||||
|
|
@ -277,7 +280,7 @@ const KEY_ALIASES: Record<string, string> = {
|
|||
'ms_graph': 'msgraph',
|
||||
};
|
||||
|
||||
function getDisabledKeys(): Set<string> {
|
||||
function getEnvDisabledKeys(): Set<string> {
|
||||
const raw = process.env.INTEGRATIONS_DISABLED;
|
||||
if (!raw) return new Set();
|
||||
return new Set(
|
||||
|
|
@ -289,9 +292,25 @@ function getDisabledKeys(): Set<string> {
|
|||
);
|
||||
}
|
||||
|
||||
function applyDisableOverlay(items: IntegrationHealth[]): IntegrationHealth[] {
|
||||
const disabled = getDisabledKeys();
|
||||
if (disabled.size === 0) return items;
|
||||
async function getDbDisabledKeys(): Promise<Set<string>> {
|
||||
// Lazy import to avoid pulling postgres-client into edge runtimes.
|
||||
const { default: postgresClient } = await import('@/lib/services/postgres-client');
|
||||
try {
|
||||
const res = await postgresClient.query<{ key: string }>(
|
||||
`SELECT key FROM integration_settings WHERE disabled = true`,
|
||||
);
|
||||
return new Set(res.rows.map((r) => r.key));
|
||||
} catch {
|
||||
// Migration not applied yet, or DB unreachable. Don't break health
|
||||
// checks — fall back to env-only behavior.
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDisableOverlay(items: IntegrationHealth[]): Promise<IntegrationHealth[]> {
|
||||
const [envSet, dbSet] = [getEnvDisabledKeys(), await getDbDisabledKeys()];
|
||||
if (envSet.size === 0 && dbSet.size === 0) return items;
|
||||
const disabled = new Set([...envSet, ...dbSet]);
|
||||
return items.map((item) =>
|
||||
disabled.has(item.key)
|
||||
? { ...item, status: 'disabled', error: null, configured: false }
|
||||
|
|
@ -327,7 +346,7 @@ export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Pr
|
|||
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
|
||||
['ANTHROPIC_API_KEY'])),
|
||||
]);
|
||||
const overlaid = applyDisableOverlay(results);
|
||||
const overlaid = await applyDisableOverlay(results);
|
||||
cache = { expiresAt: Date.now() + CACHE_TTL_MS, data: overlaid };
|
||||
return overlaid;
|
||||
}
|
||||
|
|
|
|||
45
migrations/081_integration_settings.sql
Normal file
45
migrations/081_integration_settings.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- =============================================================================
|
||||
-- Integration toggle table
|
||||
-- =============================================================================
|
||||
-- Operator-managed disable list for the /status page and integration health
|
||||
-- summary. Keyed by the same identifier the integration-health service
|
||||
-- emits (e.g. 's1', 'datto_rmm', 'itglue', 'msgraph', 'autotask', 'veeam',
|
||||
-- 'auvik', 'addigy', 'mimecast', 'duo', 'zabbix', 'qbo', 'anthropic').
|
||||
--
|
||||
-- The legacy INTEGRATIONS_DISABLED env var still works (its values merge with
|
||||
-- this table at read time), but DB-backed toggles take effect within the
|
||||
-- 5-minute health cache without a container restart.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
disabled BOOLEAN NOT NULL DEFAULT false,
|
||||
disabled_reason TEXT,
|
||||
disabled_by TEXT, -- session.user.email
|
||||
disabled_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE integration_settings IS
|
||||
'Per-integration operator config. Today only the disabled flag is exposed; expand as needed.';
|
||||
|
||||
-- Seed empty rows for known integrations so the admin UI shows everything
|
||||
-- on first load even before anyone toggles anything. The admin UI auto-
|
||||
-- discovers from the integration-health response, so this seed is purely
|
||||
-- a convenience.
|
||||
INSERT INTO integration_settings (key, disabled) VALUES
|
||||
('autotask', false),
|
||||
('datto_rmm', false),
|
||||
('itglue', false),
|
||||
('s1', false),
|
||||
('veeam', false),
|
||||
('msgraph', false),
|
||||
('auvik', false),
|
||||
('addigy', false),
|
||||
('mimecast', false),
|
||||
('duo', false),
|
||||
('zabbix', false),
|
||||
('qbo', false),
|
||||
('anthropic', false)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
Loading…
Add table
Add a link
Reference in a new issue