From a71557cb1ccf0e81eaece2de2a599f98bbf040d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Wed, 14 Jan 2026 16:38:48 +0800 Subject: [PATCH 01/10] feat(backend): add overview statistics API - Add database query functions for completions and embeddings statistics - Add time series data aggregation with configurable bucket intervals - Add model distribution statistics - Create /admin/stats/overview endpoint with time range support - Support ranges: 1m, 5m, 10m, 30m, 1h, 4h, 12h Co-Authored-By: Claude Opus 4.5 --- backend/src/api/admin/index.ts | 2 + backend/src/api/admin/stats.ts | 194 +++++++++++++++++++++++++++++++++ backend/src/db/index.ts | 188 ++++++++++++++++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 backend/src/api/admin/stats.ts diff --git a/backend/src/api/admin/index.ts b/backend/src/api/admin/index.ts index 9a0aaca..47b41de 100644 --- a/backend/src/api/admin/index.ts +++ b/backend/src/api/admin/index.ts @@ -7,6 +7,7 @@ import { adminEmbeddings } from "./embeddings"; import { adminModels } from "./models"; import { adminProviders } from "./providers"; import { adminRateLimits } from "./rateLimits"; +import { adminStats } from "./stats"; import { adminUpstream } from "./upstream"; import { adminUsage } from "./usage"; @@ -27,6 +28,7 @@ export const routes = new Elysia({ .use(adminProviders) .use(adminModels) .use(adminEmbeddings) + .use(adminStats) .get("/", () => true, { detail: { description: "Check whether the admin secret is valid." }, }) diff --git a/backend/src/api/admin/stats.ts b/backend/src/api/admin/stats.ts new file mode 100644 index 0000000..c7cf733 --- /dev/null +++ b/backend/src/api/admin/stats.ts @@ -0,0 +1,194 @@ +import { Elysia, t } from "elysia"; +import { + getCompletionsModelDistribution, + getCompletionsStats, + getCompletionsTimeSeries, + getEmbeddingsModelDistribution, + getEmbeddingsStats, + getEmbeddingsTimeSeries, +} from "@/db"; + +const RANGE_CONFIG: Record< + string, + { seconds: number; bucketSeconds: number } +> = { + "1m": { seconds: 60, bucketSeconds: 5 }, + "5m": { seconds: 300, bucketSeconds: 15 }, + "10m": { seconds: 600, bucketSeconds: 30 }, + "30m": { seconds: 1800, bucketSeconds: 60 }, + "1h": { seconds: 3600, bucketSeconds: 120 }, + "4h": { seconds: 14400, bucketSeconds: 480 }, + "12h": { seconds: 43200, bucketSeconds: 1440 }, +}; + +export const adminStats = new Elysia().group("/stats", (app) => + app.get( + "/overview", + async ({ query }) => { + const rangeKey = query.range || "1h"; + const config = RANGE_CONFIG[rangeKey]; + + if (!config) { + throw new Error(`Invalid range: ${rangeKey}`); + } + + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - config.seconds * 1000); + + // Fetch all data in parallel + const [ + completionsStats, + embeddingsStats, + completionsModelDist, + embeddingsModelDist, + completionsTimeSeries, + embeddingsTimeSeries, + ] = await Promise.all([ + getCompletionsStats(startTime, endTime), + getEmbeddingsStats(startTime, endTime), + getCompletionsModelDistribution(startTime, endTime), + getEmbeddingsModelDistribution(startTime, endTime), + getCompletionsTimeSeries(startTime, endTime, config.bucketSeconds), + getEmbeddingsTimeSeries(startTime, endTime, config.bucketSeconds), + ]); + + // Calculate success rates + const completionsTotal = + Number(completionsStats.completed) + Number(completionsStats.failed); + const embeddingsTotal = + Number(embeddingsStats.completed) + Number(embeddingsStats.failed); + + const completionsSuccessRate = + completionsTotal > 0 + ? (Number(completionsStats.completed) / completionsTotal) * 100 + : 100; + + const embeddingsSuccessRate = + embeddingsTotal > 0 + ? (Number(embeddingsStats.completed) / embeddingsTotal) * 100 + : 100; + + // Merge model distributions + const modelDistribution = [ + ...completionsModelDist.map((m) => ({ + model: m.model, + count: m.count, + type: "chat" as const, + })), + ...embeddingsModelDist.map((m) => ({ + model: m.model, + count: m.count, + type: "embedding" as const, + })), + ]; + + // Merge time series data + const timeSeriesMap = new Map< + string, + { + timestamp: string; + completionsCount: number; + embeddingsCount: number; + completionsFailed: number; + embeddingsFailed: number; + avgDuration: number; + avgTTFT: number; + } + >(); + + // Generate all buckets within the time range + const bucketCount = Math.ceil(config.seconds / config.bucketSeconds); + for (let i = 0; i < bucketCount; i++) { + const bucketTime = new Date( + startTime.getTime() + i * config.bucketSeconds * 1000, + ); + const key = bucketTime.toISOString(); + timeSeriesMap.set(key, { + timestamp: key, + completionsCount: 0, + embeddingsCount: 0, + completionsFailed: 0, + embeddingsFailed: 0, + avgDuration: 0, + avgTTFT: 0, + }); + } + + // Fill in completions data + for (const row of completionsTimeSeries) { + const key = new Date(row.bucket).toISOString(); + const existing = timeSeriesMap.get(key); + if (existing) { + existing.completionsCount = Number(row.total); + existing.completionsFailed = Number(row.failed); + existing.avgDuration = Number(row.avg_duration); + existing.avgTTFT = Number(row.avg_ttft); + } + } + + // Fill in embeddings data + for (const row of embeddingsTimeSeries) { + const key = new Date(row.bucket).toISOString(); + const existing = timeSeriesMap.get(key); + if (existing) { + existing.embeddingsCount = Number(row.total); + existing.embeddingsFailed = Number(row.failed); + // Average the duration if completions already has data + if (existing.avgDuration > 0) { + existing.avgDuration = + (existing.avgDuration + Number(row.avg_duration)) / 2; + } else { + existing.avgDuration = Number(row.avg_duration); + } + } + } + + const timeSeries = Array.from(timeSeriesMap.values()).sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + return { + summary: { + totalRequests: completionsStats.total + embeddingsStats.total, + completionsCount: completionsStats.total, + embeddingsCount: embeddingsStats.total, + completionsSuccessRate: Math.round(completionsSuccessRate * 100) / 100, + embeddingsSuccessRate: Math.round(embeddingsSuccessRate * 100) / 100, + avgDuration: Math.round(Number(completionsStats.avgDuration)), + avgTTFT: Math.round(Number(completionsStats.avgTTFT)), + }, + tokenUsage: { + promptTokens: Number(completionsStats.totalPromptTokens), + completionTokens: Number(completionsStats.totalCompletionTokens), + embeddingTokens: Number(embeddingsStats.totalInputTokens), + totalTokens: + Number(completionsStats.totalPromptTokens) + + Number(completionsStats.totalCompletionTokens) + + Number(embeddingsStats.totalInputTokens), + }, + modelDistribution, + timeSeries, + }; + }, + { + query: t.Object({ + range: t.Optional( + t.Union([ + t.Literal("1m"), + t.Literal("5m"), + t.Literal("10m"), + t.Literal("30m"), + t.Literal("1h"), + t.Literal("4h"), + t.Literal("12h"), + ]), + ), + }), + detail: { + description: + "Get overview statistics for the dashboard including request counts, success rates, latency, and time series data.", + }, + }, + ), +); diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index a6e2863..d59c82f 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1026,3 +1026,191 @@ export async function sumEmbeddingTokenUsage(apiKeyId?: number) { const [first] = r; return first ?? null; } + +// ============================================ +// Overview Statistics Operations +// ============================================ + +/** + * Get completions statistics for a time range + */ +export async function getCompletionsStats(startTime: Date, endTime: Date) { + logger.debug("getCompletionsStats", startTime, endTime); + const r = await db + .select({ + total: count(schema.CompletionsTable.id), + completed: sql`SUM(CASE WHEN ${schema.CompletionsTable.status} = 'completed' THEN 1 ELSE 0 END)::int`, + failed: sql`SUM(CASE WHEN ${schema.CompletionsTable.status} = 'failed' THEN 1 ELSE 0 END)::int`, + avgDuration: sql`COALESCE(AVG(CASE WHEN ${schema.CompletionsTable.duration} > 0 THEN ${schema.CompletionsTable.duration} END), 0)`, + avgTTFT: sql`COALESCE(AVG(CASE WHEN ${schema.CompletionsTable.status} = 'completed' AND ${schema.CompletionsTable.ttft} > 0 THEN ${schema.CompletionsTable.ttft} END), 0)`, + totalPromptTokens: sql`COALESCE(SUM(CASE WHEN ${schema.CompletionsTable.promptTokens} > 0 THEN ${schema.CompletionsTable.promptTokens} ELSE 0 END), 0)::bigint`, + totalCompletionTokens: sql`COALESCE(SUM(CASE WHEN ${schema.CompletionsTable.completionTokens} > 0 THEN ${schema.CompletionsTable.completionTokens} ELSE 0 END), 0)::bigint`, + }) + .from(schema.CompletionsTable) + .where( + and( + not(schema.CompletionsTable.deleted), + sql`${schema.CompletionsTable.createdAt} >= ${startTime}`, + sql`${schema.CompletionsTable.createdAt} < ${endTime}`, + ), + ); + const [first] = r; + return first ?? { + total: 0, + completed: 0, + failed: 0, + avgDuration: 0, + avgTTFT: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + }; +} + +/** + * Get embeddings statistics for a time range + */ +export async function getEmbeddingsStats(startTime: Date, endTime: Date) { + logger.debug("getEmbeddingsStats", startTime, endTime); + const r = await db + .select({ + total: count(schema.EmbeddingsTable.id), + completed: sql`SUM(CASE WHEN ${schema.EmbeddingsTable.status} = 'completed' THEN 1 ELSE 0 END)::int`, + failed: sql`SUM(CASE WHEN ${schema.EmbeddingsTable.status} = 'failed' THEN 1 ELSE 0 END)::int`, + avgDuration: sql`COALESCE(AVG(CASE WHEN ${schema.EmbeddingsTable.duration} > 0 THEN ${schema.EmbeddingsTable.duration} END), 0)`, + totalInputTokens: sql`COALESCE(SUM(CASE WHEN ${schema.EmbeddingsTable.inputTokens} > 0 THEN ${schema.EmbeddingsTable.inputTokens} ELSE 0 END), 0)::bigint`, + }) + .from(schema.EmbeddingsTable) + .where( + and( + not(schema.EmbeddingsTable.deleted), + sql`${schema.EmbeddingsTable.createdAt} >= ${startTime}`, + sql`${schema.EmbeddingsTable.createdAt} < ${endTime}`, + ), + ); + const [first] = r; + return first ?? { + total: 0, + completed: 0, + failed: 0, + avgDuration: 0, + totalInputTokens: 0, + }; +} + +/** + * Get completions model distribution for a time range + */ +export async function getCompletionsModelDistribution( + startTime: Date, + endTime: Date, +) { + logger.debug("getCompletionsModelDistribution", startTime, endTime); + return await db + .select({ + model: schema.CompletionsTable.model, + count: count(schema.CompletionsTable.id), + }) + .from(schema.CompletionsTable) + .where( + and( + not(schema.CompletionsTable.deleted), + sql`${schema.CompletionsTable.createdAt} >= ${startTime}`, + sql`${schema.CompletionsTable.createdAt} < ${endTime}`, + ), + ) + .groupBy(schema.CompletionsTable.model) + .orderBy(desc(count(schema.CompletionsTable.id))) + .limit(10); +} + +/** + * Get embeddings model distribution for a time range + */ +export async function getEmbeddingsModelDistribution( + startTime: Date, + endTime: Date, +) { + logger.debug("getEmbeddingsModelDistribution", startTime, endTime); + return await db + .select({ + model: schema.EmbeddingsTable.model, + count: count(schema.EmbeddingsTable.id), + }) + .from(schema.EmbeddingsTable) + .where( + and( + not(schema.EmbeddingsTable.deleted), + sql`${schema.EmbeddingsTable.createdAt} >= ${startTime}`, + sql`${schema.EmbeddingsTable.createdAt} < ${endTime}`, + ), + ) + .groupBy(schema.EmbeddingsTable.model) + .orderBy(desc(count(schema.EmbeddingsTable.id))) + .limit(10); +} + +/** + * Get completions time series data for a time range + */ +export async function getCompletionsTimeSeries( + startTime: Date, + endTime: Date, + bucketSeconds: number, +) { + logger.debug("getCompletionsTimeSeries", startTime, endTime, bucketSeconds); + const result = await db.execute(sql` + SELECT + to_timestamp(floor(extract(epoch from created_at) / ${bucketSeconds}) * ${bucketSeconds}) AS bucket, + COUNT(*) AS total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, + COALESCE(AVG(CASE WHEN duration > 0 THEN duration END), 0) AS avg_duration, + COALESCE(AVG(CASE WHEN status = 'completed' AND ttft > 0 THEN ttft END), 0) AS avg_ttft + FROM completions + WHERE deleted = false + AND created_at >= ${startTime} + AND created_at < ${endTime} + GROUP BY bucket + ORDER BY bucket ASC + `); + return result as unknown as { + bucket: Date; + total: string; + completed: string; + failed: string; + avg_duration: string; + avg_ttft: string; + }[]; +} + +/** + * Get embeddings time series data for a time range + */ +export async function getEmbeddingsTimeSeries( + startTime: Date, + endTime: Date, + bucketSeconds: number, +) { + logger.debug("getEmbeddingsTimeSeries", startTime, endTime, bucketSeconds); + const result = await db.execute(sql` + SELECT + to_timestamp(floor(extract(epoch from created_at) / ${bucketSeconds}) * ${bucketSeconds}) AS bucket, + COUNT(*) AS total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, + COALESCE(AVG(CASE WHEN duration > 0 THEN duration END), 0) AS avg_duration + FROM embeddings + WHERE deleted = false + AND created_at >= ${startTime} + AND created_at < ${endTime} + GROUP BY bucket + ORDER BY bucket ASC + `); + return result as unknown as { + bucket: Date; + total: string; + completed: string; + failed: string; + avg_duration: string; + }[]; +} From 581a8bf168475456eec45413251ef8cbad0b59ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Wed, 14 Jan 2026 16:39:01 +0800 Subject: [PATCH 02/10] feat(frontend): add Overview dashboard page - Add Overview page with real-time statistics display - Add Recharts dependency for data visualization - Implement charts: request trend, latency, model distribution, token usage, success rate - Add summary cards with key metrics - Add time range selector (1m/5m/10m/30m/1h/4h/12h) - Enable Overview nav item in sidebar - Set Overview as default landing page - Add i18n translations for en-US and zh-CN Co-Authored-By: Claude Opus 4.5 --- bun.lock | 72 +++++++++- frontend/package.json | 1 + frontend/src/components/app/app-sidebar.tsx | 12 +- frontend/src/i18n/locales/en-US.json | 34 ++++- frontend/src/i18n/locales/zh-CN.json | 34 ++++- .../pages/overview/charts/latency-chart.tsx | 61 ++++++++ .../overview/charts/model-distribution.tsx | 71 ++++++++++ .../overview/charts/requests-trend-chart.tsx | 60 ++++++++ .../overview/charts/success-rate-chart.tsx | 71 ++++++++++ .../overview/charts/token-usage-chart.tsx | 56 ++++++++ frontend/src/pages/overview/index.tsx | 134 ++++++++++++++++++ frontend/src/pages/overview/summary-cards.tsx | 67 +++++++++ .../src/pages/overview/time-range-select.tsx | 39 +++++ .../src/pages/overview/use-overview-stats.ts | 25 ++++ frontend/src/routes/_dashboard/index.tsx | 12 +- 15 files changed, 731 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/overview/charts/latency-chart.tsx create mode 100644 frontend/src/pages/overview/charts/model-distribution.tsx create mode 100644 frontend/src/pages/overview/charts/requests-trend-chart.tsx create mode 100644 frontend/src/pages/overview/charts/success-rate-chart.tsx create mode 100644 frontend/src/pages/overview/charts/token-usage-chart.tsx create mode 100644 frontend/src/pages/overview/index.tsx create mode 100644 frontend/src/pages/overview/summary-cards.tsx create mode 100644 frontend/src/pages/overview/time-range-select.tsx create mode 100644 frontend/src/pages/overview/use-overview-stats.ts diff --git a/bun.lock b/bun.lock index 630222e..62218bb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "nexus-gate", @@ -74,6 +73,7 @@ "react-hook-form": "^7.54.2", "react-i18next": "^15.4.1", "react-markdown": "^10.1.0", + "recharts": "^3.6.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", @@ -413,6 +413,8 @@ "@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=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], @@ -473,6 +475,8 @@ "@sinclair/typebox": ["@sinclair/typebox@0.34.47", "", {}, "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw=="], + "@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=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], @@ -563,6 +567,24 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@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.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@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/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -591,6 +613,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="], @@ -781,6 +805,28 @@ "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=="], + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -795,6 +841,8 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], @@ -855,6 +903,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -891,6 +941,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], @@ -1025,6 +1077,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -1033,6 +1087,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ioredis": ["ioredis@5.9.1", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-BXNqFQ66oOsR82g9ajFFsR8ZKrjVvYCLyeML9IvSMAsP56XH2VXBdZjmI11p65nXXJxTEt1hie3J2QeFJVgrtQ=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1417,6 +1473,8 @@ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "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-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "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=="], @@ -1429,10 +1487,16 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "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=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "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=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], @@ -1451,6 +1515,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1669,6 +1735,8 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "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=="], + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -1761,6 +1829,8 @@ "@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=="], + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], diff --git a/frontend/package.json b/frontend/package.json index bd606b1..7837938 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "react-hook-form": "^7.54.2", "react-i18next": "^15.4.1", "react-markdown": "^10.1.0", + "recharts": "^3.6.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", diff --git a/frontend/src/components/app/app-sidebar.tsx b/frontend/src/components/app/app-sidebar.tsx index 47fd884..769c7ab 100644 --- a/frontend/src/components/app/app-sidebar.tsx +++ b/frontend/src/components/app/app-sidebar.tsx @@ -1,5 +1,5 @@ import { Link, useMatchRoute } from '@tanstack/react-router' -import { ArrowUpDownIcon, BoxIcon, LayoutGridIcon, SettingsIcon, WaypointsIcon } from 'lucide-react' +import { ArrowUpDownIcon, BoxIcon, ChartPieIcon, LayoutGridIcon, SettingsIcon, WaypointsIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' @@ -20,11 +20,11 @@ import i18n from '@/i18n' import { AppSidebarFooter } from './app-sidebar-footer' const navItems = [ - // { - // icon: , - // title: 'Overview', - // href: '/', - // }, + { + icon: , + title: i18n.t('components.app.app-sidebar.Overview'), + href: '/', + }, { icon: , title: i18n.t('components.app.app-sidebar.Requests'), diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index b890882..8097fd5 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -8,6 +8,7 @@ "components.app.app-sidebar-footer.FrontendVersion": "Frontend version", "components.app.app-sidebar-footer.BackendVersion": "Backend version", "components.app.app-sidebar-footer.LatestVersion": "Latest version", + "components.app.app-sidebar.Overview": "Overview", "components.app.app-sidebar.Requests": "Requests", "components.app.app-sidebar.Embeddings": "Embeddings", "components.app.app-sidebar.Applications": "Applications", @@ -329,9 +330,40 @@ "pages.settings.manageModels.Confirm": "Confirm", "pages.settings.manageModels.ModelTypeChat": "Chat", "pages.settings.manageModels.ModelTypeEmbedding": "Embedding", + "pages.settings.manageModels.RemoteModelsUnsupported": "This provider type does not support automatic model listing", + "pages.settings.manageModels.UseManualAdd": "Please use the manual add form in the Saved Models tab", + "pages.settings.manageModels.FetchRemoteFailed": "Failed to fetch remote models", + "pages.settings.manageModels.Retry": "Retry", "pages.models.loadbalancing.Title": "Model Load Balancing Configuration", "pages.models.loadbalancing.TargetModel": "Target system model", "pages.models.loadbalancing.Provider": "Provider", "pages.models.loadbalancing.Weight": "Weight (0-100)", - "pages.models.loadbalancing.Probability": "Probability" + "pages.models.loadbalancing.Probability": "Probability", + "pages.overview.title": "Overview", + "pages.overview.fetchError": "Failed to fetch overview data", + "pages.overview.noData": "No data in selected time range", + "pages.overview.selectTimeRange": "Select time range", + "pages.overview.timeRange.1m": "1 minute", + "pages.overview.timeRange.5m": "5 minutes", + "pages.overview.timeRange.10m": "10 minutes", + "pages.overview.timeRange.30m": "30 minutes", + "pages.overview.timeRange.1h": "1 hour", + "pages.overview.timeRange.4h": "4 hours", + "pages.overview.timeRange.12h": "12 hours", + "pages.overview.metrics.totalRequests": "Total Requests", + "pages.overview.metrics.completions": "Completions", + "pages.overview.metrics.embeddings": "Embeddings", + "pages.overview.metrics.avgLatency": "Avg Latency", + "pages.overview.metrics.avgLatencyDesc": "Average request duration", + "pages.overview.metrics.avgTTFT": "Avg TTFT", + "pages.overview.metrics.avgTTFTDesc": "Average time to first token", + "pages.overview.metrics.successRate": "Success Rate", + "pages.overview.charts.requestsTrend": "Request Trend", + "pages.overview.charts.latencyTrend": "Latency Trend", + "pages.overview.charts.modelDistribution": "Model Distribution", + "pages.overview.charts.tokenUsage": "Token Usage", + "pages.overview.charts.successRateTrend": "Success Rate Trend", + "pages.overview.tokens.prompt": "Prompt", + "pages.overview.tokens.completion": "Completion", + "pages.overview.tokens.embedding": "Embedding" } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index a7b346d..7116a69 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -8,6 +8,7 @@ "components.app.app-sidebar-footer.FrontendVersion": "前端版本", "components.app.app-sidebar-footer.BackendVersion": "后端版本", "components.app.app-sidebar-footer.LatestVersion": "最新版本", + "components.app.app-sidebar.Overview": "概览", "components.app.app-sidebar.Requests": "请求", "components.app.app-sidebar.Embeddings": "向量化", "components.app.app-sidebar.Applications": "应用", @@ -330,9 +331,40 @@ "pages.settings.manageModels.Confirm": "确认", "pages.settings.manageModels.ModelTypeChat": "对话模型", "pages.settings.manageModels.ModelTypeEmbedding": "向量化模型", + "pages.settings.manageModels.RemoteModelsUnsupported": "此供应商类型不支持自动获取模型列表", + "pages.settings.manageModels.UseManualAdd": "请在「已保存模型」标签页使用手动添加功能", + "pages.settings.manageModels.FetchRemoteFailed": "获取远程模型列表失败", + "pages.settings.manageModels.Retry": "重试", "pages.models.loadbalancing.Title": "模型负载均衡配置", "pages.models.loadbalancing.TargetModel": "目标系统模型", "pages.models.loadbalancing.Provider": "供应商", "pages.models.loadbalancing.Weight": "权重 (0-100)", - "pages.models.loadbalancing.Probability": "概率" + "pages.models.loadbalancing.Probability": "概率", + "pages.overview.title": "概览", + "pages.overview.fetchError": "获取概览数据失败", + "pages.overview.noData": "所选时间范围内无数据", + "pages.overview.selectTimeRange": "选择时间范围", + "pages.overview.timeRange.1m": "1 分钟", + "pages.overview.timeRange.5m": "5 分钟", + "pages.overview.timeRange.10m": "10 分钟", + "pages.overview.timeRange.30m": "30 分钟", + "pages.overview.timeRange.1h": "1 小时", + "pages.overview.timeRange.4h": "4 小时", + "pages.overview.timeRange.12h": "12 小时", + "pages.overview.metrics.totalRequests": "总请求数", + "pages.overview.metrics.completions": "对话", + "pages.overview.metrics.embeddings": "向量化", + "pages.overview.metrics.avgLatency": "平均延迟", + "pages.overview.metrics.avgLatencyDesc": "平均请求耗时", + "pages.overview.metrics.avgTTFT": "平均首Token时间", + "pages.overview.metrics.avgTTFTDesc": "平均首个Token返回时间", + "pages.overview.metrics.successRate": "成功率", + "pages.overview.charts.requestsTrend": "请求趋势", + "pages.overview.charts.latencyTrend": "延迟趋势", + "pages.overview.charts.modelDistribution": "模型分布", + "pages.overview.charts.tokenUsage": "Token 使用量", + "pages.overview.charts.successRateTrend": "成功率趋势", + "pages.overview.tokens.prompt": "输入", + "pages.overview.tokens.completion": "输出", + "pages.overview.tokens.embedding": "向量化" } diff --git a/frontend/src/pages/overview/charts/latency-chart.tsx b/frontend/src/pages/overview/charts/latency-chart.tsx new file mode 100644 index 0000000..12afbfc --- /dev/null +++ b/frontend/src/pages/overview/charts/latency-chart.tsx @@ -0,0 +1,61 @@ +import { format } from 'date-fns' +import { useTranslation } from 'react-i18next' +import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' + +import type { OverviewStats } from '../use-overview-stats' + +interface LatencyChartProps { + data: OverviewStats['timeSeries'] +} + +export function LatencyChart({ data }: LatencyChartProps) { + const { t } = useTranslation() + + const chartData = data.map((item: OverviewStats['timeSeries'][number]) => ({ + timestamp: item.timestamp, + duration: Math.round(item.avgDuration), + ttft: Math.round(item.avgTTFT), + })) + + return ( + + + + format(new Date(value), 'HH:mm')} + className="text-xs" + /> + + + format(new Date(value), 'yyyy-MM-dd HH:mm:ss')} + contentStyle={{ + backgroundColor: 'hsl(var(--background))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + }} + /> + + + + + + ) +} diff --git a/frontend/src/pages/overview/charts/model-distribution.tsx b/frontend/src/pages/overview/charts/model-distribution.tsx new file mode 100644 index 0000000..02d1ba1 --- /dev/null +++ b/frontend/src/pages/overview/charts/model-distribution.tsx @@ -0,0 +1,71 @@ +import { useTranslation } from 'react-i18next' +import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts' + +import type { OverviewStats } from '../use-overview-stats' + +interface ModelDistributionChartProps { + data: OverviewStats['modelDistribution'] +} + +const COLORS = [ + 'hsl(var(--chart-1))', + 'hsl(var(--chart-2))', + 'hsl(var(--chart-3))', + 'hsl(var(--chart-4))', + 'hsl(var(--chart-5))', + 'hsl(221.2, 83.2%, 53.3%)', + 'hsl(212, 95%, 68%)', + 'hsl(216, 92%, 60%)', + 'hsl(210, 98%, 78%)', + 'hsl(212, 97%, 87%)', +] + +export function ModelDistributionChart({ data }: ModelDistributionChartProps) { + const { t } = useTranslation() + + if (data.length === 0) { + return ( +
+ {t('pages.overview.noData')} +
+ ) + } + + const chartData = data.map((item: OverviewStats['modelDistribution'][number]) => ({ + name: item.model, + value: item.count, + type: item.type, + })) + + return ( + + + + `${name ?? ''} (${((percent ?? 0) * 100).toFixed(0)}%)` + } + > + {chartData.map((_: unknown, index: number) => ( + + ))} + + + + + + ) +} diff --git a/frontend/src/pages/overview/charts/requests-trend-chart.tsx b/frontend/src/pages/overview/charts/requests-trend-chart.tsx new file mode 100644 index 0000000..1faaf51 --- /dev/null +++ b/frontend/src/pages/overview/charts/requests-trend-chart.tsx @@ -0,0 +1,60 @@ +import { format } from 'date-fns' +import { useTranslation } from 'react-i18next' +import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' + +import type { OverviewStats } from '../use-overview-stats' + +interface RequestsTrendChartProps { + data: OverviewStats['timeSeries'] +} + +export function RequestsTrendChart({ data }: RequestsTrendChartProps) { + const { t } = useTranslation() + + const chartData = data.map((item: OverviewStats['timeSeries'][number]) => ({ + timestamp: item.timestamp, + completions: item.completionsCount, + embeddings: item.embeddingsCount, + })) + + return ( + + + + format(new Date(value), 'HH:mm')} + className="text-xs" + /> + + format(new Date(value), 'yyyy-MM-dd HH:mm:ss')} + contentStyle={{ + backgroundColor: 'hsl(var(--background))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + }} + /> + + + + + + ) +} diff --git a/frontend/src/pages/overview/charts/success-rate-chart.tsx b/frontend/src/pages/overview/charts/success-rate-chart.tsx new file mode 100644 index 0000000..aba3afd --- /dev/null +++ b/frontend/src/pages/overview/charts/success-rate-chart.tsx @@ -0,0 +1,71 @@ +import { format } from 'date-fns' +import { useTranslation } from 'react-i18next' +import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' + +import type { OverviewStats } from '../use-overview-stats' + +interface SuccessRateChartProps { + data: OverviewStats['timeSeries'] +} + +export function SuccessRateChart({ data }: SuccessRateChartProps) { + const { t } = useTranslation() + + const chartData = data.map((item: OverviewStats['timeSeries'][number]) => { + const completionsTotal = item.completionsCount + const embeddingsTotal = item.embeddingsCount + const completionsFailed = item.completionsFailed + const embeddingsFailed = item.embeddingsFailed + + const completionsSuccessRate = + completionsTotal > 0 ? ((completionsTotal - completionsFailed) / completionsTotal) * 100 : 100 + const embeddingsSuccessRate = + embeddingsTotal > 0 ? ((embeddingsTotal - embeddingsFailed) / embeddingsTotal) * 100 : 100 + + return { + timestamp: item.timestamp, + completions: Math.round(completionsSuccessRate * 100) / 100, + embeddings: Math.round(embeddingsSuccessRate * 100) / 100, + } + }) + + return ( + + + + format(new Date(value), 'HH:mm')} + className="text-xs" + /> + + format(new Date(value), 'yyyy-MM-dd HH:mm:ss')} + formatter={(value) => [`${(value as number).toFixed(1)}%`, '']} + contentStyle={{ + backgroundColor: 'hsl(var(--background))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + }} + /> + + + + + + ) +} diff --git a/frontend/src/pages/overview/charts/token-usage-chart.tsx b/frontend/src/pages/overview/charts/token-usage-chart.tsx new file mode 100644 index 0000000..ed85d30 --- /dev/null +++ b/frontend/src/pages/overview/charts/token-usage-chart.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' + +import type { OverviewStats } from '../use-overview-stats' + +interface TokenUsageChartProps { + data: OverviewStats['tokenUsage'] +} + +export function TokenUsageChart({ data }: TokenUsageChartProps) { + const { t } = useTranslation() + + const chartData = [ + { + name: t('pages.overview.tokens.prompt'), + value: data.promptTokens, + }, + { + name: t('pages.overview.tokens.completion'), + value: data.completionTokens, + }, + { + name: t('pages.overview.tokens.embedding'), + value: data.embeddingTokens, + }, + ] + + const formatValue = (value: number) => { + if (value >= 1000000) { + return `${(value / 1000000).toFixed(1)}M` + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}K` + } + return value.toString() + } + + return ( + + + + + + [(value as number).toLocaleString(), 'Tokens']} + contentStyle={{ + backgroundColor: 'hsl(var(--background))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + }} + /> + + + + ) +} diff --git a/frontend/src/pages/overview/index.tsx b/frontend/src/pages/overview/index.tsx new file mode 100644 index 0000000..250baf4 --- /dev/null +++ b/frontend/src/pages/overview/index.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' + +import { LatencyChart } from './charts/latency-chart' +import { ModelDistributionChart } from './charts/model-distribution' +import { RequestsTrendChart } from './charts/requests-trend-chart' +import { SuccessRateChart } from './charts/success-rate-chart' +import { TokenUsageChart } from './charts/token-usage-chart' +import { SummaryCards } from './summary-cards' +import { TimeRangeSelect } from './time-range-select' +import { useOverviewStats, type TimeRange } from './use-overview-stats' + +function LoadingSkeleton() { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( + + + + + + + + + + ))} +
+
+ + + + + + + + + + + + + + + + +
+
+ ) +} + +export function OverviewPage() { + const { t } = useTranslation() + const [timeRange, setTimeRange] = useState('1h') + const { data, isLoading, error } = useOverviewStats(timeRange) + + if (error) { + return ( +
+

{t('pages.overview.fetchError')}

+
+ ) + } + + return ( +
+ {/* Time range selector */} +
+ +
+ + {isLoading || !data ? ( + + ) : ( + <> + {/* Summary Cards */} + + + {/* Row 2: Request Trend + Token Usage */} +
+ + + {t('pages.overview.charts.requestsTrend')} + + + + + + + + {t('pages.overview.charts.tokenUsage')} + + + + + +
+ + {/* Row 3: Latency Trend + Model Distribution */} +
+ + + {t('pages.overview.charts.latencyTrend')} + + + + + + + + {t('pages.overview.charts.modelDistribution')} + + + + + +
+ + {/* Row 4: Success Rate Trend */} + + + {t('pages.overview.charts.successRateTrend')} + + + + + + + )} +
+ ) +} diff --git a/frontend/src/pages/overview/summary-cards.tsx b/frontend/src/pages/overview/summary-cards.tsx new file mode 100644 index 0000000..be88d64 --- /dev/null +++ b/frontend/src/pages/overview/summary-cards.tsx @@ -0,0 +1,67 @@ +import { ActivityIcon, ClockIcon, GaugeIcon, ZapIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +import type { OverviewStats } from './use-overview-stats' + +interface SummaryCardsProps { + data: OverviewStats +} + +export function SummaryCards({ data }: SummaryCardsProps) { + const { t } = useTranslation() + + const { summary } = data + + // Calculate overall success rate + const totalCompleted = + (summary.completionsCount * summary.completionsSuccessRate) / 100 + + (summary.embeddingsCount * summary.embeddingsSuccessRate) / 100 + const overallSuccessRate = + summary.totalRequests > 0 ? (totalCompleted / summary.totalRequests) * 100 : 100 + + const cards = [ + { + title: t('pages.overview.metrics.totalRequests'), + value: summary.totalRequests.toLocaleString(), + description: `${summary.completionsCount} ${t('pages.overview.metrics.completions')} / ${summary.embeddingsCount} ${t('pages.overview.metrics.embeddings')}`, + icon: , + }, + { + title: t('pages.overview.metrics.avgLatency'), + value: summary.avgDuration > 0 ? `${summary.avgDuration}ms` : '-', + description: t('pages.overview.metrics.avgLatencyDesc'), + icon: , + }, + { + title: t('pages.overview.metrics.avgTTFT'), + value: summary.avgTTFT > 0 ? `${summary.avgTTFT}ms` : '-', + description: t('pages.overview.metrics.avgTTFTDesc'), + icon: , + }, + { + title: t('pages.overview.metrics.successRate'), + value: `${overallSuccessRate.toFixed(1)}%`, + description: `${t('pages.overview.metrics.completions')}: ${summary.completionsSuccessRate.toFixed(1)}% / ${t('pages.overview.metrics.embeddings')}: ${summary.embeddingsSuccessRate.toFixed(1)}%`, + icon: , + }, + ] + + return ( +
+ {cards.map((card) => ( + + + {card.title} + {card.icon} + + +
{card.value}
+

{card.description}

+
+
+ ))} +
+ ) +} diff --git a/frontend/src/pages/overview/time-range-select.tsx b/frontend/src/pages/overview/time-range-select.tsx new file mode 100644 index 0000000..55ef898 --- /dev/null +++ b/frontend/src/pages/overview/time-range-select.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +import type { TimeRange } from './use-overview-stats' + +const TIME_RANGES: { value: TimeRange; labelKey: string }[] = [ + { value: '1m', labelKey: 'pages.overview.timeRange.1m' }, + { value: '5m', labelKey: 'pages.overview.timeRange.5m' }, + { value: '10m', labelKey: 'pages.overview.timeRange.10m' }, + { value: '30m', labelKey: 'pages.overview.timeRange.30m' }, + { value: '1h', labelKey: 'pages.overview.timeRange.1h' }, + { value: '4h', labelKey: 'pages.overview.timeRange.4h' }, + { value: '12h', labelKey: 'pages.overview.timeRange.12h' }, +] + +interface TimeRangeSelectProps { + value: TimeRange + onChange: (value: TimeRange) => void +} + +export function TimeRangeSelect({ value, onChange }: TimeRangeSelectProps) { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/frontend/src/pages/overview/use-overview-stats.ts b/frontend/src/pages/overview/use-overview-stats.ts new file mode 100644 index 0000000..3493513 --- /dev/null +++ b/frontend/src/pages/overview/use-overview-stats.ts @@ -0,0 +1,25 @@ +import { queryOptions, useQuery } from '@tanstack/react-query' + +import { api } from '@/lib/api' +import { formatError } from '@/lib/error' + +export type TimeRange = '1m' | '5m' | '10m' | '30m' | '1h' | '4h' | '12h' + +export const overviewQueryOptions = (range: TimeRange) => + queryOptions({ + queryKey: ['overview', range], + queryFn: async () => { + const { data, error } = await api.admin.stats.overview.get({ + query: { range }, + }) + if (error) throw formatError(error, 'Failed to fetch overview stats') + return data + }, + refetchInterval: 30000, // Refresh every 30 seconds + }) + +export function useOverviewStats(range: TimeRange) { + return useQuery(overviewQueryOptions(range)) +} + +export type OverviewStats = NonNullable['data']> diff --git a/frontend/src/routes/_dashboard/index.tsx b/frontend/src/routes/_dashboard/index.tsx index a07f245..0940765 100644 --- a/frontend/src/routes/_dashboard/index.tsx +++ b/frontend/src/routes/_dashboard/index.tsx @@ -1,15 +1,9 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' +import { createFileRoute } from '@tanstack/react-router' import { AppErrorComponent } from '@/components/app/app-error' +import { OverviewPage } from '@/pages/overview' export const Route = createFileRoute('/_dashboard/')({ - component: RouteComponent, + component: OverviewPage, errorComponent: AppErrorComponent, - beforeLoad: () => redirect({ to: '/requests' }), }) - -function RouteComponent() { - const { t } = useTranslation() - return
{t('routes.dashboard.index.Welcome')}
-} From ec4601a96473f2e069686486d6ff630895c56b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Wed, 14 Jan 2026 16:39:13 +0800 Subject: [PATCH 03/10] fix(providers): improve test connection for Anthropic and openai-responses - Add specific test logic for Anthropic using /messages endpoint - Add fallback for openai-responses when /models is unavailable - Return informative messages for providers without model list support - Show appropriate UI feedback in manage models dialog Co-Authored-By: Claude Opus 4.5 --- backend/src/api/admin/providers.ts | 83 ++++++++++++ .../pages/settings/manage-models-dialog.tsx | 121 ++++++++++++------ 2 files changed, 166 insertions(+), 38 deletions(-) diff --git a/backend/src/api/admin/providers.ts b/backend/src/api/admin/providers.ts index 2ae6efb..5bfb9a6 100644 --- a/backend/src/api/admin/providers.ts +++ b/backend/src/api/admin/providers.ts @@ -154,6 +154,74 @@ export const adminProviders = new Elysia({ prefix: "/providers" }) } try { + // For Anthropic, send a minimal messages request to test the connection + if (provider.type === "anthropic") { + const baseUrl = provider.baseUrl.endsWith("/") + ? provider.baseUrl.slice(0, -1) + : provider.baseUrl; + + const response = await fetch(`${baseUrl}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "anthropic-version": provider.apiVersion || "2023-06-01", + ...(provider.apiKey && { "x-api-key": provider.apiKey }), + }, + body: JSON.stringify({ + model: "claude-3-haiku-20240307", // Use a common model for testing + messages: [{ role: "user", content: "Hi" }], + max_tokens: 1, + }), + }); + + if (!response.ok) { + const text = await response.text(); + // Check if the error is just about invalid model (which means auth is working) + if (response.status === 400 && text.includes("model")) { + return { + success: true, + message: "Connection successful (API key valid)", + models: [], + }; + } + throw new Error(`API error: ${response.status} ${text}`); + } + + return { + success: true, + message: "Connection successful", + models: [], + }; + } + + // For openai-responses, try the standard /models endpoint first + // since most deployments share the same OpenAI account + if (provider.type === "openai-responses") { + const client = new OpenAI({ + baseURL: provider.baseUrl, + apiKey: provider.apiKey || "not-required", + }); + + try { + const models = await client.models.list(); + return { + success: true, + models: models.data.map((m) => ({ + id: m.id, + owned_by: m.owned_by, + })), + }; + } catch { + // If /models doesn't work, just report success for connection test + return { + success: true, + message: "Connection configured (models endpoint not available)", + models: [], + }; + } + } + + // For other types (openai, azure, ollama), use the standard approach const client = new OpenAI({ baseURL: provider.baseUrl, apiKey: provider.apiKey || "not-required", @@ -193,6 +261,14 @@ export const adminProviders = new Elysia({ prefix: "/providers" }) return status(404, { error: "Provider not found" }); } + // Anthropic does not have a models list endpoint + if (provider.type === "anthropic") { + return status(400, { + error: "Anthropic API does not support listing models. Please configure models manually.", + unsupported: true, + }); + } + try { const client = new OpenAI({ baseURL: provider.baseUrl, @@ -208,6 +284,13 @@ export const adminProviders = new Elysia({ prefix: "/providers" }) })), }; } catch (e) { + // For openai-responses, the /models endpoint might not be available + if (provider.type === "openai-responses") { + return status(400, { + error: "Models list endpoint not available for this provider. Please configure models manually.", + unsupported: true, + }); + } return status(502, { error: e instanceof Error ? e.message : "Unknown error", }); diff --git a/frontend/src/pages/settings/manage-models-dialog.tsx b/frontend/src/pages/settings/manage-models-dialog.tsx index 27d0a1f..5f92541 100644 --- a/frontend/src/pages/settings/manage-models-dialog.tsx +++ b/frontend/src/pages/settings/manage-models-dialog.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { CheckIcon, SearchIcon, SettingsIcon, XIcon } from 'lucide-react' +import { AlertCircleIcon, CheckIcon, SearchIcon, SettingsIcon, XIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -14,6 +14,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import type { Model } from './models-columns' import type { Provider } from './providers-columns' +// Provider types that don't support automatic model listing +const UNSUPPORTED_REMOTE_MODEL_TYPES = ['anthropic'] as const + interface ManageModelsDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -31,6 +34,11 @@ export function ManageModelsDialog({ open, onOpenChange, provider }: ManageModel const [activeTab, setActiveTab] = useState<'saved' | 'remote'>('saved') const [searchQuery, setSearchQuery] = useState('') + // Check if this provider type supports remote model listing + const supportsRemoteModels = !UNSUPPORTED_REMOTE_MODEL_TYPES.includes( + provider.type as (typeof UNSUPPORTED_REMOTE_MODEL_TYPES)[number] + ) + // Fetch saved models for this provider const { data: savedModels = [], isLoading: isLoadingSaved } = useQuery({ queryKey: ['provider-models', provider.id], @@ -44,21 +52,26 @@ export function ManageModelsDialog({ open, onOpenChange, provider }: ManageModel // Fetch remote models from provider const { - data: remoteModels = [], + data: remoteModelsData, isLoading: isLoadingRemote, refetch: refetchRemote, + error: remoteModelsError, } = useQuery({ queryKey: ['provider-remote-models', provider.id], queryFn: async () => { const { data, error } = await api.admin.providers({ id: provider.id }).test.post() if (error) throw error - // API returns { success: true, models: [...] } - const response = data as { success: boolean; models: RemoteModel[] } - return response.models || [] + // API returns { success: true, models: [...] } or { success: true, message: "..." } + const response = data as { success: boolean; models?: RemoteModel[]; message?: string } + return response }, - enabled: open && activeTab === 'remote', + enabled: open && activeTab === 'remote' && supportsRemoteModels, + retry: false, // Don't retry if provider doesn't support it }) + const remoteModels = remoteModelsData?.models || [] + const remoteModelsMessage = remoteModelsData?.message + // Create model mutation const createMutation = useMutation({ mutationFn: async (model: { @@ -184,39 +197,71 @@ export function ManageModelsDialog({ open, onOpenChange, provider }: ManageModel -

- {t('pages.settings.manageModels.ClickToAddHint')} -

-
- {isLoadingRemote ? ( -
- {t('pages.settings.models.Loading')} -
- ) : filteredRemoteModels.length === 0 ? ( -
- {t('pages.settings.manageModels.NoRemoteModels')} + {!supportsRemoteModels ? ( +
+ +

+ {t('pages.settings.manageModels.RemoteModelsUnsupported')} +

+

+ {t('pages.settings.manageModels.UseManualAdd')} +

+
+ ) : remoteModelsMessage ? ( +
+ +

{remoteModelsMessage}

+

+ {t('pages.settings.manageModels.UseManualAdd')} +

+
+ ) : remoteModelsError ? ( +
+ +

+ {t('pages.settings.manageModels.FetchRemoteFailed')} +

+ +
+ ) : ( + <> +

+ {t('pages.settings.manageModels.ClickToAddHint')} +

+
+ {isLoadingRemote ? ( +
+ {t('pages.settings.models.Loading')} +
+ ) : filteredRemoteModels.length === 0 ? ( +
+ {t('pages.settings.manageModels.NoRemoteModels')} +
+ ) : ( + filteredRemoteModels.map((model) => ( + + createMutation.mutate({ + systemName, + remoteId: model.id, + modelType, + contextLength, + inputPrice, + outputPrice, + }) + } + isPending={createMutation.isPending} + /> + )) + )}
- ) : ( - filteredRemoteModels.map((model) => ( - - createMutation.mutate({ - systemName, - remoteId: model.id, - modelType, - contextLength, - inputPrice, - outputPrice, - }) - } - isPending={createMutation.isPending} - /> - )) - )} -
+ + )} From cd24ae0bb122fa48137a4ded9071164782a2ebc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Wed, 14 Jan 2026 16:39:24 +0800 Subject: [PATCH 04/10] fix(settings): fix layout with fixed header and scrollable content - Wrap page in flex container with h-svh - Add border-b to header - Make secondary sidebar fixed height - Enable scrolling only in content area Co-Authored-By: Claude Opus 4.5 --- frontend/src/routes/settings/route.tsx | 87 ++++++++++++++++++-------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/frontend/src/routes/settings/route.tsx b/frontend/src/routes/settings/route.tsx index 978fd84..e597ddb 100644 --- a/frontend/src/routes/settings/route.tsx +++ b/frontend/src/routes/settings/route.tsx @@ -1,8 +1,10 @@ import { createFileRoute, Link, Outlet, useMatchRoute } from '@tanstack/react-router' -import { BoxesIcon, CpuIcon } from 'lucide-react' +import { BoxesIcon, CpuIcon, MenuIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet' import { AppHeader, AppHeaderPart, @@ -10,6 +12,7 @@ import { AppSidebarSeparator, AppSidebarTrigger, } from '@/components/app/app-header' +import { useIsMobile } from '@/hooks/use-mobile' export const Route = createFileRoute('/settings')({ component: RouteComponent, @@ -18,6 +21,7 @@ export const Route = createFileRoute('/settings')({ function RouteComponent() { const { t } = useTranslation() const matchRoute = useMatchRoute() + const isMobile = useIsMobile() const navItems = [ { @@ -32,43 +36,74 @@ function RouteComponent() { }, ] + const NavContent = () => ( + + ) + return ( - <> +
+ {/* Fixed header */} + {isMobile && ( + <> + + + + + + + Settings Navigation + Navigate between settings pages + +
+ +
+
+
+ + + )} {t('routes.settings.Title')}
-
-
- + + {/* Main content area - fills remaining height */} +
+ {/* Desktop sidebar - fixed height, hidden on mobile */} +
+
-
+ {/* Content area - scrollable */} +
- +
) } From e735b38c4269e83bd5cb855665e003efef7f36d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Wed, 14 Jan 2026 16:39:36 +0800 Subject: [PATCH 05/10] fix(providers): fix card layout with long URLs - Add min-w-0 and flex-1 to allow content section to shrink - Add truncate class to provider name and URL - Add shrink-0 to buttons container to prevent compression Co-Authored-By: Claude Opus 4.5 --- .../pages/settings/providers-settings-page.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/settings/providers-settings-page.tsx b/frontend/src/pages/settings/providers-settings-page.tsx index 64ed2f5..5208938 100644 --- a/frontend/src/pages/settings/providers-settings-page.tsx +++ b/frontend/src/pages/settings/providers-settings-page.tsx @@ -246,24 +246,24 @@ function ProviderCard({ provider, onTest, onDelete, isTestPending, isDeletePendi return ( <> - -
-
+ +
+
-
+
- {provider.name} - {provider.type} + {provider.name} + {provider.type}
-
+
{provider.baseUrl} · {t('routes.settings.providers.CreatedAt')} {formatDistanceToNow(new Date(provider.createdAt), { addSuffix: true })}
-
+
@@ -124,7 +130,7 @@ export function OverviewPage() { {t('pages.overview.charts.successRateTrend')} - + diff --git a/frontend/src/pages/overview/summary-cards.tsx b/frontend/src/pages/overview/summary-cards.tsx index be88d64..a1ca7fe 100644 --- a/frontend/src/pages/overview/summary-cards.tsx +++ b/frontend/src/pages/overview/summary-cards.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { ActivityIcon, ClockIcon, GaugeIcon, ZapIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -9,7 +10,7 @@ interface SummaryCardsProps { data: OverviewStats } -export function SummaryCards({ data }: SummaryCardsProps) { +export const SummaryCards = memo(function SummaryCards({ data }: SummaryCardsProps) { const { t } = useTranslation() const { summary } = data @@ -64,4 +65,4 @@ export function SummaryCards({ data }: SummaryCardsProps) { ))}
) -} +}) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1098999..217e642 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -196,3 +196,13 @@ code { cursor: pointer; } } + +/* Recharts focus outline fix - removes the focus border on chart elements */ +.recharts-wrapper:focus, +.recharts-wrapper *:focus { + outline: none; +} + +.recharts-surface:focus { + outline: none; +} From c3b43205b2da8664e613c46a33b6fcf253859b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Thu, 15 Jan 2026 22:21:09 +0800 Subject: [PATCH 10/10] fix: address gemini-code-assist review feedback - Extract error detection logic into shared isModelEndpointUnavailable() helper - Define chart colors 6-10 as CSS variables for consistent theming - Simplify success rate rounding by only formatting in tooltip - Improve focus state accessibility with focus-visible styling Co-Authored-By: Claude Opus 4.5 --- backend/src/api/admin/providers.ts | 39 ++++++++++--------- .../overview/charts/model-distribution.tsx | 20 +++++----- .../overview/charts/success-rate-chart.tsx | 4 +- frontend/src/styles.css | 29 +++++++++++++- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/backend/src/api/admin/providers.ts b/backend/src/api/admin/providers.ts index ae8095f..1814a8e 100644 --- a/backend/src/api/admin/providers.ts +++ b/backend/src/api/admin/providers.ts @@ -14,7 +14,7 @@ import type { ProviderTypeEnumType } from "@/db/schema"; // Provider Test Strategy Pattern // ============================================ -interface ProviderTestResult { +export interface ProviderTestResult { success: boolean; message?: string; models: { id: string; owned_by?: string }[]; @@ -31,6 +31,23 @@ interface Provider { type ProviderTestFn = (provider: Provider) => Promise; +/** + * Check if an error indicates that the OpenAI models endpoint is unavailable. + * This helper detects 404/405 errors which indicate the endpoint doesn't exist + * but the connection itself may be working. + */ +function isModelEndpointUnavailable(error: Error & { status?: number }): boolean { + const errorMessage = error.message || ""; + return ( + error.status === 404 || + error.status === 405 || + errorMessage.includes("404") || + errorMessage.includes("405") || + errorMessage.includes("Not Found") || + errorMessage.includes("Method Not Allowed") + ); +} + /** * Test Anthropic provider connection by sending a minimal messages request. * Anthropic doesn't have a /models endpoint, so we test auth via messages API. @@ -100,17 +117,8 @@ async function testOpenAIResponsesConnection( } catch (e) { // Check if it's a 404/405 (endpoint not available) vs real connection error const error = e as Error & { status?: number }; - const errorMessage = error.message || ""; - // These indicate the endpoint doesn't exist but connection works - if ( - error.status === 404 || - error.status === 405 || - errorMessage.includes("404") || - errorMessage.includes("405") || - errorMessage.includes("Not Found") || - errorMessage.includes("Method Not Allowed") - ) { + if (isModelEndpointUnavailable(error)) { return { success: true, message: "Connection configured (models endpoint not available)", @@ -352,18 +360,11 @@ export const adminProviders = new Elysia({ prefix: "/providers" }) }; } catch (e) { const error = e as Error & { status?: number }; - const errorMessage = error.message || ""; // For openai-responses, the /models endpoint might not be available - // Check for 404/405 errors which indicate endpoint doesn't exist if ( provider.type === "openai-responses" && - (error.status === 404 || - error.status === 405 || - errorMessage.includes("404") || - errorMessage.includes("405") || - errorMessage.includes("Not Found") || - errorMessage.includes("Method Not Allowed")) + isModelEndpointUnavailable(error) ) { return status(400, { error: "Models list endpoint not available for this provider. Please configure models manually.", diff --git a/frontend/src/pages/overview/charts/model-distribution.tsx b/frontend/src/pages/overview/charts/model-distribution.tsx index 023dba0..1532fa3 100644 --- a/frontend/src/pages/overview/charts/model-distribution.tsx +++ b/frontend/src/pages/overview/charts/model-distribution.tsx @@ -10,16 +10,16 @@ interface ModelDistributionChartProps { } const COLORS = [ - 'hsl(var(--chart-1))', - 'hsl(var(--chart-2))', - 'hsl(var(--chart-3))', - 'hsl(var(--chart-4))', - 'hsl(var(--chart-5))', - 'hsl(221.2, 83.2%, 53.3%)', - 'hsl(212, 95%, 68%)', - 'hsl(216, 92%, 60%)', - 'hsl(210, 98%, 78%)', - 'hsl(212, 97%, 87%)', + 'var(--color-chart-1)', + 'var(--color-chart-2)', + 'var(--color-chart-3)', + 'var(--color-chart-4)', + 'var(--color-chart-5)', + 'var(--color-chart-6)', + 'var(--color-chart-7)', + 'var(--color-chart-8)', + 'var(--color-chart-9)', + 'var(--color-chart-10)', ] export const ModelDistributionChart = memo(function ModelDistributionChart({ diff --git a/frontend/src/pages/overview/charts/success-rate-chart.tsx b/frontend/src/pages/overview/charts/success-rate-chart.tsx index ade2de0..a532172 100644 --- a/frontend/src/pages/overview/charts/success-rate-chart.tsx +++ b/frontend/src/pages/overview/charts/success-rate-chart.tsx @@ -28,8 +28,8 @@ export const SuccessRateChart = memo(function SuccessRateChart({ return { timestamp: item.timestamp, - completions: Math.round(completionsSuccessRate * 100) / 100, - embeddings: Math.round(embeddingsSuccessRate * 100) / 100, + completions: completionsSuccessRate, + embeddings: embeddingsSuccessRate, } }) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 217e642..689bce7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -43,6 +43,11 @@ code { --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); + --chart-6: oklch(0.546 0.245 262.881); + --chart-7: oklch(0.623 0.214 259.815); + --chart-8: oklch(0.585 0.233 264.052); + --chart-9: oklch(0.718 0.154 241.361); + --chart-10: oklch(0.809 0.105 230.318); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.141 0.005 285.823); @@ -79,6 +84,11 @@ code { --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); + --chart-6: oklch(0.546 0.245 262.881); + --chart-7: oklch(0.623 0.214 259.815); + --chart-8: oklch(0.585 0.233 264.052); + --chart-9: oklch(0.718 0.154 241.361); + --chart-10: oklch(0.809 0.105 230.318); --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); @@ -114,6 +124,11 @@ code { --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-chart-6: var(--chart-6); + --color-chart-7: var(--chart-7); + --color-chart-8: var(--chart-8); + --color-chart-9: var(--chart-9); + --color-chart-10: var(--chart-10); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -197,12 +212,24 @@ code { } } -/* Recharts focus outline fix - removes the focus border on chart elements */ +/* Recharts focus state - use subtle styling instead of browser default */ .recharts-wrapper:focus, .recharts-wrapper *:focus { outline: none; } +.recharts-wrapper:focus-visible, +.recharts-wrapper *:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + border-radius: var(--radius); +} + .recharts-surface:focus { outline: none; } + +.recharts-surface:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +}