2026-03-11 09:34:51 -04:00
|
|
|
|
'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';
|
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
|
|
|
|
import { PageHeader } from '@/components/navigation/page-header';
|
2026-03-11 09:34:51 -04:00
|
|
|
|
|
|
|
|
|
|
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 (
|
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
|
|
|
|
<>
|
|
|
|
|
|
<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">
|
2026-03-11 09:34:51 -04:00
|
|
|
|
{/* 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 Mon–Fri)</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>
|
|
|
|
|
|
)}
|
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
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2026-03-11 09:34:51 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|