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>
This commit is contained in:
lorentz 2026-05-03 09:33:13 -04:00
parent 1112a06afe
commit 9bfb57553d
75 changed files with 9352 additions and 1827 deletions

View file

@ -2,6 +2,23 @@
import { useState, useEffect } from 'react';
import { AddigyDevice } from '@/lib/types/addigy';
import { PageHeader } from '@/components/navigation/page-header';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
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 { StatusBadge } from '@/components/ui/status-badge';
import { Check, X, Laptop, RefreshCw } from 'lucide-react';
export default function AddigyDevicesPage() {
const [devices, setDevices] = useState<AddigyDevice[]>([]);
@ -10,21 +27,19 @@ export default function AddigyDevicesPage() {
const [filterOnline, setFilterOnline] = useState(false);
useEffect(() => {
fetchDevices();
void fetchDevices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterOnline]);
const fetchDevices = async () => {
async function fetchDevices() {
setLoading(true);
setError(null);
try {
const url = filterOnline
? '/api/addigy-devices?online=true'
: '/api/addigy-devices';
const response = await fetch(url);
const result = await response.json();
const res = await fetch(url, { cache: 'no-store' });
const result = await res.json();
if (result.success) {
setDevices(result.data);
} else {
@ -35,159 +50,137 @@ export default function AddigyDevicesPage() {
} finally {
setLoading(false);
}
};
}
return (
<div className="container mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Addigy Devices</h1>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filterOnline}
onChange={(e) => setFilterOnline(e.target.checked)}
className="w-4 h-4"
/>
<span>Online Only</span>
</label>
<button
onClick={fetchDevices}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
</div>
<>
<PageHeader
title="Addigy devices"
description={
loading
? 'Loading…'
: `${devices.length} device${devices.length === 1 ? '' : 's'}${filterOnline ? ' · online only' : ''}`
}
breadcrumbs={[{ label: 'Addigy devices' }]}
actions={
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={filterOnline}
onCheckedChange={(v) => setFilterOnline(v === true)}
aria-label="Filter to online devices only"
/>
<span>Online only</span>
</label>
<Button onClick={fetchDevices} variant="outline" size="sm" disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</>
}
/>
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-4">
<strong>Error:</strong> {error}
</div>
)}
<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>
)}
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading devices...</p>
</div>
) : (
<>
<div className="mb-4 text-gray-600">
Found {devices.length} device{devices.length !== 1 ? 's' : ''}
</div>
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Device Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Model
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
OS Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Current User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Free Disk
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Security
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{devices.map((device) => (
<tr key={device.agentid} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{device['Device Name']}
</div>
<div className="text-xs text-gray-500">
{device['Serial Number'] || 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['Device Model Name'] || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['MAC OS X Version'] ||
device['iOS Version'] ||
'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['Current User'] || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
device.online
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{device.online ? 'Online' : 'Offline'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{device['Free Disk Percentage'] !== undefined ? (
<div className="flex items-center">
<span
className={`${
device['Free Disk Percentage'] < 20
? 'text-red-600'
: device['Free Disk Percentage'] < 40
? 'text-yellow-600'
: 'text-green-600'
}`}
>
{device['Free Disk Percentage']}%
</span>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-6 space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-3/4" />
</div>
) : devices.length === 0 ? (
<div className="p-6">
<EmptyState
icon={Laptop}
title="No devices found"
description={
filterOnline
? 'No devices are currently online.'
: 'Addigy has not synced any devices yet.'
}
size="sm"
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>Model</TableHead>
<TableHead>OS</TableHead>
<TableHead>Current user</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Free disk</TableHead>
<TableHead>Security</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => {
const freePct = device['Free Disk Percentage'];
const freeTone =
freePct === undefined
? 'text-muted-foreground'
: freePct < 20
? 'text-destructive'
: freePct < 40
? 'text-amber-600 dark:text-amber-400'
: 'text-emerald-600 dark:text-emerald-400';
return (
<TableRow key={device.agentid}>
<TableCell>
<div className="font-medium">{device['Device Name']}</div>
<div className="text-xs text-muted-foreground num">
{device['Serial Number'] || '—'}
</div>
) : (
'N/A'
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex flex-col gap-1">
<span
className={`text-xs ${
device['Firewall Enabled']
? 'text-green-600'
: 'text-red-600'
}`}
>
FW: {device['Firewall Enabled'] ? '✓' : '✗'}
</span>
<span
className={`text-xs ${
device['FileVault Enabled']
? 'text-green-600'
: 'text-red-600'
}`}
>
FV: {device['FileVault Enabled'] ? '✓' : '✗'}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</TableCell>
<TableCell>{device['Device Model Name'] || 'Unknown'}</TableCell>
<TableCell className="num">
{device['MAC OS X Version'] || device['iOS Version'] || '—'}
</TableCell>
<TableCell>{device['Current User'] || '—'}</TableCell>
<TableCell>
<StatusBadge tone={device.online ? 'ok' : 'inactive'}>
{device.online ? 'Online' : 'Offline'}
</StatusBadge>
</TableCell>
<TableCell className={`text-right num ${freeTone}`}>
{freePct !== undefined ? `${freePct}%` : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-3 text-xs">
<SecurityFlag label="FW" enabled={Boolean(device['Firewall Enabled'])} />
<SecurityFlag label="FV" enabled={Boolean(device['FileVault Enabled'])} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}
function SecurityFlag({ label, enabled }: { label: string; enabled: boolean }) {
return (
<span
className={`inline-flex items-center gap-0.5 ${enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}`}
title={enabled ? `${label} enabled` : `${label} disabled`}
>
{enabled ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
<span className="num font-medium">{label}</span>
</span>
);
}

View file

@ -1,20 +1,23 @@
import { Suspense } from "react";
import { AuditLogTable } from "@/components/admin/audit/audit-log-table";
import { Skeleton } from "@/components/ui/skeleton";
import { PageHeader } from '@/components/navigation/page-header';
export default function AuditLogPage() {
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold">Audit Log</h1>
<p className="text-muted-foreground mt-2">
View system activity and security events
</p>
<>
<PageHeader
title="Audit Log"
description="View system activity and security events"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Audit Log' }]}
accent
/>
<div className="container mx-auto py-8 px-4">
<Suspense fallback={<AuditLogSkeleton />}>
<AuditLogTable />
</Suspense>
</div>
<Suspense fallback={<AuditLogSkeleton />}>
<AuditLogTable />
</Suspense>
</div>
</>
);
}

View file

@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button';
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock, MessageSquare } from 'lucide-react';
import Link from 'next/link';
import { PageHeader } from '@/components/navigation/page-header';
const entities = [
{ name: 'Companies', icon: Users, path: '/admin/data-browser/companies', description: 'View all companies' },
@ -23,23 +24,24 @@ const entities = [
export default function DataBrowserPage() {
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<Home className="w-4 h-4" />
<span className="hidden sm:inline">Back to Dashboard</span>
</Button>
</Link>
<Database className="w-8 h-8" />
<div>
<h1 className="text-3xl font-bold">Database Browser</h1>
<p className="text-muted-foreground">Inspect synced data from PostgreSQL</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<>
<PageHeader
title="Database Browser"
description="Inspect synced data from PostgreSQL"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Database Browser' }]}
accent
actions={
<Link href="/">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<Home className="w-4 h-4" />
<span className="hidden sm:inline">Back to Dashboard</span>
</Button>
</Link>
}
/>
<div className="container mx-auto p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{entities.map((entity) => {
const Icon = entity.icon;
return (
@ -56,7 +58,8 @@ export default function DataBrowserPage() {
</Link>
);
})}
</div>
</div>
</div>
</>
);
}

View file

@ -15,6 +15,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface Candidate {
ciId: string;
@ -105,7 +106,14 @@ export default function DeviceLinkConflictsPage() {
}
return (
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6">
<>
<PageHeader
title="Device-link conflicts"
description="Cases where the reconciler found two or more configuration_items matching one external device record. Pick the right CI to break the tie."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Device-Link Conflicts' }]}
accent
/>
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@ -248,6 +256,7 @@ export default function DeviceLinkConflictsPage() {
))}
</CardContent>
</Card>
</div>
</div>
</>
);
}

View file

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { PageHeader } from '@/components/navigation/page-header';
interface CompanyCategory {
value: number;
@ -287,18 +288,15 @@ export default function DisplaySettingsPage() {
}
return (
<div className="container mx-auto py-8 px-4 max-w-5xl">
<div className="mb-8">
<h1 className="text-3xl font-bold flex items-center gap-2">
<SlidersHorizontal className="h-8 w-8" />
Display Settings
</h1>
<p className="text-muted-foreground mt-2">
Configure which companies appear in the Kiosk and Mobile dashboards.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<>
<PageHeader
title="Display Settings"
description="Configure which companies appear in the Kiosk and Mobile dashboards."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Display Settings' }]}
accent
/>
<div className="container mx-auto py-8 px-4 max-w-5xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section
title="Kiosk"
description="Settings for the executive kiosk display."
@ -317,7 +315,8 @@ export default function DisplaySettingsPage() {
companies={companies}
onSaved={handleSaved}
/>
</div>
</div>
</div>
</>
);
}

View file

@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { PageHeader } from '@/components/navigation/page-header';
interface WriteRow {
id: string;
@ -76,7 +77,14 @@ export default function ItglueWritesPage() {
}, [statusFilter]);
return (
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<>
<PageHeader
title="IT Glue Writes"
description="Every PATCH to IT Glue from Pulse, with before/after diffs and revert history."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'IT Glue Writes' }]}
accent
/>
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4 flex-wrap">
@ -163,6 +171,7 @@ export default function ItglueWritesPage() {
)}
</CardContent>
</Card>
</div>
</div>
</>
);
}

View file

@ -6,6 +6,7 @@ import {
Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle,
AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight,
} from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface WebhookConfig {
id: number;
@ -210,7 +211,23 @@ export default function MorningSummaryPage() {
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<>
<PageHeader
title="Morning NOC Summary"
description="Scheduled 6:30 AM MonFri · Posts to Teams channels via webhook"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Morning NOC Summary' }]}
accent
actions={
<>
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
<Button size="sm" onClick={handleSendAll} disabled={sending}>
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
Send Now
</Button>
</>
}
/>
<div className="max-w-4xl mx-auto p-6 space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
@ -219,21 +236,6 @@ export default function MorningSummaryPage() {
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> Morning NOC Summary</h1>
<p className="text-sm text-muted-foreground mt-1">Scheduled 6:30 AM MonFri · Posts to Teams channels via webhook</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
<Button size="sm" onClick={handleSendAll} disabled={sending}>
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
Send Now
</Button>
</div>
</div>
{/* Last Run Stats */}
{latestSummary && (
<div className="rounded-lg border bg-card p-4 space-y-3">
@ -462,6 +464,7 @@ export default function MorningSummaryPage() {
</div>
</div>
)}
</div>
</div>
</>
);
}

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/navigation/page-header';
import {
RefreshCw,
Network,
@ -273,15 +274,15 @@ export default function AdminIndexPage() {
];
return (
<div className="container mx-auto px-6 py-6 max-w-7xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Admin</h1>
<p className="text-sm text-muted-foreground mt-1">
Sync, mappings, workflows, reporting, and tooling.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<>
<PageHeader
title="Admin"
description="Sync, mappings, workflows, reporting, and tooling."
breadcrumbs={[{ label: 'Admin' }]}
accent
/>
<div className="container mx-auto px-6 py-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sections.map((section) => (
<Card key={section.title}>
<CardHeader className="pb-3">
@ -315,7 +316,8 @@ export default function AdminIndexPage() {
</CardContent>
</Card>
))}
</div>
</div>
</div>
</>
);
}

View file

@ -7,6 +7,7 @@ import {
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
Link2, Link2Off, FileText, CreditCard, Building2, ArrowDownToLine, BarChart3,
} from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface QboStatus {
tokenStatus: 'valid' | 'expired' | 'missing';
@ -118,19 +119,20 @@ function QboPageInner() {
}[status?.tokenStatus ?? 'missing'];
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">QuickBooks Online</h1>
<p className="text-muted-foreground text-sm mt-1">Sync invoices, payments, deposits, transactions and financial reports</p>
</div>
<Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<>
<PageHeader
title="QuickBooks Online"
description="Sync invoices, payments, deposits, transactions and financial reports"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'QuickBooks Online' }]}
accent
actions={
<Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
}
/>
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Banner */}
{banner && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
@ -233,7 +235,8 @@ function QboPageInner() {
</div>
)}
</div>
</div>
</div>
</>
);
}

View file

@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Loader2, RefreshCw, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/navigation/page-header';
interface Settings {
overshellComponentUid: string | null;
@ -94,7 +95,14 @@ export default function RmmOvershellAdminPage() {
}
return (
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<>
<PageHeader
title="RMM Overshell"
description="Datto RMM PowerShell evidence pipeline — settings, executions, and worker activity."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'RMM Overshell' }]}
accent
/>
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@ -267,6 +275,7 @@ export default function RmmOvershellAdminPage() {
)}
</CardContent>
</Card>
</div>
</div>
</>
);
}

View file

@ -1,15 +1,18 @@
import { RoleTable } from "@/components/admin/roles/role-table";
import { PageHeader } from '@/components/navigation/page-header';
export default function RolesPage() {
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold">Role Management</h1>
<p className="text-muted-foreground mt-2">
Manage roles and their permissions
</p>
<>
<PageHeader
title="Role Management"
description="Manage roles and their permissions"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Roles' }]}
accent
/>
<div className="container mx-auto py-8 px-4">
<RoleTable />
</div>
<RoleTable />
</div>
</>
);
}

View file

@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from '@/components/navigation/page-header';
export default function SettingsPage() {
const [settings, setSettings] = useState<Record<string, string>>({});
@ -62,14 +63,14 @@ export default function SettingsPage() {
}
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground mt-2">
Configure application settings
</p>
</div>
<>
<PageHeader
title="Settings"
description="Configure application settings"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Settings' }]}
accent
/>
<div className="container mx-auto py-8 px-4">
<Tabs defaultValue="microsoft" className="space-y-6">
<TabsList>
<TabsTrigger value="microsoft">Microsoft</TabsTrigger>
@ -168,6 +169,7 @@ export default function SettingsPage() {
)}
</Button>
</div>
</div>
</div>
</>
);
}

