- dashboard/page.tsx: thread tz into PageHeader description's toLocaleDateString call. - quotes/page.tsx: thread tz into formatDate arrow helper inside the default export. - veeam-analysis/page.tsx: thread tz into the summary footer's generated-at toLocaleString call. Migrates 3 of 81 audit leak callsites.
440 lines
15 KiB
TypeScript
440 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';
|
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
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 tz = useUserTimezone();
|
|
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',
|
|
timeZone: tz,
|
|
})}
|
|
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>
|
|
</>
|
|
);
|
|
}
|
|
|