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:
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user