diff --git a/backend/src/api/admin/dashboards.ts b/backend/src/api/admin/dashboards.ts new file mode 100644 index 0000000..2170deb --- /dev/null +++ b/backend/src/api/admin/dashboards.ts @@ -0,0 +1,82 @@ +import { Elysia, t } from "elysia"; +import { + isEnvOverrideActive, + getGrafanaDashboards, + setGrafanaDashboards, + clearGrafanaDashboards, + EnvOverrideError, +} from "@/utils/dashboards"; + +const dashboardSchema = t.Object({ + id: t.String(), + label: t.String(), + url: t.String({ format: "uri" }), +}); + +const dashboardsArraySchema = t.Array(dashboardSchema); + +export const adminDashboards = new Elysia().group("/dashboards", (app) => + app + .get( + "/", + async () => { + const dashboards = await getGrafanaDashboards(); + return { + dashboards, + envOverride: isEnvOverrideActive(), + }; + }, + { + detail: { + description: + "Get Grafana dashboards configuration. Returns envOverride=true if GRAFANA_DASHBOARDS env var is set.", + }, + }, + ) + .put( + "/", + async ({ body, status }) => { + try { + await setGrafanaDashboards(body.dashboards); + return { + dashboards: body.dashboards, + envOverride: false, + }; + } catch (error) { + if (error instanceof EnvOverrideError) { + return status(409, { error: error.message }); + } + throw error; + } + }, + { + body: t.Object({ + dashboards: dashboardsArraySchema, + }), + detail: { + description: + "Update Grafana dashboards configuration. Returns 409 if GRAFANA_DASHBOARDS env var is set.", + }, + }, + ) + .delete( + "/", + async ({ status }) => { + try { + await clearGrafanaDashboards(); + return { success: true }; + } catch (error) { + if (error instanceof EnvOverrideError) { + return status(409, { error: error.message }); + } + throw error; + } + }, + { + detail: { + description: + "Clear Grafana dashboards configuration. Returns 409 if GRAFANA_DASHBOARDS env var is set.", + }, + }, + ), +); diff --git a/backend/src/api/admin/index.ts b/backend/src/api/admin/index.ts index 47b41de..f67300b 100644 --- a/backend/src/api/admin/index.ts +++ b/backend/src/api/admin/index.ts @@ -3,10 +3,12 @@ import { apiKeyPlugin } from "@/plugins/apiKeyPlugin"; import { COMMIT_SHA } from "@/utils/config"; import { adminApiKey } from "./apiKey"; import { adminCompletions } from "./completions"; +import { adminDashboards } from "./dashboards"; 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 +31,8 @@ export const routes = new Elysia({ .use(adminModels) .use(adminEmbeddings) .use(adminStats) + .use(adminSettings) + .use(adminDashboards) .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/backend/src/db/index.ts b/backend/src/db/index.ts index 72a62f9..2be653f 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1534,3 +1534,54 @@ export async function getActiveEntityCounts() { embeddingModels: Number(row?.embedding_models ?? 0), }; } + +/** + * Get completion cost metrics grouped by model, provider, and api_key_comment + * Calculates costs based on model pricing: (prompt_tokens / 1M) * input_price + (completion_tokens / 1M) * output_price + * Returns all-time totals for Prometheus counters + */ +export async function getCompletionCostMetrics() { + logger.debug("getCompletionCostMetrics"); + const result = await db.execute(sql` + SELECT + c.model, + COALESCE(p.name, 'unknown') AS provider, + COALESCE(ak.comment, 'unknown') AS api_key_comment, + COALESCE(SUM( + CASE WHEN c.prompt_tokens > 0 AND m.input_price IS NOT NULL + THEN (c.prompt_tokens::numeric / 1000000) * m.input_price + ELSE 0 + END + ), 0) AS prompt_cost_usd, + COALESCE(SUM( + CASE WHEN c.completion_tokens > 0 AND m.output_price IS NOT NULL + THEN (c.completion_tokens::numeric / 1000000) * m.output_price + ELSE 0 + END + ), 0) AS completion_cost_usd, + COALESCE(SUM( + CASE WHEN c.prompt_tokens > 0 AND m.input_price IS NOT NULL + THEN (c.prompt_tokens::numeric / 1000000) * m.input_price + ELSE 0 + END + + CASE WHEN c.completion_tokens > 0 AND m.output_price IS NOT NULL + THEN (c.completion_tokens::numeric / 1000000) * m.output_price + ELSE 0 + END + ), 0) AS total_cost_usd + FROM completions c + LEFT JOIN models m ON c.model_id = m.id + LEFT JOIN providers p ON m.provider_id = p.id + LEFT JOIN api_keys ak ON c.api_key_id = ak.id + WHERE c.deleted = false + GROUP BY c.model, p.name, ak.comment + `); + return result as unknown as { + model: string; + provider: string; + api_key_comment: string; + prompt_cost_usd: string; + completion_cost_usd: string; + total_cost_usd: string; + }[]; +} diff --git a/backend/src/services/prometheus.ts b/backend/src/services/prometheus.ts index ebe29a8..4b4a86a 100644 --- a/backend/src/services/prometheus.ts +++ b/backend/src/services/prometheus.ts @@ -6,6 +6,7 @@ import { getEmbeddingDurationHistogram, getActiveEntityCounts, getApiKeyRateLimitConfig, + getCompletionCostMetrics, LATENCY_BUCKETS_MS, } from "@/db"; import { getRateLimitRejections } from "@/plugins/apiKeyRateLimitPlugin"; @@ -189,6 +190,7 @@ async function generateMetricsInternal(): Promise { entityCounts, apiKeyConfigs, rateLimitRejections, + costMetrics, ] = await Promise.all([ getCompletionMetricsByModelAndStatus(), getEmbeddingMetricsByModelAndStatus(), @@ -198,6 +200,7 @@ async function generateMetricsInternal(): Promise { getActiveEntityCounts(), getApiKeyRateLimitConfig(), getRateLimitRejections(), + getCompletionCostMetrics(), ]); const sections: string[] = []; @@ -322,6 +325,64 @@ async function generateMetricsInternal(): Promise { ); } + // Cost counters (USD) + const promptCostValues: MetricValue[] = []; + const completionCostValues: MetricValue[] = []; + const totalCostValues: MetricValue[] = []; + + for (const row of costMetrics) { + const labels = { + model: row.model, + provider: row.provider, + api_key_comment: row.api_key_comment, + }; + + const promptCost = Number(row.prompt_cost_usd); + const completionCost = Number(row.completion_cost_usd); + const totalCost = Number(row.total_cost_usd); + + // Only add metrics if there's actual cost data (pricing was configured) + if (promptCost > 0) { + promptCostValues.push({ labels, value: promptCost }); + } + if (completionCost > 0) { + completionCostValues.push({ labels, value: completionCost }); + } + if (totalCost > 0) { + totalCostValues.push({ labels, value: totalCost }); + } + } + + if (promptCostValues.length > 0) { + sections.push( + formatCounter( + "nexusgate_cost_prompt_usd_total", + "Total prompt cost in USD (based on model pricing)", + promptCostValues, + ), + ); + } + + if (completionCostValues.length > 0) { + sections.push( + formatCounter( + "nexusgate_cost_completion_usd_total", + "Total completion cost in USD (based on model pricing)", + completionCostValues, + ), + ); + } + + if (totalCostValues.length > 0) { + sections.push( + formatCounter( + "nexusgate_cost_total_usd_total", + "Total cost in USD (prompt + completion, based on model pricing)", + totalCostValues, + ), + ); + } + // Completion duration histogram const durationHistValues = parseHistogramData( completionDurationHist, diff --git a/backend/src/utils/config.ts b/backend/src/utils/config.ts index 6ff379b..65de15f 100644 --- a/backend/src/utils/config.ts +++ b/backend/src/utils/config.ts @@ -150,3 +150,18 @@ export const METRICS_CACHE_TTL_SECONDS = env( z.coerce.number().int().positive(), "30", ); + +// Grafana dashboards configuration +export const grafanaDashboardSchema = z.object({ + id: z.string(), + label: z.string(), + url: z.string().url(), +}); + +export const grafanaDashboardsSchema = z.array(grafanaDashboardSchema); +export type GrafanaDashboard = z.infer; + +export const GRAFANA_DASHBOARDS = env( + "grafana dashboards", + zObject(grafanaDashboardsSchema.optional()), +); diff --git a/backend/src/utils/dashboards.ts b/backend/src/utils/dashboards.ts new file mode 100644 index 0000000..74e4c85 --- /dev/null +++ b/backend/src/utils/dashboards.ts @@ -0,0 +1,95 @@ +import { getSetting, upsertSetting, deleteSetting } from "@/db"; +import { + GRAFANA_DASHBOARDS, + grafanaDashboardsSchema, + type GrafanaDashboard, +} from "./config"; + +const DASHBOARDS_KEY = "grafana_dashboards"; +const LEGACY_KEY = "grafana_dashboard_url"; + +/** + * Custom error for when env override prevents modification + */ +export class EnvOverrideError extends Error { + readonly code = "ENV_OVERRIDE_ACTIVE" as const; + constructor() { + super( + "Cannot modify dashboards when GRAFANA_DASHBOARDS environment variable is set", + ); + this.name = "EnvOverrideError"; + } +} + +/** + * Check if the GRAFANA_DASHBOARDS environment variable is set + * When set (even as empty array), it overrides database settings + */ +export function isEnvOverrideActive(): boolean { + return GRAFANA_DASHBOARDS !== undefined; +} + +/** + * Get Grafana dashboards from environment variable or database + * Environment variable takes precedence over database settings (even if empty) + */ +export async function getGrafanaDashboards(): Promise { + // Environment variable takes precedence (even empty array disables DB fallback) + if (isEnvOverrideActive()) { + return GRAFANA_DASHBOARDS ?? []; + } + + // Try to get from database (new format) + const setting = await getSetting(DASHBOARDS_KEY); + if (setting?.value) { + const parsed = grafanaDashboardsSchema.safeParse(setting.value); + if (parsed.success) { + return parsed.data; + } + } + + // Try legacy format and migrate if found + const legacySetting = await getSetting(LEGACY_KEY); + if (legacySetting?.value && typeof legacySetting.value === "string") { + const migrated: GrafanaDashboard[] = [ + { + id: "grafana", + label: "Grafana", + url: legacySetting.value, + }, + ]; + // Migrate to new format + await upsertSetting({ key: DASHBOARDS_KEY, value: migrated }); + // Remove legacy key + await deleteSetting(LEGACY_KEY); + return migrated; + } + + return []; +} + +/** + * Set Grafana dashboards in database + * Throws error if environment variable override is active + */ +export async function setGrafanaDashboards( + dashboards: GrafanaDashboard[], +): Promise { + if (isEnvOverrideActive()) { + throw new EnvOverrideError(); + } + + await upsertSetting({ key: DASHBOARDS_KEY, value: dashboards }); +} + +/** + * Clear Grafana dashboards from database + * Throws error if environment variable override is active + */ +export async function clearGrafanaDashboards(): Promise { + if (isEnvOverrideActive()) { + throw new EnvOverrideError(); + } + + await deleteSetting(DASHBOARDS_KEY); +} diff --git a/docker-compose.monitoring.yaml b/docker-compose.monitoring.yaml index 9d4926a..7938009 100644 --- a/docker-compose.monitoring.yaml +++ b/docker-compose.monitoring.yaml @@ -27,6 +27,17 @@ services: - "GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}" - "GF_USERS_ALLOW_SIGN_UP=false" - "GF_SERVER_ROOT_URL=http://localhost:${GRAFANA_PORT:-3001}" + # ============================================================ + # SECURITY NOTE: The following settings enable anonymous access + # and iframe embedding for local development convenience. + # For production, consider using Grafana's Public Dashboard + # feature or an auth proxy instead of anonymous access. + # ============================================================ + # Enable anonymous access for iframe embedding (read-only Viewer role) + - "GF_AUTH_ANONYMOUS_ENABLED=true" + - "GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer" + # Allow embedding in iframes (disables X-Frame-Options: deny) + - "GF_SECURITY_ALLOW_EMBEDDING=true" volumes: - "grafana_data:/var/lib/grafana" - "./grafana/provisioning:/etc/grafana/provisioning:ro" diff --git a/frontend/src/hooks/use-settings.ts b/frontend/src/hooks/use-settings.ts new file mode 100644 index 0000000..a75c7c2 --- /dev/null +++ b/frontend/src/hooks/use-settings.ts @@ -0,0 +1,42 @@ +import { queryOptions, useQuery } from '@tanstack/react-query' + +import { api } from '@/lib/api' + +export interface GrafanaDashboard { + id: string + label: string + url: string +} + +export interface DashboardsResponse { + dashboards: GrafanaDashboard[] + envOverride: boolean +} + +const dashboardsQueryOptions = queryOptions({ + queryKey: ['dashboards'], + queryFn: async (): Promise => { + const { data, error } = await api.admin.dashboards.get() + if (error) { + return { dashboards: [], envOverride: false } + } + // Runtime validation to handle unexpected API responses + const response = data as DashboardsResponse + if (!Array.isArray(response?.dashboards)) { + return { dashboards: [], envOverride: false } + } + return response + }, + staleTime: 5 * 60 * 1000, // 5 minutes + retry: false, +}) + +export function useGrafanaDashboards() { + return useQuery(dashboardsQueryOptions) +} + +// Backward compatibility - returns first dashboard URL if available +export function useGrafanaDashboardUrl() { + const { data } = useGrafanaDashboards() + return data?.dashboards?.[0]?.url +} diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 6a3d98a..73ab97e 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -394,5 +394,10 @@ "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.viewMode.label": "View mode", + "pages.overview.grafana.title": "Grafana Dashboard", + "pages.overview.grafana.invalidUrl": "Invalid Grafana dashboard URL configured" } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index d2b3064..2d8c2d8 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -395,5 +395,10 @@ "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.viewMode.label": "视图模式", + "pages.overview.grafana.title": "Grafana 仪表盘", + "pages.overview.grafana.invalidUrl": "配置的 Grafana 仪表盘 URL 无效" } diff --git a/frontend/src/pages/overview/grafana-embed.tsx b/frontend/src/pages/overview/grafana-embed.tsx new file mode 100644 index 0000000..6ea8239 --- /dev/null +++ b/frontend/src/pages/overview/grafana-embed.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react' +import { useTheme } from 'next-themes' +import { useTranslation } from 'react-i18next' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +export function GrafanaEmbed({ url }: { url: string }) { + const { t } = useTranslation() + const { resolvedTheme } = useTheme() + + // Parse and modify URL safely, handling invalid URLs gracefully + const embedUrl = useMemo(() => { + try { + const parsed = new URL(url) + // Add kiosk mode to hide Grafana navigation + if (!parsed.searchParams.has('kiosk')) { + parsed.searchParams.set('kiosk', '') + } + // Sync theme with NexusGate (light/dark) + if (resolvedTheme && !parsed.searchParams.has('theme')) { + parsed.searchParams.set('theme', resolvedTheme === 'dark' ? 'dark' : 'light') + } + return parsed.toString() + } catch { + return null + } + }, [url, resolvedTheme]) + + if (!embedUrl) { + return ( + + + {t('pages.overview.grafana.title')} + + +
+ {t('pages.overview.grafana.invalidUrl')} +
+
+
+ ) + } + + return ( + + + {t('pages.overview.grafana.title')} + + +