feat: dashboard unified widgets for all services

Implement 8 rich dashboard widgets replacing placeholder stat cards:
- ThreatScoreWidget: SVG circular gauge (0-100) with color-coded score
- AlertFeedWidget: Real-time alert stream with mark-as-read actions
- ExposureWidget: DarkWatch exposure summary with run-scan button
- VoicePrintWidget: Enrollment/analysis counts with mini bar chart
- SpamShieldWidget: Blocked calls/SMS stats with custom rules
- HomeTitleWidget: Watched properties and recent changes
- RemoveBrokersWidget: Broker registry progress with completion bar
- QuickActionsWidget: Shortcut buttons for common tasks

Update dashboard route with responsive 2-column grid layout,
auto-refresh via 60-second intervals, Suspense boundaries,
and skeleton loading states. Update tests for new widget layout.
This commit is contained in:
2026-05-25 17:45:40 -04:00
parent 7cbcde6a6b
commit 3a8e329f02
12 changed files with 1444 additions and 106 deletions

View File

@@ -1,71 +1,9 @@
import { createSignal, For, Show } from "solid-js";
import { createSignal, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { Sidebar, TopBar, StatCard, ActivityFeed, QuickActions } from "~/components/dashboard";
import { useAuth, useSubscription, useNotifications } from "~/hooks";
function AlertsIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 2.5a5.5 5.5 0 00-5.5 5.5v3l-1.5 2v1h14v-1l-1.5-2V8a5.5 5.5 0 00-5.5-5.5z" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 14.5a2 2 0 004 0" stroke="currentColor" stroke-width="1.5"/>
</svg>
);
}
function ShieldIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2z" fill="currentColor" opacity="0.3"/>
<path d="M10 4l5 2.5v3.5c0 3.7-2.5 6.8-5 7.5-2.5-.7-5-3.8-5-7.5V6.5L10 4z" fill="currentColor"/>
</svg>
);
}
function EyeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4c-4 0-7.5 2.5-9 6 1.5 3.5 5 6 9 6s7.5-2.5 9-6c-1.5-3.5-5-6-9-6z" stroke="currentColor" stroke-width="1.5"/>
<circle cx="10" cy="10" r="2.5" stroke="currentColor" stroke-width="1.5"/>
</svg>
);
}
function ActivityIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 10h4l2-5 4 10 2-5h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
);
}
import { Sidebar, TopBar, ThreatScoreWidget, AlertFeedWidget, ExposureWidget, VoicePrintWidget, SpamShieldWidget, HomeTitleWidget, RemoveBrokersWidget, QuickActionsWidget } from "~/components/dashboard";
export default function DashboardPage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const auth = useAuth();
const subscription = useSubscription();
const notifications = useNotifications();
const statCards = () => {
const activeThreats = notifications.alerts().filter(
(a: Record<string, unknown>) => a.severity === "HIGH" || a.severity === "CRITICAL",
).length;
const totalExposures = notifications.alerts().length;
return [
{ label: "Active Threats", value: String(activeThreats || 0), trend: "down" as const, trendLabel: "Real-time from alerts", icon: AlertsIcon },
{ label: "Plan Tier", value: subscription.tier().charAt(0).toUpperCase() + subscription.tier().slice(1), icon: ShieldIcon },
{ label: "Total Alerts", value: String(totalExposures), icon: EyeIcon },
{ label: "Alerts Today", value: String(notifications.alerts().slice(0, 10).length), trend: "down" as const, trendLabel: "Last 24h activity", icon: ActivityIcon },
];
};
const activities = () => notifications.alerts().slice(0, 5).map((a: Record<string, unknown>) => ({
id: String(a.id ?? ""),
title: String(a.title ?? ""),
description: String(a.description ?? ""),
timestamp: String(a.createdAt ?? ""),
type: (a.severity === "HIGH" || a.severity === "CRITICAL" ? "alert" :
a.severity === "WARNING" ? "warning" :
a.severity === "INFO" ? "info" : "success") as "alert" | "success" | "info" | "warning",
}));
return (
<div class="flex h-[calc(100vh-4rem)] bg-[var(--color-bg)]">
@@ -75,30 +13,38 @@ export default function DashboardPage() {
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main class="flex-1 overflow-y-auto p-6">
<div class="max-w-7xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Overview</h1>
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Dashboard</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<For each={statCards()}>
{(card) => (
<StatCard
label={card.label}
value={card.value}
trend={"trend" in card ? card.trend : undefined}
trendLabel={"trendLabel" in card ? card.trendLabel : undefined}
icon={card.icon}
/>
)}
</For>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Suspense fallback={<div class="h-64 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<ThreatScoreWidget />
</Suspense>
<Suspense fallback={<div class="h-64 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<QuickActionsWidget />
</Suspense>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ActivityFeed activities={activities()} class="lg:col-span-2" />
<QuickActions actions={[
{ label: "Run Scan", href: "/darkwatch", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2z" fill="currentColor"/></svg> },
{ label: "View Alerts", href: "/dashboard", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 2.5A5.5 5.5 0 004.5 8v3l-1.5 2v1h14v-1l-1.5-2V8A5.5 5.5 0 0010 2.5z" stroke="currentColor" stroke-width="1.5"/></svg> },
{ label: "Add Member", href: "/settings", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M14 6a3 3 0 11-6 0 3 3 0 016 0zM4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" stroke="currentColor" stroke-width="1.5"/></svg> },
{ label: "Run Report", href: "/dashboard", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M4 2h8l4 4v12H4V2z" stroke="currentColor" stroke-width="1.5"/><path d="M8 8h4M8 11h4M8 14h2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> },
]} />
<Suspense fallback={<div class="h-80 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<AlertFeedWidget />
</Suspense>
<div class="space-y-6">
<Suspense fallback={<div class="h-40 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<ExposureWidget />
</Suspense>
<Suspense fallback={<div class="h-40 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<SpamShieldWidget />
</Suspense>
</div>
<Suspense fallback={<div class="h-48 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<VoicePrintWidget />
</Suspense>
<Suspense fallback={<div class="h-48 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<HomeTitleWidget />
</Suspense>
<Suspense fallback={<div class="h-48 rounded-xl bg-[var(--color-bg-secondary)] animate-pulse" />}>
<RemoveBrokersWidget />
</Suspense>
</div>
</div>
</main>

View File

@@ -3,6 +3,39 @@ import { render } from "solid-js/web";
import { MetaProvider } from "@solidjs/meta";
import type { JSX } from "solid-js";
vi.mock("~/lib/api", () => ({
api: {
correlation: {
getStats: { query: vi.fn().mockResolvedValue({ threatScore: 25, totalAlerts: 5, bySeverity: {}, bySource: {}, activeGroups: 2, resolvedCount: 3, falsePositiveCount: 1, threatBreakdown: [] }) },
getAlerts: { query: vi.fn().mockResolvedValue({ items: [
{ id: "1", title: "New credential leak detected", severity: "HIGH", source: "DARKWATCH", createdAt: new Date().toISOString() },
{ id: "2", title: "Suspicious call", severity: "WARNING", source: "SPAMSHIELD", createdAt: new Date().toISOString() },
], total: 2, page: 1, limit: 10, totalPages: 1 }) },
resolveAlert: { mutate: vi.fn().mockResolvedValue({}) },
},
darkwatch: {
getExposures: { query: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, limit: 1, totalPages: 0 }) },
runScan: { mutate: vi.fn().mockResolvedValue({}) },
},
voiceprint: {
getEnrollments: { query: vi.fn().mockResolvedValue([]) },
getAnalyses: { query: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, limit: 10, totalPages: 0 }) },
},
spamshield: {
getStats: { query: vi.fn().mockResolvedValue({ period: "week", totalDetections: 10, spamCount: 8, notSpamCount: 2, accuracy: 80, activeRules: 3 }) },
getRules: { query: vi.fn().mockResolvedValue({ userRules: [], globalRules: [] }) },
},
hometitle: {
getProperties: { query: vi.fn().mockResolvedValue([]) },
getAlerts: { query: vi.fn().mockResolvedValue([]) },
},
removebrokers: {
getStats: { query: vi.fn().mockResolvedValue({ total: 0, byStatus: {}, totalListings: 0, listingsRemoved: 0, completionRate: 0 }) },
getBrokerRegistry: { query: vi.fn().mockResolvedValue([]) },
},
},
}));
vi.mock("@solidjs/router", () => ({
A: (props: { href?: string; children?: JSX.Element; class?: string; onClick?: () => void }) => (
<a href={props.href || "#"} class={props.class} onClick={props.onClick}>
@@ -199,14 +232,7 @@ describe("AdsPage", () => {
describe("DashboardPage", () => {
it("renders dashboard title", () => {
mount(() => <DashboardPage />);
expect(document.body.textContent).toContain("Overview");
});
it("renders stat cards with data from hooks", () => {
mount(() => <DashboardPage />);
expect(document.body.textContent).toContain("Plan Tier");
expect(document.body.textContent).toContain("Total Alerts");
expect(document.body.textContent).toContain("Active Threats");
expect(document.body.textContent).toContain("Dashboard");
});
it("renders sidebar with navigation links", () => {
@@ -220,20 +246,22 @@ describe("DashboardPage", () => {
expect(document.body.textContent).toContain("Settings");
});
it("renders activity feed", () => {
it("renders widget headers", async () => {
mount(() => <DashboardPage />);
expect(document.body.textContent).toContain("Recent Activity");
expect(document.body.textContent).toContain("New credential leak detected");
expect(document.body.textContent).toContain("VoicePrint scan completed");
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Threat Score");
expect(document.body.textContent).toContain("Alert Feed");
expect(document.body.textContent).toContain("Quick Actions");
});
it("renders quick actions", () => {
it("renders QuickActionsWidget with action buttons", () => {
mount(() => <DashboardPage />);
expect(document.body.textContent).toContain("Quick Actions");
expect(document.body.textContent).toContain("Run Scan");
expect(document.body.textContent).toContain("View Alerts");
expect(document.body.textContent).toContain("Add Member");
expect(document.body.textContent).toContain("Run Report");
expect(document.body.textContent).toContain("Add to Watchlist");
expect(document.body.textContent).toContain("Upload Voice Sample");
expect(document.body.textContent).toContain("Check Number");
expect(document.body.textContent).toContain("Add Property");
expect(document.body.textContent).toContain("Start Removal");
});
});