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>
186 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|