diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index b7f005e..3b731e4 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -182,25 +182,25 @@ Spec: `.ralph/specs/phase-09-auth-module.md` --- -## Phase 10: Frontend — shadcn/ui Shell [PENDIENTE] +## Phase 10: Frontend — shadcn/ui Shell [COMPLETO] Spec: `.ralph/specs/phase-10-frontend-shell.md` -- [ ] 10.1: En frontend/: instalar shadcn/ui con `npx shadcn@latest init` (Vite, Zinc, CSS variables, Tailwind) -- [ ] 10.2: Instalar componentes shadcn: button, input, card, badge, dialog, dropdown-menu, command, sidebar, tabs, table, toast, form, separator, avatar, skeleton, tooltip, sheet, select, textarea, label, switch, alert -- [ ] 10.3: Instalar deps: `npm i @tanstack/react-query @tanstack/react-table zustand react-hook-form @hookform/resolvers framer-motion react-hotkeys-hook` -- [ ] 10.4: Crear layout: `components/layout/AppSidebar.tsx` — sidebar collapsible con nav items (Dashboard, Explorations, Findings, Reports, Settings) -- [ ] 10.5: Crear `components/layout/TopBar.tsx` — logo, search trigger (⌘K), theme toggle, user avatar menu -- [ ] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet -- [ ] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component -- [ ] 10.8: Crear ThemeProvider: dark mode como default, toggle dark/light, persistir en localStorage -- [ ] 10.9: Crear `lib/api.ts` — API client con fetch, credentials: include, auto-redirect a /login en 401 -- [ ] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider -- [ ] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme -- [ ] 10.12: Crear pages/Login.tsx — form email + password con shadcn -- [ ] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org) -- [ ] 10.14: Crear `components/layout/ProtectedRoute.tsx` — check auth, redirect a /login o /setup -- [ ] 10.15: Actualizar App.tsx con React Router: / (dashboard), /login, /setup, /sessions/:id, /findings/:id, /settings — todo wrapped en ProtectedRoute excepto login/setup -- [ ] 10.16: Verificar frontend build + commit: `fase(10): frontend shadcn-ui shell with auth` +- [x] 10.1: En frontend/: instalar shadcn/ui con `npx shadcn@latest init` (Vite, Zinc, CSS variables, Tailwind) +- [x] 10.2: Instalar componentes shadcn: button, input, card, badge, dialog, dropdown-menu, command, sidebar, tabs, table, toast, form, separator, avatar, skeleton, tooltip, sheet, select, textarea, label, switch, alert +- [x] 10.3: Instalar deps: `npm i @tanstack/react-query @tanstack/react-table zustand react-hook-form @hookform/resolvers framer-motion react-hotkeys-hook` +- [x] 10.4: Crear layout: `components/layout/AppSidebar.tsx` — sidebar collapsible con nav items (Dashboard, Explorations, Findings, Reports, Settings) +- [x] 10.5: Crear `components/layout/TopBar.tsx` — logo, search trigger (⌘K), theme toggle, user avatar menu +- [x] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet +- [x] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component +- [x] 10.8: Crear ThemeProvider: dark mode como default, toggle dark/light, persistir en localStorage +- [x] 10.9: Crear `lib/api.ts` — API client con fetch, credentials: include, auto-redirect a /login en 401 +- [x] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider +- [x] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme +- [x] 10.12: Crear pages/Login.tsx — form email + password con shadcn +- [x] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org) +- [x] 10.14: Crear `components/layout/ProtectedRoute.tsx` — check auth, redirect a /login o /setup +- [x] 10.15: Actualizar App.tsx con React Router: / (dashboard), /login, /setup, /sessions/:id, /findings/:id, /settings — todo wrapped en ProtectedRoute excepto login/setup +- [x] 10.16: Verificar frontend build + commit: `fase(10): frontend shadcn-ui shell with auth` --- diff --git a/frontend/src/components/common/SeverityBadge.tsx b/frontend/src/components/common/SeverityBadge.tsx new file mode 100644 index 0000000..2158a55 --- /dev/null +++ b/frontend/src/components/common/SeverityBadge.tsx @@ -0,0 +1,24 @@ +import { Badge } from '@/components/ui/badge' +import type { Severity } from '../../types' + +interface SeverityBadgeProps { + severity: Severity +} + +const VARIANT_MAP: Record = { + critical: 'bg-red-500/15 text-red-500 border-red-500/30', + high: 'bg-orange-500/15 text-orange-500 border-orange-500/30', + medium: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30', + low: 'bg-blue-500/15 text-blue-500 border-blue-500/30', +} + +export function SeverityBadge({ severity }: SeverityBadgeProps) { + return ( + + {severity} + + ) +} diff --git a/frontend/src/components/dashboard/ActiveSessions.tsx b/frontend/src/components/dashboard/ActiveSessions.tsx new file mode 100644 index 0000000..e4f4666 --- /dev/null +++ b/frontend/src/components/dashboard/ActiveSessions.tsx @@ -0,0 +1,64 @@ +import { useNavigate } from 'react-router-dom' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import type { Session } from '../../types' + +interface ActiveSessionsProps { + sessions: Session[] +} + +export function ActiveSessions({ sessions }: ActiveSessionsProps) { + const navigate = useNavigate() + const active = sessions.filter(s => s.status === 'running') + + return ( + + + + Active Sessions + {active.length > 0 && ( + + {active.length} + + )} + + + + {active.length === 0 ? ( +

