wulf-pulse/app/dashboard/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

446 lines
15 KiB
TypeScript

/* /dashboard — Operations home.
*
* KPI-first. Health and sync status moved to /status (linked from the
* top-bar StatusLight). This page surfaces:
* • Today snapshot — opened, resolved, open total, SLA breaches
* • Needs attention — admin housekeeping that pulls a human's eyes
* • Recent observations + recent audits
*
* Trends (volume by day, queue heatmap) will land here next once the
* supporting endpoints exist; for now the page is intentionally minimal
* and load-fast. */
'use client';
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { KpiCard } from '@/components/dashboard/kpi-card';
import { VolumeTrend } from '@/components/dashboard/volume-trend';
import { ResolutionTrend } from '@/components/dashboard/resolution-trend';
import { QueueHeatmap } from '@/components/dashboard/queue-heatmap';
import { ActiveEngineers } from '@/components/dashboard/active-engineers';
import {
RefreshCw,
Activity,
Sparkles,
Users,
Layers,
TrendingUp,
Timer,
} from 'lucide-react';
interface Overview {
today: {
openedToday: number;
resolvedToday: number;
openTotal: number;
slaBreaches: number;
yesterdayOpened: number;
last7DayAvgResolved: number;
};
attention: {
linkConflicts: number;
itglueUnlinked: number;
s1Unmapped: number;
schedules: { enabled: number; total: number };
};
observations: Array<{
id: string;
kind: string;
source: string;
collectedAt: string;
hostname: string | null;
companyName: string | null;
runId: string | null;
}>;
audits: Array<{
id: string;
generatedAt: string;
hostname: string | null;
companyName: string | null;
overallScore: number | null;
fieldGapsCount: number;
status: string;
}>;
stats: {
activeCompanies: number;
configurationItems: number;
xref: { total: number; linked: number };
};
}
interface Trends {
volumeByDay: Array<{ date: string; count: number }>;
resolutionByDay: Array<{ date: string; avgHours: number | null }>;
queueHeatmap: Array<{
queueId: number;
queueLabel: string;
total: number;
byPriority: Record<number, number>;
}>;
activeEngineers: Array<{
resourceId: string;
name: string;
hours: number;
ticketsTouched: number;
}>;
}
function relTime(iso: string | null): string {
if (!iso) return 'never';
const ms = Date.now() - new Date(iso).getTime();
if (ms < 0) return 'in the future';
const min = Math.floor(ms / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min} min ago`;
const hr = Math.floor(min / 60);
if (hr < 48) return `${hr} h ago`;
const day = Math.floor(hr / 24);
return `${day} d ago`;
}
export default function DashboardPage() {
const [data, setData] = useState<Overview | null>(null);
const [trends, setTrends] = useState<Trends | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function load() {
setLoading(true);
try {
const [overviewRes, trendsRes] = await Promise.all([
fetch('/api/dashboard/overview', { cache: 'no-store' }),
fetch('/api/dashboard/trends', { cache: 'no-store' }),
]);
if (!overviewRes.ok) {
const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
}
setData((await overviewRes.json()) as Overview);
if (trendsRes.ok) {
setTrends((await trendsRes.json()) as Trends);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
const today = data?.today;
const openedDelta = today
? today.openedToday - today.yesterdayOpened
: 0;
const resolvedDelta = today
? Math.round((today.resolvedToday - today.last7DayAvgResolved) * 10) / 10
: 0;
return (
<>
<PageHeader
title="Operations"
description={new Date().toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
accent
watermark
actions={
<Button onClick={load} 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>
)}
{/* TODAY SNAPSHOT ----------------------------------------------- */}
<section>
<h2 className="metric-label mb-3">Today</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
label="Opened"
value={today?.openedToday ?? null}
delta={
today
? { value: openedDelta, label: 'vs yesterday' }
: undefined
}
caption={today && `Yesterday: ${today.yesterdayOpened}`}
loading={!data}
/>
<KpiCard
label="Resolved"
value={today?.resolvedToday ?? null}
delta={
today
? {
value: resolvedDelta,
label: 'vs 7-day avg',
}
: undefined
}
caption={today && `7-day avg: ${today.last7DayAvgResolved}`}
tone="accent"
loading={!data}
/>
<KpiCard
label="Open total"
value={today?.openTotal ?? null}
loading={!data}
/>
<KpiCard
label="SLA breaches"
value={today?.slaBreaches ?? null}
tone={
today && today.slaBreaches > 0 ? 'attention' : 'default'
}
caption={
today && today.slaBreaches === 0
? 'All on track'
: 'Past due, still open'
}
loading={!data}
/>
</div>
</section>
{/* NEEDS ATTENTION ---------------------------------------------- */}
<section>
<h2 className="metric-label mb-3">Needs attention</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
label="Device-link conflicts"
value={data?.attention.linkConflicts ?? null}
tone={
data && data.attention.linkConflicts > 0 ? 'warn' : 'default'
}
href="/admin/device-link-conflicts"
loading={!data}
/>
<KpiCard
label="IT Glue unlinked"
value={data?.attention.itglueUnlinked ?? null}
caption="Configurations without an Autotask CI"
href="/admin/device-link-conflicts"
loading={!data}
/>
<KpiCard
label="S1 unmapped"
value={data?.attention.s1Unmapped ?? null}
caption="Sites missing a company mapping"
href="/sentinelone/mappings"
loading={!data}
/>
<KpiCard
label="Schedules on"
value={
data
? `${data.attention.schedules.enabled}/${data.attention.schedules.total}`
: null
}
href="/admin"
loading={!data}
/>
</div>
</section>
{/* QUEUE POSTURE ------------------------------------------------ */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<Card className="lg:col-span-8">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Layers className="h-4 w-4" />
Queue posture
</CardTitle>
</CardHeader>
<CardContent>
{!trends ? (
<Skeleton className="h-48" />
) : (
<QueueHeatmap data={trends.queueHeatmap} />
)}
</CardContent>
</Card>
<Card className="lg:col-span-4">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Active engineers
</CardTitle>
</CardHeader>
<CardContent>
{!trends ? (
<Skeleton className="h-48" />
) : (
<ActiveEngineers data={trends.activeEngineers} />
)}
</CardContent>
</Card>
</div>
{/* TRENDS ------------------------------------------------------- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Volume · last 30 days
</CardTitle>
</CardHeader>
<CardContent>
{!trends ? (
<Skeleton className="h-44" />
) : (
<VolumeTrend data={trends.volumeByDay} />
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Timer className="h-4 w-4" />
Mean resolution time · last 30 days
</CardTitle>
</CardHeader>
<CardContent>
{!trends ? (
<Skeleton className="h-44" />
) : (
<ResolutionTrend data={trends.resolutionByDay} />
)}
</CardContent>
</Card>
</div>
{/* RECENT ACTIVITY --------------------------------------------- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="h-4 w-4" />
Recent device observations
</CardTitle>
</CardHeader>
<CardContent>
{!data ? (
<RowSkeletons />
) : data.observations.length === 0 ? (
<EmptyState
icon={Activity}
title="No observations recorded"
description="Device telemetry from LogLift and RMM will appear here."
size="sm"
/>
) : (
<div className="space-y-1">
{data.observations.map((o) => (
<div
key={o.id}
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{o.hostname ?? '(unanchored)'}</div>
<div className="text-xs text-muted-foreground">
<span className="num">{o.kind}</span>
{o.companyName && <span> · {o.companyName}</span>}
</div>
</div>
<div className="num text-xs text-muted-foreground shrink-0 ml-3">
{relTime(o.collectedAt)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Recent audits
</CardTitle>
</CardHeader>
<CardContent>
{!data ? (
<RowSkeletons />
) : data.audits.length === 0 ? (
<EmptyState
icon={Sparkles}
title="No endpoint audits yet"
description="Asset-audit results from the analyzer will appear here."
size="sm"
/>
) : (
<div className="space-y-1">
{data.audits.map((a) => (
<div
key={a.id}
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
<div className="text-xs text-muted-foreground">
<span className="num">score {a.overallScore?.toFixed(2) ?? '—'}</span>
{' · '}
<span className="num">{a.fieldGapsCount} gaps</span>
{a.companyName && <span> · {a.companyName}</span>}
</div>
</div>
<div className="num text-xs text-muted-foreground shrink-0 ml-3">
{relTime(a.generatedAt)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* STATS FOOTER ------------------------------------------------- */}
{data && (
<p className="text-xs text-muted-foreground">
<span className="num">{data.stats.activeCompanies}</span> active companies ·{' '}
<span className="num">{data.stats.configurationItems.toLocaleString()}</span> configuration items ·{' '}
<span className="num">{data.stats.xref.total.toLocaleString()}</span> xref rows{' '}
({data.stats.xref.total > 0
? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100)
: 0}% linked)
</p>
)}
</div>
</>
);
}
function RowSkeletons() {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
);
}