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:
lorentz 2026-05-03 09:55:22 -04:00
parent ab78e7bd4f
commit e1427b62d7
13 changed files with 561 additions and 34 deletions

View file

@ -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;
}