View file

@ -4,6 +4,15 @@ import { useState, useEffect } 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, Monitor, Loader2, RefreshCw,
ExternalLink, Server, Wifi, WifiOff, AlertTriangle, Bell, XCircle, CheckCircle2, Clock,
@ -108,39 +117,38 @@ function HistoryTab({ refreshKey }: { refreshKey: number }) {
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Records</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-right">Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row: any, i: number) => {
const dur = row.completed_at && row.started_at
? Math.round((new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 1000)
: null;
const tone = row.status === 'completed' ? 'ok' : row.status === 'failed' ? 'error' : 'warn';
return (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 capitalize">{row.sync_type}</td>
<td className="px-4 py-2">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
row.status === 'completed' ? 'bg-green-500/15 text-green-700' :
row.status === 'failed' ? 'bg-red-500/15 text-red-600' :
'bg-yellow-500/15 text-yellow-700'
}`}>{row.status}</span>
</td>
<td className="px-4 py-2 tabular-nums">{row.records_added ?? 0}</td>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2 text-muted-foreground">{dur != null ? `${dur}s` : '—'}</td>
</tr>
<TableRow key={i}>
<TableCell className="capitalize">{row.sync_type}</TableCell>
<TableCell>
<StatusBadge tone={tone}>{row.status}</StatusBadge>
</TableCell>
<TableCell className="text-right num">{row.records_added ?? 0}</TableCell>
<TableCell className="text-muted-foreground num">{fmtDate(row.started_at)}</TableCell>
<TableCell className="text-right text-muted-foreground num">
{dur != null ? `${dur}s` : '—'}
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
);
}

View file

@ -3,6 +3,14 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { RefreshCw, ArrowLeft, Loader2, CheckCircle2, AlertTriangle, Shield, Users, Smartphone, ScrollText, Layers, AppWindow, ChevronDown, ChevronUp, ShieldOff, ShieldAlert, ShieldX } from 'lucide-react';
interface DuoStatus {
@ -226,37 +234,37 @@ export default function DuoSyncPage() {
<div>
<h2 className="text-lg font-semibold mb-3">Child Accounts ({childAccounts.length})</h2>
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-2 font-medium">Account Name</th>
<th className="text-right px-4 py-2 font-medium">Users</th>
<th className="text-right px-4 py-2 font-medium">Integrations</th>
<th className="text-left px-4 py-2 font-medium">Matched Company</th>
<th className="text-left px-4 py-2 font-medium">Last Sync</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Account name</TableHead>
<TableHead className="text-right">Users</TableHead>
<TableHead className="text-right">Integrations</TableHead>
<TableHead>Matched company</TableHead>
<TableHead>Last sync</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{childAccounts.map(a => (
<tr key={a.account_id} className="hover:bg-muted/20">
<td className="px-4 py-2 font-medium">{a.name}</td>
<td className="px-4 py-2 text-right">{a.user_count}</td>
<td className="px-4 py-2 text-right">{a.integration_count}</td>
<td className="px-4 py-2">
<TableRow key={a.account_id}>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell className="text-right num">{a.user_count}</TableCell>
<TableCell className="text-right num">{a.integration_count}</TableCell>
<TableCell>
{a.autotask_company_name ? (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-500" />
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
{a.autotask_company_name}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(a.synced_at)}</td>
</tr>
</TableCell>
<TableCell className="text-muted-foreground num">{fmtDate(a.synced_at)}</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
</div>
@ -294,40 +302,38 @@ function FlaggedUsersTable({ title, description, users, icon, borderColor, bgCol
</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className={bgColor}>
<tr>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Email</th>
<th className="text-left px-4 py-2 font-medium">Account</th>
<th className="text-center px-4 py-2 font-medium">Enrolled</th>
<th className="text-left px-4 py-2 font-medium">Last Login</th>
<th className="text-left px-4 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map(u => (
<tr key={u.user_id} className={`hover:${bgColor}`}>
<td className="px-4 py-2">
<div className="font-medium">{u.realname || u.username}</div>
{u.realname && <div className="text-xs text-muted-foreground">{u.username}</div>}
</td>
<td className="px-4 py-2 text-muted-foreground">{u.email || '\u2014'}</td>
<td className="px-4 py-2">{u.account_name}</td>
<td className="px-4 py-2 text-center">
{u.is_enrolled
? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" />
: <span className="text-muted-foreground">No</span>
}
</td>
<td className="px-4 py-2 text-muted-foreground">{u.last_login ? fmtDate(u.last_login) : 'Never'}</td>
<td className="px-4 py-2 text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Table>
<TableHeader className={bgColor}>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Account</TableHead>
<TableHead className="text-center">Enrolled</TableHead>
<TableHead>Last login</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(u => (
<TableRow key={u.user_id}>
<TableCell>
<div className="font-medium">{u.realname || u.username}</div>
{u.realname && <div className="text-xs text-muted-foreground">{u.username}</div>}
</TableCell>
<TableCell className="text-muted-foreground">{u.email || '\u2014'}</TableCell>
<TableCell>{u.account_name}</TableCell>
<TableCell className="text-center">
{u.is_enrolled
? <CheckCircle2 className="w-4 h-4 text-emerald-500 mx-auto" />
: <span className="text-muted-foreground">No</span>
}
</TableCell>
<TableCell className="text-muted-foreground num">{u.last_login ? fmtDate(u.last_login) : 'Never'}</TableCell>
<TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View file

@ -4,6 +4,15 @@ 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,
@ -41,17 +50,17 @@ function StatCard({
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'completed' ? 'bg-green-500/15 text-green-700' :
status === 'failed' ? 'bg-red-500/15 text-red-600' :
status === 'running' ? 'bg-blue-500/15 text-blue-700' :
'bg-yellow-500/15 text-yellow-700';
function SyncStatusBadge({ status }: { status: string }) {
const tone =
status === 'completed' ? 'ok' :
status === 'failed' ? 'error' :
status === 'running' ? 'info' :
'warn';
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
<StatusBadge tone={tone}>
{status === 'running' && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
{status}
</span>
</StatusBadge>
);
}
@ -122,33 +131,33 @@ function StatusTab({
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Last Sync Breakdown
{latest.status && <span className="ml-2"><StatusBadge status={latest.status} /></span>}
{latest.status && <span className="ml-2"><SyncStatusBadge status={latest.status} /></span>}
</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Entity</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
<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) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-mono text-xs">{e.entity}</td>
<td className="px-4 py-2 text-right tabular-nums">{e.recordsUpserted.toLocaleString()}</td>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(e.duration)}</td>
<td className="px-4 py-2 text-right">
<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-green-500 inline" />
: <span title={e.error}><XCircle className="w-4 h-4 text-red-500 inline" /></span>}
</td>
</tr>
? <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>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
)}
@ -167,28 +176,28 @@ function HistoryTab({ history }: { history: any[] }) {
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
<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) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2"><StatusBadge status={row.status} /></td>
<td className="px-4 py-2 text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</td>
<td className="px-4 py-2 text-right tabular-nums">{(row.total_upserted ?? 0).toLocaleString()}</td>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(row.duration_ms)}</td>
</tr>
<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>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
);
}

View file

@ -11,6 +11,16 @@ import {
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
PauseCircle, Building2, Check, Info, TrendingUp, ExternalLink, Eye, Trash2,
} from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import { Checkbox } from '@/components/ui/checkbox';
import SyncScheduler from '@/components/admin/SyncScheduler';
function fmtDate(d: string | null | undefined) {
@ -39,32 +49,24 @@ function StatCard({ label, value, sub, icon: Icon, cls }: {
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'delivered' ? 'bg-green-500/15 text-green-700' :
status === 'rejected' ? 'bg-red-500/15 text-red-600' :
status === 'held' ? 'bg-yellow-500/15 text-yellow-700' :
status === 'bounced' ? 'bg-orange-500/15 text-orange-700' :
status === 'spam' ? 'bg-purple-500/15 text-purple-700' :
'bg-muted text-muted-foreground';
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{status || '—'}
</span>
);
function MessageStatusBadge({ status }: { status: string }) {
const tone =
status === 'delivered' ? 'ok' :
status === 'rejected' ? 'error' :
status === 'held' ? 'warn' :
status === 'bounced' ? 'warn' :
status === 'spam' ? 'accent' :
'inactive';
return <StatusBadge tone={tone}>{status || '—'}</StatusBadge>;
}
function ThreatBadge({ level }: { level: string }) {
const cls =
level === 'high' ? 'bg-red-500/15 text-red-600' :
level === 'medium' ? 'bg-orange-500/15 text-orange-700' :
level === 'low' ? 'bg-yellow-500/15 text-yellow-700' :
'bg-muted text-muted-foreground';
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{level || 'info'}
</span>
);
function ThreatLevelBadge({ level }: { level: string }) {
const tone =
level === 'high' ? 'error' :
level === 'medium' ? 'warn' :
level === 'low' ? 'pending' :
'inactive';
return <StatusBadge tone={tone}>{level || 'info'}</StatusBadge>;
}
// ── Status Tab ────────────────────────────────────────────────────────────────
@ -211,30 +213,30 @@ function MessagesTab() {
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">From</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">To</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Subject</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Direction</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Sent</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>From</TableHead>
<TableHead>To</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Status</TableHead>
<TableHead>Sent</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.sender_address}>{r.sender_address ?? '—'}</td>
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.recipient_address}>{r.recipient_address ?? '—'}</td>
<td className="px-4 py-2 text-xs truncate max-w-[200px]" title={r.subject}>{r.subject ?? '—'}</td>
<td className="px-4 py-2 text-xs capitalize text-muted-foreground">{r.direction ?? '—'}</td>
<td className="px-4 py-2"><StatusBadge status={r.status} /></td>
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.sent_datetime)}</td>
</tr>
<TableRow key={r.id}>
<TableCell className="text-xs truncate max-w-[180px]" title={r.sender_address}>{r.sender_address ?? '—'}</TableCell>
<TableCell className="text-xs truncate max-w-[180px]" title={r.recipient_address}>{r.recipient_address ?? '—'}</TableCell>
<TableCell className="text-xs truncate max-w-[200px]" title={r.subject}>{r.subject ?? '—'}</TableCell>
<TableCell className="text-xs capitalize text-muted-foreground">{r.direction ?? '—'}</TableCell>
<TableCell><MessageStatusBadge status={r.status} /></TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap num">{fmtDate(r.sent_datetime)}</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
)}
</div>
@ -260,32 +262,32 @@ function ThreatsTab() {
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Level</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Actor</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Verdict</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">URL / File</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">When</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Level</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Verdict</TableHead>
<TableHead>URL / File</TableHead>
<TableHead>When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 text-xs capitalize">{r.event_type ?? '—'}</td>
<td className="px-4 py-2"><ThreatBadge level={r.threat_level} /></td>
<td className="px-4 py-2 text-xs text-muted-foreground">{r.actor_email ?? '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground capitalize">{r.verdict ?? '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground truncate max-w-[200px]" title={r.url ?? r.file_name ?? ''}>
<TableRow key={r.id}>
<TableCell className="text-xs capitalize">{r.event_type ?? '—'}</TableCell>
<TableCell><ThreatLevelBadge level={r.threat_level} /></TableCell>
<TableCell className="text-xs text-muted-foreground">{r.actor_email ?? '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground capitalize">{r.verdict ?? '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[200px]" title={r.url ?? r.file_name ?? ''}>
{r.url ?? r.file_name ?? '—'}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.event_datetime)}</td>
</tr>
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap num">{fmtDate(r.event_datetime)}</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
);
}
@ -420,41 +422,44 @@ function HistoryTab() {
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Messages</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Threats</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Messages</TableHead>
<TableHead>Threats</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r: any, i: number) => {
const dur = r.completed_at && r.started_at
? new Date(r.completed_at).getTime() - new Date(r.started_at).getTime()
: null;
const durStr = dur == null ? '—' : dur < 60000 ? `${Math.round(dur / 1000)}s` : `${Math.floor(dur / 60000)}m ${Math.round((dur % 60000) / 1000)}s`;
const statusCls = r.status === 'completed' ? 'bg-green-500/15 text-green-700' : r.status === 'failed' ? 'bg-red-500/15 text-red-600' : 'bg-muted text-muted-foreground';
const statusTone =
r.status === 'completed' ? 'ok' :
r.status === 'failed' ? 'error' :
'inactive';
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata ?? {});
return (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 capitalize text-xs">{r.sync_type ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusCls}`}>{r.status}</span>
</td>
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.messagesUpserted ?? r.records_added)}</td>
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.threatsUpserted)}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(r.started_at)}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{durStr}</td>
</tr>
<TableRow key={i}>
<TableCell className="capitalize text-xs">{r.sync_type ?? '—'}</TableCell>
<TableCell>
<StatusBadge tone={statusTone}>{r.status}</StatusBadge>
</TableCell>
<TableCell className="num text-xs">{fmtNum(meta.messagesUpserted ?? r.records_added)}</TableCell>
<TableCell className="num text-xs">{fmtNum(meta.threatsUpserted)}</TableCell>
<TableCell className="text-xs text-muted-foreground num">{fmtDate(r.started_at)}</TableCell>
<TableCell className="text-xs text-muted-foreground num">{durStr}</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
);
}
@ -901,74 +906,74 @@ function HeldMailTab() {
: ''}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed">
<thead className="bg-muted/30">
<tr>
<th style={{width:'120px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th>
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">To</th>
<th style={{width:'180px'}} className="text-left px-3 py-2 font-medium text-xs">From</th>
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th>
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">Policy</th>
<th style={{width:'160px'}} className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((m: any) => (
<tr key={m.id} className="hover:bg-muted/20">
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs">
{new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.to}</div>
</td>
<td className="px-3 py-2" style={{overflow:'hidden'}}>
<div className="font-medium text-xs truncate">{m.fromDisplay || m.from}</div>
{m.fromDisplay && <div className="text-xs text-muted-foreground truncate">{m.from}</div>}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.subject || '(no subject)'}</div>
</td>
<td className="px-3 py-2" style={{overflow:'hidden'}}>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
<Table className="table-fixed">
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead style={{width:'120px'}} className="text-xs">Date</TableHead>
<TableHead style={{width:'160px'}} className="text-xs">To</TableHead>
<TableHead style={{width:'180px'}} className="text-xs">From</TableHead>
<TableHead className="text-xs">Subject</TableHead>
<TableHead style={{width:'160px'}} className="text-xs">Policy</TableHead>
<TableHead style={{width:'160px'}}></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((m: any) => (
<TableRow key={m.id}>
<TableCell className="text-muted-foreground whitespace-nowrap text-xs num">
{new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</TableCell>
<TableCell className="text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.to}</div>
</TableCell>
<TableCell style={{overflow:'hidden'}}>
<div className="font-medium text-xs truncate">{m.fromDisplay || m.from}</div>
{m.fromDisplay && <div className="text-xs text-muted-foreground truncate">{m.from}</div>}
</TableCell>
<TableCell className="text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.subject || '(no subject)'}</div>
</TableCell>
<TableCell style={{overflow:'hidden'}}>
<StatusBadge
tone={
m.policyInfo?.includes('DMARC') || m.policyInfo?.includes('Impersonation')
? 'bg-red-500/10 text-red-600'
: 'bg-muted text-muted-foreground'
}`}>
{m.policyInfo || m.reason || '—'}
</span>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1 justify-end">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs px-2 whitespace-nowrap"
onClick={() => setAnalysisMessage(m)}
>
<Info className="w-3 h-3 mr-1" />
Analyze
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2 whitespace-nowrap text-green-700 border-green-300 hover:bg-green-50 dark:hover:bg-green-950/20"
disabled={releasing[m.id]}
onClick={() => release(m)}
>
{releasing[m.id] ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
Release
</Button>
</div>
{releaseErrors[m.id] && (
<div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
? 'error'
: 'inactive'
}
>
{m.policyInfo || m.reason || '—'}
</StatusBadge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 justify-end">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs px-2 whitespace-nowrap"
onClick={() => setAnalysisMessage(m)}
>
<Info className="w-3 h-3 mr-1" />
Analyze
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2 whitespace-nowrap text-green-700 border-green-300 hover:bg-green-50 dark:hover:bg-green-950/20"
disabled={releasing[m.id]}
onClick={() => release(m)}
>
{releasing[m.id] ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
Release
</Button>
</div>
{releaseErrors[m.id] && (
<div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
@ -1385,13 +1390,15 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages
<div className="max-h-40 overflow-y-auto divide-y">
{remedMatches.map(m => (
<label key={m.id} className="flex items-start gap-2 px-3 py-1.5 hover:bg-muted/20 cursor-pointer">
<input type="checkbox" className="mt-0.5 flex-shrink-0"
<Checkbox
className="mt-0.5 flex-shrink-0"
checked={remedSelected.has(m.id)}
onChange={e => {
onCheckedChange={(v) => {
const s = new Set(remedSelected);
e.target.checked ? s.add(m.id) : s.delete(m.id);
if (v === true) s.add(m.id); else s.delete(m.id);
setRemedSelected(s);
}} />
}}
/>
<div className="min-w-0">
<div className="text-xs truncate">{m.subject || '(no subject)'}</div>
<div className="text-xs text-muted-foreground">
@ -1808,68 +1815,70 @@ function DeliveredMailTab() {
{loaded && !loading && filtered.length > 0 && (
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed">
<thead className="bg-muted/30">
<tr>
<th style={{width:'110px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th>
<th style={{width:'150px'}} className="text-left px-3 py-2 font-medium text-xs">To</th>
<th style={{width:'170px'}} className="text-left px-3 py-2 font-medium text-xs">From</th>
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th>
<th style={{width:'90px'}} className="text-left px-3 py-2 font-medium text-xs">Status</th>
<th style={{width:'80px'}} className="text-left px-3 py-2 font-medium text-xs">Spam</th>
<th style={{width:'90px'}} className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((m: any) => (
<tr key={m.id} className={`hover:bg-muted/20 ${
m.spamScore >= 10 || detectSubjectThreat(m.subject) !== null
? 'bg-red-500/5'
: m.spamScore >= 5
? 'bg-amber-500/5'
: ''
}`}>
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs">
{new Date(m.received).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.to}</div>
</td>
<td className="px-3 py-2" style={{overflow:'hidden'}}>
<div className="text-xs truncate font-medium">{m.from}</div>
{m.fromEnv && m.fromEnv !== m.from && (
<div className="text-xs text-muted-foreground truncate">{m.fromEnv}</div>
)}
</td>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.subject || '(no subject)'}</div>
</td>
<td className="px-3 py-2">
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
m.status === 'accepted' ? 'bg-green-500/10 text-green-700'
: m.status === 'held' ? 'bg-amber-500/10 text-amber-700'
: m.status === 'rejected' || m.status === 'bounced' ? 'bg-red-500/10 text-red-600'
: 'bg-muted text-muted-foreground'
}`}>{m.status}</span>
</td>
<td className="px-3 py-2">
<span className={`text-xs font-semibold ${m.spamScore >= 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}>
{m.spamScore}
</span>
</td>
<td className="px-3 py-2">
<Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap"
onClick={() => setAnalysisMessage(m)}>
<Eye className="w-3 h-3 mr-1" />
View
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Table className="table-fixed">
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead style={{width:'110px'}} className="text-xs">Date</TableHead>
<TableHead style={{width:'150px'}} className="text-xs">To</TableHead>
<TableHead style={{width:'170px'}} className="text-xs">From</TableHead>
<TableHead className="text-xs">Subject</TableHead>
<TableHead style={{width:'90px'}} className="text-xs">Status</TableHead>
<TableHead style={{width:'80px'}} className="text-xs">Spam</TableHead>
<TableHead style={{width:'90px'}}></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((m: any) => (
<TableRow key={m.id} className={
m.spamScore >= 10 || detectSubjectThreat(m.subject) !== null
? 'bg-red-500/5'
: m.spamScore >= 5
? 'bg-amber-500/5'
: ''
}>
<TableCell className="text-muted-foreground whitespace-nowrap text-xs num">
{new Date(m.received).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</TableCell>
<TableCell className="text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.to}</div>
</TableCell>
<TableCell style={{overflow:'hidden'}}>
<div className="text-xs truncate font-medium">{m.from}</div>
{m.fromEnv && m.fromEnv !== m.from && (
<div className="text-xs text-muted-foreground truncate">{m.fromEnv}</div>
)}
</TableCell>
<TableCell className="text-xs" style={{overflow:'hidden'}}>
<div className="truncate">{m.subject || '(no subject)'}</div>
</TableCell>
<TableCell>
<StatusBadge
tone={
m.status === 'accepted' ? 'ok' :
m.status === 'held' ? 'warn' :
m.status === 'rejected' || m.status === 'bounced' ? 'error' :
'inactive'
}
>
{m.status}
</StatusBadge>
</TableCell>
<TableCell>
<span className={`text-xs font-semibold num ${m.spamScore >= 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}>
{m.spamScore}
</span>
</TableCell>
<TableCell>
<Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap"
onClick={() => setAnalysisMessage(m)}>
<Eye className="w-3 h-3 mr-1" />
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { RefreshCw, CheckCircle2, XCircle, AlertTriangle, Clock, Loader2, ChevronRight } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface IntegrationCard {
id: string;
@ -222,18 +223,20 @@ export default function SyncOverviewPage() {
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold">Integrations & Sync</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage data sync across all connected platforms</p>
</div>
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
</div>
<>
<PageHeader
title="Integrations & Sync"
description="Manage data sync across all connected platforms"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Integrations & Sync' }]}
accent
actions={
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
}
/>
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
@ -423,6 +426,7 @@ export default function SyncOverviewPage() {
})}
</div>
)}
</div>
</div>
</>
);
}

View file

@ -9,6 +9,15 @@ import {
CheckCircle2, XCircle, AlertTriangle, Clock, Server, HardDrive,
Bot, Bell, ChevronDown, ChevronRight, Target, Play,
} from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import SyncScheduler from '@/components/admin/SyncScheduler';
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -35,13 +44,13 @@ function StatCard({ label, value, sub, icon: Icon, cls }: {
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'completed' ? 'bg-green-500/15 text-green-700' :
status === 'failed' ? 'bg-red-500/15 text-red-600' :
status === 'started' ? 'bg-blue-500/15 text-blue-700' :
'bg-yellow-500/15 text-yellow-700';
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>{status}</span>;
function SyncStatusBadge({ status }: { status: string }) {
const tone =
status === 'completed' ? 'ok' :
status === 'failed' ? 'error' :
status === 'started' ? 'info' :
'warn';
return <StatusBadge tone={tone}>{status}</StatusBadge>;
}
// ── Status Tab ────────────────────────────────────────────────────────────────
@ -135,11 +144,11 @@ function HistoryRow({ row }: { row: any }) {
return (
<>
<tr
className={`border-b hover:bg-muted/30 ${entities.length > 0 ? 'cursor-pointer' : ''}`}
<TableRow
className={entities.length > 0 ? 'cursor-pointer' : ''}
onClick={() => entities.length > 0 && setExpanded(e => !e)}
>
<td className="px-4 py-2.5">
<TableCell>
<div className="flex items-center gap-1.5">
{entities.length > 0
? (expanded
@ -148,16 +157,16 @@ function HistoryRow({ row }: { row: any }) {
: <span className="w-3.5" />}
<span className="capitalize">{row.sync_type}</span>
</div>
</td>
<td className="px-4 py-2.5"><StatusBadge status={row.status} /></td>
<td className="px-4 py-2.5 tabular-nums font-medium">{(row.records_added ?? 0).toLocaleString()}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{dur != null ? fmtDur(dur) : '—'}</td>
<td className="px-4 py-2.5 text-muted-foreground capitalize">{row.triggered_by ?? '—'}</td>
</tr>
</TableCell>
<TableCell><SyncStatusBadge status={row.status} /></TableCell>
<TableCell className="num font-medium">{(row.records_added ?? 0).toLocaleString()}</TableCell>
<TableCell className="text-muted-foreground text-xs num">{fmtDate(row.started_at)}</TableCell>
<TableCell className="text-muted-foreground num">{dur != null ? fmtDur(dur) : '—'}</TableCell>
<TableCell className="text-muted-foreground capitalize">{row.triggered_by ?? '—'}</TableCell>
</TableRow>
{expanded && entities.length > 0 && (
<tr className="border-b bg-muted/20">
<td colSpan={6} className="px-8 py-3">
<TableRow className="bg-muted/20">
<TableCell colSpan={6} className="px-8 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">Entity Breakdown</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{entities.map((e) => (
@ -176,8 +185,8 @@ function HistoryRow({ row }: { row: any }) {
{row.error_message}
</div>
)}
</td>
</tr>
</TableCell>
</TableRow>
)}
</>
);
@ -201,21 +210,21 @@ function VeeamHistoryTab({ refreshKey }: { refreshKey: number }) {
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Records</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Triggered By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => <HistoryRow key={i} row={row} />)}
</tbody>
</table>
</TableBody>
</Table>
</div>
);
}
@ -253,45 +262,49 @@ function AgentsTab({ refreshKey }: { refreshKey: number }) {
</div>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Name</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Platform</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Agent Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Version</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Mode</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Jobs</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Platform</TableHead>
<TableHead>Status</TableHead>
<TableHead>Agent Status</TableHead>
<TableHead>Version</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Jobs</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agents.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium">{a.name}</td>
<td className="px-4 py-2 text-muted-foreground text-xs">{a.organization_name ?? '—'}</td>
<td className="px-4 py-2 text-xs">{a.agent_platform ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.status === 'Active' ? 'bg-green-500/15 text-green-700' : 'bg-muted text-muted-foreground'
}`}>{a.status ?? '—'}</span>
</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.management_agent_status === 'Inaccessible' ? 'bg-red-500/15 text-red-600' :
a.management_agent_status === 'Accessible' ? 'bg-green-500/15 text-green-700' :
'bg-muted text-muted-foreground'
}`}>{a.management_agent_status ?? '—'}</span>
</td>
<td className="px-4 py-2 text-xs">
<TableRow key={a.instance_uid}>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell className="text-muted-foreground text-xs">{a.organization_name ?? '—'}</TableCell>
<TableCell className="text-xs">{a.agent_platform ?? '—'}</TableCell>
<TableCell>
<StatusBadge tone={a.status === 'Active' ? 'ok' : 'inactive'}>
{a.status ?? '—'}
</StatusBadge>
</TableCell>
<TableCell>
<StatusBadge
tone={
a.management_agent_status === 'Inaccessible' ? 'error' :
a.management_agent_status === 'Accessible' ? 'ok' :
'inactive'
}
>
{a.management_agent_status ?? '—'}
</StatusBadge>
</TableCell>
<TableCell className="text-xs">
<span className={a.version_status === 'Outdated' ? 'text-yellow-700 font-medium' : 'text-muted-foreground'}>
{a.version ?? '—'}
{a.version_status === 'Outdated' && ' ⚠'}
</span>
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.operation_mode ?? '—'}</td>
<td className="px-4 py-2 text-xs tabular-nums">
</TableCell>
<TableCell className="text-xs text-muted-foreground">{a.operation_mode ?? '—'}</TableCell>
<TableCell className="text-xs num">
<span className="text-green-700">{a.success_jobs_count ?? 0}</span>
{(a.running_jobs_count ?? 0) > 0 && <span className="text-blue-600 ml-1">{a.running_jobs_count}</span>}
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0) > 0 && (
@ -299,11 +312,11 @@ function AgentsTab({ refreshKey }: { refreshKey: number }) {
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0)}
</span>
)}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
);
@ -342,41 +355,45 @@ function AlarmsTab({ refreshKey }: { refreshKey: number }) {
</div>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Object</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Repeats</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Activation</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Message</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Object</TableHead>
<TableHead>Type</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Status</TableHead>
<TableHead>Repeats</TableHead>
<TableHead>Last Activation</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alarms.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium">{a.object_computer_name || a.object_name || '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.object_type ?? '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.organization_name ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.last_activation_status === 'Active' ? 'bg-red-500/15 text-red-600' :
a.last_activation_status === 'Warning' ? 'bg-yellow-500/15 text-yellow-700' :
a.last_activation_status === 'Resolved' ? 'bg-green-500/15 text-green-700' :
'bg-muted text-muted-foreground'
}`}>{a.last_activation_status ?? '—'}</span>
</td>
<td className="px-4 py-2 tabular-nums text-xs">{a.repeat_count ?? 0}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(a.last_activation_time)}</td>
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={a.last_activation_message ?? ''}>
<TableRow key={a.instance_uid}>
<TableCell className="font-medium">{a.object_computer_name || a.object_name || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{a.object_type ?? '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{a.organization_name ?? '—'}</TableCell>
<TableCell>
<StatusBadge
tone={
a.last_activation_status === 'Active' ? 'error' :
a.last_activation_status === 'Warning' ? 'warn' :
a.last_activation_status === 'Resolved' ? 'ok' :
'inactive'
}
>
{a.last_activation_status ?? '—'}
</StatusBadge>
</TableCell>
<TableCell className="num text-xs">{a.repeat_count ?? 0}</TableCell>
<TableCell className="text-xs text-muted-foreground num">{fmtDate(a.last_activation_time)}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-xs truncate" title={a.last_activation_message ?? ''}>
{a.last_activation_message?.trim() || '—'}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
);
@ -463,47 +480,48 @@ function RpoTab({ refreshKey }: { refreshKey: number }) {
<XCircle className="w-4 h-4 text-red-600" />
<p className="text-xs font-semibold uppercase tracking-wider text-red-700">RPO Breached ({breachedJobs.length})</p>
</div>
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Overdue</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Failure Reason</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Ticket</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Job</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Last Backup</TableHead>
<TableHead>Overdue</TableHead>
<TableHead>Failure Reason</TableHead>
<TableHead>Ticket</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{breachedJobs.map((j: any) => {
const hrs = j.hours_since_backup;
const display = hrs === null ? 'Never' : hrs >= 48 ? `${Math.round(hrs / 24)}d` : `${Math.round(hrs)}h`;
const ticketPriCls = j.open_ticket?.priority_level === 'critical' ? 'bg-red-500/15 text-red-700'
: j.open_ticket?.priority_level === 'high' ? 'bg-orange-500/15 text-orange-700'
: 'bg-yellow-500/15 text-yellow-700';
const ticketTone =
j.open_ticket?.priority_level === 'critical' ? 'error' :
j.open_ticket?.priority_level === 'high' ? 'warn' :
'pending';
return (
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td>
<td className="px-4 py-2 tabular-nums text-xs font-semibold text-red-600">{display}</td>
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={j.failure_category ?? ''}>
<TableRow key={j.job_instance_uid}>
<TableCell className="font-medium text-xs">{j.job_name}</TableCell>
<TableCell className="text-xs text-muted-foreground">{j.org_name}</TableCell>
<TableCell className="text-xs text-muted-foreground num">{fmtDate(j.last_end_time)}</TableCell>
<TableCell className="num text-xs font-semibold text-red-600">{display}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-xs truncate" title={j.failure_category ?? ''}>
{j.failure_category ?? '—'}
</td>
<td className="px-4 py-2 text-xs">
</TableCell>
<TableCell className="text-xs">
{j.open_ticket ? (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${ticketPriCls}`}>
<StatusBadge tone={ticketTone}>
{j.open_ticket.at_ticket_number} · {j.open_ticket.priority_level}
</span>
</StatusBadge>
) : (
<span className="text-muted-foreground">No ticket yet</span>
)}
</td>
</tr>
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
)}
@ -512,30 +530,30 @@ function RpoTab({ refreshKey }: { refreshKey: number }) {
<summary className="px-4 py-2.5 bg-green-500/5 border-b cursor-pointer flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-green-700">
<CheckCircle2 className="w-4 h-4" />Within RPO ({healthyJobs.length})
</summary>
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Hours Ago</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">RPO</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Job</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Last Backup</TableHead>
<TableHead>Hours Ago</TableHead>
<TableHead>RPO</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{healthyJobs.map((j: any) => (
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td>
<td className="px-4 py-2 tabular-nums text-xs text-green-700">
<TableRow key={j.job_instance_uid}>
<TableCell className="font-medium text-xs">{j.job_name}</TableCell>
<TableCell className="text-xs text-muted-foreground">{j.org_name}</TableCell>
<TableCell className="text-xs text-muted-foreground num">{fmtDate(j.last_end_time)}</TableCell>
<TableCell className="num text-xs text-green-700">
{j.hours_since_backup !== null ? `${j.hours_since_backup}h` : '—'}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.rpo_hours}h</td>
</tr>
</TableCell>
<TableCell className="text-xs text-muted-foreground num">{j.rpo_hours}h</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</details>
)}
</div>

View file

@ -7,6 +7,7 @@ import {
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
} from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface DigestConfig {
daily_enabled: boolean;
@ -172,7 +173,19 @@ export default function TicketDigestPage() {
);
return (
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
<>
<PageHeader
title="Ticket Digest Reports"
description="LLM-analyzed ticket reports delivered to Teams — daily, weekly, and monthly"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Ticket Digest Reports' }]}
accent
actions={
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
}
/>
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}>
@ -180,21 +193,6 @@ export default function TicketDigestPage() {
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BarChart3 className="h-6 w-6" /> Ticket Digest Reports
</h1>
<p className="text-muted-foreground text-sm mt-1">
LLM-analyzed ticket reports delivered to Teams daily, weekly, and monthly
</p>
</div>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
</div>
{/* Generate Reports */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
@ -451,6 +449,7 @@ export default function TicketDigestPage() {
</div>
)}
</div>
</div>
</div>
</>
);
}

View file

@ -1,20 +1,23 @@
import { Suspense } from "react";
import { UserTable } from "@/components/admin/users/user-table";
import { Skeleton } from "@/components/ui/skeleton";
import { PageHeader } from '@/components/navigation/page-header';
export default function UsersPage() {
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground mt-2">
Manage users, roles, and permissions
</p>
<>
<PageHeader
title="User Management"
description="Manage users, roles, and permissions"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Users' }]}
accent
/>
<div className="container mx-auto py-8 px-4">
<Suspense fallback={<UserTableSkeleton />}>
<UserTable />
</Suspense>
</div>
<Suspense fallback={<UserTableSkeleton />}>
<UserTable />
</Suspense>
</div>
</>
);
}

