diff --git a/web/src/components/dashboard/AlertFeedWidget.tsx b/web/src/components/dashboard/AlertFeedWidget.tsx new file mode 100644 index 0000000..d34946e --- /dev/null +++ b/web/src/components/dashboard/AlertFeedWidget.tsx @@ -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 ( + + + + + + ); +} + +export default function AlertFeedWidget(props: AlertFeedWidgetProps) { + const [tick, setTick] = createSignal(0); + const [resolving, setResolving] = createSignal>({}); + + 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 ( + + Alert Feed + 0}> + {items().length} alerts + + + } + > + + + {(alert) => { + const severity = String(alert.severity ?? "INFO"); + return ( +
+
+ +
+
+
+ + {String(alert.title ?? "")} + + {severity} +
+
+ {timeAgo(alert.createdAt as string)} + + + {String(alert.source)} + + +
+
+
+ +
+
+ ); + }} +
+ +
+ No active alerts +
+
+ + }> +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ + + + + ); +} diff --git a/web/src/components/dashboard/ExposureWidget.tsx b/web/src/components/dashboard/ExposureWidget.tsx new file mode 100644 index 0000000..488ecc7 --- /dev/null +++ b/web/src/components/dashboard/ExposureWidget.tsx @@ -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 = {}; + 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 ( + + DarkWatch Exposure + +
+ } + > + +
+ + {(item) => ( +
+
{item.count}
+ {item.label} +
+ )} +
+
+ + + {(l) => { + const data = l(); + return ( +
+
+ + {severityLabel(String(data.severity ?? "info"))} + + + {String(data.source ?? "")} + +
+

+ {String(data.dataType ?? "")} — {String(data.identifier ?? "")} +

+

+ {String(data.detectedAt ? new Date(data.detectedAt as string).toLocaleDateString() : "")} +

+
+ ); + }} +
+ + +
+ No exposures found +
+
+
+ }> +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ + + + + ); +} diff --git a/web/src/components/dashboard/HomeTitleWidget.tsx b/web/src/components/dashboard/HomeTitleWidget.tsx new file mode 100644 index 0000000..b4e2143 --- /dev/null +++ b/web/src/components/dashboard/HomeTitleWidget.tsx @@ -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 ( + HomeTitle + } + > + +
+ {[1, 2].map((i) => ( +
+ ))} +
+
+
+ }> +
+
+
+
{propertyCount()}
+
Properties
+
+
+
{changeCount()}
+
Changes
+
+
+ + + {(lc) => { + const c = lc(); + return ( +
+
+ + {severityLabel(String(c.severity ?? "info"))} + + + {String(c.changeType ?? "").replace(/_/g, " ")} + +
+

+ {String(c.propertyAddress ?? "")} +

+

+ {c.detectedAt ? new Date(c.detectedAt as string).toLocaleDateString() : ""} +

+
+ ); + }} +
+ + +
+ No recent changes +
+
+
+ + + + + ); +} diff --git a/web/src/components/dashboard/QuickActionsWidget.tsx b/web/src/components/dashboard/QuickActionsWidget.tsx new file mode 100644 index 0000000..4a232a1 --- /dev/null +++ b/web/src/components/dashboard/QuickActionsWidget.tsx @@ -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 = { + 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 ( + + + + ); +} + +export default function QuickActionsWidget(props: QuickActionsWidgetProps) { + return ( + Quick Actions + } + > +
+ {actions.map((action) => ( + +
+ +
+
+
+ {action.label} +
+
{action.desc}
+
+ + + +
+ ))} +
+
+ ); +} diff --git a/web/src/components/dashboard/RemoveBrokersWidget.tsx b/web/src/components/dashboard/RemoveBrokersWidget.tsx new file mode 100644 index 0000000..bb301bf --- /dev/null +++ b/web/src/components/dashboard/RemoveBrokersWidget.tsx @@ -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 ( + Remove Brokers + } + > + +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ }> +
+
+
+
{totalBrokers()}
+
Brokers
+
+
+
{pending()}
+
Pending
+
+
+
{completed()}
+
Completed
+
+
+ +
+
+ Removal Progress + {completionRate()}% +
+
+
+
+
+ +
+ {totalRequests()} total removal requests +
+
+ + + + + ); +} diff --git a/web/src/components/dashboard/SpamShieldWidget.tsx b/web/src/components/dashboard/SpamShieldWidget.tsx new file mode 100644 index 0000000..b9e51b8 --- /dev/null +++ b/web/src/components/dashboard/SpamShieldWidget.tsx @@ -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 ( + SpamShield + } + > + +
+
+
{blockedToday()}
+
Blocked
+
+
+
{totalDetections()}
+
Detections
+
+
+
{accuracy()}%
+
Accuracy
+
+
+ + 0}> +
+
Custom Rules
+
+ + {(rule) => ( +
+ + {rule.pattern} + + + {rule.action} + +
+ )} +
+
+
+
+ +
+ {rulesCount()} active rules +
+
+ }> +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ + + + + ); +} diff --git a/web/src/components/dashboard/ThreatScoreWidget.tsx b/web/src/components/dashboard/ThreatScoreWidget.tsx new file mode 100644 index 0000000..a724f8e --- /dev/null +++ b/web/src/components/dashboard/ThreatScoreWidget.tsx @@ -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(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 ( + Threat Score} + > + + + + + + {score()} + + + {label()} + + +
+ + + + + + + {trend() === "up" ? "Increased" : trend() === "down" ? "Decreased" : "Stable"} vs last check + +
+
+ }> +
+
+
+ + + ); +} diff --git a/web/src/components/dashboard/VoicePrintWidget.tsx b/web/src/components/dashboard/VoicePrintWidget.tsx new file mode 100644 index 0000000..1ea6aec --- /dev/null +++ b/web/src/components/dashboard/VoicePrintWidget.tsx @@ -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 ( + VoicePrint + } + > + +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ }> +
+
+
+
{enrollmentCount()}
+
Enrollments
+
+
+
{analysisCount()}
+
Analyses
+
+
+
{syntheticRate()}%
+
Synthetic
+
+
+ + 0}> +
+
Recent Analyses
+ + {(a) => { + const conf = Number(a.confidence ?? 0); + const pct = maxConfidence() > 0 ? (conf / maxConfidence()) * 100 : 0; + return ( +
+
+
+
+ + {a.isSynthetic ? "AI" : "Human"} + +
+ ); + }} + +
+ + + +
+ No analyses yet +
+
+
+
+ +
+ + + Open VoicePrint → + +
+ + ); +} diff --git a/web/src/components/dashboard/dashboard.test.tsx b/web/src/components/dashboard/dashboard.test.tsx new file mode 100644 index 0000000..4cbb8d3 --- /dev/null +++ b/web/src/components/dashboard/dashboard.test.tsx @@ -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 }) => + {props.children}, + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("Threat Score"); + }); + + it("displays the threat score number", async () => { + mount(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("35"); + }); + + it("calls getStats API on mount", async () => { + mount(() => ); + 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(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("Alert Feed"); + }); + + it("renders alert titles", async () => { + mountWithRouter(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("HIGH"); + }); + + it("has mark as read buttons", async () => { + mountWithRouter(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("DarkWatch Exposure"); + }); + + it("shows Run scan button", async () => { + mountWithRouter(() => ); + await new Promise((r) => setTimeout(r, 0)); + const buttons = document.querySelectorAll("button"); + expect(buttons.length).toBeGreaterThanOrEqual(1); + }); + + it("calls getExposures API", async () => { + mountWithRouter(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(mockGetExposures).toHaveBeenCalledWith({ limit: 1 }); + }); + + it("displays link to DarkWatch page", async () => { + mountWithRouter(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("VoicePrint"); + }); + + it("displays enrollment count", async () => { + mountWithRouter(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("1"); + }); + + it("displays analysis count", async () => { + mountWithRouter(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("2"); + }); + + it("shows link to VoicePrint page", async () => { + mountWithRouter(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("SpamShield"); + }); + + it("displays blocked count", async () => { + mountWithRouter(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("12"); + }); + + it("shows link to SpamShield page", async () => { + mountWithRouter(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("HomeTitle"); + }); + + it("displays property count", async () => { + mountWithRouter(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("1"); + }); + + it("shows link to HomeTitle page", async () => { + mountWithRouter(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("Remove Brokers"); + }); + + it("displays broker count", async () => { + mountWithRouter(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("2"); + }); + + it("shows progress bar", async () => { + mountWithRouter(() => ); + 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(() => ); + await new Promise((r) => setTimeout(r, 0)); + expect(document.body.textContent).toContain("Manage removals"); + }); +}); + +describe("QuickActionsWidget", () => { + it("renders header with Quick Actions label", () => { + mount(() => ); + expect(document.body.textContent).toContain("Quick Actions"); + }); + + it("displays all action buttons", () => { + mount(() => ); + 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(() => ); + 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"); + }); +}); diff --git a/web/src/components/dashboard/index.ts b/web/src/components/dashboard/index.ts index 0ae59de..ca8c263 100644 --- a/web/src/components/dashboard/index.ts +++ b/web/src/components/dashboard/index.ts @@ -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"; diff --git a/web/src/routes/(webapp)/dashboard.tsx b/web/src/routes/(webapp)/dashboard.tsx index 4750fc5..24a85ff 100644 --- a/web/src/routes/(webapp)/dashboard.tsx +++ b/web/src/routes/(webapp)/dashboard.tsx @@ -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 ( - - - - - ); -} - -function ShieldIcon() { - return ( - - - - - ); -} - -function EyeIcon() { - return ( - - - - - ); -} - -function ActivityIcon() { - return ( - - - - ); -} +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) => 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) => ({ - 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 (
@@ -75,30 +13,38 @@ export default function DashboardPage() { setSidebarOpen(v => !v)} />
-

Overview

+

Dashboard

-
- - {(card) => ( - - )} - -
+
+ }> + + + }> + + -
- - }, - { label: "View Alerts", href: "/dashboard", icon: () => }, - { label: "Add Member", href: "/settings", icon: () => }, - { label: "Run Report", href: "/dashboard", icon: () => }, - ]} /> + }> + + +
+ }> + + + }> + + +
+ + }> + + + }> + + + + }> + +
diff --git a/web/src/routes/migrated-pages.test.tsx b/web/src/routes/migrated-pages.test.tsx index c047fa5..838818c 100644 --- a/web/src/routes/migrated-pages.test.tsx +++ b/web/src/routes/migrated-pages.test.tsx @@ -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 }) => ( @@ -199,14 +232,7 @@ describe("AdsPage", () => { describe("DashboardPage", () => { it("renders dashboard title", () => { mount(() => ); - expect(document.body.textContent).toContain("Overview"); - }); - - it("renders stat cards with data from hooks", () => { - mount(() => ); - 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(() => ); - 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(() => ); 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"); }); });