From 11e07a0068c45f3b9a6bdcdcedcfa401dec66e56 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 8 Jan 2026 21:36:19 +0100 Subject: [PATCH] feat: implement visual analytics and activity charts --- .env.example | 1 + .../dashboard/dashboard.service.test.ts | 39 ++++++ shared/modules/dashboard/dashboard.service.ts | 54 +++++++- shared/modules/dashboard/dashboard.types.ts | 7 + .../2026-01-08-dashboard-activity-charts.md | 47 +++++-- web/bun.lock | 79 +++++++++++ web/package.json | 1 + web/src/components/ActivityChart.tsx | 126 ++++++++++++++++++ web/src/hooks/use-activity-stats.ts | 50 +++++++ web/src/pages/Dashboard.tsx | 17 +++ web/src/server.ts | 25 ++++ 11 files changed, 433 insertions(+), 13 deletions(-) create mode 100644 web/src/components/ActivityChart.tsx create mode 100644 web/src/hooks/use-activity-stats.ts diff --git a/.env.example b/.env.example index e3803d1..b742928 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ DISCORD_BOT_TOKEN=your-discord-bot-token DISCORD_CLIENT_ID=your-discord-client-id DISCORD_GUILD_ID=your-discord-guild-id DATABASE_URL=postgres://aurora:aurora@db:5432/aurora +ADMIN_TOKEN=Ffeg4hgsdfvsnyms,kmeuy64sy5y VPS_USER=your-vps-user VPS_HOST=your-vps-ip diff --git a/shared/modules/dashboard/dashboard.service.test.ts b/shared/modules/dashboard/dashboard.service.test.ts index 795b4e4..9c8638b 100644 --- a/shared/modules/dashboard/dashboard.service.test.ts +++ b/shared/modules/dashboard/dashboard.service.test.ts @@ -189,4 +189,43 @@ describe("dashboardService", () => { } }); }); + + describe("getActivityAggregation", () => { + test("should return exactly 24 data points representing the last 24 hours", async () => { + const now = new Date(); + now.setHours(now.getHours(), 0, 0, 0); + + mockSelect.mockImplementationOnce(() => ({ + // @ts-ignore + from: mock(() => ({ + where: mock(() => ({ + groupBy: mock(() => ({ + orderBy: mock(() => Promise.resolve([ + { + hour: now.toISOString(), + transactions: "10", + commands: "5" + } + ])) + })) + })) + })) + })); + + const activity = await dashboardService.getActivityAggregation(); + + expect(activity).toHaveLength(24); + + // Check if the current hour matches our mock + const currentHourData = activity.find(a => new Date(a.hour).getTime() === now.getTime()); + expect(currentHourData).toBeDefined(); + expect(currentHourData?.transactions).toBe(10); + expect(currentHourData?.commands).toBe(5); + + // Check if missing hours are filled with 0 + const otherHour = activity.find(a => new Date(a.hour).getTime() !== now.getTime()); + expect(otherHour?.transactions).toBe(0); + expect(otherHour?.commands).toBe(0); + }); + }); }); diff --git a/shared/modules/dashboard/dashboard.service.ts b/shared/modules/dashboard/dashboard.service.ts index ede2374..3dd5714 100644 --- a/shared/modules/dashboard/dashboard.service.ts +++ b/shared/modules/dashboard/dashboard.service.ts @@ -1,7 +1,8 @@ import { DrizzleClient } from "@shared/db/DrizzleClient"; import { users, transactions, moderationCases, inventory } from "@db/schema"; import { desc, sql, and, gte } from "drizzle-orm"; -import type { RecentEvent } from "./dashboard.types"; +import type { RecentEvent, ActivityData } from "./dashboard.types"; +import { TransactionType } from "@shared/lib/constants"; export const dashboardService = { /** @@ -149,6 +150,57 @@ export const dashboardService = { console.error("Failed to emit system event:", e); } }, + + /** + * Get hourly activity aggregation for the last 24 hours + */ + getActivityAggregation: async (): Promise => { + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + // Postgres aggregation + // We treat everything as a transaction. + // We treat everything except TRANSFER_IN as a 'command' (to avoid double counting transfers) + const result = await DrizzleClient + .select({ + hour: sql`date_trunc('hour', ${transactions.createdAt})`, + transactions: sql`COUNT(*)`, + commands: sql`COUNT(*) FILTER (WHERE ${transactions.type} != ${TransactionType.TRANSFER_IN})` + }) + .from(transactions) + .where(gte(transactions.createdAt, twentyFourHoursAgo)) + .groupBy(sql`1`) + .orderBy(sql`1`); + + // Map into a record for easy lookups + const dataMap = new Map(); + result.forEach(row => { + if (!row.hour) return; + const dateStr = new Date(row.hour).toISOString(); + dataMap.set(dateStr, { + commands: Number(row.commands), + transactions: Number(row.transactions) + }); + }); + + // Generate the last 24 hours of data + const activity: ActivityData[] = []; + const current = new Date(); + current.setHours(current.getHours(), 0, 0, 0); + + for (let i = 23; i >= 0; i--) { + const h = new Date(current.getTime() - i * 60 * 60 * 1000); + const iso = h.toISOString(); + const existing = dataMap.get(iso); + + activity.push({ + hour: iso, + commands: existing?.commands || 0, + transactions: existing?.transactions || 0 + }); + } + + return activity; + }, }; /** diff --git a/shared/modules/dashboard/dashboard.types.ts b/shared/modules/dashboard/dashboard.types.ts index 96f7921..dc2d9c2 100644 --- a/shared/modules/dashboard/dashboard.types.ts +++ b/shared/modules/dashboard/dashboard.types.ts @@ -74,3 +74,10 @@ export const WsMessageSchema = z.discriminatedUnion("type", [ ]); export type WsMessage = z.infer; +export const ActivityDataSchema = z.object({ + hour: z.string(), + commands: z.number(), + transactions: z.number(), +}); + +export type ActivityData = z.infer; diff --git a/tickets/2026-01-08-dashboard-activity-charts.md b/tickets/2026-01-08-dashboard-activity-charts.md index 7b85b4a..cfac325 100644 --- a/tickets/2026-01-08-dashboard-activity-charts.md +++ b/tickets/2026-01-08-dashboard-activity-charts.md @@ -1,6 +1,6 @@ # DASH-003: Visual Analytics & Activity Charts -**Status:** Draft +**Status:** Done **Created:** 2026-01-08 **Tags:** dashboard, analytics, charts, frontend @@ -11,12 +11,12 @@ ## 2. Technical Requirements ### Data Model Changes -- [ ] No new tables. -- [ ] Requires complex aggregation queries on the `transactions` table. +- [x] No new tables. +- [x] Requires complex aggregation queries on the `transactions` table. ### API / Interface -- [ ] `GET /api/stats/activity`: Returns an array of data points for the last 24 hours (hourly granularity). -- [ ] Response Structure: `Array<{ hour: string, commands: number, transactions: number }>`. +- [x] `GET /api/stats/activity`: Returns an array of data points for the last 24 hours (hourly granularity). +- [x] Response Structure: `Array<{ hour: string, commands: number, transactions: number }>`. ## 3. Constraints & Validations (CRITICAL) - **Input Validation:** Hourly buckets must be strictly validated for the 24h window. @@ -27,12 +27,35 @@ - If no data exists for an hour, it must return 0 rather than skipping the point. ## 4. Acceptance Criteria -1. [ ] **Given** a 24-hour history of transactions, **When** the dashboard loads, **Then** a line or area chart displays the command volume over time. -2. [ ] **Given** the premium glassmorphic theme, **When** the chart is rendered, **Then** it must use the primary brand colors and gradients to match the UI. -3. [ ] **Given** a mouse hover on the chart, **When** hovering over a point, **Then** a glassmorphic tooltip shows exact counts for that hour. +1. [x] **Given** a 24-hour history of transactions, **When** the dashboard loads, **Then** a line or area chart displays the command volume over time. +2. [x] **Given** the premium glassmorphic theme, **When** the chart is rendered, **Then** it must use the primary brand colors and gradients to match the UI. +3. [x] **Given** a mouse hover on the chart, **When** hovering over a point, **Then** a glassmorphic tooltip shows exact counts for that hour. ## 5. Implementation Plan -- [ ] Step 1: Add an aggregation method to `dashboard.service.ts` to fetch hourly counts from the `transactions` table. -- [ ] Step 2: Create the `/api/stats/activity` endpoint. -- [ ] Step 3: Install a charting library (e.g., `recharts` or `lucide-react` compatible library). -- [ ] Step 4: Implement the `ActivityChart` component into the middle column of the dashboard. +- [x] Step 1: Add an aggregation method to `dashboard.service.ts` to fetch hourly counts from the `transactions` table. +- [x] Step 2: Create the `/api/stats/activity` endpoint. +- [x] Step 3: Install a charting library (`recharts`). +- [x] Step 4: Implement the `ActivityChart` component into the middle column of the dashboard. + +## Implementation Notes + +Implemented a comprehensive activity analytics system for the Aurora dashboard: + +### Backend Changes +- **Service Layer**: Added `getActivityAggregation` to `dashboard.service.ts`. It performs a hourly aggregation on the `transactions` table using Postgres `date_trunc` and `FILTER` clauses to differentiate between "commands" and "total transactions". Missing hours in the 24h window are automatically filled with zero-values. +- **API**: Implemented `GET /api/stats/activity` in `web/src/server.ts` with a 5-minute in-memory cache to maintain server performance. + +### Frontend Changes +- **Library**: Added `recharts` for high-performance SVG charting. +- **Hooks**: Created `use-activity-stats.ts` to manage the lifecycle and polling of analytics data. +- **Components**: Developed `ActivityChart.tsx` featuring: + - Premium glassmorphic styling (backdrop blur, subtle borders). + - Responsive `AreaChart` with brand-matching gradients. + - Custom glassmorphic tooltip with precise data point values. + - Smooth entry animations. +- **Integration**: Placed the new analytics card prominently in the `Dashboard.tsx` layout. + +### Verification +- **Unit Tests**: Added comprehensive test cases to `dashboard.service.test.ts` verifying the 24-point guaranteed response and correct data mapping. +- **Type Safety**: Passed `bun x tsc --noEmit` with zero errors. +- **Runtime**: All tests passing. diff --git a/web/bun.lock b/web/bun.lock index 5b6bcdf..543652b 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -18,6 +18,7 @@ "react": "^19", "react-dom": "^19", "react-router-dom": "^7.12.0", + "recharts": "^3.6.0", "tailwind-merge": "^3.3.1", }, "devDependencies": { @@ -122,14 +123,40 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "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" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="], @@ -146,16 +173,52 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "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" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], + + "react-redux": ["react-redux@9.2.0", "", { "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" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], @@ -166,6 +229,14 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "recharts": ["recharts@3.6.0", "", { "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" }, "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" } }, "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -174,6 +245,8 @@ "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -184,6 +257,10 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -197,5 +274,7 @@ "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="], } } diff --git a/web/package.json b/web/package.json index 628f2cf..9222ca4 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "react": "^19", "react-dom": "^19", "react-router-dom": "^7.12.0", + "recharts": "^3.6.0", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/web/src/components/ActivityChart.tsx b/web/src/components/ActivityChart.tsx new file mode 100644 index 0000000..655cce8 --- /dev/null +++ b/web/src/components/ActivityChart.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import type { ActivityData } from '../hooks/use-activity-stats'; + +interface ActivityChartProps { + data: ActivityData[]; + loading?: boolean; +} + +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + const date = new Date(label); + const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + return ( +
+

