From e1427b62d7d7ecfd53a3428bcd86b9d3fc653a97 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 09:55:22 -0400 Subject: [PATCH] feat(admin): DB-backed integration toggles + sticky cols + dark contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 19 +- DESIGN.md | 23 +- app/addigy-devices/page.tsx | 2 +- app/admin/integrations/page.tsx | 318 +++++++++++++++++++++++ app/api/admin/integrations/route.ts | 99 +++++++ app/globals.css | 6 +- components/admin/DataTable.tsx | 5 +- components/admin/DetailModal.tsx | 2 +- components/navigation/app-navigation.tsx | 6 + components/ui/status-light.tsx | 2 +- components/ui/table.tsx | 29 ++- lib/services/integration-health.ts | 39 ++- migrations/081_integration_settings.sql | 45 ++++ 13 files changed, 561 insertions(+), 34 deletions(-) create mode 100644 app/admin/integrations/page.tsx create mode 100644 app/api/admin/integrations/route.ts create mode 100644 migrations/081_integration_settings.sql diff --git a/CLAUDE.md b/CLAUDE.md index 7214769..3c917a5 100644 --- a/CLAUDE.md +++ b/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 diff --git a/DESIGN.md b/DESIGN.md index be91bd3..52cc86a 100644 --- a/DESIGN.md +++ b/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%. `` 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 + `` 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 diff --git a/app/addigy-devices/page.tsx b/app/addigy-devices/page.tsx index f299551..0857ffb 100644 --- a/app/addigy-devices/page.tsx +++ b/app/addigy-devices/page.tsx @@ -110,7 +110,7 @@ export default function AddigyDevicesPage() { /> ) : ( - +
Device diff --git a/app/admin/integrations/page.tsx b/app/admin/integrations/page.tsx new file mode 100644 index 0000000..11502a7 --- /dev/null +++ b/app/admin/integrations/page.tsx @@ -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(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [pending, setPending] = useState(null); + const [reasons, setReasons] = useState>({}); + + 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 ( + <> + + + Refresh + + } + /> + +
+ {error && ( + + Failed to load + {error} + + )} + + + + How this works + +

+ Disabling an integration here suppresses it from /status 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. +

+

{ENV_OVERRIDE_NOTE}

+
+
+ + + + + + Toggle integrations + + + + {!rows ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ) : rows.length === 0 ? ( +
+ +
+ ) : ( +
    + {rows.map((row) => ( + + setReasons((prev) => ({ ...prev, [row.key]: v })) + } + onToggle={(next) => void toggle(row, next)} + /> + ))} +
+ )} +
+
+
+ + ); +} + +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 ( +
  • +
    + +
    +
    + {row.name} + + {row.category} + + {isDisabled && disabled} +
    +

    + {row.key} + {row.setting.disabledBy && ( + <> + {' · disabled by '} + {row.setting.disabledBy} + {' '} + {fmtDate(row.setting.disabledAt)} + + )} +

    + {isDisabled && row.setting.reason && ( +

    + "{row.setting.reason}" +

    + )} + {!isDisabled && ( + onReasonChange(e.target.value)} + className="h-7 text-xs mt-2 max-w-md" + disabled={pending} + /> + )} +
    +
    +
    + onToggle(!v)} + disabled={pending} + aria-label={`Toggle ${row.name}`} + /> + + {isDisabled ? 'Disabled' : 'Enabled'} + +
    +
  • + ); +} diff --git a/app/api/admin/integrations/route.ts b/app/api/admin/integrations/route.ts new file mode 100644 index 0000000..533108f --- /dev/null +++ b/app/api/admin/integrations/route.ts @@ -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( + `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( + `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]) }); +} diff --git a/app/globals.css b/app/globals.css index 7c4dda4..398ec58 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); } diff --git a/components/admin/DataTable.tsx b/components/admin/DataTable.tsx index 97b094e..fb9a917 100644 --- a/components/admin/DataTable.tsx +++ b/components/admin/DataTable.tsx @@ -92,6 +92,8 @@ export interface DataTableProps { /** 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({ @@ -109,6 +111,7 @@ export default function DataTable({ renderSubRow, emptyTitle = 'No data found', emptyDescription = 'Try adjusting your search or filters.', + stickyFirstColumn = true, }: DataTableProps) { const [searchQuery, setSearchQuery] = useState(''); const [sorting, setSorting] = useState([]); @@ -210,7 +213,7 @@ export default function DataTable({ )}
    -
    +
    {table.getHeaderGroups().map((headerGroup) => ( diff --git a/components/admin/DetailModal.tsx b/components/admin/DetailModal.tsx index 8bc1f81..ef6df4b 100644 --- a/components/admin/DetailModal.tsx +++ b/components/admin/DetailModal.tsx @@ -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: , isEmpty: true }; + return { display: , isEmpty: true }; } switch (type) { diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index 6f83f78..a93b898 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -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', diff --git a/components/ui/status-light.tsx b/components/ui/status-light.tsx index 98f9e63..47818a4 100644 --- a/components/ui/status-light.tsx +++ b/components/ui/status-light.tsx @@ -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', diff --git a/components/ui/table.tsx b/components/ui/table.tsx index 51b74dd..96cdb93 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -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 (
    ) { >
    diff --git a/lib/services/integration-health.ts b/lib/services/integration-health.ts index 1b3720a..e6c2f86 100644 --- a/lib/services/integration-health.ts +++ b/lib/services/integration-health.ts @@ -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 = { 'ms_graph': 'msgraph', }; -function getDisabledKeys(): Set { +function getEnvDisabledKeys(): Set { const raw = process.env.INTEGRATIONS_DISABLED; if (!raw) return new Set(); return new Set( @@ -289,9 +292,25 @@ function getDisabledKeys(): Set { ); } -function applyDisableOverlay(items: IntegrationHealth[]): IntegrationHealth[] { - const disabled = getDisabledKeys(); - if (disabled.size === 0) return items; +async function getDbDisabledKeys(): Promise> { + // 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 { + 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; } diff --git a/migrations/081_integration_settings.sql b/migrations/081_integration_settings.sql new file mode 100644 index 0000000..12001e2 --- /dev/null +++ b/migrations/081_integration_settings.sql @@ -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;