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:
parent
1112a06afe
commit
9bfb57553d
75 changed files with 9352 additions and 1827 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Mon–Fri · 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 Mon–Fri · 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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’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’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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
143
app/api/dashboard/trends/route.ts
Normal file
143
app/api/dashboard/trends/route.ts
Normal 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),
|
||||
})),
|
||||
});
|
||||
}
|
||||
102
app/api/status/workers/route.ts
Normal file
102
app/api/status/workers/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
541
app/status/page.tsx
Normal 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
170
app/styles/brand.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue