wulf-pulse/app/addigy-devices/page.tsx
lorentz e1427b62d7 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>
2026-05-03 09:55:22 -04:00

186 lines
6.9 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { AddigyDevice } from '@/lib/types/addigy';
import { PageHeader } from '@/components/navigation/page-header';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { EmptyState } from '@/components/ui/empty-state';
import { StatusBadge } from '@/components/ui/status-badge';
import { Check, X, Laptop, RefreshCw } from 'lucide-react';
export default function AddigyDevicesPage() {
const [devices, setDevices] = useState<AddigyDevice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterOnline, setFilterOnline] = useState(false);
useEffect(() => {
void fetchDevices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterOnline]);
async function fetchDevices() {
setLoading(true);
setError(null);
try {
const url = filterOnline
? '/api/addigy-devices?online=true'
: '/api/addigy-devices';
const res = await fetch(url, { cache: 'no-store' });
const result = await res.json();
if (result.success) {
setDevices(result.data);
} else {
setError(result.error || 'Failed to fetch devices');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
return (
<>
<PageHeader
title="Addigy devices"
description={
loading
? 'Loading…'
: `${devices.length} device${devices.length === 1 ? '' : 's'}${filterOnline ? ' · online only' : ''}`
}
breadcrumbs={[{ label: 'Addigy devices' }]}
actions={
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={filterOnline}
onCheckedChange={(v) => setFilterOnline(v === true)}
aria-label="Filter to online devices only"
/>
<span>Online only</span>
</label>
<Button onClick={fetchDevices} 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">
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to load</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-6 space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-3/4" />
</div>
) : devices.length === 0 ? (
<div className="p-6">
<EmptyState
icon={Laptop}
title="No devices found"
description={
filterOnline
? 'No devices are currently online.'
: 'Addigy has not synced any devices yet.'
}
size="sm"
/>
</div>
) : (
<Table stickyFirstColumn>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>Model</TableHead>
<TableHead>OS</TableHead>
<TableHead>Current user</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Free disk</TableHead>
<TableHead>Security</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => {
const freePct = device['Free Disk Percentage'];
const freeTone =
freePct === undefined
? 'text-muted-foreground'
: freePct < 20
? 'text-destructive'
: freePct < 40
? 'text-amber-600 dark:text-amber-400'
: 'text-emerald-600 dark:text-emerald-400';
return (
<TableRow key={device.agentid}>
<TableCell>
<div className="font-medium">{device['Device Name']}</div>
<div className="text-xs text-muted-foreground num">
{device['Serial Number'] || '—'}
</div>
</TableCell>
<TableCell>{device['Device Model Name'] || 'Unknown'}</TableCell>
<TableCell className="num">
{device['MAC OS X Version'] || device['iOS Version'] || '—'}
</TableCell>
<TableCell>{device['Current User'] || '—'}</TableCell>
<TableCell>
<StatusBadge tone={device.online ? 'ok' : 'inactive'}>
{device.online ? 'Online' : 'Offline'}
</StatusBadge>
</TableCell>
<TableCell className={`text-right num ${freeTone}`}>
{freePct !== undefined ? `${freePct}%` : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-3 text-xs">
<SecurityFlag label="FW" enabled={Boolean(device['Firewall Enabled'])} />
<SecurityFlag label="FV" enabled={Boolean(device['FileVault Enabled'])} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}
function SecurityFlag({ label, enabled }: { label: string; enabled: boolean }) {
return (
<span
className={`inline-flex items-center gap-0.5 ${enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}`}
title={enabled ? `${label} enabled` : `${label} disabled`}
>
{enabled ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
<span className="num font-medium">{label}</span>
</span>
);
}