View file

@ -18,6 +18,7 @@ import {
PauseCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/navigation/page-header';
interface TicketWorkflow {
id: number;
@ -109,25 +110,22 @@ export default function WorkflowListPage() {
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Workflow className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Ticket Workflows</h1>
<p className="text-sm text-muted-foreground">Automated ticket triage and classification</p>
</div>
</div>
<Link href="/admin/workflow/create">
<Button>
<Plus className="w-4 h-4 mr-2" />
Create Workflow
</Button>
</Link>
</div>
<>
<PageHeader
title="Ticket Workflows"
description="Automated ticket triage and classification"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Ticket Workflows' }]}
accent
actions={
<Link href="/admin/workflow/create">
<Button>
<Plus className="w-4 h-4 mr-2" />
Create Workflow
</Button>
</Link>
}
/>
<div className="container mx-auto p-6 space-y-6">
{/* Master Control */}
<Card className={globalEnabled ? 'border-green-500/50' : 'border-gray-300'}>
<CardHeader>
@ -284,6 +282,7 @@ export default function WorkflowListPage() {
</div>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View file

@ -50,6 +50,7 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { HostManager } from '@/components/zabbix/host-manager';
import { PageHeader } from '@/components/navigation/page-header';
type SyncMode = 'all' | 'client' | 'site';
@ -433,19 +434,14 @@ export default function ZabbixWanPage() {
};
return (
<div className="container mx-auto py-8 max-w-6xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing
</p>
</div>
</div>
<>
<PageHeader
title="Zabbix WAN Monitor Setup"
description="Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Zabbix WAN' }]}
accent
/>
<div className="container mx-auto py-8 max-w-6xl space-y-6">
{/* Tabs */}
<div className="flex gap-1 border-b">
{([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => (
@ -1422,6 +1418,7 @@ export default function ZabbixWanPage() {
)}
</div>
)}
</div>
</div>
</>
);
}

View file

@ -6,6 +6,7 @@ import { AnalysisView } from '@/components/analyzer/analysis-view';
import { ItglueSuggestionsPanel } from '@/components/analyzer/itglue-suggestions-panel';
import type { PersistedAnalysis } from '@/lib/types/analyzer';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { PageHeader } from '@/components/navigation/page-header';
export default function AnalysisDetailPage({
params,
@ -36,27 +37,42 @@ export default function AnalysisDetailPage({
};
}, [id]);
const ticketNumber = analysis?.ticketNumber;
return (
<div className="container mx-auto px-6 py-6 max-w-5xl">
{error && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load this analysis</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{!error && !analysis && (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{analysis && (
<div className="space-y-6">
<AnalysisView analysis={analysis} />
<ItglueSuggestionsPanel analysisId={analysis.id} />
</div>
)}
</div>
<>
<PageHeader
title={analysis?.ticketNumber ? `Analysis · ${analysis.ticketNumber}` : 'Analysis'}
description={analysis?.id && `id ${analysis.id.slice(0, 8)}`}
breadcrumbs={[
{ label: 'Analyzer', href: '/analyzer/tickets' },
...(ticketNumber
? [{ label: ticketNumber, href: `/analyzer/ticket/${encodeURIComponent(ticketNumber)}` }]
: []),
{ label: 'Analysis' },
]}
accent
/>
<div className="container mx-auto px-6 py-6 max-w-5xl">
{error && (
<Alert variant="destructive">
<AlertTitle>Couldn&rsquo;t load this analysis</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{!error && !analysis && (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{analysis && (
<div className="space-y-6">
<AnalysisView analysis={analysis} />
<ItglueSuggestionsPanel analysisId={analysis.id} />
</div>
)}
</div>
</>
);
}

View file

@ -12,6 +12,7 @@ import {
ProviderToggle,
type AnalyzerProvider,
} from '@/components/analyzer/provider-toggle';
import { PageHeader } from '@/components/navigation/page-header';
import { Sparkles, Zap } from 'lucide-react';
import type { PersistedAnalysis } from '@/lib/types/analyzer';
@ -50,29 +51,24 @@ export default function TicketAnalyzerPage({
const latest = analyses?.[0];
return (
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ticket</p>
<CardTitle className="font-mono">{ticketNumber}</CardTitle>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Click <strong>Analyze</strong> to run the AI pipeline using the
selected provider. Each provider keeps its own analysis history,
so you can compare Claude and DeepSeek output side-by-side. A run
with the same content hash on the same provider returns instantly.
</p>
</CardContent>
</Card>
<>
<PageHeader
title={ticketNumber}
description="Run the analyzer pipeline against this ticket. Each provider keeps its own history; same-hash runs are instant."
breadcrumbs={[
{ label: 'Analyzer', href: '/analyzer/tickets' },
{ label: 'Tickets', href: '/analyzer/tickets' },
{ label: ticketNumber },
]}
accent
actions={
<>
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
</>
}
/>
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
{error && (
<Alert variant="destructive">
@ -162,6 +158,7 @@ export default function TicketAnalyzerPage({
)}
</CardContent>
</Card>
</div>
</div>
</>
);
}

View file

@ -2,10 +2,11 @@
* GET /api/dashboard/overview
* Single round-trip backing the new dashboard. All queries run in parallel.
*
* today KPI snapshot: opened, resolved, open total, SLA breaches
* attention counts that should pull a human's eyes
* observations recent device_observations (loglift et al.)
* audits recent endpoint_audits
* syncHealth per-schedule last_run / last_status from sync_schedules
* syncHealth per-schedule last_run / last_status (consumed by /status)
* stats small footer: companies, CIs, xref linkage
*/
@ -20,6 +21,9 @@ export async function GET() {
type Counts = { count: string };
const [
todayRes,
yesterdayOpenedRes,
last7AvgResolvedRes,
linkConflictsRes,
itglueUnlinkedRes,
s1UnmappedRes,
@ -31,6 +35,44 @@ export async function GET() {
ciRes,
xrefRes,
] = await Promise.all([
/* today snapshot — single row, all four KPIs */
postgresClient.query<{
opened_today: string;
resolved_today: string;
open_total: string;
sla_breaches: string;
}>(`
SELECT
COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND due_date_time IS NOT NULL
AND due_date_time < NOW()
)::text AS sla_breaches
FROM tickets
WHERE is_deleted = false OR is_deleted IS NULL
`),
/* yesterday's opened count for the today-vs-yesterday delta */
postgresClient.query<{ count: string }>(`
SELECT COUNT(*)::text AS count
FROM tickets
WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day'
AND (is_deleted = false OR is_deleted IS NULL)
`),
/* 7-day average resolved (excluding today) for the resolved delta */
postgresClient.query<{ avg_resolved: string }>(`
SELECT COALESCE(AVG(daily_count), 0)::text AS avg_resolved
FROM (
SELECT completed_date::date AS d, COUNT(*) AS daily_count
FROM tickets
WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days'
AND completed_date < CURRENT_DATE
AND (is_deleted = false OR is_deleted IS NULL)
GROUP BY completed_date::date
) sub
`),
postgresClient.query<Counts>(
`SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL`
),
@ -118,7 +160,19 @@ export async function GET() {
),
]);
const today = todayRes.rows[0];
const yesterdayOpened = parseInt(yesterdayOpenedRes.rows[0]?.count ?? '0', 10);
const last7Avg = parseFloat(last7AvgResolvedRes.rows[0]?.avg_resolved ?? '0');
return NextResponse.json({
today: {
openedToday: parseInt(today?.opened_today ?? '0', 10),
resolvedToday: parseInt(today?.resolved_today ?? '0', 10),
openTotal: parseInt(today?.open_total ?? '0', 10),
slaBreaches: parseInt(today?.sla_breaches ?? '0', 10),
yesterdayOpened,
last7DayAvgResolved: Math.round(last7Avg * 10) / 10,
},
attention: {
linkConflicts: parseInt(linkConflictsRes.rows[0]?.count ?? '0', 10),
itglueUnlinked: parseInt(itglueUnlinkedRes.rows[0]?.count ?? '0', 10),

View file

@ -0,0 +1,143 @@
/**
* GET /api/dashboard/trends
* Operational trend data backing /dashboard's chart row + queue posture.
*
* volumeByDay last 30 days, ticket creation count per day
* resolutionByDay last 30 days, mean resolution hours per day completed
* queueHeatmap open tickets grouped by (queue, priority)
* activeEngineers top engineers today by hours logged
*
* All queries run in parallel. ~50 ms total against a warm DB.
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
const TREND_DAYS = 30;
const TOP_QUEUES = 10;
const TOP_ENGINEERS = 8;
export async function GET() {
const { error } = await requireAuth();
if (error) return error;
const [volumeRes, resolutionRes, heatmapRes, engineersRes] = await Promise.all([
postgresClient.query<{ d: string; count: string }>(
`WITH days AS (
SELECT generate_series(
CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
CURRENT_DATE,
INTERVAL '1 day'
)::date AS d
)
SELECT d::text AS d,
COALESCE(COUNT(t.id), 0)::text AS count
FROM days
LEFT JOIN tickets t
ON t.create_date::date = days.d
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY d
ORDER BY d`,
),
postgresClient.query<{ d: string; avg_hours: string | null }>(
`WITH days AS (
SELECT generate_series(
CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
CURRENT_DATE,
INTERVAL '1 day'
)::date AS d
)
SELECT d::text AS d,
AVG(EXTRACT(EPOCH FROM (t.completed_date - t.create_date)) / 3600.0)::text AS avg_hours
FROM days
LEFT JOIN tickets t
ON t.completed_date::date = days.d
AND t.create_date IS NOT NULL
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY d
ORDER BY d`,
),
postgresClient.query<{
queue_id: number | null;
queue_label: string | null;
priority: number | null;
count: string;
}>(
`SELECT t.queue_id,
q.label AS queue_label,
t.priority,
COUNT(*)::text AS count
FROM tickets t
LEFT JOIN queues q ON q.value = t.queue_id
WHERE t.completed_date IS NULL
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY t.queue_id, q.label, t.priority
ORDER BY COUNT(*) DESC`,
),
postgresClient.query<{
resource_id: string;
resource_name: string;
hours: string;
tickets_touched: string;
}>(
`SELECT te.resource_id::text,
COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''),
r.email,
'Resource ' || te.resource_id) AS resource_name,
SUM(te.hours_worked)::text AS hours,
COUNT(DISTINCT te.ticket_id)::text AS tickets_touched
FROM time_entries te
LEFT JOIN resources r ON r.id = te.resource_id
WHERE te.entry_date::date = CURRENT_DATE
AND te.hours_worked > 0
GROUP BY te.resource_id, r.first_name, r.last_name, r.email
ORDER BY SUM(te.hours_worked) DESC
LIMIT ${TOP_ENGINEERS}`,
),
]);
// Heatmap: top N queues by open volume × priority columns
const heatmapRows = heatmapRes.rows;
const queueTotals = new Map<number, { id: number; label: string; total: number }>();
for (const row of heatmapRows) {
if (row.queue_id == null) continue;
const t = queueTotals.get(row.queue_id) ?? {
id: row.queue_id,
label: row.queue_label ?? `Queue ${row.queue_id}`,
total: 0,
};
t.total += parseInt(row.count, 10);
queueTotals.set(row.queue_id, t);
}
const topQueues = [...queueTotals.values()]
.sort((a, b) => b.total - a.total)
.slice(0, TOP_QUEUES);
const heatmap = topQueues.map((q) => {
const cells: Record<number, number> = {};
for (const row of heatmapRows) {
if (row.queue_id !== q.id || row.priority == null) continue;
cells[row.priority] = (cells[row.priority] ?? 0) + parseInt(row.count, 10);
}
return { queueId: q.id, queueLabel: q.label, total: q.total, byPriority: cells };
});
return NextResponse.json({
volumeByDay: volumeRes.rows.map((r) => ({
date: r.d,
count: parseInt(r.count, 10),
})),
resolutionByDay: resolutionRes.rows.map((r) => ({
date: r.d,
avgHours: r.avg_hours == null ? null : Math.round(parseFloat(r.avg_hours) * 10) / 10,
})),
queueHeatmap: heatmap,
activeEngineers: engineersRes.rows.map((r) => ({
resourceId: r.resource_id,
name: r.resource_name,
hours: Math.round(parseFloat(r.hours) * 10) / 10,
ticketsTouched: parseInt(r.tickets_touched, 10),
})),
});
}

View file

@ -0,0 +1,102 @@
/**
* GET /api/status/workers
* Heartbeat snapshot for the three in-process workers:
* analyzer analyzer_jobs
* rmm rmm_executions
* sync sync_schedules / sync_history (proxy for the scheduler)
*
* For each: last activity timestamp, in-flight count, last-1h success/
* failure totals. Cheap just SELECT COUNT(*) FILTER queries. */
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
export interface WorkerSnapshot {
name: string;
lastActivity: string | null;
inFlight: number;
oneHour: { success: number; failure: number };
}
export async function GET() {
const { error } = await requireAuth();
if (error) return error;
const [analyzerRes, rmmRes, syncRes] = await Promise.all([
postgresClient.query<{
last_activity: string | null;
in_flight: string;
ok_1h: string;
fail_1h: string;
}>(
`SELECT
GREATEST(MAX(queued_at), MAX(started_at), MAX(finished_at))::text AS last_activity,
COUNT(*) FILTER (WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review'))::text AS in_flight,
COUNT(*) FILTER (WHERE status = 'complete' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM analyzer_jobs`,
),
postgresClient.query<{
last_activity: string | null;
in_flight: string;
ok_1h: string;
fail_1h: string;
}>(
`SELECT
GREATEST(MAX(queued_at), MAX(started_at), MAX(completed_at))::text AS last_activity,
COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight,
COUNT(*) FILTER (WHERE status = 'complete' AND completed_at >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM rmm_executions`,
),
postgresClient.query<{
last_run: string | null;
ok_1h: string;
fail_1h: string;
}>(
`SELECT
MAX(last_run)::text AS last_run,
COUNT(*) FILTER (WHERE last_status = 'success' AND last_run >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE last_status = 'failed' AND last_run >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM sync_schedules
WHERE is_enabled = true`,
),
]);
const a = analyzerRes.rows[0];
const r = rmmRes.rows[0];
const s = syncRes.rows[0];
const workers: WorkerSnapshot[] = [
{
name: 'Analyzer',
lastActivity: a?.last_activity ?? null,
inFlight: parseInt(a?.in_flight ?? '0', 10),
oneHour: {
success: parseInt(a?.ok_1h ?? '0', 10),
failure: parseInt(a?.fail_1h ?? '0', 10),
},
},
{
name: 'RMM Overshell',
lastActivity: r?.last_activity ?? null,
inFlight: parseInt(r?.in_flight ?? '0', 10),
oneHour: {
success: parseInt(r?.ok_1h ?? '0', 10),
failure: parseInt(r?.fail_1h ?? '0', 10),
},
},
{
name: 'Sync scheduler',
lastActivity: s?.last_run ?? null,
inFlight: 0,
oneHour: {
success: parseInt(s?.ok_1h ?? '0', 10),
failure: parseInt(s?.fail_1h ?? '0', 10),
},
},
];
return NextResponse.json({ workers });
}

View file

@ -12,6 +12,15 @@ import { ContractCoverageTable } from '@/components/backup/contract-coverage-tab
import { RefreshCw, CheckCircle2, AlertTriangle, XCircle, Clock, WifiOff } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import { RpoJobSummary } from '@/lib/services/veeam-rpo-service';
interface BackupStatusData {
@ -284,28 +293,28 @@ export default function BackupStatusPage() {
{/* Job Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Job</th>
<th className="px-4 py-3 text-left font-medium">Organization</th>
<th className="px-4 py-3 text-left font-medium">Last Backup</th>
<th className="px-4 py-3 text-left font-medium">RMM Device</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Ticket</th>
<th className="px-4 py-3 text-left font-medium">Failure Reason</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Job</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Last Backup</TableHead>
<TableHead>RMM Device</TableHead>
<TableHead>Status</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Failure Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rpo.jobs.map((job) => (
<tr key={job.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{job.job_name}</td>
<td className="px-4 py-3 text-muted-foreground">{job.org_name}</td>
<td className="px-4 py-3 text-muted-foreground">{timeAgoHours(job.hours_since_backup)}</td>
<td className="px-4 py-3 text-xs">
<TableRow key={job.job_instance_uid}>
<TableCell className="font-medium">{job.job_name}</TableCell>
<TableCell className="text-muted-foreground">{job.org_name}</TableCell>
<TableCell className="text-muted-foreground num">{timeAgoHours(job.hours_since_backup)}</TableCell>
<TableCell className="text-xs">
{job.rmm_hostname ? (
<div>
<span className="font-mono">{job.rmm_hostname}</span>
<span className="num">{job.rmm_hostname}</span>
{job.is_offline_suppressed && (
<div className="flex items-center gap-1 mt-0.5 text-muted-foreground">
<WifiOff className="h-3 w-3" />
@ -316,21 +325,21 @@ export default function BackupStatusPage() {
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3">
</TableCell>
<TableCell>
{job.is_offline_suppressed ? (
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
<StatusBadge tone="neutral" className="gap-1">
<WifiOff className="h-3 w-3" />Offline
</Badge>
</StatusBadge>
) : job.is_breached ? (
<Badge variant="destructive">Breached</Badge>
<StatusBadge tone="error">Breached</StatusBadge>
) : (
<Badge variant="outline" className="text-green-600 border-green-600">Healthy</Badge>
<StatusBadge tone="ok">Healthy</StatusBadge>
)}
</td>
<td className="px-4 py-3">
</TableCell>
<TableCell>
{job.open_ticket ? (
<span className={`text-xs font-mono ${
<span className={`text-xs num ${
job.open_ticket.priority_level === 'critical' ? 'text-destructive' :
job.open_ticket.priority_level === 'high' ? 'text-orange-500' : 'text-muted-foreground'
}`}>
@ -339,19 +348,19 @@ export default function BackupStatusPage() {
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate">
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-xs truncate">
{job.failure_category ?? '—'}
</td>
</tr>
</TableCell>
</TableRow>
))}
{rpo.jobs.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</td>
</tr>
<TableRow>
<TableCell colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
</>
)}
@ -363,43 +372,43 @@ export default function BackupStatusPage() {
No Autotask ticket is created while the device is offline.
</p>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Device</th>
<th className="px-4 py-3 text-left font-medium">Job</th>
<th className="px-4 py-3 text-left font-medium">Organization</th>
<th className="px-4 py-3 text-left font-medium">Type</th>
<th className="px-4 py-3 text-left font-medium">Last Seen</th>
<th className="px-4 py-3 text-left font-medium">Offline</th>
<th className="px-4 py-3 text-left font-medium">Checked</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Device</TableHead>
<TableHead>Job</TableHead>
<TableHead>Organization</TableHead>
<TableHead>Type</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead>Offline</TableHead>
<TableHead>Checked</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{offlineLog.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-mono text-xs">{row.rmm_hostname}</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[180px] truncate">{row.job_name}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{row.org_name}</td>
<td className="px-4 py-3">
<TableRow key={row.id}>
<TableCell className="num text-xs">{row.rmm_hostname}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[180px] truncate">{row.job_name}</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.org_name}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">{row.device_type_category}</Badge>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.rmm_last_seen)}</td>
<td className="px-4 py-3 text-xs">
</TableCell>
<TableCell className="text-xs text-muted-foreground num">{timeAgo(row.rmm_last_seen)}</TableCell>
<TableCell className="text-xs num">
{row.hours_offline >= 48
? `${Math.round(row.hours_offline / 24)}d`
: `${Math.round(row.hours_offline)}h`}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.checked_at)}</td>
</tr>
</TableCell>
<TableCell className="text-xs text-muted-foreground num">{timeAgo(row.checked_at)}</TableCell>
</TableRow>
))}
{offlineLog.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No offline suppressions logged yet</td>
</tr>
<TableRow>
<TableCell colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No offline suppressions logged yet</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
</TabsContent>

View file

@ -585,9 +585,9 @@ function ConfigurationItemsContent() {
<CardContent className="pt-6">
<div className="space-y-4">
{/* Company Selector Row */}
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="w-[500px]">
<div className="w-full sm:flex-1 sm:min-w-[280px] sm:max-w-[500px]">
<CompanySelectorEnhanced
value={selectedCompany}
onValueChange={handleCompanyChange}
@ -595,10 +595,10 @@ function ConfigurationItemsContent() {
/>
</div>
{selectedCompany && (
<div className="flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 rounded-lg flex-shrink-0">
<Server className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium whitespace-nowrap">
PSA: {stats?.totalAutotask || 0} | RMM: {stats?.totalRmm || 0} | NMS: {stats?.totalAuvik || 0} | ARMM: {stats?.totalAddigy || 0}
<div className="flex items-center gap-2 px-3 py-1.5 bg-primary/10 rounded-md flex-shrink-0">
<Server className="h-4 w-4 text-primary" />
<span className="text-xs font-medium num">
PSA {stats?.totalAutotask || 0} · RMM {stats?.totalRmm || 0} · NMS {stats?.totalAuvik || 0} · ARMM {stats?.totalAddigy || 0}
</span>
</div>
)}

View file

@ -1,59 +1,48 @@
/* /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 Link from 'next/link';
import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
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 {
AlertTriangle,
Database,
Shield,
CalendarClock,
RefreshCw,
ArrowRight,
CheckCircle2,
XCircle,
Clock,
Activity,
Sparkles,
Plug,
KeyRound,
Users,
Layers,
TrendingUp,
Timer,
} from 'lucide-react';
interface IntegrationHealthItem {
key: string;
name: string;
category: string;
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown';
configured: boolean;
latencyMs?: number;
error?: string | null;
tokenExpiry?: {
envVar: string;
expiresAt: string;
daysRemaining: number;
subject?: string | null;
} | null;
checkedAt: string;
}
interface IntegrationHealthResponse {
items: IntegrationHealthItem[];
summary: {
total: number;
ok: number;
failed: number;
notConfigured: number;
expiringWithin14Days: number;
expired: number;
hasIssues: boolean;
};
}
interface Overview {
today: {
openedToday: number;
resolvedToday: number;
openTotal: number;
slaBreaches: number;
yesterdayOpened: number;
last7DayAvgResolved: number;
};
attention: {
linkConflicts: number;
itglueUnlinked: number;
@ -78,16 +67,6 @@ interface Overview {
fieldGapsCount: number;
status: string;
}>;
syncHealth: Array<{
id: string;
name: string;
syncType: string;
isEnabled: boolean;
lastRun: string | null;
lastStatus: string | null;
lastError: string | null;
nextRun: string | null;
}>;
stats: {
activeCompanies: number;
configurationItems: number;
@ -95,7 +74,22 @@ interface Overview {
};
}
const STALE_HOURS = 24;
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';
@ -110,42 +104,26 @@ function relTime(iso: string | null): string {
return `${day} d ago`;
}
function isStale(iso: string | null): boolean {
if (!iso) return true;
return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000;
}
function syncStatusIcon(s: { lastStatus: string | null; lastRun: string | null; isEnabled: boolean }) {
if (!s.isEnabled) return <span className="text-muted-foreground text-xs">off</span>;
if (s.lastStatus === 'failed')
return <XCircle className="size-4 text-destructive" aria-label="failed" />;
if (isStale(s.lastRun))
return <Clock className="size-4 text-amber-500" aria-label="stale" />;
if (s.lastStatus === 'success')
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
return <Clock className="size-4 text-muted-foreground" aria-label="never run" />;
}
export default function DashboardPage() {
const [data, setData] = useState<Overview | null>(null);
const [health, setHealth] = useState<IntegrationHealthResponse | null>(null);
const [trends, setTrends] = useState<Trends | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function load(): Promise<void> {
async function load() {
setLoading(true);
try {
const [overviewRes, healthRes] = await Promise.all([
fetch('/api/dashboard/overview'),
fetch('/api/dashboard/integration-health'),
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 (healthRes.ok) {
setHealth((await healthRes.json()) as IntegrationHealthResponse);
if (trendsRes.ok) {
setTrends((await trendsRes.json()) as Trends);
}
setError(null);
} catch (err) {
@ -159,287 +137,301 @@ export default function DashboardPage() {
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 (
<div className="container mx-auto px-6 py-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<>
<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>
}
/>
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to load</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<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>
)}
{/* NEEDS ATTENTION ----------------------------------------------------- */}
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Needs attention
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<AttentionCard
icon={AlertTriangle}
value={data?.attention.linkConflicts}
label="Device-link conflicts"
href="/admin/device-link-conflicts"
tone={data && data.attention.linkConflicts > 0 ? 'warn' : 'ok'}
/>
<AttentionCard
icon={Database}
value={data?.attention.itglueUnlinked}
label="IT Glue ↛ Autotask"
sub="unlinked configurations"
href="/admin/device-link-conflicts"
tone="info"
/>
<AttentionCard
icon={Shield}
value={data?.attention.s1Unmapped}
label="S1 unmapped"
sub="missing site → company mapping"
href="/sentinelone/mappings"
tone="info"
/>
<AttentionCard
icon={CalendarClock}
value={data?.attention.schedules.enabled}
label={`Schedules on / ${data?.attention.schedules.total ?? '—'}`}
href="/admin/sync/autotask"
tone="info"
/>
{/* 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>
</section>
{/* RECENT OBSERVATIONS + AUDITS ---------------------------------------- */}
<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="size-4" />
Recent device observations
</CardTitle>
</CardHeader>
<CardContent>
{data === null && !error ? (
<RowSkeletons />
) : data?.observations.length === 0 ? (
<p className="text-sm text-muted-foreground">No observations recorded yet.</p>
) : (
<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="font-mono">{o.kind}</span>
{o.companyName && <span> · {o.companyName}</span>}
{/* 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 className="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="size-4" />
Recent audits
</CardTitle>
</CardHeader>
<CardContent>
{data === null && !error ? (
<RowSkeletons />
) : data?.audits.length === 0 ? (
<p className="text-sm text-muted-foreground">No endpoint audits yet.</p>
) : (
<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">
score {a.overallScore?.toFixed(2) ?? '—'} · {a.fieldGapsCount} gaps
{a.companyName && <span> · {a.companyName}</span>}
</div>
</div>
<div className="text-xs text-muted-foreground shrink-0 ml-3">
{relTime(a.generatedAt)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* INTEGRATION HEALTH -------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Plug className="size-4" />
Integration health
{health?.summary.hasIssues && (
<Badge variant="destructive" className="text-[10px]">issues</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{!health ? (
<RowSkeletons />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{health.items
.slice()
.sort((a, b) => statusOrder(a.status) - statusOrder(b.status))
.map((i) => (
<IntegrationRow key={i.key} item={i} />
))}
</div>
)}
</CardContent>
</Card>
{/* SYNC HEALTH --------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Sync health</CardTitle>
</CardHeader>
<CardContent>
{data === null && !error ? (
<RowSkeletons />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{data?.syncHealth.map((s) => (
<div key={s.id} className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
<div className="min-w-0 flex-1 truncate">{s.name}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
<span>{relTime(s.lastRun)}</span>
{syncStatusIcon(s)}
</div>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</CardContent>
</Card>
{/* STATS FOOTER -------------------------------------------------------- */}
{data && (
<p className="text-xs text-muted-foreground">
{data.stats.activeCompanies} companies · {data.stats.configurationItems.toLocaleString()} CIs ·{' '}
{data.stats.xref.total.toLocaleString()} xref rows (
{data.stats.xref.total > 0
? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100)
: 0}
% linked)
</p>
)}
</div>
);
}
<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>
function AttentionCard(props: {
icon: React.ElementType;
value: number | undefined;
label: string;
sub?: string;
href: string;
tone: 'ok' | 'warn' | 'info';
}) {
const { icon: Icon, value, label, sub, href, tone } = props;
const valueColor =
tone === 'warn' && value && value > 0
? 'text-amber-600 dark:text-amber-500'
: tone === 'ok'
? 'text-foreground'
: 'text-foreground';
return (
<Link href={href} className="block">
<Card className="hover:shadow-md transition-shadow h-full">
<CardContent className="pt-4 pb-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<Icon className="size-4 text-muted-foreground" />
<ArrowRight className="size-3.5 text-muted-foreground" />
</div>
<div className={`text-2xl font-semibold tabular-nums ${valueColor}`}>
{value === undefined ? '—' : value.toLocaleString()}
</div>
<div className="text-sm font-medium leading-tight">{label}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</CardContent>
</Card>
</Link>
);
}
function statusOrder(s: IntegrationHealthItem['status']): number {
switch (s) {
case 'auth_failed': return 0;
case 'unreachable': return 1;
case 'unknown': return 2;
case 'ok': return 3;
case 'not_configured': return 4;
default: return 5;
}
}
function statusBadge(item: IntegrationHealthItem) {
const expiringSoon =
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
if (item.status === 'auth_failed' || item.status === 'unreachable')
return <XCircle className="size-4 text-destructive" aria-label={item.status} />;
if (expired)
return <KeyRound className="size-4 text-destructive" aria-label="token expired" />;
if (expiringSoon)
return <KeyRound className="size-4 text-amber-500" aria-label="token expires soon" />;
if (item.status === 'ok')
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
if (item.status === 'unknown')
return <CheckCircle2 className="size-4 text-muted-foreground" aria-label="configured" />;
return <span className="text-xs text-muted-foreground">off</span>;
}
function IntegrationRow({ item }: { item: IntegrationHealthItem }) {
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
const expiringSoon =
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
const detail =
item.status === 'auth_failed' || item.status === 'unreachable'
? item.error?.slice(0, 80)
: expired
? `token expired ${Math.abs(item.tokenExpiry!.daysRemaining).toFixed(0)} d ago`
: expiringSoon
? `token expires in ${item.tokenExpiry!.daysRemaining.toFixed(0)} d`
: item.latencyMs !== undefined
? `${item.latencyMs} ms`
: null;
return (
<div className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
<div className="min-w-0 flex-1 truncate">{item.name}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
{detail && <span className="truncate max-w-[20ch]">{detail}</span>}
{statusBadge(item)}
{/* 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>
</div>
</>
);
}

View file

@ -27,6 +27,14 @@ import {
} from 'recharts';
import { Users, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
interface UserOption {
@ -579,66 +587,61 @@ export default function EngagementProfilePage() {
<CardTitle className="text-sm font-medium">Monthly Breakdown</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground uppercase tracking-wide">
<th className="text-left px-4 py-2.5 font-medium">Month</th>
<th className="text-right px-3 py-2.5 font-medium">Hours</th>
<th className="text-right px-3 py-2.5 font-medium">Billable</th>
<th className="text-right px-3 py-2.5 font-medium">Bill %</th>
<th className="text-right px-3 py-2.5 font-medium hidden sm:table-cell">Days</th>
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Meetings</th>
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Messages</th>
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Emails</th>
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Calls</th>
</tr>
</thead>
<tbody>
{[...monthly].reverse().map(m => {
const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0;
const isEmpty =
m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0;
const totalCalls = m.zoomClientCalls + m.teamsCalls;
return (
<tr
key={m.month}
className={cn(
'border-b last:border-0 hover:bg-muted/30 transition-colors',
isEmpty && 'opacity-40'
)}
>
<td className="px-4 py-2 font-medium">{monthLabel(m.month)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400">
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{m.hoursWorked > 0 ? `${pct}%` : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden sm:table-cell">
{m.daysWorked > 0 ? m.daysWorked : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
{m.emailsSent > 0 ? m.emailsSent : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
{totalCalls > 0 ? totalCalls : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<Table>
<TableHeader>
<TableRow className="text-xs text-muted-foreground uppercase tracking-wide">
<TableHead>Month</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Billable</TableHead>
<TableHead className="text-right">Bill %</TableHead>
<TableHead className="text-right hidden sm:table-cell">Days</TableHead>
<TableHead className="text-right hidden md:table-cell">Meetings</TableHead>
<TableHead className="text-right hidden md:table-cell">Messages</TableHead>
<TableHead className="text-right hidden lg:table-cell">Emails</TableHead>
<TableHead className="text-right hidden lg:table-cell">Calls</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...monthly].reverse().map(m => {
const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0;
const isEmpty =
m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0;
const totalCalls = m.zoomClientCalls + m.teamsCalls;
return (
<TableRow
key={m.month}
className={cn(isEmpty && 'opacity-40')}
>
<TableCell className="font-medium">{monthLabel(m.month)}</TableCell>
<TableCell className="text-right num">
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-right num text-emerald-600 dark:text-emerald-400">
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-right num">
{m.hoursWorked > 0 ? `${pct}%` : '—'}
</TableCell>
<TableCell className="text-right num hidden sm:table-cell">
{m.daysWorked > 0 ? m.daysWorked : '—'}
</TableCell>
<TableCell className="text-right num hidden md:table-cell">
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
</TableCell>
<TableCell className="text-right num hidden md:table-cell">
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
</TableCell>
<TableCell className="text-right num hidden lg:table-cell">
{m.emailsSent > 0 ? m.emailsSent : '—'}
</TableCell>
<TableCell className="text-right num hidden lg:table-cell">
{totalCalls > 0 ? totalCalls : '—'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</>

View file

@ -7,8 +7,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-plex-sans), 'Helvetica Neue', Helvetica, Arial, 'Liberation Sans', sans-serif;
--font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -121,3 +121,5 @@
@apply bg-background text-foreground;
}
}
@import "./styles/brand.css";

View file

@ -1,16 +1,32 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { AppNavigation } from "@/components/navigation/app-navigation";
import { TaglineFooter } from "@/components/branding/tagline-footer";
import { Toaster } from "sonner";
import { AuthProvider } from "@/components/auth/auth-provider";
const inter = Inter({ subsets: ["latin"] });
// IBM Plex Sans replaces the brand-mandated Helvetica/Arial. The 2013
// standards guide called for Helvetica Bold for headers and Helvetica
// Light for the tagline; Plex Sans honors the spirit (clean engineered
// sans) while loading reliably from Google Fonts. Weight 300 covers the
// "Light" usage in the tagline footer.
const plexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-plex-sans",
});
const plexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-plex-mono",
});
export const metadata: Metadata = {
title: "Pulse - PSA Management System",
description: "Modern dashboard for Autotask PSA integration with RMM and NMS mapping",
title: "Pulse · Operations console",
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
icons: {
icon: [
{ url: "/favicon.png", sizes: "any" },
@ -27,8 +43,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<html lang="en" suppressHydrationWarning className={`${plexSans.variable} ${plexMono.variable}`}>
<body className="font-sans antialiased">
<ThemeProvider
attribute="class"
defaultTheme="system"
@ -36,9 +52,10 @@ export default function RootLayout({
disableTransitionOnChange
>
<AuthProvider>
<div className="min-h-screen bg-background">
<div className="min-h-screen bg-background flex flex-col">
<AppNavigation />
<main>{children}</main>
<main className="flex-1">{children}</main>
<TaglineFooter />
</div>
</AuthProvider>
<Toaster position="top-right" richColors />

541
app/status/page.tsx Normal file
View file

@ -0,0 +1,541 @@
/* /status System health dashboard.
*
* Pulls from:
* GET /api/dashboard/integration-health (live API check + token expiry)
* GET /api/dashboard/overview (syncHealth array)
*
* Surfaces what's wrong so the dashboard can stay focused on operational
* KPIs. Polls every 60 s while the page is visible. */
'use client';
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
import { StatusBadge } from '@/components/ui/status-badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { WorkerPulse } from '@/components/status/worker-pulse';
import {
AlertTriangle,
KeyRound,
RefreshCw,
ShieldCheck,
Plug,
Clock,
Activity,
} from 'lucide-react';
// ── Types ────────────────────────────────────────────────────────────
type IntegrationCategory =
| 'psa' | 'rmm' | 'docs' | 'security' | 'backup'
| 'network' | 'identity' | 'mdm' | 'mail'
| 'finance' | 'productivity' | 'llm';
interface IntegrationHealthItem {
key: string;
name: string;
category: IntegrationCategory;
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown' | 'disabled';
configured: boolean;
latencyMs?: number;
error?: string | null;
tokenExpiry?: {
envVar: string;
expiresAt: string;
daysRemaining: number;
subject?: string | null;
} | null;
checkedAt: string;
}
interface IntegrationHealthResponse {
items: IntegrationHealthItem[];
summary: {
total: number;
ok: number;
failed: number;
notConfigured: number;
disabled: number;
expiringWithin14Days: number;
expired: number;
hasIssues: boolean;
};
}
interface SyncHealthItem {
id: string;
name: string;
syncType: string;
isEnabled: boolean;
lastRun: string | null;
lastStatus: string | null;
lastError: string | null;
nextRun: string | null;
}
interface OverviewResponse {
syncHealth: SyncHealthItem[];
}
interface WorkerSnapshot {
name: string;
lastActivity: string | null;
inFlight: number;
oneHour: { success: number; failure: number };
}
interface WorkersResponse {
workers: WorkerSnapshot[];
}
const WORKER_FRESHNESS: Record<string, number> = {
Analyzer: 5,
'RMM Overshell': 10,
'Sync scheduler': 60,
};
// ── Helpers ──────────────────────────────────────────────────────────
const STALE_HOURS = 24;
const POLL_MS = 60_000;
const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
psa: 'PSA',
rmm: 'RMM',
docs: 'Documentation',
security: 'Security',
backup: 'Backup',
network: 'Network',
identity: 'Identity',
mdm: 'MDM',
mail: 'Mail',
finance: 'Finance',
productivity: 'Productivity',
llm: 'LLM',
};
const CATEGORY_ORDER: IntegrationCategory[] = [
'psa', 'rmm', 'docs', 'security', 'backup',
'network', 'identity', 'mdm', 'mail',
'finance', 'productivity', 'llm',
];
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`;
}
function isStale(iso: string | null): boolean {
if (!iso) return true;
return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000;
}
function integrationLight(item: IntegrationHealthItem): StatusLightState {
if (item.status === 'disabled') return 'idle';
const tokenExpired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
const tokenExpiring =
item.tokenExpiry &&
item.tokenExpiry.daysRemaining > 0 &&
item.tokenExpiry.daysRemaining <= 14;
if (item.status === 'auth_failed' || item.status === 'unreachable' || tokenExpired) {
return 'error';
}
if (tokenExpiring) return 'warn';
if (item.status === 'ok') return 'ok';
if (item.status === 'not_configured') return 'idle';
return 'idle';
}
function syncLight(item: SyncHealthItem): StatusLightState {
if (!item.isEnabled) return 'idle';
if (item.lastStatus === 'failed') return 'error';
if (isStale(item.lastRun)) return 'warn';
if (item.lastStatus === 'success') return 'ok';
return 'idle';
}
// ── Page ─────────────────────────────────────────────────────────────
export default function StatusPage() {
const [health, setHealth] = useState<IntegrationHealthResponse | null>(null);
const [overview, setOverview] = useState<OverviewResponse | null>(null);
const [workers, setWorkers] = useState<WorkerSnapshot[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
async function load(force = false) {
setRefreshing(true);
try {
const [hRes, oRes, wRes] = await Promise.all([
fetch(`/api/dashboard/integration-health${force ? '?refresh=1' : ''}`, { cache: 'no-store' }),
fetch('/api/dashboard/overview', { cache: 'no-store' }),
fetch('/api/status/workers', { cache: 'no-store' }),
]);
if (hRes.ok) setHealth((await hRes.json()) as IntegrationHealthResponse);
if (oRes.ok) setOverview((await oRes.json()) as OverviewResponse);
if (wRes.ok) {
const j = (await wRes.json()) as WorkersResponse;
setWorkers(j.workers);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
setRefreshing(false);
}
}
useEffect(() => {
void load();
const id = setInterval(() => void load(), POLL_MS);
return () => clearInterval(id);
}, []);
// Roll-up
const overall: StatusLightState = !health
? 'idle'
: health.summary.failed > 0 || health.summary.expired > 0
? 'error'
: health.summary.expiringWithin14Days > 0 ||
(overview?.syncHealth.some((s) => syncLight(s) === 'error') ?? false)
? 'warn'
: 'ok';
const overallTitle = !health
? 'Loading…'
: overall === 'error'
? `${health.summary.failed} integration${health.summary.failed === 1 ? '' : 's'} failing`
: overall === 'warn'
? health.summary.expiringWithin14Days > 0
? `${health.summary.expiringWithin14Days} token${health.summary.expiringWithin14Days === 1 ? '' : 's'} expiring soon`
: 'Some sync tasks degraded'
: 'All systems operational';
// Group integrations
const grouped = (() => {
if (!health) return null;
const map: Record<string, IntegrationHealthItem[]> = {};
for (const item of health.items) {
(map[item.category] ??= []).push(item);
}
return map;
})();
const expiring = health?.items
.filter((i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 30)
.sort((a, b) => (a.tokenExpiry!.daysRemaining ?? 999) - (b.tokenExpiry!.daysRemaining ?? 999));
const failingSyncs = overview?.syncHealth.filter((s) => syncLight(s) === 'error');
const failingIntegrations = health?.items.filter(
(i) => i.status === 'auth_failed' || i.status === 'unreachable',
);
return (
<>
<PageHeader
title="System Status"
description={overallTitle}
breadcrumbs={[{ label: 'Status' }]}
accent
watermark
actions={
<>
<StatusLight state={overall} size="lg" label={overallTitle} />
<Button
onClick={() => void load(true)}
variant="outline"
size="sm"
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</>
}
/>
<div className="container mx-auto px-6 py-6 space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to load status</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* CONDITIONAL BANNER -------------------------------------------- */}
{(failingIntegrations?.length || failingSyncs?.length) ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Action needed</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5 mt-1 space-y-0.5">
{failingIntegrations?.map((i) => (
<li key={i.key}>
<span className="font-medium">{i.name}</span> {' '}
{i.status === 'auth_failed' ? 'authentication failed' : 'unreachable'}
{i.error && <span className="text-muted-foreground"> · {i.error.slice(0, 120)}</span>}
</li>
))}
{failingSyncs?.map((s) => (
<li key={s.id}>
<span className="font-medium">{s.name}</span> sync failed
{s.lastError && <span className="text-muted-foreground"> · {s.lastError.slice(0, 120)}</span>}
</li>
))}
</ul>
</AlertDescription>
</Alert>
) : null}
{/* INTEGRATION TILES --------------------------------------------- */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Plug className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Integrations
</h2>
{health && (
<span className="text-xs text-muted-foreground">
{health.summary.ok} of {health.summary.total - health.summary.notConfigured} healthy
</span>
)}
</div>
{!grouped ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => <Skeleton key={i} className="h-24" />)}
</div>
) : (
<div className="space-y-6">
{CATEGORY_ORDER.filter((c) => grouped[c]?.length).map((category) => (
<div key={category} className="space-y-2">
<h3 className="metric-label">{CATEGORY_LABELS[category]}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{grouped[category]
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((item) => <IntegrationTile key={item.key} item={item} />)}
</div>
</div>
))}
</div>
)}
</section>
{/* WORKERS ------------------------------------------------------- */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Workers
</h2>
</div>
{!workers ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-36" />)}
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{workers.map((w) => (
<WorkerPulse
key={w.name}
worker={w}
freshnessMinutes={WORKER_FRESHNESS[w.name]}
/>
))}
</div>
)}
</section>
{/* TOKEN EXPIRY -------------------------------------------------- */}
{expiring && expiring.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<KeyRound className="h-4 w-4" />
Tokens expiring within 30 days
</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border">
{expiring.map((item) => {
const days = item.tokenExpiry!.daysRemaining;
const tone = days <= 0 ? 'error' : days <= 14 ? 'warn' : 'pending';
return (
<div key={item.key} className="flex items-center justify-between py-2 text-sm">
<div className="min-w-0">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground"> · {item.tokenExpiry!.envVar}</span>
</div>
<StatusBadge tone={tone}>
{days <= 0 ? `expired ${Math.abs(days)} d ago` : `${days} d`}
</StatusBadge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* SYNC HEALTH --------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Clock className="h-4 w-4" />
Scheduled syncs
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{!overview ? (
<div className="px-6 py-6">
<Skeleton className="h-32" />
</div>
) : overview.syncHealth.length === 0 ? (
<div className="px-6 py-6">
<EmptyState
icon={Clock}
title="No scheduled syncs"
description="Configure schedules in Admin to populate this list."
action={{ label: 'Open admin', href: '/admin' }}
/>
</div>
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Schedule</TableHead>
<TableHead>Type</TableHead>
<TableHead>Last run</TableHead>
<TableHead>Next run</TableHead>
<TableHead className="text-right">Status</TableHead>
<TableHead className="w-10" aria-label="indicator" />
</TableRow>
</TableHeader>
<TableBody>
{overview.syncHealth.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="num text-muted-foreground">{s.syncType}</TableCell>
<TableCell className="num text-muted-foreground">{relTime(s.lastRun)}</TableCell>
<TableCell className="num text-muted-foreground">{relTime(s.nextRun)}</TableCell>
<TableCell className="text-right">
{s.isEnabled
? s.lastStatus === 'failed'
? <StatusBadge tone="error">failed</StatusBadge>
: isStale(s.lastRun)
? <StatusBadge tone="warn">stale</StatusBadge>
: s.lastStatus === 'success'
? <StatusBadge tone="ok">success</StatusBadge>
: <StatusBadge tone="neutral">idle</StatusBadge>
: <StatusBadge tone="inactive">off</StatusBadge>}
</TableCell>
<TableCell className="text-right">
<StatusLight state={syncLight(s)} size="sm" label={s.lastStatus ?? 'idle'} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* COMPLIANCE FOOTER -------------------------------------------- */}
{health && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
<span><span className="num">{health.summary.ok}</span> healthy</span>
<span>·</span>
<span><span className="num">{health.summary.failed}</span> failing</span>
<span>·</span>
<span><span className="num">{health.summary.notConfigured}</span> unconfigured</span>
{health.summary.disabled > 0 && (
<>
<span>·</span>
<span><span className="num">{health.summary.disabled}</span> disabled</span>
</>
)}
<span>·</span>
<span><span className="num">{health.summary.expiringWithin14Days}</span> expiring</span>
<span>·</span>
<span>last checked {relTime(health.items[0]?.checkedAt ?? null)}</span>
</p>
)}
</div>
</>
);
}
// ── Integration tile ────────────────────────────────────────────────
function IntegrationTile({ item }: { item: IntegrationHealthItem }) {
const light = integrationLight(item);
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
const expiringSoon =
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
let detail: string | null = null;
if (item.status === 'disabled') detail = 'disabled by operator';
else if (item.status === 'auth_failed') detail = 'authentication failed';
else if (item.status === 'unreachable') detail = 'unreachable';
else if (expired) detail = `token expired ${Math.abs(item.tokenExpiry!.daysRemaining)} d ago`;
else if (expiringSoon) detail = `token expires in ${item.tokenExpiry!.daysRemaining} d`;
else if (item.status === 'not_configured') detail = 'not configured';
else if (item.status === 'ok' && item.latencyMs !== undefined) detail = `${item.latencyMs} ms`;
else if (item.status === 'unknown' && item.configured) detail = 'configured';
return (
<div
className={
'rounded-md border bg-card px-3 py-3 flex items-start gap-3 ' +
(light === 'error'
? 'border-destructive/40'
: item.status === 'disabled'
? 'border-border opacity-60'
: 'border-border')
}
>
<StatusLight state={light} size="md" label={item.status} className="mt-1" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="font-medium truncate">{item.name}</p>
{(expired || expiringSoon) && (
<KeyRound className={`h-3.5 w-3.5 shrink-0 ${expired ? 'text-destructive' : 'text-amber-500'}`} />
)}
</div>
{detail && (
<p className={`text-xs num truncate ${light === 'error' ? 'text-destructive' : 'text-muted-foreground'}`}>
{detail}
</p>
)}
{item.error && light === 'error' && (
<p className="text-xs text-muted-foreground/80 truncate" title={item.error}>
{item.error.slice(0, 80)}
</p>
)}
</div>
</div>
);
}

170
app/styles/brand.css Normal file
View file

@ -0,0 +1,170 @@
/* === Wulf Consulting brand layer ====================================
*
* Authoritative palette per the Logo Standards Guide (docs/StandardsGuide
* (1).pdf, dated 2013-09-04):
*
* Primary blue #0075AD (Pantone 110-7 U)
* Secondary gray #6D6E70 (Pantone 179-10 U)
* Primary face Helvetica / Arial Bold for headers, Light for tagline
* Tagline "Don't be afraid to cry" (Helvetica Light, gray, 16pt ref.)
*
* Typography update (2026-05): the brand-mandated Helvetica/Arial has
* been replaced with IBM Plex Sans. Plex Sans honors the spirit of the
* mandate (clean engineered sans, supports a Light weight for the
* tagline) while loading reliably from Google Fonts; Helvetica/Arial
* stay in the fallback chain so the look degrades gracefully. Plex
* Mono carries numeric data (KPIs, IDs, timestamps).
*
* Imported once from app/globals.css. The :root overrides below repoint
* --primary / --ring / --accent / fonts; the rest of the shadcn token
* graph stays untouched.
* ==================================================================== */
:root {
/* --- Brand-scoped tokens (do not consume directly from components;
they exist so we can reason about brand vs. app intent). ---- */
--wulf-blue: oklch(0.540 0.136 233.3); /* #0075AD */
--wulf-blue-700: oklch(0.470 0.142 234.5); /* hover / pressed */
--wulf-blue-300: oklch(0.760 0.090 232); /* tinted fills */
--wulf-gray: oklch(0.515 0.001 271); /* #6D6E70 */
--wulf-gray-100: oklch(0.970 0.002 271); /* near-white surface */
--wulf-gray-300: oklch(0.880 0.002 271); /* borders */
--wulf-gray-700: oklch(0.380 0.002 271); /* body text on light bg */
/* --- App-level overrides: repoint shadcn tokens to Wulf values. ---
Light theme. --- */
--primary: var(--wulf-blue);
--primary-foreground: oklch(0.985 0 0);
--accent: var(--wulf-blue);
--accent-foreground: oklch(0.985 0 0);
--ring: var(--wulf-blue);
--sidebar-primary: var(--wulf-blue);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: var(--wulf-blue);
/* Chart slot 2 = Wulf Blue so single-series charts default to brand. */
--chart-2: var(--wulf-blue);
/* Font tokens are wired in app/globals.css (@theme inline) they
resolve --font-helvetica and --font-plex-mono variables that are
supplied by app/layout.tsx via next/font. We don't override them
here. */
}
.dark {
/* Slightly lifted blue for dark surfaces — keeps the tone, raises L. */
--primary: oklch(0.660 0.150 233.3);
--accent: oklch(0.660 0.150 233.3);
--ring: oklch(0.660 0.150 233.3);
--sidebar-primary: oklch(0.660 0.150 233.3);
--sidebar-ring: oklch(0.660 0.150 233.3);
--chart-2: oklch(0.660 0.150 233.3);
}
/* === Utility classes ================================================
*
* These are the canonical helpers for the dashboard refresh. Prefer
* them over re-rolling spacing / typography per page.
* ==================================================================== */
@utility num {
/* Numeric data tabular nums in the mono face. Use on KPI values,
table cells, timestamps, IDs. Never wrap full sentences in this. */
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
@utility num-lg {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.015em;
font-size: 1.875rem; /* text-3xl */
line-height: 2.25rem;
font-weight: 500;
}
@utility num-xl {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
font-size: 2.25rem; /* text-4xl */
line-height: 2.5rem;
font-weight: 600;
}
@utility metric-label {
/* Pair with .num / .num-lg. 11px uppercase tracked label sitting
above a metric value. */
font-size: 0.6875rem; /* 11px */
line-height: 1rem;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 500;
color: var(--muted-foreground);
}
@utility surface-brand {
background-color: var(--wulf-gray-100);
}
@utility surface-brand-ink {
background-color: var(--wulf-gray);
color: oklch(0.985 0 0);
}
@utility rule-brand {
/* The 2px Wulf-blue rule used under PageHeader when accent={true}.
Echoes the standards-guide blue header band. */
border-bottom: 2px solid var(--wulf-blue);
}
@utility text-chrome {
/* Sidebar / chrome text. Use for nav text, table column headers, and
anywhere the brand gray should carry weight without going full ink. */
color: var(--wulf-gray);
}
@utility border-chrome {
border-color: var(--wulf-gray-300);
}
@utility tagline {
/* Footer tagline. Helvetica Light, gray, 12px, gentle tracking.
Do NOT use this anywhere except the page footer line. */
font-family: var(--font-sans);
font-weight: 300;
color: var(--wulf-gray);
font-size: 0.75rem;
letter-spacing: 0.01em;
}
/* === Wolf-mark watermark ============================================
*
* Apply .has-mark-watermark to a positioned container; place a child
* with class `mark-watermark` inside. The child stays behind content
* via z-index, anchored to the right edge.
* ==================================================================== */
@utility has-mark-watermark {
position: relative;
isolation: isolate;
}
/* The child class can't be a `@utility` (it's only meaningful in the
parent context), so it's a plain rule. */
.mark-watermark {
position: absolute;
inset-block: 0;
inset-inline-end: 1.5rem;
z-index: -1;
opacity: 0.04;
pointer-events: none;
height: 100%;
aspect-ratio: 412 / 290; /* W mark intrinsic ratio */
color: var(--wulf-blue);
}
.dark .mark-watermark {
opacity: 0.06;
}

View file

@ -6,6 +6,14 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
@ -144,19 +152,19 @@ function TicketRow({ t, categoryFilter, onFilter }: {
return (
<>
<tr
className="border-b hover:bg-muted/10 cursor-pointer text-xs align-middle"
<TableRow
className="cursor-pointer text-xs align-middle"
onClick={() => setOpen(o => !o)}
>
<td className="pl-3 pr-2 py-2 w-6">
<TableCell className="w-6 pl-3">
{open
? <ChevronDown className="h-3 w-3 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 text-muted-foreground" />}
</td>
<td className="pr-3 py-2 font-mono font-medium">{t.ticket_number}</td>
<td className="px-3 py-2 max-w-[140px] truncate">{t.company_name ?? '—'}</td>
<td className="px-3 py-2 font-mono text-[11px]">{t.device_hostname ?? '—'}</td>
<td className="px-3 py-2">
</TableCell>
<TableCell className="num font-medium">{t.ticket_number}</TableCell>
<TableCell className="max-w-[140px] truncate">{t.company_name ?? '—'}</TableCell>
<TableCell className="num text-[11px]">{t.device_hostname ?? '—'}</TableCell>
<TableCell>
<Badge
variant="outline"
style={{ borderColor: catCfg?.color, color: catCfg?.color }}
@ -164,15 +172,15 @@ function TicketRow({ t, categoryFilter, onFilter }: {
>
{catLabel(t.problem_category)}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{resLabel(t.resolution_type)}</td>
<td className="px-3 py-2 text-center">
</TableCell>
<TableCell className="text-muted-foreground">{resLabel(t.resolution_type)}</TableCell>
<TableCell className="text-center">
{t.same_day_close
? <CheckCircle2 className="h-3.5 w-3.5 text-green-500 mx-auto" />
? <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 mx-auto" />
: <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2 text-right">{parseFloat(t.hours_worked).toFixed(2)}h</td>
<td className="px-3 py-2">
</TableCell>
<TableCell className="text-right num">{parseFloat(t.hours_worked).toFixed(2)}h</TableCell>
<TableCell>
<Badge
variant="outline"
style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }}
@ -180,12 +188,12 @@ function TicketRow({ t, categoryFilter, onFilter }: {
>
{t.complexity}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{timeAgo(t.ticket_created_at)}</td>
</tr>
</TableCell>
<TableCell className="text-muted-foreground num">{timeAgo(t.ticket_created_at)}</TableCell>
</TableRow>
{open && (
<tr className="border-b bg-muted/5">
<td colSpan={10} className="px-8 pb-3 pt-2">
<TableRow className="bg-muted/5">
<TableCell colSpan={10} className="px-8 pb-3 pt-2">
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
{t.work_summary && (
@ -222,8 +230,8 @@ function TicketRow({ t, categoryFilter, onFilter }: {
</div>
)}
</div>
</td>
</tr>
</TableCell>
</TableRow>
)}
</>
);
@ -668,42 +676,40 @@ export default function VeeamAnalysisPage() {
</Button>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-xs">
<th className="w-6" />
<th className="px-3 py-2.5 text-left font-medium">Ticket</th>
<th className="px-3 py-2.5 text-left font-medium">Client</th>
<th className="px-3 py-2.5 text-left font-medium">Device</th>
<th className="px-3 py-2.5 text-left font-medium">Category</th>
<th className="px-3 py-2.5 text-left font-medium">Resolution</th>
<th className="px-3 py-2.5 text-center font-medium">Same-day</th>
<th className="px-3 py-2.5 text-right font-medium">Hours</th>
<th className="px-3 py-2.5 text-left font-medium">Complexity</th>
<th className="px-3 py-2.5 text-left font-medium">Age</th>
</tr>
</thead>
<tbody>
{data.tickets.length > 0
? data.tickets.map(t => (
<TicketRow
key={t.ticket_number}
t={t}
categoryFilter={catFilter}
onFilter={handleCatFilter}
/>
))
: (
<tr>
<td colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
No analyzed tickets yet run the analysis above.
</td>
</tr>
)}
</tbody>
</table>
</div>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="w-6" />
<TableHead>Ticket</TableHead>
<TableHead>Client</TableHead>
<TableHead>Device</TableHead>
<TableHead>Category</TableHead>
<TableHead>Resolution</TableHead>
<TableHead className="text-center">Same-day</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead>Complexity</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.tickets.length > 0
? data.tickets.map(t => (
<TicketRow
key={t.ticket_number}
t={t}
categoryFilter={catFilter}
onFilter={handleCatFilter}
/>
))
: (
<TableRow>
<TableCell colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
No analyzed tickets yet run the analysis above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Pagination */}
{totalPages > 1 && (

View file

@ -8,6 +8,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
RefreshCw, CheckCircle2, AlertTriangle, WifiOff, GitCompare,
Ticket, ChevronRight, ChevronDown, Sparkles, Loader2,
@ -326,21 +334,21 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
return (
<>
{/* Group summary header — columns align with the detail table below */}
<tr
className="border-b bg-muted/30 hover:bg-muted/50 cursor-pointer select-none"
<TableRow
className="bg-muted/30 cursor-pointer select-none"
onClick={() => setOpen(o => !o)}
>
{/* Device col: chevron + org name */}
<td className="pl-3 pr-2 py-2.5 w-44">
<TableCell className="w-44">
<div className="flex items-center gap-1.5">
{open
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />}
<span className="font-semibold text-sm truncate">{group.org_name ?? 'Unknown'}</span>
</div>
</td>
</TableCell>
{/* Match col: status pills */}
<td className="px-3 py-2.5 w-36">
<TableCell className="w-36">
<div className="flex flex-wrap gap-1">
{group.counts.both > 0 && (
<Badge variant="default" className="text-[10px] h-4 px-1">{group.counts.both} Both</Badge>
@ -355,33 +363,33 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
<Badge variant="outline" className="text-[10px] h-4 px-1">{group.counts.offline_suppressed} Offline</Badge>
)}
</div>
</td>
</TableCell>
{/* Pulse shadow col: total devices flagged */}
<td className="px-3 py-2.5 text-xs text-muted-foreground">
<TableCell className="text-xs text-muted-foreground">
{actionable > 0
? <span className="font-medium text-foreground">{actionable} device{actionable !== 1 ? 's' : ''} need attention</span>
: <span>{group.rows.length} device{group.rows.length !== 1 ? 's' : ''}</span>}
</td>
</TableCell>
{/* AT tickets col: ticket count */}
<td className="px-3 py-2.5 text-xs text-muted-foreground">
<TableCell className="text-xs text-muted-foreground">
{group.totalAtTickets > 0
? <>{group.totalAtTickets} ticket{group.totalAtTickets !== 1 ? 's' : ''}{group.totalAtOpen > 0 && <span className="text-orange-500 ml-1">· {group.totalAtOpen} open</span>}</>
: <span></span>}
</td>
</tr>
</TableCell>
</TableRow>
{/* Device detail rows */}
{open && group.rows.map((row) => {
const cfg = STATUS_CONFIG[row.status];
return (
<tr key={row.key} className={`border-b last:border-0 hover:bg-muted/10 align-top text-xs ${cfg.rowAccent}`}>
<td className="pl-9 pr-3 py-2.5 font-mono font-medium w-44 text-[11px]">
<TableRow key={row.key} className={`align-top text-xs ${cfg.rowAccent}`}>
<TableCell className="pl-9 num font-medium w-44 text-[11px]">
{row.hostname ?? <span className="italic text-muted-foreground">unknown</span>}
</td>
<td className="px-3 py-2.5 w-36">
</TableCell>
<TableCell className="w-36">
<Badge variant={cfg.badgeVariant} className="text-[10px]">{cfg.label}</Badge>
</td>
<td className="px-3 py-2.5 space-y-0.5 max-w-[240px]">
</TableCell>
<TableCell className="space-y-0.5 max-w-[240px]">
{row.pulse ? (
<>
<div className="flex items-center gap-1.5">
@ -398,8 +406,8 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<AtTicketCell
tickets={row.at_tickets}
ticketCount={row.at_ticket_count}
@ -407,8 +415,8 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
orgName={row.org_name}
hoursOffline={row.offline?.hours_offline}
/>
</td>
</tr>
</TableCell>
</TableRow>
);
})}
</>
@ -533,16 +541,16 @@ export default function VeeamComparisonPage() {
<TabsContent value={filter} className="mt-4">
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2.5 text-left font-medium text-xs w-44">Device</th>
<th className="px-3 py-2.5 text-left font-medium text-xs w-36">Match</th>
<th className="px-3 py-2.5 text-left font-medium text-xs">Pulse Shadow</th>
<th className="px-3 py-2.5 text-left font-medium text-xs">Autotask Tickets</th>
</tr>
</thead>
<tbody>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="text-xs w-44">Device</TableHead>
<TableHead className="text-xs w-36">Match</TableHead>
<TableHead className="text-xs">Pulse Shadow</TableHead>
<TableHead className="text-xs">Autotask Tickets</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.length > 0 ? groups.map(group => (
<ClientGroupRow
key={group.org_name ?? 'unknown'}
@ -550,16 +558,16 @@ export default function VeeamComparisonPage() {
defaultOpen={(group.counts.both + group.counts.pulse_only) > 0}
/>
)) : (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground">
<TableRow>
<TableCell colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground">
{data.matches.length === 0
? 'No data yet — RPO check must run at least once.'
: 'No rows match this filter.'}
</td>
</tr>
</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
{groups.length > 0 && (
<p className="text-xs text-muted-foreground mt-2 pl-1">