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:
164
web/src/components/dashboard/AlertFeedWidget.tsx
Normal file
164
web/src/components/dashboard/AlertFeedWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
web/src/components/dashboard/ExposureWidget.tsx
Normal file
151
web/src/components/dashboard/ExposureWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
web/src/components/dashboard/HomeTitleWidget.tsx
Normal file
120
web/src/components/dashboard/HomeTitleWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
web/src/components/dashboard/QuickActionsWidget.tsx
Normal file
70
web/src/components/dashboard/QuickActionsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
web/src/components/dashboard/RemoveBrokersWidget.tsx
Normal file
102
web/src/components/dashboard/RemoveBrokersWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
web/src/components/dashboard/SpamShieldWidget.tsx
Normal file
113
web/src/components/dashboard/SpamShieldWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
web/src/components/dashboard/ThreatScoreWidget.tsx
Normal file
105
web/src/components/dashboard/ThreatScoreWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
web/src/components/dashboard/VoicePrintWidget.tsx
Normal file
127
web/src/components/dashboard/VoicePrintWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
407
web/src/components/dashboard/dashboard.test.tsx
Normal file
407
web/src/components/dashboard/dashboard.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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