+ No active sessions +

+ ) : ( +
+ {active.map(s => { + const pct = s.anomaliesFound > 0 + ? Math.min(100, (s.statesVisited / 100) * 100) + : (s.statesVisited / 50) * 100 + + return ( +
navigate(`/sessions/${s.sessionId}`)} + > +
+ {s.url} + + {s.statesVisited} states + +
+
+
+
+
+ ) + })} +
+ )} + + + ) +} diff --git a/frontend/src/components/dashboard/KPICards.tsx b/frontend/src/components/dashboard/KPICards.tsx new file mode 100644 index 0000000..deab2c2 --- /dev/null +++ b/frontend/src/components/dashboard/KPICards.tsx @@ -0,0 +1,64 @@ +import { Card, CardContent } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import type { Stats } from '../../types' + +interface KPICardsProps { + stats: Stats | undefined + isLoading: boolean +} + +interface KPICardProps { + title: string + value: number | string + isLoading: boolean + valueClass?: string +} + +function KPICard({ title, value, isLoading, valueClass }: KPICardProps) { + return ( + + + {isLoading ? ( + <> + + + + ) : ( + <> +

{value}

+

{title}

+ + )} +
+
+ ) +} + +export function KPICards({ stats, isLoading }: KPICardsProps) { + return ( +
+ + 0 ? 'text-destructive' : undefined} + /> + 0 ? 'text-green-500' : undefined} + /> + +
+ ) +} diff --git a/frontend/src/components/dashboard/QuickActions.tsx b/frontend/src/components/dashboard/QuickActions.tsx new file mode 100644 index 0000000..38b8fcb --- /dev/null +++ b/frontend/src/components/dashboard/QuickActions.tsx @@ -0,0 +1,27 @@ +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Plus, FileText } from 'lucide-react' + +export function QuickActions() { + const navigate = useNavigate() + + return ( +
+ + +
+ ) +} diff --git a/frontend/src/components/dashboard/RecentFindings.tsx b/frontend/src/components/dashboard/RecentFindings.tsx new file mode 100644 index 0000000..39eec26 --- /dev/null +++ b/frontend/src/components/dashboard/RecentFindings.tsx @@ -0,0 +1,67 @@ +import { useNavigate } from 'react-router-dom' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { SeverityBadge } from '@/components/common/SeverityBadge' +import type { AnomalySummary } from '../../types' + +interface RecentFindingsProps { + findings: AnomalySummary[] +} + +export function RecentFindings({ findings }: RecentFindingsProps) { + const navigate = useNavigate() + const recent = findings.slice(0, 10) + + return ( + + + Recent Findings + + + {recent.length === 0 ? ( +

+ No findings yet. Start an exploration! +

+ ) : ( + + + + Severity + Type + Description + Time + + + + {recent.map(f => ( + navigate(`/findings/${f.id}`)} + > + + + + {f.type} + + {f.description} + + + {new Date(f.timestamp).toLocaleString()} + + + ))} + +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard/SeverityDistribution.tsx b/frontend/src/components/dashboard/SeverityDistribution.tsx new file mode 100644 index 0000000..d882c4f --- /dev/null +++ b/frontend/src/components/dashboard/SeverityDistribution.tsx @@ -0,0 +1,68 @@ +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import type { AnomalySummary } from '../../types' + +const SEVERITY_COLORS: Record = { + critical: '#ef4444', + high: '#f97316', + medium: '#eab308', + low: '#3b82f6', +} + +interface SeverityDistributionProps { + findings: AnomalySummary[] +} + +export function SeverityDistribution({ findings }: SeverityDistributionProps) { + const counts: Record = {} + for (const f of findings) { + counts[f.severity] = (counts[f.severity] ?? 0) + 1 + } + + const data = Object.entries(counts).map(([name, value]) => ({ name, value })) + + if (data.length === 0) { + return ( + + + Severity Distribution + + +

No findings yet

+
+
+ ) + } + + return ( + + + Severity Distribution + + + + + + {data.map(entry => ( + + ))} + + + + + + + + ) +} diff --git a/frontend/src/components/dashboard/TrendChart.tsx b/frontend/src/components/dashboard/TrendChart.tsx new file mode 100644 index 0000000..6f6313e --- /dev/null +++ b/frontend/src/components/dashboard/TrendChart.tsx @@ -0,0 +1,72 @@ +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import type { AnomalySummary } from '../../types' + +interface TrendChartProps { + findings: AnomalySummary[] +} + +function getLast30Days(): string[] { + const days: string[] = [] + for (let i = 29; i >= 0; i--) { + const d = new Date() + d.setDate(d.getDate() - i) + days.push(d.toISOString().slice(0, 10)) + } + return days +} + +export function TrendChart({ findings }: TrendChartProps) { + const days = getLast30Days() + + const data = days.map(day => { + const dayFindings = findings.filter(f => { + const d = new Date(f.timestamp).toISOString().slice(0, 10) + return d === day + }) + return { + date: day.slice(5), // MM-DD + critical: dayFindings.filter(f => f.severity === 'critical').length, + high: dayFindings.filter(f => f.severity === 'high').length, + medium: dayFindings.filter(f => f.severity === 'medium').length, + low: dayFindings.filter(f => f.severity === 'low').length, + } + }) + + return ( + + + Findings Trend (30 days) + + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/hooks/useFindings.ts b/frontend/src/hooks/useFindings.ts new file mode 100644 index 0000000..14cf654 --- /dev/null +++ b/frontend/src/hooks/useFindings.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import type { AnomalySummary, Stats } from '../types' + +export function useFindings(params?: { sessionId?: string; severity?: string }) { + const qs = new URLSearchParams() + if (params?.sessionId) qs.set('sessionId', params.sessionId) + if (params?.severity) qs.set('severity', params.severity) + const q = qs.toString() ? `?${qs.toString()}` : '' + + return useQuery({ + queryKey: ['findings', params], + queryFn: () => apiFetch(`/api/anomalies${q}`), + }) +} + +export function useFindingStats() { + return useQuery({ + queryKey: ['findings', 'stats'], + queryFn: () => apiFetch('/api/stats'), + }) +} diff --git a/frontend/src/hooks/useSessions.ts b/frontend/src/hooks/useSessions.ts new file mode 100644 index 0000000..d41bc16 --- /dev/null +++ b/frontend/src/hooks/useSessions.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import type { Session } from '../types' + +export function useSessions() { + return useQuery({ + queryKey: ['sessions'], + queryFn: () => apiFetch('/api/sessions'), + }) +} + +export function useSession(id: string) { + return useQuery({ + queryKey: ['sessions', id], + queryFn: () => apiFetch(`/api/sessions/${id}`), + enabled: !!id, + }) +} diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts index 51052f3..236501c 100644 --- a/frontend/src/hooks/useSocket.ts +++ b/frontend/src/hooks/useSocket.ts @@ -1,40 +1,40 @@ -/** - * useSocket — reusable socket.io-client connection. - */ +import { useEffect, useRef, useCallback } from 'react' +import { io, type Socket } from 'socket.io-client' -import { useEffect, useRef, useCallback } from 'react'; -import { io, Socket } from 'socket.io-client'; +export type SocketHandler = (event: string, data: unknown) => void -export type SocketHandler = (event: string, data: unknown) => void; +const SOCKET_EVENTS = [ + 'session:started', + 'state:discovered', + 'action:executed', + 'anomaly:detected', + 'session:completed', + 'session:error', +] -export function useSocket(onEvent: SocketHandler) { - const socketRef = useRef(null); - const handlerRef = useRef(onEvent); - handlerRef.current = onEvent; +export function useSocket(onEvent?: SocketHandler) { + const socketRef = useRef(null) + const handlerRef = useRef(onEvent) + handlerRef.current = onEvent useEffect(() => { - const socket = io({ path: '/socket.io' }); - socketRef.current = socket; + const socket = io({ path: '/socket.io' }) + socketRef.current = socket - const events = [ - 'session:started', - 'state:discovered', - 'action:executed', - 'anomaly:detected', - 'session:completed', - 'session:error', - ]; + SOCKET_EVENTS.forEach(evt => { + socket.on(evt, (data: unknown) => { + handlerRef.current?.(evt, data) + }) + }) - events.forEach((evt) => { - socket.on(evt, (data: unknown) => handlerRef.current(evt, data)); - }); - - return () => { socket.disconnect(); }; - }, []); + return () => { + socket.disconnect() + } + }, []) const emit = useCallback((event: string, data: unknown) => { - socketRef.current?.emit(event, data); - }, []); + socketRef.current?.emit(event, data) + }, []) - return { emit }; + return { emit } } diff --git a/frontend/src/pages/AnomalyDetail.tsx b/frontend/src/pages/AnomalyDetail.tsx index 3f26dfc..45dd8db 100644 --- a/frontend/src/pages/AnomalyDetail.tsx +++ b/frontend/src/pages/AnomalyDetail.tsx @@ -163,7 +163,7 @@ export function AnomalyDetail() { useEffect(() => { loadAnomaly(); }, [loadAnomaly]); // Listen for AI enrichment via WebSocket - useSocket(useCallback((event, data) => { + useSocket(useCallback((event: string, data: unknown) => { if (event === 'anomaly:enriched') { const d = data as WsAnomalyEnriched; if (d.anomalyId === anomalyId) { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ef94a4c..bdca851 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,64 @@ +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { KPICards } from '@/components/dashboard/KPICards' +import { TrendChart } from '@/components/dashboard/TrendChart' +import { SeverityDistribution } from '@/components/dashboard/SeverityDistribution' +import { RecentFindings } from '@/components/dashboard/RecentFindings' +import { ActiveSessions } from '@/components/dashboard/ActiveSessions' +import { QuickActions } from '@/components/dashboard/QuickActions' +import { useFindings, useFindingStats } from '@/hooks/useFindings' +import { useSessions } from '@/hooks/useSessions' +import { useSocket } from '@/hooks/useSocket' + export function Dashboard() { + const queryClient = useQueryClient() + + const { data: findings = [], isLoading: findingsLoading } = useFindings() + const { data: stats, isLoading: statsLoading } = useFindingStats() + const { data: sessions = [] } = useSessions() + + const refreshData = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: ['findings'] }) + void queryClient.invalidateQueries({ queryKey: ['sessions'] }) + }, [queryClient]) + + useSocket(useCallback((event: string) => { + if (['session:started', 'session:completed', 'session:error', 'anomaly:detected'].includes(event)) { + refreshData() + } + }, [refreshData])) + return ( -
-

Dashboard

-

Coming in Phase 11 — charts and real-time KPIs

+
+
+
+

Dashboard

+

Overview of your security findings

+
+ +
+ + + +
+
+ +
+ +
+ +
+
+ b.timestamp - a.timestamp)} /> +
+ +
+ + {findingsLoading && ( +
+ Loading findings... +
+ )}
) } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 0688295..5d59f1b 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -151,7 +151,7 @@ export function SessionDetail() { setFeedEvents((prev) => [...prev, { id: nextId(), event, text, timestamp: Date.now() }]); } - useSocket(useCallback((event, data) => { + useSocket(useCallback((event: string, data: unknown) => { const d = data as Record; if (d['sessionId'] !== sessionId) return; diff --git a/package-lock.json b/package-lock.json index 20073f5..1d8afac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "pino-pretty": "^13.1.3", "pixelmatch": "^7.1.0", "playwright": "^1.40.0", + "recharts": "^3.7.0", "sharp": "^0.34.5", "socket.io": "^4.8.3", "uuid": "^13.0.0", @@ -1546,6 +1547,42 @@ "node": ">=18" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1579,6 +1616,18 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -1707,6 +1756,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -1886,6 +1998,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -2590,6 +2708,15 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2823,6 +2950,127 @@ "node": ">= 8" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -2849,6 +3097,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3172,6 +3426,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3221,6 +3485,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3824,6 +4094,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -3878,6 +4158,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -5674,13 +5963,58 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -5704,6 +6038,51 @@ "node": ">= 12.13.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5714,6 +6093,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5819,6 +6204,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -6467,6 +6859,12 @@ "node": ">=20" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6749,6 +7147,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6799,6 +7206,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index da2e17b..5013643 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "pino-pretty": "^13.1.3", "pixelmatch": "^7.1.0", "playwright": "^1.40.0", + "recharts": "^3.7.0", "sharp": "^0.34.5", "socket.io": "^4.8.3", "uuid": "^13.0.0",