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

@@ -0,0 +1,164 @@
import { createResource, createSignal, For, Show, onMount, onCleanup } from "solid-js";
import { A } from "@solidjs/router";
import { api } from "~/lib/api";
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
import Badge from "~/components/ui/Badge";
import Button from "~/components/ui/Button";
interface AlertFeedWidgetProps {
class?: string;
}
const severityVariant = (severity: string) => {
switch (severity) {
case "CRITICAL":
case "HIGH": return "error" as const;
case "WARNING":
case "MEDIUM": return "warning" as const;
case "INFO":
case "LOW": return "info" as const;
default: return "default" as const;
}
};
function timeAgo(date: Date | string): string {
const now = Date.now();
const then = typeof date === "string" ? new Date(date).getTime() : date.getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function SeverityIcon(props: { severity: string }) {
const color = () => {
switch (props.severity) {
case "CRITICAL":
case "HIGH": return "var(--color-error)";
case "WARNING":
case "MEDIUM": return "var(--color-warning)";
default: return "var(--color-info)";
}
};
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 2l6 11H2L8 2z" fill={color()} opacity="0.3" />
<path d="M8 2l6 11H2L8 2z" stroke={color()} stroke-width="1.2" stroke-linejoin="round" />
<path d="M8 6v3M8 11v.5" stroke="white" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
}
export default function AlertFeedWidget(props: AlertFeedWidgetProps) {
const [tick, setTick] = createSignal(0);
const [resolving, setResolving] = createSignal<Record<string, boolean>>({});
onMount(() => {
const interval = setInterval(() => setTick((c) => c + 1), 60_000);
onCleanup(() => clearInterval(interval));
});
const [alerts, { refetch }] = createResource(tick, () =>
api.correlation.getAlerts.query({ limit: 10 }),
);
const items = () => alerts()?.items ?? [];
const handleMarkRead = async (alertId: string) => {
setResolving((prev) => ({ ...prev, [alertId]: true }));
try {
await api.correlation.resolveAlert.mutate({ alertId, resolution: "RESOLVED" });
refetch();
} finally {
setResolving((prev) => ({ ...prev, [alertId]: false }));
}
};
return (
<Card
class={props.class}
header={
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-[var(--color-text-primary)]">Alert Feed</span>
<Show when={items().length > 0}>
<span class="text-xs text-[var(--color-text-tertiary)]">{items().length} alerts</span>
</Show>
</div>
}
>
<Show when={alerts.loading && !alerts()} fallback={
<div class="divide-y divide-[var(--color-border)]/50 -mx-6 -mb-4">
<For each={items()}>
{(alert) => {
const severity = String(alert.severity ?? "INFO");
return (
<div class="px-6 py-3 flex items-start gap-3">
<div class="flex-shrink-0 pt-0.5">
<SeverityIcon severity={severity} />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-sm font-medium text-[var(--color-text-primary)] truncate">
{String(alert.title ?? "")}
</span>
<Badge variant={severityVariant(severity)}>{severity}</Badge>
</div>
<div class="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{timeAgo(alert.createdAt as string)}</span>
<Show when={alert.source}>
<span class="px-1.5 py-0.5 rounded bg-[var(--color-bg-secondary)] text-[var(--color-text-tertiary)]">
{String(alert.source)}
</span>
</Show>
</div>
</div>
<div class="flex-shrink-0 flex gap-1">
<Button
size="sm"
variant="ghost"
loading={resolving()[alert.id as string]}
onClick={() => handleMarkRead(alert.id as string)}
>
Mark read
</Button>
</div>
</div>
);
}}
</For>
<Show when={items().length === 0}>
<div class="px-6 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
No active alerts
</div>
</Show>
</div>
}>
<div class="space-y-3 -mx-6 -mb-4 px-6 pb-4">
{[1, 2, 3].map((i) => (
<div class="flex items-start gap-3">
<div class="w-4 h-4 rounded bg-[var(--color-bg-secondary)] animate-pulse flex-shrink-0 mt-0.5" />
<div class="flex-1 space-y-1.5">
<div class="h-4 bg-[var(--color-bg-secondary)] rounded animate-pulse w-3/4" />
<div class="h-3 bg-[var(--color-bg-secondary)] rounded animate-pulse w-1/2" />
</div>
</div>
))}
</div>
</Show>
<div class="border-t border-[var(--color-border)]/50 -mx-6 px-6 py-2">
<A
href="/dashboard"
class="text-xs font-medium text-[var(--color-brand-primary)] hover:underline"
>
View all alerts
</A>
</div>
</Card>
);
}

View File

@@ -0,0 +1,151 @@
import { createResource, createSignal, For, Show, onMount, onCleanup } from "solid-js";
import { A } from "@solidjs/router";
import { api } from "~/lib/api";
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
import Badge from "~/components/ui/Badge";
import Button from "~/components/ui/Button";
interface ExposureWidgetProps {
class?: string;
}
const severityColor = (severity: string) => {
switch (severity) {
case "critical": return "var(--color-error)";
case "warning": return "var(--color-warning)";
default: return "var(--color-info)";
}
};
const severityVariant = (severity: string) => {
switch (severity) {
case "critical": return "error" as const;
case "warning": return "warning" as const;
default: return "info" as const;
}
};
const severityLabel = (severity: string) => {
switch (severity) {
case "critical": return "Critical";
case "warning": return "Warning";
default: return "Info";
}
};
export default function ExposureWidget(props: ExposureWidgetProps) {
const [tick, setTick] = createSignal(0);
const [scanning, setScanning] = createSignal(false);
onMount(() => {
const interval = setInterval(() => setTick((c) => c + 1), 60_000);
onCleanup(() => clearInterval(interval));
});
const [exposures] = createResource(tick, () =>
api.darkwatch.getExposures.query({ limit: 1 }),
);
const latest = () => exposures()?.items?.[0] ?? null;
const severityBreakdown = () => {
const d = exposures();
if (!d?.items) return [];
const counts: Record<string, number> = {};
for (const item of d.items) {
const sev = String(item.severity ?? "info");
counts[sev] = (counts[sev] ?? 0) + 1;
}
return Object.entries(counts).map(([severity, count]) => ({
severity,
count,
color: severityColor(severity),
label: severityLabel(severity),
}));
};
const handleRunScan = async () => {
setScanning(true);
try {
await api.darkwatch.runScan.mutate({});
} finally {
setScanning(false);
}
};
return (
<Card
class={props.class}
header={
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-[var(--color-text-primary)]">DarkWatch Exposure</span>
<Button size="sm" loading={scanning()} onClick={handleRunScan}>Run scan</Button>
</div>
}
>
<Show when={exposures.loading && !exposures()} fallback={
<div class="space-y-4">
<div class="grid grid-cols-3 gap-2">
<For each={severityBreakdown()}>
{(item) => (
<div class="text-center">
<div class="text-lg font-bold text-[var(--color-text-primary)]">{item.count}</div>
<Badge variant={severityVariant(item.severity)}>{item.label}</Badge>
</div>
)}
</For>
</div>
<Show when={latest()}>
{(l) => {
const data = l();
return (
<div class="rounded-lg bg-[var(--color-bg-secondary)] p-3">
<div class="flex items-center gap-2 mb-1">
<Badge variant={severityVariant(String(data.severity ?? "info"))}>
{severityLabel(String(data.severity ?? "info"))}
</Badge>
<span class="text-xs text-[var(--color-text-tertiary)]">
{String(data.source ?? "")}
</span>
</div>
<p class="text-xs text-[var(--color-text-secondary)] truncate">
{String(data.dataType ?? "")} {String(data.identifier ?? "")}
</p>
<p class="text-xs text-[var(--color-text-tertiary)] mt-1">
{String(data.detectedAt ? new Date(data.detectedAt as string).toLocaleDateString() : "")}
</p>
</div>
);
}}
</Show>
<Show when={!latest()}>
<div class="text-center py-4 text-sm text-[var(--color-text-tertiary)]">
No exposures found
</div>
</Show>
</div>
}>
<div class="space-y-3">
<div class="flex gap-2">
{[1, 2, 3].map((i) => (
<div class="flex-1 h-16 bg-[var(--color-bg-secondary)] rounded-lg animate-pulse" />
))}
</div>
<div class="h-20 bg-[var(--color-bg-secondary)] rounded-lg animate-pulse" />
</div>
</Show>
<div class="border-t border-[var(--color-border)]/50 -mx-6 px-6 pt-3 mt-3">
<A
href="/darkwatch"
class="text-xs font-medium text-[var(--color-brand-primary)] hover:underline"
>
View full DarkWatch
</A>
</div>
</Card>
);
}

View File

@@ -0,0 +1,120 @@
import { createResource, createSignal, Show, onMount, onCleanup } from "solid-js";
import { A } from "@solidjs/router";
import { api } from "~/lib/api";
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
import Badge from "~/components/ui/Badge";
interface HomeTitleWidgetProps {
class?: string;
}
const severityVariant = (severity: string) => {
switch (severity) {
case "critical": return "error" as const;
case "warning": return "warning" as const;
default: return "info" as const;
}
};
const severityLabel = (severity: string) => {
switch (severity) {
case "critical": return "Critical";
case "warning": return "Warning";
default: return "Info";
}
};
export default function HomeTitleWidget(props: HomeTitleWidgetProps) {
const [tick, setTick] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setTick((c) => c + 1), 60_000);
onCleanup(() => clearInterval(interval));
});
const [properties] = createResource(tick, () =>
api.hometitle.getProperties.query(),
);
const [alerts] = createResource(tick, () =>
api.hometitle.getAlerts.query(),
);
const propertyCount = () => properties()?.length ?? 0;
const changes = () => alerts() ?? [];
const changeCount = () => changes().length;
const latestChange = () => changes()[0] ?? null;
return (
<Card
class={props.class}
header={
<span class="text-sm font-semibold text-[var(--color-text-primary)]">HomeTitle</span>
}
>
<Show when={!properties.loading || properties()} fallback={
<div class="space-y-3">
<div class="flex gap-4">
{[1, 2].map((i) => (
<div class="flex-1 h-12 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
))}
</div>
<div class="h-16 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
</div>
}>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{propertyCount()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Properties</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{changeCount()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Changes</div>
</div>
</div>
<Show when={latestChange()}>
{(lc) => {
const c = lc();
return (
<div class="rounded-lg bg-[var(--color-bg-secondary)] p-3">
<div class="flex items-center gap-2 mb-1">
<Badge variant={severityVariant(String(c.severity ?? "info"))}>
{severityLabel(String(c.severity ?? "info"))}
</Badge>
<span class="text-xs text-[var(--color-text-tertiary)]">
{String(c.changeType ?? "").replace(/_/g, " ")}
</span>
</div>
<p class="text-xs text-[var(--color-text-secondary)] truncate">
{String(c.propertyAddress ?? "")}
</p>
<p class="text-xs text-[var(--color-text-tertiary)] mt-1">
{c.detectedAt ? new Date(c.detectedAt as string).toLocaleDateString() : ""}
</p>
</div>
);
}}
</Show>
<Show when={!latestChange()}>
<div class="text-center py-2 text-sm text-[var(--color-text-tertiary)]">
No recent changes
</div>
</Show>
</div>
</Show>
<div class="border-t border-[var(--color-border)]/50 -mx-6 px-6 pt-3 mt-3">
<A
href="/hometitle"
class="text-xs font-medium text-[var(--color-brand-primary)] hover:underline"
>
View properties
</A>
</div>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
interface QuickActionsWidgetProps {
class?: string;
}
interface ActionItem {
label: string;
href: string;
icon: string;
desc: string;
}
const actions: ActionItem[] = [
{ label: "Add to Watchlist", href: "/darkwatch", icon: "eye", desc: "Monitor emails, phones & domains" },
{ label: "Upload Voice Sample", href: "/voiceprint", icon: "mic", desc: "Enroll voice for analysis" },
{ label: "Check Number", href: "/spamshield", icon: "shield", desc: "Check spam reputation" },
{ label: "Add Property", href: "/hometitle", icon: "home", desc: "Monitor title changes" },
{ label: "Start Removal", href: "/removebrokers", icon: "trash", desc: "Remove from data brokers" },
];
const iconPaths: Record<string, string> = {
eye: "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 M10 13a3 3 0 100-6 3 3 0 000 6z",
mic: "M9 2a3 3 0 00-3 3v4a3 3 0 106 0V5a3 3 0 00-3-3z M5 10a5 5 0 0010 0 M9 15v3 M6 18h6",
shield: "M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2z",
home: "M2 10l8-7 8 7 M4 8v8a1 1 0 001 1h3v-5h4v5h3a1 1 0 001-1V8",
trash: "M4 5h12M7 5V3a1 1 0 011-1h4a1 1 0 011 1v2M6 5v11a1 1 0 001 1h6a1 1 0 001-1V5",
};
function ActionSVG(props: { name: string }) {
return (
<svg width="24" height="24" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d={iconPaths[props.name] ?? iconPaths.shield} stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
export default function QuickActionsWidget(props: QuickActionsWidgetProps) {
return (
<Card
class={props.class}
header={
<span class="text-sm font-semibold text-[var(--color-text-primary)]">Quick Actions</span>
}
>
<div class="grid grid-cols-1 gap-2">
{actions.map((action) => (
<a
href={action.href}
class="flex items-center gap-3 p-3 rounded-lg bg-[var(--color-bg-secondary)] hover:bg-[var(--color-brand-primary)]/10 text-[var(--color-text-secondary)] hover:text-[var(--color-brand-primary)] transition-colors group"
>
<div class="flex-shrink-0 w-9 h-9 rounded-lg bg-[var(--color-bg)] flex items-center justify-center group-hover:bg-[var(--color-brand-primary)]/10">
<ActionSVG name={action.icon} />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-[var(--color-text-primary)] group-hover:text-[var(--color-brand-primary)]">
{action.label}
</div>
<div class="text-xs text-[var(--color-text-tertiary)]">{action.desc}</div>
</div>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-brand-primary)]">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,102 @@
import { createResource, createSignal, Show, onMount, onCleanup } from "solid-js";
import { A } from "@solidjs/router";
import { api } from "~/lib/api";
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
interface RemoveBrokersWidgetProps {
class?: string;
}
export default function RemoveBrokersWidget(props: RemoveBrokersWidgetProps) {
const [tick, setTick] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setTick((c) => c + 1), 60_000);
onCleanup(() => clearInterval(interval));
});
const [stats] = createResource(tick, () =>
api.removebrokers.getStats.query(),
);
const [registry] = createResource(tick, () =>
api.removebrokers.getBrokerRegistry.query(),
);
const totalBrokers = () => registry()?.length ?? 0;
const totalRequests = () => stats()?.total ?? 0;
const pending = () => {
const s = stats();
if (!s) return 0;
return (s.byStatus?.PENDING ?? 0) + (s.byStatus?.SUBMITTED ?? 0) + (s.byStatus?.IN_PROGRESS ?? 0);
};
const completed = () => stats()?.byStatus?.COMPLETED ?? 0;
const completionRate = () => stats()?.completionRate ?? 0;
return (
<Card
class={props.class}
header={
<span class="text-sm font-semibold text-[var(--color-text-primary)]">Remove Brokers</span>
}
>
<Show when={!stats.loading || stats()} fallback={
<div class="space-y-3">
<div class="flex gap-4">
{[1, 2, 3].map((i) => (
<div class="flex-1 h-12 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
))}
</div>
<div class="h-6 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
</div>
}>
<div class="space-y-4">
<div class="grid grid-cols-3 gap-2">
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{totalBrokers()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Brokers</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-warning)]">{pending()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Pending</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-success)]">{completed()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Completed</div>
</div>
</div>
<div>
<div class="flex items-center justify-between text-xs text-[var(--color-text-secondary)] mb-1">
<span>Removal Progress</span>
<span>{completionRate()}%</span>
</div>
<div class="h-2.5 rounded-full bg-[var(--color-bg-secondary)] overflow-hidden">
<div
class="h-full rounded-full transition-all"
style={{
width: `${completionRate()}%`,
background: "linear-gradient(90deg, var(--color-brand-primary), var(--color-brand-accent))",
}}
/>
</div>
</div>
<div class="text-center text-xs text-[var(--color-text-tertiary)]">
{totalRequests()} total removal requests
</div>
</div>
</Show>
<div class="border-t border-[var(--color-border)]/50 -mx-6 px-6 pt-3 mt-3">
<A
href="/removebrokers"
class="text-xs font-medium text-[var(--color-brand-primary)] hover:underline"
>
Manage removals
</A>
</div>
</Card>
);
}

View File

@@ -0,0 +1,113 @@
import { createResource, createSignal, For, Show, onMount, onCleanup } from "solid-js";
import { A } from "@solidjs/router";
import { api } from "~/lib/api";
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
import Badge from "~/components/ui/Badge";
interface SpamShieldWidgetProps {
class?: string;
}
export default function SpamShieldWidget(props: SpamShieldWidgetProps) {
const [tick, setTick] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setTick((c) => c + 1), 60_000);
onCleanup(() => clearInterval(interval));
});
const [stats] = createResource(tick, () =>
api.spamshield.getStats.query({ period: "week" }),
);
const [rules] = createResource(tick, () =>
api.spamshield.getRules.query(),
);
const blockedToday = () => stats()?.spamCount ?? 0;
const totalDetections = () => stats()?.totalDetections ?? 0;
const accuracy = () => stats()?.accuracy ?? 0;
const rulesCount = () => stats()?.activeRules ?? 0;
const topSources = () => {
const r = rules();
if (!r) return [];
const userRules = r.userRules ?? [];
return userRules.slice(0, 3).map((rule) => ({
pattern: String(rule.pattern ?? ""),
type: String(rule.ruleType ?? ""),
action: String(rule.action ?? ""),
}));
};
return (
<Card
class={props.class}
header={
<span class="text-sm font-semibold text-[var(--color-text-primary)]">SpamShield</span>
}
>
<Show when={stats.loading && !stats()} fallback={
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{blockedToday()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Blocked</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{totalDetections()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Detections</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-success)]">{accuracy()}%</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Accuracy</div>
</div>
</div>
<Show when={topSources().length > 0}>
<div>
<div class="text-xs font-medium text-[var(--color-text-secondary)] mb-1">Custom Rules</div>
<div class="space-y-1">
<For each={topSources()}>
{(rule) => (
<div class="flex items-center justify-between py-1 px-2 rounded bg-[var(--color-bg-secondary)]">
<span class="text-xs text-[var(--color-text-primary)] truncate max-w-[120px]">
{rule.pattern}
</span>
<Badge variant={rule.action === "block" ? "error" : rule.action === "flag" ? "warning" : "info"}>
{rule.action}
</Badge>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex items-center justify-between text-xs text-[var(--color-text-tertiary)]">
<span>{rulesCount()} active rules</span>
</div>
</div>
}>
<div class="space-y-3">
<div class="flex gap-4">
{[1, 2, 3].map((i) => (
<div class="flex-1 h-12 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
))}
</div>
<div class="h-16 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
</div>
</Show>
<div class="border-t border-[var(--color-border)]/50 -mx-6 px-6 pt-3 mt-3">
<A
href="/spamshield"
class="text-xs font-medium text-[var(--color-brand-primary)] hover:underline"
>
View rules
</A>
</div>
</Card>
);
}

View File

@@ -0,0 +1,105 @@
import { createResource, createSignal, onMount, onCleanup, Show } from "solid-js";
import { api } from "~/lib/api";
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
interface ThreatScoreWidgetProps {
class?: string;
}
const CIRCUMFERENCE = 2 * Math.PI * 45;
function scoreColor(score: number): string {
if (score <= 30) return "var(--color-success)";
if (score <= 70) return "var(--color-warning)";
return "var(--color-error)";
}
function scoreLabel(score: number): string {
if (score <= 30) return "Low";
if (score <= 70) return "Medium";
return "High";
}
export default function ThreatScoreWidget(props: ThreatScoreWidgetProps) {
const [prevScore, setPrevScore] = createSignal<number | null>(null);
const [tick, setTick] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setTick((c) => c + 1), 60_000);
onCleanup(() => clearInterval(interval));
});
const [stats] = createResource(tick, () => api.correlation.getStats.query());
const score = () => {
const d = stats();
if (!d) return 0;
return d.threatScore ?? 0;
};
const trend = () => {
const s = score();
const prev = prevScore();
setPrevScore(s);
if (prev === null) return "stable";
if (s > prev) return "up";
if (s < prev) return "down";
return "stable";
};
const dashOffset = () => CIRCUMFERENCE * (1 - score() / 100);
const color = () => scoreColor(score());
const label = () => scoreLabel(score());
return (
<Card
class={cn("hover:shadow-glow-primary/20 transition-shadow cursor-pointer", props.class)}
header={<span class="text-sm font-semibold text-[var(--color-text-primary)]">Threat Score</span>}
>
<Show when={stats.loading && !stats()} fallback={
<div class="flex flex-col items-center py-2">
<svg width="140" height="140" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="45" fill="none" stroke="var(--color-bg-secondary)" stroke-width="8" />
<circle
cx="60" cy="60" r="45"
fill="none"
stroke={color()}
stroke-width="8"
stroke-linecap="round"
stroke-dasharray={String(CIRCUMFERENCE)}
stroke-dashoffset={String(dashOffset())}
transform="rotate(-90 60 60)"
style="transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease"
/>
<text x="60" y="52" text-anchor="middle" fill="var(--color-text-primary)" font-size="28" font-weight="700">
{score()}
</text>
<text x="60" y="72" text-anchor="middle" fill={color()} font-size="12" font-weight="600">
{label()}
</text>
</svg>
<div class="flex items-center gap-2 mt-1">
<Show when={trend() !== "stable"}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class={cn(
trend() === "up" ? "text-[var(--color-error)]" : "text-[var(--color-success)]",
)}>
<path
d={trend() === "up" ? "M8 12V4M8 4l-4 4M8 4l4 4" : "M8 4v8M8 12l-4-4M8 12l4-4"}
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</Show>
<span class="text-xs text-[var(--color-text-tertiary)]">
{trend() === "up" ? "Increased" : trend() === "down" ? "Decreased" : "Stable"} vs last check
</span>
</div>
</div>
}>
<div class="flex flex-col items-center py-2">
<div class="w-[140px] h-[140px] rounded-full bg-[var(--color-bg-secondary)] animate-pulse" />
</div>
</Show>
</Card>
);
}

View File

@@ -0,0 +1,127 @@
import { createResource, createSignal, For, Show, onMount, onCleanup } from "solid-js";
import { A } from "@solidjs/router";
import { api } from "~/lib/api";
import { cn } from "~/lib/utils";
import Card from "~/components/ui/Card";
import Button from "~/components/ui/Button";
interface VoicePrintWidgetProps {
class?: string;
}
export default function VoicePrintWidget(props: VoicePrintWidgetProps) {
const [tick, setTick] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setTick((c) => c + 1), 60_000);
onCleanup(() => clearInterval(interval));
});
const [enrollments] = createResource(tick, () =>
api.voiceprint.getEnrollments.query(),
);
const [analyses] = createResource(tick, () =>
api.voiceprint.getAnalyses.query({ limit: 10 }),
);
const enrollmentCount = () => enrollments()?.length ?? 0;
const analysisItems = () => analyses()?.items ?? [];
const analysisCount = () => analyses()?.total ?? 0;
const syntheticRate = () => {
const items = analysisItems();
if (items.length === 0) return 0;
const synthetic = items.filter((a) => a.isSynthetic).length;
return Math.round((synthetic / items.length) * 100);
};
const maxConfidence = () => {
const items = analysisItems();
if (items.length === 0) return 1;
return Math.max(...items.map((a) => Number(a.confidence ?? 0)), 1);
};
return (
<Card
class={props.class}
header={
<span class="text-sm font-semibold text-[var(--color-text-primary)]">VoicePrint</span>
}
>
<Show when={!enrollments.loading || enrollments()} fallback={
<div class="space-y-3">
<div class="flex gap-4">
{[1, 2, 3].map((i) => (
<div class="flex-1 h-12 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
))}
</div>
<div class="h-24 bg-[var(--color-bg-secondary)] rounded animate-pulse" />
</div>
}>
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{enrollmentCount()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Enrollments</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{analysisCount()}</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Analyses</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-warning)]">{syntheticRate()}%</div>
<div class="text-xs text-[var(--color-text-tertiary)]">Synthetic</div>
</div>
</div>
<Show when={analysisItems().length > 0}>
<div class="space-y-1">
<div class="text-xs font-medium text-[var(--color-text-secondary)] mb-1">Recent Analyses</div>
<For each={analysisItems().slice(0, 5)}>
{(a) => {
const conf = Number(a.confidence ?? 0);
const pct = maxConfidence() > 0 ? (conf / maxConfidence()) * 100 : 0;
return (
<div class="flex items-center gap-2">
<div class="flex-1 h-2 rounded-full bg-[var(--color-bg-secondary)] overflow-hidden">
<div
class={cn(
"h-full rounded-full transition-all",
a.isSynthetic ? "bg-[var(--color-warning)]" : "bg-[var(--color-success)]",
)}
style={{ width: `${pct}%` }}
/>
</div>
<span class="text-xs text-[var(--color-text-tertiary)] w-12 text-right">
{a.isSynthetic ? "AI" : "Human"}
</span>
</div>
);
}}
</For>
</div>
</Show>
<Show when={analysisItems().length === 0}>
<div class="text-center py-2 text-sm text-[var(--color-text-tertiary)]">
No analyses yet
</div>
</Show>
</div>
</Show>
<div class="border-t border-[var(--color-border)]/50 -mx-6 px-6 pt-3 mt-3 flex justify-between items-center">
<Button size="sm" variant="secondary" onClick={() => window.location.href = "/voiceprint"}>
Analyze audio
</Button>
<A
href="/voiceprint"
class="text-xs font-medium text-[var(--color-brand-primary)] hover:underline"
>
Open VoicePrint
</A>
</div>
</Card>
);
}

