wulf-pulse/app/admin/morning-summary/page.tsx
lorentz 9bfb57553d feat(design): nav/visual overhaul — brand layer, /status route, KPI dashboard, TanStack DataTable
Major UI refresh on the nav-design-improvements branch.  Drops 2013-era
inline styles and consolidates patterns behind shared primitives.

Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
  the standards-guide blue (#0075AD) with utility classes for numerics
  (.num / .num-lg / .num-xl), metric labels, surface tints, and the
  wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
  Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
  "Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page

Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
  health table, worker pulse cards (analyzer / RMM / sync scheduler),
  token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
  to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
  integrations (e.g. SentinelOne) — no failure noise from broken-on-
  purpose entries.  Aliases supported (sentinelone → s1, etc.)

Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
  total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
  area chart, 30-day mean resolution time line chart, today's active
  engineers leaderboard

Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
  status, classification, source, company type, publish, active /
  yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)

Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
  PageHeader rule (consistent across flat links and submenu triggers);
  active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config

Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs

DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
  unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow

Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
  collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below

Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
  workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
  rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
  INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)

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

470 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
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;
label: string;
webhook_url: string;
enabled: boolean;
last_delivered_at: string | null;
last_status: string | null;
created_at: string;
}
interface SummaryConfig {
weekend_suppression: boolean;
monday_extended_window: boolean;
severity_filter: number;
outages_only: boolean;
}
interface SummaryRow {
id: number;
generated_at: string;
open_count: number;
resolved_count: number;
mttr_minutes: number | null;
is_weekend_window: boolean;
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
card_payload?: object;
window_from?: string;
window_to?: string;
clients_affected?: string[];
}
function fmtDate(d: string | null) {
if (!d) return 'Never';
const date = new Date(d);
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function StatusBadge({ status }: { status: string | null }) {
if (!status) return <span className="text-xs text-muted-foreground">Never sent</span>;
if (status === 'success') return (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3 w-3" /> Success
</span>
);
return (
<span className="flex items-center gap-1 text-xs text-red-500">
<XCircle className="h-3 w-3" /> Failed
</span>
);
}
export default function MorningSummaryPage() {
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([]);
const [config, setConfig] = useState<SummaryConfig | null>(null);
const [history, setHistory] = useState<SummaryRow[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [testingId, setTestingId] = useState<number | null>(null);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
const [cardExpanded, setCardExpanded] = useState(false);
const [showAddWebhook, setShowAddWebhook] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [newUrl, setNewUrl] = useState('');
const [addingWebhook, setAddingWebhook] = useState(false);
const showToast = (msg: string, ok: boolean) => {
setToast({ msg, ok });
setTimeout(() => setToast(null), 4000);
};
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const [wRes, cRes, hRes] = await Promise.all([
fetch('/api/notifications/morning-summary/webhooks'),
fetch('/api/notifications/morning-summary/config'),
fetch('/api/notifications/morning-summary/history'),
]);
const [wData, cData, hData] = await Promise.all([wRes.json(), cRes.json(), hRes.json()]);
setWebhooks(wData.webhooks ?? []);
setConfig(cData.config ?? null);
setHistory(hData.history ?? []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchAll(); }, [fetchAll]);
const handleSendAll = async () => {
setSending(true);
try {
const res = await fetch('/api/notifications/morning-summary/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const ok = data.results?.filter((r: any) => r.success).length ?? 0;
const fail = data.results?.filter((r: any) => !r.success).length ?? 0;
showToast(`Sent — ${ok} succeeded, ${fail} failed`, fail === 0);
fetchAll();
} catch (e) {
showToast(String(e), false);
} finally {
setSending(false);
}
};
const handleTest = async (webhookId: number, label: string) => {
setTestingId(webhookId);
try {
const res = await fetch('/api/notifications/morning-summary/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhookId }),
});
const data = await res.json();
showToast(data.result?.success ? `✅ Test sent to ${label}` : `❌ Test failed: ${data.result?.error ?? data.error}`, data.result?.success);
fetchAll();
} catch (e) {
showToast(String(e), false);
} finally {
setTestingId(null);
}
};
const handleToggleWebhook = async (webhook: WebhookConfig) => {
try {
await fetch(`/api/notifications/morning-summary/webhooks/${webhook.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !webhook.enabled }),
});
setWebhooks(prev => prev.map(w => w.id === webhook.id ? { ...w, enabled: !w.enabled } : w));
} catch (e) {
showToast(String(e), false);
}
};
const handleDeleteWebhook = async (id: number) => {
if (!confirm('Delete this webhook?')) return;
try {
await fetch(`/api/notifications/morning-summary/webhooks/${id}`, { method: 'DELETE' });
setWebhooks(prev => prev.filter(w => w.id !== id));
} catch (e) {
showToast(String(e), false);
}
};
const handleAddWebhook = async () => {
if (!newLabel.trim() || !newUrl.trim()) return;
setAddingWebhook(true);
try {
const res = await fetch('/api/notifications/morning-summary/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: newLabel.trim(), webhook_url: newUrl.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setWebhooks(prev => [...prev, data.webhook]);
setNewLabel('');
setNewUrl('');
setShowAddWebhook(false);
showToast('Webhook added', true);
} catch (e) {
showToast(String(e), false);
} finally {
setAddingWebhook(false);
}
};
const handleConfigToggle = async (field: keyof SummaryConfig) => {
if (!config) return;
const updated = { ...config, [field]: !config[field] };
setConfig(updated);
try {
await fetch('/api/notifications/morning-summary/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: updated[field] }),
});
} catch (e) {
showToast(String(e), false);
setConfig(config);
}
};
const latestSummary = history[0] ?? null;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<>
<PageHeader
title="Morning NOC Summary"
description="Scheduled 6:30 AM MonFri · Posts to Teams channels via webhook"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Morning NOC Summary' }]}
accent
actions={
<>
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
<Button size="sm" onClick={handleSendAll} disabled={sending}>
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
Send Now
</Button>
</>
}
/>
<div className="max-w-4xl mx-auto p-6 space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
{toast.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{toast.msg}
</div>
)}
{/* Last Run Stats */}
{latestSummary && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Last Run</h2>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" /> {fmtDate(latestSummary.generated_at)}
{latestSummary.is_weekend_window && <span className="ml-2 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded text-xs">Weekend</span>}
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className={`text-2xl font-bold ${latestSummary.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}`}>{latestSummary.open_count}</div>
<div className="text-xs text-muted-foreground">Open</div>
</div>
<div className="text-center">
<div className={`text-2xl font-bold ${latestSummary.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}`}>{latestSummary.resolved_count}</div>
<div className="text-xs text-muted-foreground">Resolved</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{latestSummary.mttr_minutes != null ? `${latestSummary.mttr_minutes}m` : '—'}</div>
<div className="text-xs text-muted-foreground">Avg MTTR</div>
</div>
</div>
{/* Delivery results */}
{Object.keys(latestSummary.delivery_status).length > 0 && (
<div className="pt-2 border-t space-y-1">
<p className="text-xs text-muted-foreground font-medium">Delivery</p>
{Object.entries(latestSummary.delivery_status).map(([wid, r]) => {
const webhook = webhooks.find(w => w.id === parseInt(wid));
return (
<div key={wid} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{webhook?.label ?? `Webhook #${wid}`}</span>
{r.success
? <span className="text-green-500 flex items-center gap-1"><CheckCircle2 className="h-3 w-3" /> Delivered</span>
: <span className="text-red-500 flex items-center gap-1"><XCircle className="h-3 w-3" /> {r.error ?? `HTTP ${r.httpStatus}`}</span>
}
</div>
);
})}
</div>
)}
{/* Card preview toggle */}
{latestSummary.card_payload && (
<div className="pt-2 border-t">
<button
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setCardExpanded(v => !v)}
>
{cardExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{cardExpanded ? 'Hide' : 'Show'} card payload
</button>
{cardExpanded && (
<pre className="mt-2 text-xs bg-muted/30 rounded p-3 overflow-auto max-h-64 text-muted-foreground">
{JSON.stringify(latestSummary.card_payload, null, 2)}
</pre>
)}
</div>
)}
</div>
)}
{/* Webhooks */}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Webhooks</h2>
<Button variant="outline" size="sm" onClick={() => setShowAddWebhook(v => !v)}>
<Plus className="h-3 w-3 mr-1" /> Add
</Button>
</div>
{showAddWebhook && (
<div className="rounded-md border border-dashed p-3 space-y-2 bg-muted/10">
<input
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="Label (e.g. Technical / On-Call)"
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
/>
<input
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring font-mono"
placeholder="Webhook URL"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAddWebhook} disabled={addingWebhook || !newLabel || !newUrl}>
{addingWebhook ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add Webhook'}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowAddWebhook(false); setNewLabel(''); setNewUrl(''); }}>Cancel</Button>
</div>
</div>
)}
{webhooks.length === 0 && !showAddWebhook && (
<p className="text-sm text-muted-foreground text-center py-4">No webhooks configured. Add one above.</p>
)}
<div className="space-y-2">
{webhooks.map(webhook => (
<div key={webhook.id} className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${webhook.enabled ? 'bg-background' : 'bg-muted/20 opacity-60'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{webhook.label}</span>
{webhook.enabled
? <span className="text-xs px-1.5 py-0.5 bg-green-500/10 text-green-400 rounded">Enabled</span>
: <span className="text-xs px-1.5 py-0.5 bg-muted/40 text-muted-foreground rounded">Disabled</span>
}
</div>
<div className="flex items-center gap-3 mt-0.5">
<StatusBadge status={webhook.last_status} />
{webhook.last_delivered_at && (
<span className="text-xs text-muted-foreground">{fmtDate(webhook.last_delivered_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
size="sm" variant="ghost"
className="h-7 px-2 text-xs"
disabled={testingId === webhook.id}
onClick={() => handleTest(webhook.id, webhook.label)}
>
{testingId === webhook.id ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Test'}
</Button>
<button
className="p-1.5 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={() => handleToggleWebhook(webhook)}
title={webhook.enabled ? 'Disable' : 'Enable'}
>
{webhook.enabled
? <ToggleRight className="h-4 w-4 text-green-500" />
: <ToggleLeft className="h-4 w-4" />
}
</button>
<button
className="p-1.5 rounded hover:bg-red-500/10 transition-colors text-muted-foreground hover:text-red-500"
onClick={() => handleDeleteWebhook(webhook.id)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Schedule Config */}
{config && (
<div className="rounded-lg border bg-card p-4 space-y-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Schedule Settings</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Weekend Suppression</p>
<p className="text-xs text-muted-foreground">Skip Saturday & Sunday (cron already limits to MonFri)</p>
</div>
<button onClick={() => handleConfigToggle('weekend_suppression')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.weekend_suppression
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Monday Extended Window</p>
<p className="text-xs text-muted-foreground">On Mondays, extend window to cover the full weekend (Fri 6 PM Mon 6:30 AM)</p>
</div>
<button onClick={() => handleConfigToggle('monday_extended_window')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.monday_extended_window
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Outages Only</p>
<p className="text-xs text-muted-foreground">Only show "Unavailable" problems filter out slow response and other non-outage alerts</p>
</div>
<button onClick={() => handleConfigToggle('outages_only')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.outages_only
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
</div>
</div>
)}
{/* Run History */}
{history.length > 0 && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Recent Runs</h2>
<div className="space-y-1">
{history.map(row => {
const statusEntries = Object.values(row.delivery_status);
const allOk = statusEntries.length > 0 && statusEntries.every((r: any) => r.success);
const anyFail = statusEntries.some((r: any) => !r.success);
return (
<div key={row.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
<div className="flex items-center gap-3">
{allOk && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
{anyFail && <AlertTriangle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />}
{statusEntries.length === 0 && <Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="text-muted-foreground">{fmtDate(row.generated_at)}</span>
{row.is_weekend_window && <span className="text-xs px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">Weekend</span>}
</div>
<div className="flex items-center gap-4">
<span className={row.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}>
{row.open_count} open
</span>
<span className={row.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}>
{row.resolved_count} resolved
</span>
{row.mttr_minutes != null && (
<span className="text-muted-foreground">{row.mttr_minutes}m MTTR</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</>
);
}