wulf-pulse/app/admin/sync/itglue/page.tsx
lorentz 9bfb57553d feat(design): nav/visual overhaul — brand layer, /status route, KPI dashboard, TanStack DataTable
Major UI refresh on the nav-design-improvements branch.  Drops 2013-era
inline styles and consolidates patterns behind shared primitives.

Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
  the standards-guide blue (#0075AD) with utility classes for numerics
  (.num / .num-lg / .num-xl), metric labels, surface tints, and the
  wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
  Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
  "Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page

Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
  health table, worker pulse cards (analyzer / RMM / sync scheduler),
  token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
  to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
  integrations (e.g. SentinelOne) — no failure noise from broken-on-
  purpose entries.  Aliases supported (sentinelone → s1, etc.)

Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
  total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
  area chart, 30-day mean resolution time line chart, today's active
  engineers leaderboard

Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
  status, classification, source, company type, publish, active /
  yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)

Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
  PageHeader rule (consistent across flat links and submenu triggers);
  active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config

Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs

DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
  unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow

Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
  collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below

Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
  workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
  rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
  INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:33:13 -04:00

357 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import {
ArrowLeft, Activity, History, BookOpen, Loader2, RefreshCw,
ExternalLink, CheckCircle2, XCircle, Clock, AlertTriangle,
Building2, Monitor, Users, Key, FileText, Globe, Shield, Package,
} from 'lucide-react';
function fmtDate(d: string | null) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function fmtDuration(ms: number | null) {
if (!ms) return '—';
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
}
function StatCard({
label, value, sub, icon: Icon, cls,
}: {
label: string; value: string | number; sub?: string;
icon?: React.ElementType; cls?: string;
}) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function SyncStatusBadge({ status }: { status: string }) {
const tone =
status === 'completed' ? 'ok' :
status === 'failed' ? 'error' :
status === 'running' ? 'info' :
'warn';
return (
<StatusBadge tone={tone}>
{status === 'running' && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
{status}
</StatusBadge>
);
}
function StatusTab({
syncData, onSync, syncing,
}: {
syncData: any; onSync: () => void; syncing: boolean;
}) {
if (!syncData) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
}
const counts = syncData.counts ?? {};
const latest = syncData.history?.[0];
return (
<div className="space-y-6">
{/* Connection bar */}
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
Connected to IT Glue
</p>
<p className="text-xs text-muted-foreground">
Last sync: {fmtDate(latest?.completed_at ?? null)}
{latest?.duration_ms && ` · ${fmtDuration(latest.duration_ms)}`}
</p>
</div>
<div className="flex gap-2">
<a href="https://app.itglue.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Portal
</Button>
</a>
<Button size="sm" onClick={onSync} disabled={syncing || syncData.inProgress}>
{syncing || syncData.inProgress
? <Loader2 className="w-4 h-4 animate-spin mr-2" />
: <RefreshCw className="w-4 h-4 mr-2" />}
{syncing || syncData.inProgress ? 'Syncing…' : 'Full Sync'}
</Button>
</div>
</div>
{/* Record counts */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Synced Records</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Organizations" value={Number(counts.organizations ?? 0).toLocaleString()} icon={Building2} />
<StatCard label="Configurations" value={Number(counts.configurations ?? 0).toLocaleString()} icon={Monitor} />
<StatCard label="Contacts" value={Number(counts.contacts ?? 0).toLocaleString()} icon={Users} />
<StatCard label="Flexible Assets" value={Number(counts.flexible_assets ?? 0).toLocaleString()} icon={Package} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3">
<StatCard label="Passwords" value={Number(counts.passwords ?? 0).toLocaleString()} icon={Key} />
<StatCard label="Documents" value={Number(counts.documents ?? 0).toLocaleString()} icon={FileText} />
<StatCard label="Locations" value={Number(counts.locations ?? 0).toLocaleString()} icon={Building2} />
<StatCard label="Domains" value={Number(counts.domains ?? 0).toLocaleString()} icon={Globe} />
</div>
</div>
{/* Latest sync entity breakdown */}
{latest?.entities?.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Last Sync Breakdown
{latest.status && <span className="ml-2"><SyncStatusBadge status={latest.status} /></span>}
</p>
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Entity</TableHead>
<TableHead className="text-right">Records</TableHead>
<TableHead className="text-right">Duration</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{latest.entities.map((e: any, i: number) => (
<TableRow key={i}>
<TableCell className="num text-xs">{e.entity}</TableCell>
<TableCell className="text-right num">{e.recordsUpserted.toLocaleString()}</TableCell>
<TableCell className="text-right text-muted-foreground num">{fmtDuration(e.duration)}</TableCell>
<TableCell className="text-right">
{e.success
? <CheckCircle2 className="w-4 h-4 text-emerald-500 inline" />
: <span title={e.error}><XCircle className="w-4 h-4 text-destructive inline" /></span>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
);
}
function HistoryTab({ history }: { history: any[] }) {
if (!history.length) {
return (
<div className="text-center py-12 text-muted-foreground text-sm">
No sync history yet run a sync to populate
</div>
);
}
return (
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Triggered by</TableHead>
<TableHead className="text-right">Records</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-right">Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((row: any, i: number) => (
<TableRow key={i}>
<TableCell><SyncStatusBadge status={row.status} /></TableCell>
<TableCell className="text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</TableCell>
<TableCell className="text-right num">{(row.total_upserted ?? 0).toLocaleString()}</TableCell>
<TableCell className="text-muted-foreground num">{fmtDate(row.started_at)}</TableCell>
<TableCell className="text-right text-muted-foreground num">{fmtDuration(row.duration_ms)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function AboutTab() {
return (
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-3">
<p className="font-medium text-foreground">Synced Entities</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{[
['Organizations', 'All IT Glue organizations with type, status, PSA linkage'],
['Locations', 'Physical locations per organization with address details'],
['Contacts', 'Contacts with emails, phones, type, and location linkage'],
['Configurations', 'All CIs with hostname, IP, serial, OS, manufacturer, model'],
['Flexible Assets', 'All flexible asset types with full trait data as JSONB'],
['Flexible Asset Types', 'Type definitions and field schemas'],
['Passwords', 'Credentials with category, folder, username, URL'],
['Password Folders', 'Folder hierarchy per organization'],
['Documents', 'IT Glue documents with full content'],
['Domains', 'Domain records with expiry and registrar info'],
['Expirations', 'All expiration records across organizations'],
['Reference Tables', 'Org types/statuses, config types/statuses, contact types, manufacturers, models, OS, platforms, countries'],
].map(([name, desc]) => (
<div key={name} className="flex gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
<div>
<span className="font-medium text-foreground">{name}</span>
<span className="text-xs block">{desc}</span>
</div>
</div>
))}
</div>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>API key via <code className="text-xs bg-muted px-1 py-0.5 rounded">x-api-key</code> header · Base URL: <code className="text-xs bg-muted px-1 py-0.5 rounded">https://api.itglue.com</code></p>
<p>Response format: JSON:API (<code className="text-xs bg-muted px-1 py-0.5 rounded">application/vnd.api+json</code>)</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Database Tables</p>
<p>All data is stored in tables prefixed <code className="text-xs bg-muted px-1 py-0.5 rounded">itg_</code> in the Pulse PostgreSQL database. Each table includes a <code className="text-xs bg-muted px-1 py-0.5 rounded">synced_at</code> timestamp and uses <code className="text-xs bg-muted px-1 py-0.5 rounded">ON CONFLICT DO UPDATE</code> for idempotent upserts.</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-500" />
Notes
</p>
<ul className="space-y-1 list-disc list-inside text-xs">
<li>A full sync takes ~710 minutes depending on data volume</li>
<li>Configuration interfaces are not synced the IT Glue API has no flat endpoint and per-config calls are impractical at 14k+ configs</li>
<li>Flexible assets require a per-type API call (API enforces <code className="bg-muted px-1 py-0.5 rounded">filter[flexible-asset-type-id]</code>)</li>
<li>Password folders, documents, and expirations require per-organization calls</li>
</ul>
</div>
</div>
);
}
export default function ITGluePage() {
const [syncData, setSyncData] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/itglue/sync');
if (res.ok) setSyncData(await res.json());
} catch {}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Poll while sync is in progress
useEffect(() => {
if (!syncData?.inProgress && !syncing) return;
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, [syncData?.inProgress, syncing, fetchStatus]);
const handleSync = async () => {
setSyncing(true);
try {
await fetch('/api/itglue/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ triggeredBy: 'manual' }),
});
await fetchStatus();
} catch {
setSyncing(false);
}
// syncing flag cleared by poll detecting inProgress=false
};
// Clear syncing flag once inProgress goes false
useEffect(() => {
if (syncData && !syncData.inProgress && syncing) {
setSyncing(false);
}
}, [syncData, syncing]);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Shield className="w-5 h-5 text-blue-500" />
</div>
<div>
<h1 className="text-2xl font-bold">IT Glue</h1>
<p className="text-sm text-muted-foreground">
Organizations, configurations, contacts, passwords, flexible assets
</p>
</div>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />History
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<BookOpen className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<StatusTab syncData={syncData} onSync={handleSync} syncing={syncing} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<HistoryTab history={syncData?.history ?? []} />
</TabsContent>
<TabsContent value="about" className="mt-6">
<AboutTab />
</TabsContent>
</Tabs>
</div>
);
}