From 797b0b4746ac05abf429cb43a955d8c7755ba275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Wed, 28 Jan 2026 17:13:57 +0800 Subject: [PATCH 01/12] feat(dashboard): add optional Grafana dashboard embedding on overview page Add an admin settings API and allow users to configure a Grafana dashboard URL. When configured, a toggle appears on the overview page to switch between built-in Recharts charts and an embedded Grafana iframe. Includes a pre-built Grafana dashboard JSON for easy import. Co-Authored-By: Claude Opus 4.5 --- backend/src/api/admin/index.ts | 2 + backend/src/api/admin/settings.ts | 58 +++ frontend/src/hooks/use-settings.ts | 21 + frontend/src/i18n/locales/en-US.json | 5 +- frontend/src/i18n/locales/zh-CN.json | 5 +- frontend/src/pages/overview/grafana-embed.tsx | 30 ++ frontend/src/pages/overview/index.tsx | 112 +++--- .../src/pages/overview/view-mode-toggle.tsx | 36 ++ frontend/src/routeTree.gen.ts | 51 +-- frontend/src/routes/_dashboard/index.tsx | 1 + frontend/src/routes/_dashboard/route.tsx | 22 +- grafana/nexusgate-overview.json | 367 ++++++++++++++++++ 12 files changed, 617 insertions(+), 93 deletions(-) create mode 100644 backend/src/api/admin/settings.ts create mode 100644 frontend/src/hooks/use-settings.ts create mode 100644 frontend/src/pages/overview/grafana-embed.tsx create mode 100644 frontend/src/pages/overview/view-mode-toggle.tsx create mode 100644 grafana/nexusgate-overview.json diff --git a/backend/src/api/admin/index.ts b/backend/src/api/admin/index.ts index 47b41de..78a5ed3 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 { adminSettings } from "./settings"; import { adminStats } from "./stats"; import { adminUpstream } from "./upstream"; import { adminUsage } from "./usage"; @@ -29,6 +30,7 @@ export const routes = new Elysia({ .use(adminModels) .use(adminEmbeddings) .use(adminStats) + .use(adminSettings) .get("/", () => true, { detail: { description: "Check whether the admin secret is valid." }, }) diff --git a/backend/src/api/admin/settings.ts b/backend/src/api/admin/settings.ts new file mode 100644 index 0000000..7199b1a --- /dev/null +++ b/backend/src/api/admin/settings.ts @@ -0,0 +1,58 @@ +import { Elysia, t } from "elysia"; +import { deleteSetting, getAllSettings, getSetting, upsertSetting } from "@/db"; + +export const adminSettings = new Elysia().group("/settings", (app) => + app + .get( + "/", + async () => { + return await getAllSettings(); + }, + { + detail: { description: "List all settings" }, + }, + ) + .get( + "/:key", + async ({ params, status }) => { + const setting = await getSetting(params.key); + if (!setting) { + return status(404, "Setting not found"); + } + return setting; + }, + { + params: t.Object({ key: t.String() }), + detail: { description: "Get a setting by key" }, + }, + ) + .put( + "/:key", + async ({ params, body }) => { + const result = await upsertSetting({ + key: params.key, + value: body.value, + }); + return result; + }, + { + params: t.Object({ key: t.String() }), + body: t.Object({ value: t.Any() }), + detail: { description: "Create or update a setting" }, + }, + ) + .delete( + "/:key", + async ({ params, status }) => { + const result = await deleteSetting(params.key); + if (!result) { + return status(404, "Setting not found"); + } + return result; + }, + { + params: t.Object({ key: t.String() }), + detail: { description: "Delete a setting" }, + }, + ), +); diff --git a/frontend/src/hooks/use-settings.ts b/frontend/src/hooks/use-settings.ts new file mode 100644 index 0000000..be91655 --- /dev/null +++ b/frontend/src/hooks/use-settings.ts @@ -0,0 +1,21 @@ +import { queryOptions, useQuery } from '@tanstack/react-query' + +import { api } from '@/lib/api' + +const GRAFANA_DASHBOARD_URL_KEY = 'grafana_dashboard_url' + +const grafanaDashboardUrlQueryOptions = queryOptions({ + queryKey: ['settings', GRAFANA_DASHBOARD_URL_KEY], + queryFn: async () => { + const { data, error } = await api.admin.settings[GRAFANA_DASHBOARD_URL_KEY].get() + if (error) return null + return (data as { value: unknown } | null)?.value as string | undefined + }, + staleTime: 5 * 60 * 1000, // 5 minutes + retry: false, +}) + +export function useGrafanaDashboardUrl() { + const { data } = useQuery(grafanaDashboardUrlQueryOptions) + return data ?? undefined +} diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 6a3d98a..a8261cf 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -394,5 +394,8 @@ "pages.overview.charts.successRateTrend": "Success Rate Trend", "pages.overview.tokens.prompt": "Prompt", "pages.overview.tokens.completion": "Completion", - "pages.overview.tokens.embedding": "Embedding" + "pages.overview.tokens.embedding": "Embedding", + "pages.overview.viewMode.builtin": "Built-in", + "pages.overview.viewMode.grafana": "Grafana", + "pages.overview.grafana.title": "Grafana Dashboard" } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index d2b3064..40ddfc9 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -395,5 +395,8 @@ "pages.overview.charts.successRateTrend": "成功率趋势", "pages.overview.tokens.prompt": "输入", "pages.overview.tokens.completion": "输出", - "pages.overview.tokens.embedding": "向量化" + "pages.overview.tokens.embedding": "向量化", + "pages.overview.viewMode.builtin": "内置", + "pages.overview.viewMode.grafana": "Grafana", + "pages.overview.grafana.title": "Grafana 仪表盘" } diff --git a/frontend/src/pages/overview/grafana-embed.tsx b/frontend/src/pages/overview/grafana-embed.tsx new file mode 100644 index 0000000..acda4fa --- /dev/null +++ b/frontend/src/pages/overview/grafana-embed.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +export function GrafanaEmbed({ url }: { url: string }) { + const { t } = useTranslation() + + // Append kiosk param to hide Grafana navigation chrome + const embedUrl = new URL(url) + if (!embedUrl.searchParams.has('kiosk')) { + embedUrl.searchParams.set('kiosk', '') + } + + return ( + + + {t('pages.overview.grafana.title')} + + +