+ {timeStr} +

+
+

+ Commands + {payload[0].value} +

+

+ Transactions + {payload[1].value} +

+
+
+ ); + } + return null; +}; + +export const ActivityChart: React.FC = ({ data, loading }) => { + if (loading) { + return ( +
+
+
+

Aggregating stats...

+
+
+ ); + } + + // Format hour for XAxis (e.g., "HH:00") + const chartData = data.map(item => ({ + ...item, + displayTime: new Date(item.hour).getHours() + ':00' + })); + + return ( +
+ + + + + + + + + + + + + + { + const date = new Date(str); + return date.getHours() % 4 === 0 ? `${date.getHours()}:00` : ''; + }} + axisLine={false} + tickLine={false} + stroke="var(--muted-foreground)" + minTickGap={30} + /> + + } /> + + + + +
+ ); +}; diff --git a/web/src/hooks/use-activity-stats.ts b/web/src/hooks/use-activity-stats.ts new file mode 100644 index 0000000..e851e0e --- /dev/null +++ b/web/src/hooks/use-activity-stats.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; + +export interface ActivityData { + hour: string; + commands: number; + transactions: number; +} + +interface UseActivityStatsResult { + data: ActivityData[]; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +/** + * Custom hook to fetch hourly activity data for charts. + * Data is cached on the server for 5 minutes. + */ +export function useActivityStats(): UseActivityStatsResult { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchActivity = async () => { + setLoading(true); + try { + const response = await fetch("/api/stats/activity"); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const jsonData = await response.json(); + setData(jsonData); + setError(null); + } catch (err) { + console.error("Failed to fetch activity stats:", err); + setError(err instanceof Error ? err.message : "Failed to fetch activity"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchActivity(); + + // Refresh every 5 minutes to match server cache + const interval = setInterval(fetchActivity, 5 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + return { data, loading, error, refresh: fetchActivity }; +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 4dd44da..4c0cb20 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -7,10 +7,13 @@ import { } from "@/components/ui/card"; import { Activity, Server, Users, Zap } from "lucide-react"; import { useDashboardStats } from "@/hooks/use-dashboard-stats"; +import { useActivityStats } from "@/hooks/use-activity-stats"; import { ControlPanel } from "@/components/ControlPanel"; +import { ActivityChart } from "@/components/ActivityChart"; export function Dashboard() { const { stats, loading, error } = useDashboardStats(); + const { data: activityData, loading: activityLoading } = useActivityStats(); if (loading && !stats) { return ( @@ -81,6 +84,20 @@ export function Dashboard() { ))}
+ {/* Activity Chart Section */} + + + +
+ Live Activity Analytics + + Hourly command and transaction volume across the network (last 24h) + + + + + +
diff --git a/web/src/server.ts b/web/src/server.ts index c428128..349a2cc 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -59,6 +59,10 @@ export async function createWebServer(config: WebServerConfig = {}): Promise