wulf-pulse/app/dashboard/page.tsx
lorentz ab78e7bd4f refactor(design): adopt TanStack DataTable on /veeam-analysis, fill gaps
Follow-on polish for the nav-design overhaul (#9bfb575).

- /veeam-analysis migrates the bespoke TicketRow + custom pagination to
  the new DataTable using getRowCanExpand + renderSubRow. Drops ~85
  lines of fragment/colspan markup in favor of the standard pattern.

- PageHeader on the last common stragglers — /settings,
  /settings/security, /sentinelone/coverage, /sentinelone/mappings.
  Settings is reachable from the new top-bar UserMenu so it had to
  match the rest of the visual system.

- /dashboard and /status load with the new Skeleton helpers
  (SkeletonRows, SkeletonChart, SkeletonTable) so loading shells now
  approximate the post-load layout instead of a single h-NN bar.

- DESIGN.md: closed the straggler PageHeader item; deprioritized the
  hard-coded palette audit with a note that ~770 references are mostly
  semantic via the documented bg-{hue}-500/15 / text-{hue}-700 recipe.

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

437 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 { SkeletonRows, SkeletonChart } from '@/components/ui/skeleton-helpers';
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 ? (
<SkeletonChart height={196} />
) : (
<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 ? (
<SkeletonChart height={196} />
) : (
<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 ? (
<SkeletonChart height={180} />
) : (
<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 ? (
<SkeletonChart height={180} />
) : (
<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 ? (
<SkeletonRows count={4} />
) : 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 ? (
<SkeletonRows count={4} />
) : 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>
</>
);
}