View File

@@ -0,0 +1,407 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "solid-js/web";
import type { JSX } from "solid-js";
vi.mock("@solidjs/router", () => ({
A: (props: { href?: string; children?: JSX.Element; class?: string }) =>
<a href={props.href ?? ""} class={props.class}>{props.children}</a>,
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
useNavigate: () => (() => {}),
useParams: () => ({}),
useSearchParams: () => [() => ({}), () => {}],
useIsRouting: () => false,
Navigate: () => null as unknown as JSX.Element,
}));
import ThreatScoreWidget from "./ThreatScoreWidget";
import AlertFeedWidget from "./AlertFeedWidget";
import ExposureWidget from "./ExposureWidget";
import VoicePrintWidget from "./VoicePrintWidget";
import SpamShieldWidget from "./SpamShieldWidget";
import HomeTitleWidget from "./HomeTitleWidget";
import RemoveBrokersWidget from "./RemoveBrokersWidget";
import QuickActionsWidget from "./QuickActionsWidget";
const mockGetStats = vi.hoisted(() => vi.fn());
const mockGetAlerts = vi.hoisted(() => vi.fn());
const mockResolveAlert = vi.hoisted(() => vi.fn());
const mockGetExposures = vi.hoisted(() => vi.fn());
const mockRunScan = vi.hoisted(() => vi.fn());
const mockGetEnrollments = vi.hoisted(() => vi.fn());
const mockGetAnalyses = vi.hoisted(() => vi.fn());
const mockGetSpamStats = vi.hoisted(() => vi.fn());
const mockGetRules = vi.hoisted(() => vi.fn());
const mockGetProperties = vi.hoisted(() => vi.fn());
const mockGetAlertsHT = vi.hoisted(() => vi.fn());
const mockGetRemoveStats = vi.hoisted(() => vi.fn());
const mockGetBrokerRegistry = vi.hoisted(() => vi.fn());
vi.mock("~/lib/api", () => ({
api: {
correlation: {
getStats: { query: mockGetStats },
getAlerts: { query: mockGetAlerts },
resolveAlert: { mutate: mockResolveAlert },
},
darkwatch: {
getExposures: { query: mockGetExposures },
runScan: { mutate: mockRunScan },
},
voiceprint: {
getEnrollments: { query: mockGetEnrollments },
getAnalyses: { query: mockGetAnalyses },
},
spamshield: {
getStats: { query: mockGetSpamStats },
getRules: { query: mockGetRules },
},
hometitle: {
getProperties: { query: mockGetProperties },
getAlerts: { query: mockGetAlertsHT },
},
removebrokers: {
getStats: { query: mockGetRemoveStats },
getBrokerRegistry: { query: mockGetBrokerRegistry },
},
},
}));
function mount(comp: () => JSX.Element): HTMLDivElement {
const container = document.createElement("div");
document.body.appendChild(container);
render(() => comp(), container);
return container;
}
function mountWithRouter(comp: () => JSX.Element): HTMLDivElement {
return mount(comp);
}
beforeEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
afterEach(() => {
document.body.innerHTML = "";
});
describe("ThreatScoreWidget", () => {
beforeEach(() => {
mockGetStats.mockResolvedValue({
threatScore: 35,
totalAlerts: 12,
bySeverity: { HIGH: 3, MEDIUM: 5, LOW: 4 },
bySource: { DARKWATCH: 5, SPAMSHIELD: 4, VOICEPRINT: 3 },
activeGroups: 2,
resolvedCount: 8,
falsePositiveCount: 1,
threatBreakdown: [{ source: "DARKWATCH", score: 15 }, { source: "SPAMSHIELD", score: 12 }],
});
});
it("renders header with Threat Score label", async () => {
mount(() => <ThreatScoreWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Threat Score");
});
it("displays the threat score number", async () => {
mount(() => <ThreatScoreWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("35");
});
it("calls getStats API on mount", async () => {
mount(() => <ThreatScoreWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(mockGetStats).toHaveBeenCalledTimes(1);
});
it("displays color label based on score range", async () => {
mockGetStats.mockResolvedValue({ threatScore: 85, totalAlerts: 10, bySeverity: {}, bySource: {}, activeGroups: 0, resolvedCount: 0, falsePositiveCount: 0, threatBreakdown: [] });
mount(() => <ThreatScoreWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("High");
});
});
describe("AlertFeedWidget", () => {
beforeEach(() => {
mockGetAlerts.mockResolvedValue({
items: [
{ id: "1", title: "Data breach detected", severity: "HIGH", source: "DARKWATCH", createdAt: new Date().toISOString() },
{ id: "2", title: "Suspicious call", severity: "MEDIUM", source: "SPAMSHIELD", createdAt: new Date().toISOString() },
],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
});
});
it("renders header with Alert Feed label", async () => {
mountWithRouter(() => <AlertFeedWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Alert Feed");
});
it("renders alert titles", async () => {
mountWithRouter(() => <AlertFeedWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Data breach detected");
expect(document.body.textContent).toContain("Suspicious call");
});
it("renders severity badges", async () => {
mountWithRouter(() => <AlertFeedWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("HIGH");
});
it("has mark as read buttons", async () => {
mountWithRouter(() => <AlertFeedWidget />);
await new Promise((r) => setTimeout(r, 0));
const buttons = document.querySelectorAll("button");
expect(buttons.length).toBeGreaterThanOrEqual(2);
});
it("calls getAlerts API with correct params", async () => {
mountWithRouter(() => <AlertFeedWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(mockGetAlerts).toHaveBeenCalledWith({ limit: 10 });
});
it("shows empty state when no alerts", async () => {
mockGetAlerts.mockResolvedValue({ items: [], total: 0, page: 1, limit: 10, totalPages: 0 });
mountWithRouter(() => <AlertFeedWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("No active alerts");
});
it("calls resolveAlert on mark read click", async () => {
mockResolveAlert.mockResolvedValue({});
mountWithRouter(() => <AlertFeedWidget />);
await new Promise((r) => setTimeout(r, 0));
const button = document.querySelector("button");
if (button) button.click();
expect(mockResolveAlert).toHaveBeenCalled();
});
});
describe("ExposureWidget", () => {
beforeEach(() => {
mockGetExposures.mockResolvedValue({
items: [
{ id: "1", severity: "critical", source: "hibp", dataType: "email", identifier: "user@example.com", detectedAt: "2025-05-20T10:00:00Z" },
{ id: "2", severity: "warning", source: "shodan", dataType: "phone", identifier: "+1234567890", detectedAt: "2025-05-19T10:00:00Z" },
],
total: 2,
page: 1,
limit: 1,
totalPages: 2,
});
});
it("renders header with DarkWatch Exposure label", async () => {
mountWithRouter(() => <ExposureWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("DarkWatch Exposure");
});
it("shows Run scan button", async () => {
mountWithRouter(() => <ExposureWidget />);
await new Promise((r) => setTimeout(r, 0));
const buttons = document.querySelectorAll("button");
expect(buttons.length).toBeGreaterThanOrEqual(1);
});
it("calls getExposures API", async () => {
mountWithRouter(() => <ExposureWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(mockGetExposures).toHaveBeenCalledWith({ limit: 1 });
});
it("displays link to DarkWatch page", async () => {
mountWithRouter(() => <ExposureWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("View full DarkWatch");
});
});
describe("VoicePrintWidget", () => {
beforeEach(() => {
mockGetEnrollments.mockResolvedValue([
{ id: "1", name: "My Voice", createdAt: "2025-05-01T10:00:00Z" },
]);
mockGetAnalyses.mockResolvedValue({
items: [
{ id: "1", isSynthetic: false, confidence: 0.95, createdAt: "2025-05-20T10:00:00Z" },
{ id: "2", isSynthetic: true, confidence: 0.82, createdAt: "2025-05-19T10:00:00Z" },
],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
});
});
it("renders header with VoicePrint label", async () => {
mountWithRouter(() => <VoicePrintWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("VoicePrint");
});
it("displays enrollment count", async () => {
mountWithRouter(() => <VoicePrintWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("1");
});
it("displays analysis count", async () => {
mountWithRouter(() => <VoicePrintWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("2");
});
it("shows link to VoicePrint page", async () => {
mountWithRouter(() => <VoicePrintWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Open VoicePrint");
});
});
describe("SpamShieldWidget", () => {
beforeEach(() => {
mockGetSpamStats.mockResolvedValue({
period: "week",
totalDetections: 15,
spamCount: 12,
notSpamCount: 3,
accuracy: 80,
activeRules: 5,
});
mockGetRules.mockResolvedValue({
userRules: [
{ id: "1", pattern: "+1234", ruleType: "prefix", action: "block" },
{ id: "2", pattern: "spam@spam.com", ruleType: "pattern", action: "flag" },
],
globalRules: [],
});
});
it("renders header with SpamShield label", async () => {
mountWithRouter(() => <SpamShieldWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("SpamShield");
});
it("displays blocked count", async () => {
mountWithRouter(() => <SpamShieldWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("12");
});
it("shows link to SpamShield page", async () => {
mountWithRouter(() => <SpamShieldWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("View rules");
});
});
describe("HomeTitleWidget", () => {
beforeEach(() => {
mockGetProperties.mockResolvedValue([
{ id: "1", address: "123 Main St", createdAt: "2025-01-01T10:00:00Z" },
]);
mockGetAlertsHT.mockResolvedValue([
{ id: "1", severity: "critical", changeType: "ownership_transfer", propertyAddress: "123 Main St", detectedAt: "2025-05-20T10:00:00Z" },
]);
});
it("renders header with HomeTitle label", async () => {
mountWithRouter(() => <HomeTitleWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("HomeTitle");
});
it("displays property count", async () => {
mountWithRouter(() => <HomeTitleWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("1");
});
it("shows link to HomeTitle page", async () => {
mountWithRouter(() => <HomeTitleWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("View properties");
});
});
describe("RemoveBrokersWidget", () => {
beforeEach(() => {
mockGetRemoveStats.mockResolvedValue({
total: 10,
byStatus: { PENDING: 3, COMPLETED: 5, FAILED: 1, SUBMITTED: 1, IN_PROGRESS: 0, REJECTED: 0, CANCELLED: 0 },
totalListings: 20,
listingsRemoved: 8,
completionRate: 50,
});
mockGetBrokerRegistry.mockResolvedValue([
{ id: "1", name: "Broker A", domain: "brokera.com" },
{ id: "2", name: "Broker B", domain: "brokerb.com" },
]);
});
it("renders header with Remove Brokers label", async () => {
mountWithRouter(() => <RemoveBrokersWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Remove Brokers");
});
it("displays broker count", async () => {
mountWithRouter(() => <RemoveBrokersWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("2");
});
it("shows progress bar", async () => {
mountWithRouter(() => <RemoveBrokersWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Removal Progress");
expect(document.body.textContent).toContain("50%");
});
it("shows link to remove brokers page", async () => {
mountWithRouter(() => <RemoveBrokersWidget />);
await new Promise((r) => setTimeout(r, 0));
expect(document.body.textContent).toContain("Manage removals");
});
});
describe("QuickActionsWidget", () => {
it("renders header with Quick Actions label", () => {
mount(() => <QuickActionsWidget />);
expect(document.body.textContent).toContain("Quick Actions");
});
it("displays all action buttons", () => {
mount(() => <QuickActionsWidget />);
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");
});
it("renders links with correct hrefs", () => {
mount(() => <QuickActionsWidget />);
const links = document.querySelectorAll("a");
const hrefs = Array.from(links).map((l) => l.getAttribute("href"));
expect(hrefs).toContain("/darkwatch");
expect(hrefs).toContain("/voiceprint");
expect(hrefs).toContain("/spamshield");
expect(hrefs).toContain("/hometitle");
expect(hrefs).toContain("/removebrokers");
});
});

View File

@@ -1,5 +1,10 @@
export { default as Sidebar } from "./Sidebar";
export { default as TopBar } from "./TopBar";
export { default as StatCard } from "./StatCard";
export { default as ActivityFeed } from "./ActivityFeed";
export { default as QuickActions } from "./QuickActions";
export { default as ThreatScoreWidget } from "./ThreatScoreWidget";
export { default as AlertFeedWidget } from "./AlertFeedWidget";
export { default as ExposureWidget } from "./ExposureWidget";
export { default as VoicePrintWidget } from "./VoicePrintWidget";
export { default as SpamShieldWidget } from "./SpamShieldWidget";
export { default as HomeTitleWidget } from "./HomeTitleWidget";
export { default as RemoveBrokersWidget } from "./RemoveBrokersWidget";
export { default as QuickActionsWidget } from "./QuickActionsWidget";

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");
});
});