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>
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
'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 ~7–10 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>
|
||
);
|
||
}
|