From 44916b3048e5baabe8b90058b4b5d241b8910a79 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?=
Date: Sat, 31 Jan 2026 23:28:03 +0800
Subject: [PATCH 1/5] feat: add Grafana integration and alert system
Add a complete alert system with notification channels (webhook, email,
Feishu), configurable alert rules (budget, error rate, latency, quota),
and alert history tracking.
Add Grafana integration for syncing alert rules as Prometheus-based
Grafana alerts via the Provisioning API. When Grafana is connected,
the built-in alert engine defers to Grafana for evaluation.
Restructure frontend navigation: model configuration moves to /models
(Providers + Registry sub-nav), system settings moves to /settings
(Alerts + Grafana sub-nav) as separate sidebar items.
Co-Authored-By: Claude Opus 4.5
---
backend/drizzle/0013_flowery_maria_hill.sql | 35 +
backend/drizzle/0014_opposite_dragon_man.sql | 6 +
backend/drizzle/meta/0013_snapshot.json | 1289 ++++++++++++++++
backend/drizzle/meta/0014_snapshot.json | 1325 +++++++++++++++++
backend/drizzle/meta/_journal.json | 14 +
backend/package.json | 2 +
backend/src/adapters/upstream/anthropic.ts | 3 +-
backend/src/api/admin/alerts.ts | 310 ++++
backend/src/api/admin/grafana.ts | 300 ++++
backend/src/api/admin/index.ts | 4 +
backend/src/db/index.ts | 342 ++++-
backend/src/db/schema.ts | 130 ++
backend/src/index.ts | 3 +
backend/src/services/alertDispatcher.ts | 192 +++
backend/src/services/alertEngine.ts | 377 +++++
backend/src/services/grafanaSync.ts | 367 +++++
backend/src/utils/grafanaClient.ts | 208 +++
bun.lock | 10 +
frontend/src/components/app/app-sidebar.tsx | 6 +
frontend/src/components/ui/alert.tsx | 66 +
frontend/src/hooks/use-copy.tsx | 2 +-
frontend/src/hooks/use-settings.ts | 37 +
frontend/src/i18n/locales/en-US.json | 125 +-
frontend/src/i18n/locales/zh-CN.json | 125 +-
.../pages/settings/alerts-settings-page.tsx | 1193 +++++++++++++++
.../pages/settings/grafana-settings-page.tsx | 398 +++++
frontend/src/routeTree.gen.ts | 205 ++-
frontend/src/routes/models/index.tsx | 5 +
.../routes/{settings => models}/providers.tsx | 2 +-
.../models.tsx => models/registry.tsx} | 2 +-
frontend/src/routes/models/route.tsx | 105 ++
frontend/src/routes/settings/alerts.tsx | 75 +
frontend/src/routes/settings/grafana.tsx | 54 +
frontend/src/routes/settings/index.tsx | 2 +-
frontend/src/routes/settings/route.tsx | 18 +-
35 files changed, 7260 insertions(+), 77 deletions(-)
create mode 100644 backend/drizzle/0013_flowery_maria_hill.sql
create mode 100644 backend/drizzle/0014_opposite_dragon_man.sql
create mode 100644 backend/drizzle/meta/0013_snapshot.json
create mode 100644 backend/drizzle/meta/0014_snapshot.json
create mode 100644 backend/src/api/admin/alerts.ts
create mode 100644 backend/src/api/admin/grafana.ts
create mode 100644 backend/src/services/alertDispatcher.ts
create mode 100644 backend/src/services/alertEngine.ts
create mode 100644 backend/src/services/grafanaSync.ts
create mode 100644 backend/src/utils/grafanaClient.ts
create mode 100644 frontend/src/components/ui/alert.tsx
create mode 100644 frontend/src/pages/settings/alerts-settings-page.tsx
create mode 100644 frontend/src/pages/settings/grafana-settings-page.tsx
create mode 100644 frontend/src/routes/models/index.tsx
rename frontend/src/routes/{settings => models}/providers.tsx (94%)
rename frontend/src/routes/{settings/models.tsx => models/registry.tsx} (94%)
create mode 100644 frontend/src/routes/models/route.tsx
create mode 100644 frontend/src/routes/settings/alerts.tsx
create mode 100644 frontend/src/routes/settings/grafana.tsx
diff --git a/backend/drizzle/0013_flowery_maria_hill.sql b/backend/drizzle/0013_flowery_maria_hill.sql
new file mode 100644
index 0000000..a7d2307
--- /dev/null
+++ b/backend/drizzle/0013_flowery_maria_hill.sql
@@ -0,0 +1,35 @@
+CREATE TYPE "public"."alert_channel_type" AS ENUM('webhook', 'email', 'feishu');--> statement-breakpoint
+CREATE TYPE "public"."alert_history_status" AS ENUM('sent', 'failed', 'suppressed');--> statement-breakpoint
+CREATE TYPE "public"."alert_rule_type" AS ENUM('budget', 'error_rate', 'latency', 'quota');--> statement-breakpoint
+CREATE TABLE "alert_channels" (
+ "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "alert_channels_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
+ "name" varchar(100) NOT NULL,
+ "type" "alert_channel_type" NOT NULL,
+ "config" jsonb NOT NULL,
+ "enabled" boolean DEFAULT true NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "alert_history" (
+ "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "alert_history_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
+ "rule_id" integer NOT NULL,
+ "triggered_at" timestamp DEFAULT now() NOT NULL,
+ "resolved_at" timestamp,
+ "payload" jsonb NOT NULL,
+ "status" "alert_history_status" NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "alert_rules" (
+ "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "alert_rules_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
+ "name" varchar(100) NOT NULL,
+ "type" "alert_rule_type" NOT NULL,
+ "condition" jsonb NOT NULL,
+ "channel_ids" integer[] NOT NULL,
+ "cooldown_minutes" integer DEFAULT 60 NOT NULL,
+ "enabled" boolean DEFAULT true NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "alert_history" ADD CONSTRAINT "alert_history_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE no action ON UPDATE no action;
\ No newline at end of file
diff --git a/backend/drizzle/0014_opposite_dragon_man.sql b/backend/drizzle/0014_opposite_dragon_man.sql
new file mode 100644
index 0000000..18bf64d
--- /dev/null
+++ b/backend/drizzle/0014_opposite_dragon_man.sql
@@ -0,0 +1,6 @@
+ALTER TABLE "alert_channels" ADD COLUMN "grafana_uid" varchar(127);--> statement-breakpoint
+ALTER TABLE "alert_channels" ADD COLUMN "grafana_synced_at" timestamp;--> statement-breakpoint
+ALTER TABLE "alert_channels" ADD COLUMN "grafana_sync_error" varchar(500);--> statement-breakpoint
+ALTER TABLE "alert_rules" ADD COLUMN "grafana_uid" varchar(127);--> statement-breakpoint
+ALTER TABLE "alert_rules" ADD COLUMN "grafana_synced_at" timestamp;--> statement-breakpoint
+ALTER TABLE "alert_rules" ADD COLUMN "grafana_sync_error" varchar(500);
\ No newline at end of file
diff --git a/backend/drizzle/meta/0013_snapshot.json b/backend/drizzle/meta/0013_snapshot.json
new file mode 100644
index 0000000..076f16b
--- /dev/null
+++ b/backend/drizzle/meta/0013_snapshot.json
@@ -0,0 +1,1289 @@
+{
+ "id": "976baa6f-9ac9-403e-a0ca-e3a1dacab706",
+ "prevId": "108dfda0-d871-4758-b0c7-678298118347",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.alert_channels": {
+ "name": "alert_channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_channels_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "alert_channel_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_history": {
+ "name": "alert_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_history_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "triggered_at": {
+ "name": "triggered_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "alert_history_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "alert_history_rule_id_alert_rules_id_fk": {
+ "name": "alert_history_rule_id_alert_rules_id_fk",
+ "tableFrom": "alert_history",
+ "tableTo": "alert_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_rules": {
+ "name": "alert_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_rules_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "alert_rule_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "condition": {
+ "name": "condition",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_ids": {
+ "name": "channel_ids",
+ "type": "integer[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cooldown_minutes": {
+ "name": "cooldown_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "api_keys_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rpm_limit": {
+ "name": "rpm_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 50
+ },
+ "tpm_limit": {
+ "name": "tpm_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 50000
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "api_key_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_keys_key_unique": {
+ "name": "api_keys_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ },
+ "api_keys_external_id_unique": {
+ "name": "api_keys_external_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.completions": {
+ "name": "completions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "completions_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_id": {
+ "name": "upstream_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prompt": {
+ "name": "prompt",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prompt_tokens": {
+ "name": "prompt_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completion": {
+ "name": "completion",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completion_tokens": {
+ "name": "completion_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "completions_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "ttft": {
+ "name": "ttft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "req_id": {
+ "name": "req_id",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_completion_id": {
+ "name": "source_completion_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_format": {
+ "name": "api_format",
+ "type": "varchar(31)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cached_response": {
+ "name": "cached_response",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "completions_api_key_id_api_keys_id_fk": {
+ "name": "completions_api_key_id_api_keys_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "completions_upstream_id_upstreams_id_fk": {
+ "name": "completions_upstream_id_upstreams_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "upstreams",
+ "columnsFrom": [
+ "upstream_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "completions_source_completion_id_completions_id_fk": {
+ "name": "completions_source_completion_id_completions_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "completions",
+ "columnsFrom": [
+ "source_completion_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "completions_id_unique": {
+ "name": "completions_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.embeddings": {
+ "name": "embeddings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "embeddings_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input": {
+ "name": "input",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dimensions": {
+ "name": "dimensions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "completions_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "embeddings_api_key_id_api_keys_id_fk": {
+ "name": "embeddings_api_key_id_api_keys_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "embeddings_model_id_models_id_fk": {
+ "name": "embeddings_model_id_models_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "models",
+ "columnsFrom": [
+ "model_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "embeddings_id_unique": {
+ "name": "embeddings_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.models": {
+ "name": "models",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "models_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "system_name": {
+ "name": "system_name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_id": {
+ "name": "remote_id",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_type": {
+ "name": "model_type",
+ "type": "model_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'chat'"
+ },
+ "context_length": {
+ "name": "context_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_price": {
+ "name": "input_price",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output_price": {
+ "name": "output_price",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "models_provider_id_providers_id_fk": {
+ "name": "models_provider_id_providers_id_fk",
+ "tableFrom": "models",
+ "tableTo": "providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "models_provider_system_name_unique": {
+ "name": "models_provider_system_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "provider_id",
+ "system_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.providers": {
+ "name": "providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "providers_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "provider_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'openai'"
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_version": {
+ "name": "api_version",
+ "type": "varchar(31)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "settings_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "settings_key_unique": {
+ "name": "settings_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.srv_logs": {
+ "name": "srv_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "srv_logs_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "related_api_key_id": {
+ "name": "related_api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "related_upstream_id": {
+ "name": "related_upstream_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "related_completion_id": {
+ "name": "related_completion_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "srv_logs_level",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "acknowledged": {
+ "name": "acknowledged",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ack_at": {
+ "name": "ack_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "srv_logs_related_api_key_id_api_keys_id_fk": {
+ "name": "srv_logs_related_api_key_id_api_keys_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "related_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "srv_logs_related_upstream_id_upstreams_id_fk": {
+ "name": "srv_logs_related_upstream_id_upstreams_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "upstreams",
+ "columnsFrom": [
+ "related_upstream_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "srv_logs_related_completion_id_completions_id_fk": {
+ "name": "srv_logs_related_completion_id_completions_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "completions",
+ "columnsFrom": [
+ "related_completion_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "srv_logs_id_unique": {
+ "name": "srv_logs_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upstreams": {
+ "name": "upstreams",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "upstreams_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_model": {
+ "name": "upstream_model",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.alert_channel_type": {
+ "name": "alert_channel_type",
+ "schema": "public",
+ "values": [
+ "webhook",
+ "email",
+ "feishu"
+ ]
+ },
+ "public.alert_history_status": {
+ "name": "alert_history_status",
+ "schema": "public",
+ "values": [
+ "sent",
+ "failed",
+ "suppressed"
+ ]
+ },
+ "public.alert_rule_type": {
+ "name": "alert_rule_type",
+ "schema": "public",
+ "values": [
+ "budget",
+ "error_rate",
+ "latency",
+ "quota"
+ ]
+ },
+ "public.api_key_source": {
+ "name": "api_key_source",
+ "schema": "public",
+ "values": [
+ "manual",
+ "operator",
+ "init"
+ ]
+ },
+ "public.completions_status": {
+ "name": "completions_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "completed",
+ "failed",
+ "aborted",
+ "cache_hit"
+ ]
+ },
+ "public.model_type": {
+ "name": "model_type",
+ "schema": "public",
+ "values": [
+ "chat",
+ "embedding"
+ ]
+ },
+ "public.provider_type": {
+ "name": "provider_type",
+ "schema": "public",
+ "values": [
+ "openai",
+ "openai-responses",
+ "anthropic",
+ "azure",
+ "ollama"
+ ]
+ },
+ "public.srv_logs_level": {
+ "name": "srv_logs_level",
+ "schema": "public",
+ "values": [
+ "unspecific",
+ "info",
+ "warn",
+ "error"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/backend/drizzle/meta/0014_snapshot.json b/backend/drizzle/meta/0014_snapshot.json
new file mode 100644
index 0000000..15269fe
--- /dev/null
+++ b/backend/drizzle/meta/0014_snapshot.json
@@ -0,0 +1,1325 @@
+{
+ "id": "5baa8f77-48e3-4e1d-9078-d979c2312412",
+ "prevId": "976baa6f-9ac9-403e-a0ca-e3a1dacab706",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.alert_channels": {
+ "name": "alert_channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_channels_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "alert_channel_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "grafana_uid": {
+ "name": "grafana_uid",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_synced_at": {
+ "name": "grafana_synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_sync_error": {
+ "name": "grafana_sync_error",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_history": {
+ "name": "alert_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_history_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "triggered_at": {
+ "name": "triggered_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "alert_history_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "alert_history_rule_id_alert_rules_id_fk": {
+ "name": "alert_history_rule_id_alert_rules_id_fk",
+ "tableFrom": "alert_history",
+ "tableTo": "alert_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_rules": {
+ "name": "alert_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_rules_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "alert_rule_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "condition": {
+ "name": "condition",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_ids": {
+ "name": "channel_ids",
+ "type": "integer[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cooldown_minutes": {
+ "name": "cooldown_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "grafana_uid": {
+ "name": "grafana_uid",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_synced_at": {
+ "name": "grafana_synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_sync_error": {
+ "name": "grafana_sync_error",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "api_keys_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rpm_limit": {
+ "name": "rpm_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 50
+ },
+ "tpm_limit": {
+ "name": "tpm_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 50000
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "api_key_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_keys_key_unique": {
+ "name": "api_keys_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ },
+ "api_keys_external_id_unique": {
+ "name": "api_keys_external_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.completions": {
+ "name": "completions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "completions_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_id": {
+ "name": "upstream_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prompt": {
+ "name": "prompt",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prompt_tokens": {
+ "name": "prompt_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completion": {
+ "name": "completion",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completion_tokens": {
+ "name": "completion_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "completions_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "ttft": {
+ "name": "ttft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "req_id": {
+ "name": "req_id",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_completion_id": {
+ "name": "source_completion_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_format": {
+ "name": "api_format",
+ "type": "varchar(31)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cached_response": {
+ "name": "cached_response",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "completions_api_key_id_api_keys_id_fk": {
+ "name": "completions_api_key_id_api_keys_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "completions_upstream_id_upstreams_id_fk": {
+ "name": "completions_upstream_id_upstreams_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "upstreams",
+ "columnsFrom": [
+ "upstream_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "completions_source_completion_id_completions_id_fk": {
+ "name": "completions_source_completion_id_completions_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "completions",
+ "columnsFrom": [
+ "source_completion_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "completions_id_unique": {
+ "name": "completions_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.embeddings": {
+ "name": "embeddings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "embeddings_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input": {
+ "name": "input",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dimensions": {
+ "name": "dimensions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "completions_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "embeddings_api_key_id_api_keys_id_fk": {
+ "name": "embeddings_api_key_id_api_keys_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "embeddings_model_id_models_id_fk": {
+ "name": "embeddings_model_id_models_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "models",
+ "columnsFrom": [
+ "model_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "embeddings_id_unique": {
+ "name": "embeddings_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.models": {
+ "name": "models",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "models_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "system_name": {
+ "name": "system_name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_id": {
+ "name": "remote_id",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_type": {
+ "name": "model_type",
+ "type": "model_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'chat'"
+ },
+ "context_length": {
+ "name": "context_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_price": {
+ "name": "input_price",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output_price": {
+ "name": "output_price",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "models_provider_id_providers_id_fk": {
+ "name": "models_provider_id_providers_id_fk",
+ "tableFrom": "models",
+ "tableTo": "providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "models_provider_system_name_unique": {
+ "name": "models_provider_system_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "provider_id",
+ "system_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.providers": {
+ "name": "providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "providers_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "provider_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'openai'"
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_version": {
+ "name": "api_version",
+ "type": "varchar(31)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "settings_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "settings_key_unique": {
+ "name": "settings_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.srv_logs": {
+ "name": "srv_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "srv_logs_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "related_api_key_id": {
+ "name": "related_api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "related_upstream_id": {
+ "name": "related_upstream_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "related_completion_id": {
+ "name": "related_completion_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "srv_logs_level",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "acknowledged": {
+ "name": "acknowledged",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ack_at": {
+ "name": "ack_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "srv_logs_related_api_key_id_api_keys_id_fk": {
+ "name": "srv_logs_related_api_key_id_api_keys_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "related_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "srv_logs_related_upstream_id_upstreams_id_fk": {
+ "name": "srv_logs_related_upstream_id_upstreams_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "upstreams",
+ "columnsFrom": [
+ "related_upstream_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "srv_logs_related_completion_id_completions_id_fk": {
+ "name": "srv_logs_related_completion_id_completions_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "completions",
+ "columnsFrom": [
+ "related_completion_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "srv_logs_id_unique": {
+ "name": "srv_logs_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upstreams": {
+ "name": "upstreams",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "upstreams_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_model": {
+ "name": "upstream_model",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.alert_channel_type": {
+ "name": "alert_channel_type",
+ "schema": "public",
+ "values": [
+ "webhook",
+ "email",
+ "feishu"
+ ]
+ },
+ "public.alert_history_status": {
+ "name": "alert_history_status",
+ "schema": "public",
+ "values": [
+ "sent",
+ "failed",
+ "suppressed"
+ ]
+ },
+ "public.alert_rule_type": {
+ "name": "alert_rule_type",
+ "schema": "public",
+ "values": [
+ "budget",
+ "error_rate",
+ "latency",
+ "quota"
+ ]
+ },
+ "public.api_key_source": {
+ "name": "api_key_source",
+ "schema": "public",
+ "values": [
+ "manual",
+ "operator",
+ "init"
+ ]
+ },
+ "public.completions_status": {
+ "name": "completions_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "completed",
+ "failed",
+ "aborted",
+ "cache_hit"
+ ]
+ },
+ "public.model_type": {
+ "name": "model_type",
+ "schema": "public",
+ "values": [
+ "chat",
+ "embedding"
+ ]
+ },
+ "public.provider_type": {
+ "name": "provider_type",
+ "schema": "public",
+ "values": [
+ "openai",
+ "openai-responses",
+ "anthropic",
+ "azure",
+ "ollama"
+ ]
+ },
+ "public.srv_logs_level": {
+ "name": "srv_logs_level",
+ "schema": "public",
+ "values": [
+ "unspecific",
+ "info",
+ "warn",
+ "error"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json
index c5728e8..a7b8915 100644
--- a/backend/drizzle/meta/_journal.json
+++ b/backend/drizzle/meta/_journal.json
@@ -92,6 +92,20 @@
"when": 1769694986161,
"tag": "0012_right_iceman",
"breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1769859175301,
+ "tag": "0013_flowery_maria_hill",
+ "breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "7",
+ "when": 1769870349819,
+ "tag": "0014_opposite_dragon_man",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/backend/package.json b/backend/package.json
index 6fccf9c..1b87694 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -25,11 +25,13 @@
"elysia": "^1.4.22",
"ioredis": "^5.9.1",
"loglayer": "^8.4.0",
+ "nodemailer": "^7.0.13",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/bun": "^1.3.6",
"@types/node": "^24.10.9",
+ "@types/nodemailer": "^7.0.9",
"@typescript/native-preview": "7.0.0-dev.20260124.1",
"drizzle-kit": "^0.31.8",
"openai": "^6.16.0",
diff --git a/backend/src/adapters/upstream/anthropic.ts b/backend/src/adapters/upstream/anthropic.ts
index c236592..5c4f61d 100644
--- a/backend/src/adapters/upstream/anthropic.ts
+++ b/backend/src/adapters/upstream/anthropic.ts
@@ -461,8 +461,7 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = {
...(request.stopSequences && { stop_sequences: request.stopSequences }),
...(request.tools && { tools: convertTools(request.tools) }),
...(request.toolChoice && {
- tool_choice: convertToolChoice(request.toolChoice),
- }),
+ tool_choice: convertToolChoice(request.toolChoice), }),
...request.extraParams,
};
diff --git a/backend/src/api/admin/alerts.ts b/backend/src/api/admin/alerts.ts
new file mode 100644
index 0000000..09731fe
--- /dev/null
+++ b/backend/src/api/admin/alerts.ts
@@ -0,0 +1,310 @@
+import { Elysia, t } from "elysia";
+import {
+ deleteAlertChannel,
+ deleteAlertRule,
+ findAlertChannel,
+ findAlertRule,
+ insertAlertChannel,
+ insertAlertRule,
+ listAlertChannels,
+ listAlertHistory,
+ listAlertRules,
+ updateAlertChannel,
+ updateAlertRule,
+} from "@/db";
+import { sendTestNotification } from "@/services/alertDispatcher";
+import type {
+ AlertChannelConfig,
+ AlertCondition,
+} from "@/db/schema";
+
+export const adminAlerts = new Elysia({ prefix: "/alerts" })
+ // ============================================
+ // Alert Channels
+ // ============================================
+ .get(
+ "/channels",
+ async () => {
+ return await listAlertChannels();
+ },
+ {
+ detail: {
+ description: "List all alert channels",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .get(
+ "/channels/:id",
+ async ({ params: { id }, status }) => {
+ const channel = await findAlertChannel(id);
+ if (!channel) {
+ return status(404, { error: "Alert channel not found" });
+ }
+ return channel;
+ },
+ {
+ params: t.Object({ id: t.Numeric() }),
+ detail: {
+ description: "Get an alert channel by ID",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .post(
+ "/channels",
+ async ({ body }) => {
+ const channel = await insertAlertChannel({
+ name: body.name,
+ type: body.type,
+ config: body.config as AlertChannelConfig,
+ enabled: body.enabled,
+ });
+ return channel;
+ },
+ {
+ body: t.Object({
+ name: t.String({ minLength: 1, maxLength: 100 }),
+ type: t.Union([
+ t.Literal("webhook"),
+ t.Literal("email"),
+ t.Literal("feishu"),
+ ]),
+ config: t.Unknown(),
+ enabled: t.Optional(t.Boolean()),
+ }),
+ detail: {
+ description: "Create a new alert channel",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .put(
+ "/channels/:id",
+ async ({ params: { id }, body, status }) => {
+ const existing = await findAlertChannel(id);
+ if (!existing) {
+ return status(404, { error: "Alert channel not found" });
+ }
+ const channel = await updateAlertChannel(id, {
+ name: body.name,
+ type: body.type,
+ config: body.config as AlertChannelConfig | undefined,
+ enabled: body.enabled,
+ });
+ return channel;
+ },
+ {
+ params: t.Object({ id: t.Numeric() }),
+ body: t.Object({
+ name: t.Optional(t.String({ minLength: 1, maxLength: 100 })),
+ type: t.Optional(
+ t.Union([
+ t.Literal("webhook"),
+ t.Literal("email"),
+ t.Literal("feishu"),
+ ]),
+ ),
+ config: t.Optional(t.Unknown()),
+ enabled: t.Optional(t.Boolean()),
+ }),
+ detail: {
+ description: "Update an alert channel",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .delete(
+ "/channels/:id",
+ async ({ params: { id }, status }) => {
+ const channel = await deleteAlertChannel(id);
+ if (!channel) {
+ return status(404, { error: "Alert channel not found" });
+ }
+ return { success: true };
+ },
+ {
+ params: t.Object({ id: t.Numeric() }),
+ detail: {
+ description: "Delete an alert channel",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .post(
+ "/channels/:id/test",
+ async ({ params: { id }, status }) => {
+ const channel = await findAlertChannel(id);
+ if (!channel) {
+ return status(404, { error: "Alert channel not found" });
+ }
+
+ try {
+ await sendTestNotification(channel.type, channel.config);
+ return { success: true, message: "Test notification sent" };
+ } catch (e) {
+ return status(502, {
+ success: false,
+ error: e instanceof Error ? e.message : "Unknown error",
+ });
+ }
+ },
+ {
+ params: t.Object({ id: t.Numeric() }),
+ detail: {
+ description: "Send a test notification to an alert channel",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ // ============================================
+ // Alert Rules
+ // ============================================
+ .get(
+ "/rules",
+ async () => {
+ return await listAlertRules();
+ },
+ {
+ detail: {
+ description: "List all alert rules",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .get(
+ "/rules/:id",
+ async ({ params: { id }, status }) => {
+ const rule = await findAlertRule(id);
+ if (!rule) {
+ return status(404, { error: "Alert rule not found" });
+ }
+ return { ...rule, channelIds: [...rule.channelIds] };
+ },
+ {
+ params: t.Object({ id: t.Numeric() }),
+ detail: {
+ description: "Get an alert rule by ID",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .post(
+ "/rules",
+ async ({ body }) => {
+ const rule = await insertAlertRule({
+ name: body.name,
+ type: body.type,
+ condition: body.condition as AlertCondition,
+ channelIds: body.channelIds,
+ cooldownMinutes: body.cooldownMinutes,
+ enabled: body.enabled,
+ });
+ if (!rule) {
+ return rule;
+ }
+ return { ...rule, channelIds: [...rule.channelIds] };
+ },
+ {
+ body: t.Object({
+ name: t.String({ minLength: 1, maxLength: 100 }),
+ type: t.Union([
+ t.Literal("budget"),
+ t.Literal("error_rate"),
+ t.Literal("latency"),
+ t.Literal("quota"),
+ ]),
+ condition: t.Unknown(),
+ channelIds: t.Array(t.Number()),
+ cooldownMinutes: t.Optional(t.Number({ minimum: 1 })),
+ enabled: t.Optional(t.Boolean()),
+ }),
+ detail: {
+ description: "Create a new alert rule",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .put(
+ "/rules/:id",
+ async ({ params: { id }, body, status }) => {
+ const existing = await findAlertRule(id);
+ if (!existing) {
+ return status(404, { error: "Alert rule not found" });
+ }
+ const rule = await updateAlertRule(id, {
+ name: body.name,
+ type: body.type,
+ condition: body.condition as AlertCondition | undefined,
+ channelIds: body.channelIds,
+ cooldownMinutes: body.cooldownMinutes,
+ enabled: body.enabled,
+ });
+ if (!rule) {
+ return rule;
+ }
+ return { ...rule, channelIds: [...rule.channelIds] };
+ },
+ {
+ params: t.Object({ id: t.Numeric() }),
+ body: t.Object({
+ name: t.Optional(t.String({ minLength: 1, maxLength: 100 })),
+ type: t.Optional(
+ t.Union([
+ t.Literal("budget"),
+ t.Literal("error_rate"),
+ t.Literal("latency"),
+ t.Literal("quota"),
+ ]),
+ ),
+ condition: t.Optional(t.Unknown()),
+ channelIds: t.Optional(t.Array(t.Number())),
+ cooldownMinutes: t.Optional(t.Number({ minimum: 1 })),
+ enabled: t.Optional(t.Boolean()),
+ }),
+ detail: {
+ description: "Update an alert rule",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ .delete(
+ "/rules/:id",
+ async ({ params: { id }, status }) => {
+ const rule = await deleteAlertRule(id);
+ if (!rule) {
+ return status(404, { error: "Alert rule not found" });
+ }
+ return { success: true };
+ },
+ {
+ params: t.Object({ id: t.Numeric() }),
+ detail: {
+ description: "Delete an alert rule",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ )
+ // ============================================
+ // Alert History
+ // ============================================
+ .get(
+ "/history",
+ async ({ query }) => {
+ const offset = query.offset ?? 0;
+ const limit = query.limit ?? 50;
+ const ruleId = query.ruleId;
+ return await listAlertHistory(offset, limit, ruleId);
+ },
+ {
+ query: t.Object({
+ offset: t.Optional(t.Numeric()),
+ limit: t.Optional(t.Numeric()),
+ ruleId: t.Optional(t.Numeric()),
+ }),
+ detail: {
+ description: "List alert history (paginated, optionally filtered by rule)",
+ tags: ["Admin - Alerts"],
+ },
+ },
+ );
diff --git a/backend/src/api/admin/grafana.ts b/backend/src/api/admin/grafana.ts
new file mode 100644
index 0000000..733bde9
--- /dev/null
+++ b/backend/src/api/admin/grafana.ts
@@ -0,0 +1,300 @@
+import { Elysia, t } from "elysia";
+import {
+ getSetting,
+ upsertSetting,
+ deleteSetting,
+ listAlertRules,
+ listAlertChannels,
+} from "@/db";
+import { createLogger } from "@/utils/logger";
+import {
+ syncRulesToGrafana,
+ syncChannelsToGrafana,
+ syncAllToGrafana,
+} from "@/services/grafanaSync";
+
+const logger = createLogger("adminGrafana");
+
+const GRAFANA_CONNECTION_KEY = "grafana_connection";
+
+interface GrafanaConnection {
+ apiUrl: string;
+ authToken: string;
+ datasourceUid?: string;
+ verified: boolean;
+ verifiedAt: string | null;
+}
+
+async function getGrafanaConnection(): Promise {
+ const setting = await getSetting(GRAFANA_CONNECTION_KEY);
+ if (!setting?.value) {
+ return null;
+ }
+ return setting.value as GrafanaConnection;
+}
+
+export const adminGrafana = new Elysia({ prefix: "/grafana" })
+ // ============================================
+ // Connection Configuration
+ // ============================================
+ .get(
+ "/connection",
+ async () => {
+ const config = await getGrafanaConnection();
+ if (!config) {
+ return {
+ configured: false,
+ apiUrl: null,
+ hasToken: false,
+ verified: false,
+ verifiedAt: null,
+ datasourceUid: null,
+ };
+ }
+ return {
+ configured: true,
+ apiUrl: config.apiUrl,
+ hasToken: !!config.authToken,
+ verified: config.verified,
+ verifiedAt: config.verifiedAt,
+ datasourceUid: config.datasourceUid ?? null,
+ };
+ },
+ {
+ detail: {
+ description:
+ "Get Grafana connection configuration. Never returns the auth token.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ )
+ .put(
+ "/connection",
+ async ({ body }) => {
+ await upsertSetting({
+ key: GRAFANA_CONNECTION_KEY,
+ value: {
+ apiUrl: body.apiUrl,
+ authToken: body.authToken,
+ verified: false,
+ verifiedAt: null,
+ } satisfies GrafanaConnection,
+ });
+ return { success: true };
+ },
+ {
+ body: t.Object({
+ apiUrl: t.String({ format: "uri" }),
+ authToken: t.String({ minLength: 1 }),
+ }),
+ detail: {
+ description: "Save Grafana API connection configuration.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ )
+ .post(
+ "/connection/test",
+ async ({ status }) => {
+ const config = await getGrafanaConnection();
+ if (!config) {
+ return status(404, { error: "Grafana connection not configured" });
+ }
+
+ try {
+ // Step 1: Test health endpoint
+ const healthRes = await fetch(`${config.apiUrl}/api/health`, {
+ headers: { Authorization: `Bearer ${config.authToken}` },
+ });
+ if (!healthRes.ok) {
+ throw new Error(
+ `Grafana health check failed: ${healthRes.status} ${await healthRes.text()}`,
+ );
+ }
+
+ // Step 2: Discover Prometheus datasource
+ let datasourceUid: string | undefined;
+ try {
+ const dsRes = await fetch(`${config.apiUrl}/api/datasources`, {
+ headers: { Authorization: `Bearer ${config.authToken}` },
+ });
+ if (dsRes.ok) {
+ const datasources = (await dsRes.json()) as Array<{
+ uid: string;
+ type: string;
+ name: string;
+ }>;
+ const promDs = datasources.find((ds) => ds.type === "prometheus");
+ if (promDs) {
+ datasourceUid = promDs.uid;
+ }
+ }
+ } catch {
+ logger.warn("Failed to discover Prometheus datasource");
+ }
+
+ // Step 3: Update verified status
+ await upsertSetting({
+ key: GRAFANA_CONNECTION_KEY,
+ value: {
+ ...config,
+ datasourceUid,
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ } satisfies GrafanaConnection,
+ });
+
+ return {
+ success: true,
+ message: "Connection verified",
+ datasourceUid: datasourceUid ?? null,
+ };
+ } catch (e) {
+ // Mark as not verified
+ await upsertSetting({
+ key: GRAFANA_CONNECTION_KEY,
+ value: {
+ ...config,
+ verified: false,
+ verifiedAt: null,
+ } satisfies GrafanaConnection,
+ });
+
+ return status(502, {
+ success: false,
+ error: e instanceof Error ? e.message : "Connection failed",
+ });
+ }
+ },
+ {
+ detail: {
+ description:
+ "Test Grafana connection and discover Prometheus datasource.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ )
+ .delete(
+ "/connection",
+ async () => {
+ await deleteSetting(GRAFANA_CONNECTION_KEY);
+ return { success: true };
+ },
+ {
+ detail: {
+ description: "Remove Grafana connection configuration.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ )
+
+ // ============================================
+ // Sync Operations
+ // ============================================
+ .post(
+ "/sync",
+ async ({ status }) => {
+ try {
+ const result = await syncAllToGrafana();
+ return result;
+ } catch (e) {
+ return status(502, {
+ error: e instanceof Error ? e.message : "Sync failed",
+ });
+ }
+ },
+ {
+ detail: {
+ description:
+ "Sync all alert rules and channels to Grafana.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ )
+ .post(
+ "/sync/rules",
+ async ({ status }) => {
+ try {
+ const result = await syncRulesToGrafana();
+ return result;
+ } catch (e) {
+ return status(502, {
+ error: e instanceof Error ? e.message : "Sync failed",
+ });
+ }
+ },
+ {
+ detail: {
+ description: "Sync alert rules to Grafana.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ )
+ .post(
+ "/sync/channels",
+ async ({ status }) => {
+ try {
+ const result = await syncChannelsToGrafana();
+ return result;
+ } catch (e) {
+ return status(502, {
+ error: e instanceof Error ? e.message : "Sync failed",
+ });
+ }
+ },
+ {
+ detail: {
+ description: "Sync alert channels to Grafana as contact points.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ )
+ .get(
+ "/sync/status",
+ async () => {
+ const rules = await listAlertRules();
+ const channels = await listAlertChannels();
+
+ return {
+ rules: rules.map((r) => ({
+ id: r.id,
+ name: r.name,
+ enabled: r.enabled,
+ grafanaUid: r.grafanaUid ?? null,
+ grafanaSyncedAt: r.grafanaSyncedAt
+ ? r.grafanaSyncedAt.toISOString()
+ : null,
+ grafanaSyncError: r.grafanaSyncError ?? null,
+ })),
+ channels: channels.map((c) => ({
+ id: c.id,
+ name: c.name,
+ enabled: c.enabled,
+ grafanaUid: c.grafanaUid ?? null,
+ grafanaSyncedAt: c.grafanaSyncedAt
+ ? c.grafanaSyncedAt.toISOString()
+ : null,
+ grafanaSyncError: c.grafanaSyncError ?? null,
+ })),
+ };
+ },
+ {
+ detail: {
+ description:
+ "Get Grafana sync status for all rules and channels.",
+ tags: ["Admin - Grafana"],
+ },
+ },
+ );
+
+/**
+ * Helper to get the current verified Grafana connection.
+ * Returns null if not configured or not verified.
+ */
+export async function getVerifiedGrafanaConnection(): Promise {
+ const config = await getGrafanaConnection();
+ if (!config?.verified) {
+ return null;
+ }
+ return config;
+}
diff --git a/backend/src/api/admin/index.ts b/backend/src/api/admin/index.ts
index f67300b..26a7db5 100644
--- a/backend/src/api/admin/index.ts
+++ b/backend/src/api/admin/index.ts
@@ -1,9 +1,11 @@
import { Elysia } from "elysia";
import { apiKeyPlugin } from "@/plugins/apiKeyPlugin";
import { COMMIT_SHA } from "@/utils/config";
+import { adminAlerts } from "./alerts";
import { adminApiKey } from "./apiKey";
import { adminCompletions } from "./completions";
import { adminDashboards } from "./dashboards";
+import { adminGrafana } from "./grafana";
import { adminEmbeddings } from "./embeddings";
import { adminModels } from "./models";
import { adminProviders } from "./providers";
@@ -22,6 +24,7 @@ export const routes = new Elysia({
.group("/admin", (app) =>
app.guard({ checkAdminApiKey: true }, (app) =>
app
+ .use(adminAlerts)
.use(adminApiKey)
.use(adminUpstream)
.use(adminCompletions)
@@ -33,6 +36,7 @@ export const routes = new Elysia({
.use(adminStats)
.use(adminSettings)
.use(adminDashboards)
+ .use(adminGrafana)
.get("/", () => true, {
detail: { description: "Check whether the admin secret is valid." },
})
diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts
index 2be653f..9cde582 100644
--- a/backend/src/db/index.ts
+++ b/backend/src/db/index.ts
@@ -3,7 +3,13 @@ import { drizzle } from "drizzle-orm/bun-sql";
import { migrate } from "drizzle-orm/bun-sql/migrator";
import { DATABASE_URL } from "@/utils/config";
import { createLogger } from "@/utils/logger";
-import type { ModelTypeEnumType } from "./schema";
+import type {
+ ModelTypeEnumType,
+ AlertChannelConfig,
+ AlertCondition,
+ AlertPayload,
+ AlertHistoryStatusEnumType,
+} from "./schema";
import * as schema from "./schema";
const globalThis_ = globalThis as typeof globalThis & {
@@ -45,6 +51,14 @@ export type ModelInsert = typeof schema.ModelsTable.$inferInsert;
export type Embedding = typeof schema.EmbeddingsTable.$inferSelect;
export type EmbeddingInsert = typeof schema.EmbeddingsTable.$inferInsert;
+// Alert system types
+export type AlertChannel = typeof schema.AlertChannelsTable.$inferSelect;
+export type AlertChannelInsert = typeof schema.AlertChannelsTable.$inferInsert;
+export type AlertRule = typeof schema.AlertRulesTable.$inferSelect;
+export type AlertRuleInsert = typeof schema.AlertRulesTable.$inferInsert;
+export type AlertHistory = typeof schema.AlertHistoryTable.$inferSelect;
+export type AlertHistoryInsert = typeof schema.AlertHistoryTable.$inferInsert;
+
export type PartialList = {
data: T[];
total: number;
@@ -1585,3 +1599,329 @@ export async function getCompletionCostMetrics() {
total_cost_usd: string;
}[];
}
+
+// ============================================
+// Alert Channel CRUD Operations
+// ============================================
+
+export async function listAlertChannels(): Promise {
+ logger.debug("listAlertChannels");
+ return await db
+ .select()
+ .from(schema.AlertChannelsTable)
+ .orderBy(asc(schema.AlertChannelsTable.id));
+}
+
+export async function findAlertChannel(
+ id: number,
+): Promise {
+ logger.debug("findAlertChannel", id);
+ const r = await db
+ .select()
+ .from(schema.AlertChannelsTable)
+ .where(eq(schema.AlertChannelsTable.id, id));
+ const [first] = r;
+ return first ?? null;
+}
+
+export async function insertAlertChannel(c: {
+ name: string;
+ type: schema.AlertChannelTypeEnumType;
+ config: AlertChannelConfig;
+ enabled?: boolean;
+}): Promise {
+ logger.debug("insertAlertChannel", c.name);
+ const r = await db.insert(schema.AlertChannelsTable).values(c).returning();
+ const [first] = r;
+ return first ?? null;
+}
+
+export async function updateAlertChannel(
+ id: number,
+ c: Partial<{
+ name: string;
+ type: schema.AlertChannelTypeEnumType;
+ config: AlertChannelConfig;
+ enabled: boolean;
+ }>,
+): Promise {
+ logger.debug("updateAlertChannel", id);
+ const r = await db
+ .update(schema.AlertChannelsTable)
+ .set({ ...c, updatedAt: new Date() })
+ .where(eq(schema.AlertChannelsTable.id, id))
+ .returning();
+ const [first] = r;
+ return first ?? null;
+}
+
+export async function deleteAlertChannel(
+ id: number,
+): Promise {
+ logger.debug("deleteAlertChannel", id);
+ const r = await db
+ .delete(schema.AlertChannelsTable)
+ .where(eq(schema.AlertChannelsTable.id, id))
+ .returning();
+ const [first] = r;
+ return first ?? null;
+}
+
+// ============================================
+// Alert Rule CRUD Operations
+// ============================================
+
+export async function listAlertRules(): Promise {
+ logger.debug("listAlertRules");
+ return await db
+ .select()
+ .from(schema.AlertRulesTable)
+ .orderBy(asc(schema.AlertRulesTable.id));
+}
+
+export async function findAlertRule(id: number): Promise {
+ logger.debug("findAlertRule", id);
+ const r = await db
+ .select()
+ .from(schema.AlertRulesTable)
+ .where(eq(schema.AlertRulesTable.id, id));
+ const [first] = r;
+ return first ?? null;
+}
+
+export async function insertAlertRule(c: {
+ name: string;
+ type: schema.AlertRuleTypeEnumType;
+ condition: AlertCondition;
+ channelIds: number[];
+ cooldownMinutes?: number;
+ enabled?: boolean;
+}): Promise {
+ logger.debug("insertAlertRule", c.name);
+ const r = await db.insert(schema.AlertRulesTable).values(c).returning();
+ const [first] = r;
+ return first ?? null;
+}
+
+export async function updateAlertRule(
+ id: number,
+ c: Partial<{
+ name: string;
+ type: schema.AlertRuleTypeEnumType;
+ condition: AlertCondition;
+ channelIds: number[];
+ cooldownMinutes: number;
+ enabled: boolean;
+ }>,
+): Promise {
+ logger.debug("updateAlertRule", id);
+ const r = await db
+ .update(schema.AlertRulesTable)
+ .set({ ...c, updatedAt: new Date() })
+ .where(eq(schema.AlertRulesTable.id, id))
+ .returning();
+ const [first] = r;
+ return first ?? null;
+}
+
+export async function deleteAlertRule(id: number): Promise {
+ logger.debug("deleteAlertRule", id);
+ const r = await db
+ .delete(schema.AlertRulesTable)
+ .where(eq(schema.AlertRulesTable.id, id))
+ .returning();
+ const [first] = r;
+ return first ?? null;
+}
+
+// ============================================
+// Alert History Operations
+// ============================================
+
+export async function listAlertHistory(
+ offset: number,
+ limit: number,
+ ruleId?: number,
+): Promise> {
+ logger.debug("listAlertHistory", offset, limit, ruleId);
+ const whereClause = ruleId
+ ? eq(schema.AlertHistoryTable.ruleId, ruleId)
+ : undefined;
+
+ const r = await db
+ .select()
+ .from(schema.AlertHistoryTable)
+ .where(whereClause)
+ .orderBy(desc(schema.AlertHistoryTable.id))
+ .offset(offset)
+ .limit(limit);
+
+ const [total] = await db
+ .select({ total: count(schema.AlertHistoryTable.id) })
+ .from(schema.AlertHistoryTable)
+ .where(whereClause);
+
+ if (!total) {
+ throw new Error("total count failed");
+ }
+
+ return {
+ data: r,
+ total: total.total,
+ from: offset,
+ };
+}
+
+export async function insertAlertHistory(c: {
+ ruleId: number;
+ payload: AlertPayload;
+ status: AlertHistoryStatusEnumType;
+}): Promise {
+ logger.debug("insertAlertHistory", c.ruleId, c.status);
+ const r = await db.insert(schema.AlertHistoryTable).values(c).returning();
+ const [first] = r;
+ return first ?? null;
+}
+
+export async function getLastAlertForRule(
+ ruleId: number,
+): Promise {
+ logger.debug("getLastAlertForRule", ruleId);
+ const r = await db
+ .select()
+ .from(schema.AlertHistoryTable)
+ .where(
+ and(
+ eq(schema.AlertHistoryTable.ruleId, ruleId),
+ eq(schema.AlertHistoryTable.status, "sent"),
+ ),
+ )
+ .orderBy(desc(schema.AlertHistoryTable.triggeredAt))
+ .limit(1);
+ const [first] = r;
+ return first ?? null;
+}
+
+// ============================================
+// Alert Aggregation Queries
+// ============================================
+
+/**
+ * Get total cost in a given period (for budget alerts)
+ * @param periodDays number of days to look back
+ * @param apiKeyId optional, filter by specific API key
+ */
+export async function getCompletionCostInPeriod(
+ periodDays: number,
+ apiKeyId?: number,
+): Promise {
+ logger.debug("getCompletionCostInPeriod", periodDays, apiKeyId);
+ const result = await db.execute(sql`
+ SELECT 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
+ FROM completions c
+ LEFT JOIN models m ON c.model_id = m.id
+ WHERE c.deleted = false
+ AND c.created_at >= NOW() - INTERVAL '${sql.raw(String(periodDays))} days'
+ ${apiKeyId ? sql`AND c.api_key_id = ${apiKeyId}` : sql``}
+ `);
+ const row = (result as unknown as { total_cost: string }[])[0];
+ return Number(row?.total_cost ?? 0);
+}
+
+/**
+ * Get error rate in a given window (for error rate alerts)
+ * @param windowMinutes number of minutes to look back
+ * @param model optional, filter by model name
+ */
+export async function getCompletionErrorRate(
+ windowMinutes: number,
+ model?: string,
+): Promise<{ total: number; failed: number; rate: number }> {
+ logger.debug("getCompletionErrorRate", windowMinutes, model);
+ const result = await db.execute(sql`
+ SELECT
+ COUNT(*) AS total,
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed
+ FROM completions
+ WHERE deleted = false
+ AND created_at >= NOW() - INTERVAL '${sql.raw(String(windowMinutes))} minutes'
+ ${model ? sql`AND model = ${model}` : sql``}
+ `);
+ const row = (result as unknown as { total: string; failed: string }[])[0];
+ const total = Number(row?.total ?? 0);
+ const failed = Number(row?.failed ?? 0);
+ return {
+ total,
+ failed,
+ rate: total > 0 ? (failed / total) * 100 : 0,
+ };
+}
+
+/**
+ * Get latency percentile in a given window (for latency alerts)
+ * @param windowMinutes number of minutes to look back
+ * @param percentile the percentile to calculate (e.g. 95 for P95)
+ * @param model optional, filter by model name
+ */
+export async function getCompletionLatencyPercentile(
+ windowMinutes: number,
+ percentile: number,
+ model?: string,
+): Promise {
+ logger.debug("getCompletionLatencyPercentile", windowMinutes, percentile, model);
+ const pValue = percentile / 100;
+ const result = await db.execute(sql`
+ SELECT COALESCE(
+ percentile_cont(${pValue}) WITHIN GROUP (ORDER BY duration), 0
+ ) AS latency_percentile
+ FROM completions
+ WHERE deleted = false
+ AND status = 'completed'
+ AND duration > 0
+ AND created_at >= NOW() - INTERVAL '${sql.raw(String(windowMinutes))} minutes'
+ ${model ? sql`AND model = ${model}` : sql``}
+ `);
+ const row = (result as unknown as { latency_percentile: string }[])[0];
+ return Number(row?.latency_percentile ?? 0);
+}
+
+// ============================================
+// Grafana Sync Helpers
+// ============================================
+
+export async function updateAlertRuleGrafanaSync(
+ id: number,
+ fields: {
+ grafanaUid?: string | null;
+ grafanaSyncedAt?: Date | null;
+ grafanaSyncError?: string | null;
+ },
+): Promise {
+ await db
+ .update(schema.AlertRulesTable)
+ .set(fields)
+ .where(eq(schema.AlertRulesTable.id, id));
+}
+
+export async function updateAlertChannelGrafanaSync(
+ id: number,
+ fields: {
+ grafanaUid?: string | null;
+ grafanaSyncedAt?: Date | null;
+ grafanaSyncError?: string | null;
+ },
+): Promise {
+ await db
+ .update(schema.AlertChannelsTable)
+ .set(fields)
+ .where(eq(schema.AlertChannelsTable.id, id));
+}
diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts
index 92d8fe4..7332bc1 100644
--- a/backend/src/db/schema.ts
+++ b/backend/src/db/schema.ts
@@ -11,6 +11,68 @@ import {
type AnyPgColumn,
} from "drizzle-orm/pg-core";
+// ============================================
+// Alert System Types
+// ============================================
+
+export type WebhookChannelConfig = {
+ url: string;
+ headers?: Record;
+ secret?: string;
+};
+export type EmailChannelConfig = {
+ host: string;
+ port: number;
+ user: string;
+ password: string;
+ from: string;
+ to: string[];
+};
+export type FeishuChannelConfig = {
+ webhookUrl: string;
+ secret?: string;
+};
+export type AlertChannelConfig =
+ | WebhookChannelConfig
+ | EmailChannelConfig
+ | FeishuChannelConfig;
+
+export type BudgetCondition = {
+ thresholdUsd: number;
+ periodDays: number;
+ apiKeyId?: number;
+};
+export type ErrorRateCondition = {
+ thresholdPercent: number;
+ windowMinutes: number;
+ model?: string;
+};
+export type LatencyCondition = {
+ thresholdMs: number;
+ percentile: number;
+ windowMinutes: number;
+ model?: string;
+};
+export type QuotaCondition = {
+ thresholdPercent: number;
+ apiKeyId?: number;
+ limitType: "rpm" | "tpm" | "both";
+};
+export type AlertCondition =
+ | BudgetCondition
+ | ErrorRateCondition
+ | LatencyCondition
+ | QuotaCondition;
+
+export type AlertPayload = {
+ ruleType: string;
+ ruleName: string;
+ message: string;
+ currentValue: number;
+ threshold: number;
+ details?: Record;
+};
+
/**
* API Key source enum - tracks how the key was created
*/
@@ -351,3 +413,71 @@ export const EmbeddingsTable = pgTable("embeddings", {
updatedAt: timestamp("updated_at").notNull().defaultNow(),
deleted: boolean("deleted").notNull().default(false),
});
+
+// ============================================
+// Alert System Tables
+// ============================================
+
+export const AlertChannelTypeEnum = pgEnum("alert_channel_type", [
+ "webhook",
+ "email",
+ "feishu",
+]);
+export type AlertChannelTypeEnumType =
+ (typeof AlertChannelTypeEnum.enumValues)[number];
+
+export const AlertRuleTypeEnum = pgEnum("alert_rule_type", [
+ "budget",
+ "error_rate",
+ "latency",
+ "quota",
+]);
+export type AlertRuleTypeEnumType =
+ (typeof AlertRuleTypeEnum.enumValues)[number];
+
+export const AlertHistoryStatusEnum = pgEnum("alert_history_status", [
+ "sent",
+ "failed",
+ "suppressed",
+]);
+export type AlertHistoryStatusEnumType =
+ (typeof AlertHistoryStatusEnum.enumValues)[number];
+
+export const AlertChannelsTable = pgTable("alert_channels", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ name: varchar("name", { length: 100 }).notNull(),
+ type: AlertChannelTypeEnum("type").notNull(),
+ config: jsonb("config").notNull().$type(),
+ enabled: boolean("enabled").notNull().default(true),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
+ grafanaUid: varchar("grafana_uid", { length: 127 }),
+ grafanaSyncedAt: timestamp("grafana_synced_at"),
+ grafanaSyncError: varchar("grafana_sync_error", { length: 500 }),
+});
+
+export const AlertRulesTable = pgTable("alert_rules", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ name: varchar("name", { length: 100 }).notNull(),
+ type: AlertRuleTypeEnum("type").notNull(),
+ condition: jsonb("condition").notNull().$type(),
+ channelIds: integer("channel_ids").array().notNull(),
+ cooldownMinutes: integer("cooldown_minutes").notNull().default(60),
+ enabled: boolean("enabled").notNull().default(true),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
+ grafanaUid: varchar("grafana_uid", { length: 127 }),
+ grafanaSyncedAt: timestamp("grafana_synced_at"),
+ grafanaSyncError: varchar("grafana_sync_error", { length: 500 }),
+});
+
+export const AlertHistoryTable = pgTable("alert_history", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ ruleId: integer("rule_id")
+ .notNull()
+ .references((): AnyPgColumn => AlertRulesTable.id),
+ triggeredAt: timestamp("triggered_at").notNull().defaultNow(),
+ resolvedAt: timestamp("resolved_at"),
+ payload: jsonb("payload").notNull().$type(),
+ status: AlertHistoryStatusEnum("status").notNull(),
+});
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 7f9559b..4ec080b 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -18,6 +18,7 @@ import { join } from "node:path";
import { routes } from "@/api";
import { metricsApi } from "@/api/metrics";
import { loggerPlugin } from "@/plugins/loggerPlugin";
+import { startAlertEngine } from "@/services/alertEngine";
import {
ALLOWED_ORIGINS,
DOCS_DIR,
@@ -225,4 +226,6 @@ const app = new Elysia()
log.info(`Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
+startAlertEngine();
+
export type App = typeof app;
diff --git a/backend/src/services/alertDispatcher.ts b/backend/src/services/alertDispatcher.ts
new file mode 100644
index 0000000..d1ed017
--- /dev/null
+++ b/backend/src/services/alertDispatcher.ts
@@ -0,0 +1,192 @@
+import { createTransport } from "nodemailer";
+import { createLogger } from "@/utils/logger";
+import type {
+ AlertChannelConfig,
+ AlertPayload,
+ EmailChannelConfig,
+ FeishuChannelConfig,
+ WebhookChannelConfig,
+ AlertChannelTypeEnumType,
+} from "@/db/schema";
+
+const logger = createLogger("alertDispatcher");
+
+/**
+ * Compute HMAC-SHA256 signature for webhook payloads
+ */
+async function computeHmacSha256(
+ secret: string,
+ payload: string,
+): Promise {
+ const encoder = new TextEncoder();
+ const key = await crypto.subtle.importKey(
+ "raw",
+ encoder.encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"],
+ );
+ const signature = await crypto.subtle.sign(
+ "HMAC",
+ key,
+ encoder.encode(payload),
+ );
+ return Array.from(new Uint8Array(signature))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+/**
+ * Dispatch alert via webhook
+ */
+async function dispatchWebhook(
+ config: WebhookChannelConfig,
+ payload: AlertPayload,
+): Promise {
+ const body = JSON.stringify(payload);
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...config.headers,
+ };
+
+ if (config.secret) {
+ const signature = await computeHmacSha256(config.secret, body);
+ headers["X-Signature-256"] = `sha256=${signature}`;
+ }
+
+ const response = await fetch(config.url, {
+ method: "POST",
+ headers,
+ body,
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Webhook failed: ${response.status} ${text}`);
+ }
+}
+
+/**
+ * Dispatch alert via email (SMTP)
+ */
+async function dispatchEmail(
+ config: EmailChannelConfig,
+ payload: AlertPayload,
+): Promise {
+ const transport = createTransport({
+ host: config.host,
+ port: config.port,
+ auth: {
+ user: config.user,
+ pass: config.password,
+ },
+ });
+
+ const subject = `[NexusGate Alert] ${payload.ruleName}: ${payload.ruleType}`;
+ const html = `
+ NexusGate Alert
+ Rule: ${payload.ruleName}
+ Type: ${payload.ruleType}
+ Message: ${payload.message}
+ Current Value: ${payload.currentValue}
+ Threshold: ${payload.threshold}
+ ${payload.details ? `Details:
${JSON.stringify(payload.details, null, 2)}
` : ""}
+ `;
+
+ await transport.sendMail({
+ from: config.from,
+ to: config.to.join(", "),
+ subject,
+ html,
+ });
+}
+
+/**
+ * Dispatch alert via Feishu webhook
+ */
+async function dispatchFeishu(
+ config: FeishuChannelConfig,
+ payload: AlertPayload,
+): Promise {
+ const body: Record = {
+ msg_type: "interactive",
+ card: {
+ header: {
+ title: {
+ tag: "plain_text",
+ content: `NexusGate Alert: ${payload.ruleName}`,
+ },
+ template: "red",
+ },
+ elements: [
+ {
+ tag: "div",
+ text: {
+ tag: "lark_md",
+ content: [
+ `**Type:** ${payload.ruleType}`,
+ `**Message:** ${payload.message}`,
+ `**Current Value:** ${payload.currentValue}`,
+ `**Threshold:** ${payload.threshold}`,
+ ].join("\n"),
+ },
+ },
+ ],
+ },
+ };
+
+ if (config.secret) {
+ const timestamp = Math.floor(Date.now() / 1000).toString();
+ const stringToSign = `${timestamp}\n${config.secret}`;
+ const signature = await computeHmacSha256(config.secret, stringToSign);
+ Object.assign(body, { timestamp, sign: signature });
+ }
+
+ const response = await fetch(config.webhookUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Feishu webhook failed: ${response.status} ${text}`);
+ }
+}
+
+/**
+ * Dispatch alert to a single channel based on its type
+ */
+export async function dispatchToChannel(
+ channelType: AlertChannelTypeEnumType,
+ config: AlertChannelConfig,
+ payload: AlertPayload,
+): Promise {
+ switch (channelType) {
+ case "webhook":
+ return dispatchWebhook(config as WebhookChannelConfig, payload);
+ case "email":
+ return dispatchEmail(config as EmailChannelConfig, payload);
+ case "feishu":
+ return dispatchFeishu(config as FeishuChannelConfig, payload);
+ }
+}
+
+/**
+ * Send a test notification to a channel
+ */
+export async function sendTestNotification(
+ channelType: AlertChannelTypeEnumType,
+ config: AlertChannelConfig,
+): Promise {
+ const testPayload: AlertPayload = {
+ ruleType: "test",
+ ruleName: "Test Notification",
+ message: "This is a test notification from NexusGate alert system.",
+ currentValue: 0,
+ threshold: 0,
+ };
+
+ await dispatchToChannel(channelType, config, testPayload);
+ logger.info("Test notification sent successfully");
+}
diff --git a/backend/src/services/alertEngine.ts b/backend/src/services/alertEngine.ts
new file mode 100644
index 0000000..8d47115
--- /dev/null
+++ b/backend/src/services/alertEngine.ts
@@ -0,0 +1,377 @@
+import { createLogger } from "@/utils/logger";
+import { redisClient } from "@/utils/redisClient";
+import {
+ listAlertRules,
+ listAlertChannels,
+ insertAlertHistory,
+ getCompletionCostInPeriod,
+ getCompletionErrorRate,
+ getCompletionLatencyPercentile,
+ listApiKeys,
+ type AlertRule,
+ type AlertChannel,
+} from "@/db";
+import type {
+ AlertPayload,
+ BudgetCondition,
+ ErrorRateCondition,
+ LatencyCondition,
+ QuotaCondition,
+} from "@/db/schema";
+import { getRateLimitStatus } from "@/utils/apiKeyRateLimit";
+import { dispatchToChannel } from "./alertDispatcher";
+import { isGrafanaConnected } from "./grafanaSync";
+
+const logger = createLogger("alertEngine");
+
+const ALERT_CHECK_INTERVAL_MS = 60_000; // 60 seconds
+const COOLDOWN_KEY_PREFIX = "nexusgate:alert:cooldown";
+
+let intervalId: ReturnType | null = null;
+
+/**
+ * Check if a rule is currently in cooldown
+ */
+async function isInCooldown(ruleId: number): Promise {
+ const key = `${COOLDOWN_KEY_PREFIX}:${ruleId}`;
+ const result = await redisClient.get(key);
+ return result !== null;
+}
+
+/**
+ * Set cooldown for a rule
+ */
+async function setCooldown(
+ ruleId: number,
+ cooldownMinutes: number,
+): Promise {
+ const key = `${COOLDOWN_KEY_PREFIX}:${ruleId}`;
+ await redisClient.set(key, "1", { EX: cooldownMinutes * 60 });
+}
+
+/**
+ * Evaluate a budget alert condition
+ */
+async function evaluateBudget(
+ condition: BudgetCondition,
+): Promise<{ triggered: boolean; currentValue: number }> {
+ const cost = await getCompletionCostInPeriod(
+ condition.periodDays,
+ condition.apiKeyId,
+ );
+ return {
+ triggered: cost >= condition.thresholdUsd,
+ currentValue: cost,
+ };
+}
+
+/**
+ * Evaluate an error rate alert condition
+ */
+async function evaluateErrorRate(
+ condition: ErrorRateCondition,
+): Promise<{ triggered: boolean; currentValue: number }> {
+ const { rate } = await getCompletionErrorRate(
+ condition.windowMinutes,
+ condition.model,
+ );
+ return {
+ triggered: rate >= condition.thresholdPercent,
+ currentValue: rate,
+ };
+}
+
+/**
+ * Evaluate a latency alert condition
+ */
+async function evaluateLatency(
+ condition: LatencyCondition,
+): Promise<{ triggered: boolean; currentValue: number }> {
+ const latency = await getCompletionLatencyPercentile(
+ condition.windowMinutes,
+ condition.percentile,
+ condition.model,
+ );
+ return {
+ triggered: latency >= condition.thresholdMs,
+ currentValue: latency,
+ };
+}
+
+/**
+ * Evaluate a quota alert condition
+ */
+async function evaluateQuota(
+ condition: QuotaCondition,
+): Promise<{ triggered: boolean; currentValue: number }> {
+ // If a specific API key is specified, check just that one
+ if (condition.apiKeyId) {
+ // Need to get the key's limits from the DB
+ const apiKeys = await listApiKeys();
+ const apiKey = apiKeys.find((k) => k.id === condition.apiKeyId);
+ if (!apiKey) {
+ return { triggered: false, currentValue: 0 };
+ }
+
+ const status = await getRateLimitStatus(apiKey.id, {
+ rpmLimit: apiKey.rpmLimit,
+ tpmLimit: apiKey.tpmLimit,
+ });
+
+ let usagePercent = 0;
+ if (condition.limitType === "rpm") {
+ usagePercent =
+ status.rpm.limit > 0
+ ? (status.rpm.current / status.rpm.limit) * 100
+ : 0;
+ } else if (condition.limitType === "tpm") {
+ usagePercent =
+ status.tpm.limit > 0
+ ? (status.tpm.current / status.tpm.limit) * 100
+ : 0;
+ } else {
+ // both: use the higher of the two
+ const rpmPct =
+ status.rpm.limit > 0
+ ? (status.rpm.current / status.rpm.limit) * 100
+ : 0;
+ const tpmPct =
+ status.tpm.limit > 0
+ ? (status.tpm.current / status.tpm.limit) * 100
+ : 0;
+ usagePercent = Math.max(rpmPct, tpmPct);
+ }
+
+ return {
+ triggered: usagePercent >= condition.thresholdPercent,
+ currentValue: usagePercent,
+ };
+ }
+
+ // Check all active API keys, trigger if any exceed threshold
+ const apiKeys = await listApiKeys();
+ let maxUsagePercent = 0;
+
+ for (const key of apiKeys) {
+ const status = await getRateLimitStatus(key.id, {
+ rpmLimit: key.rpmLimit,
+ tpmLimit: key.tpmLimit,
+ });
+
+ let usagePercent = 0;
+ if (condition.limitType === "rpm") {
+ usagePercent =
+ status.rpm.limit > 0
+ ? (status.rpm.current / status.rpm.limit) * 100
+ : 0;
+ } else if (condition.limitType === "tpm") {
+ usagePercent =
+ status.tpm.limit > 0
+ ? (status.tpm.current / status.tpm.limit) * 100
+ : 0;
+ } else {
+ const rpmPct =
+ status.rpm.limit > 0
+ ? (status.rpm.current / status.rpm.limit) * 100
+ : 0;
+ const tpmPct =
+ status.tpm.limit > 0
+ ? (status.tpm.current / status.tpm.limit) * 100
+ : 0;
+ usagePercent = Math.max(rpmPct, tpmPct);
+ }
+
+ if (usagePercent > maxUsagePercent) {
+ maxUsagePercent = usagePercent;
+ }
+ }
+
+ return {
+ triggered: maxUsagePercent >= condition.thresholdPercent,
+ currentValue: maxUsagePercent,
+ };
+}
+
+/**
+ * Build alert payload based on rule type and evaluation result
+ */
+function buildPayload(
+ rule: AlertRule,
+ currentValue: number,
+): AlertPayload {
+ const condition = rule.condition;
+ let threshold: number;
+ let message: string;
+
+ switch (rule.type) {
+ case "budget": {
+ const c = condition as BudgetCondition;
+ threshold = c.thresholdUsd;
+ message = `Budget alert: $${currentValue.toFixed(4)} spent in last ${c.periodDays} days (threshold: $${threshold})`;
+ break;
+ }
+ case "error_rate": {
+ const c = condition as ErrorRateCondition;
+ threshold = c.thresholdPercent;
+ message = `Error rate alert: ${currentValue.toFixed(1)}% in last ${c.windowMinutes} minutes (threshold: ${threshold}%)`;
+ break;
+ }
+ case "latency": {
+ const c = condition as LatencyCondition;
+ threshold = c.thresholdMs;
+ message = `Latency alert: P${c.percentile} = ${currentValue.toFixed(0)}ms in last ${c.windowMinutes} minutes (threshold: ${threshold}ms)`;
+ break;
+ }
+ case "quota": {
+ const c = condition as QuotaCondition;
+ threshold = c.thresholdPercent;
+ message = `Quota alert: ${currentValue.toFixed(1)}% ${c.limitType} usage (threshold: ${threshold}%)`;
+ break;
+ }
+ default:
+ threshold = 0;
+ message = "Unknown alert type";
+ }
+
+ return {
+ ruleType: rule.type,
+ ruleName: rule.name,
+ message,
+ currentValue,
+ threshold,
+ };
+}
+
+/**
+ * Dispatch alert to all configured channels for a rule
+ */
+async function dispatchAlert(
+ rule: AlertRule,
+ channels: AlertChannel[],
+ payload: AlertPayload,
+): Promise {
+ const ruleChannels = channels.filter((ch) =>
+ rule.channelIds.includes(ch.id),
+ );
+
+ for (const channel of ruleChannels) {
+ if (!channel.enabled) {
+ continue;
+ }
+
+ try {
+ await dispatchToChannel(channel.type, channel.config, payload);
+ await insertAlertHistory({
+ ruleId: rule.id,
+ payload,
+ status: "sent",
+ });
+ } catch (error) {
+ await insertAlertHistory({
+ ruleId: rule.id,
+ payload,
+ status: "failed",
+ });
+ logger.error("Alert dispatch failed", {
+ channelId: channel.id,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ await setCooldown(rule.id, rule.cooldownMinutes);
+}
+
+/**
+ * Evaluate a single alert rule
+ */
+async function evaluateRule(
+ rule: AlertRule,
+): Promise<{ triggered: boolean; currentValue: number }> {
+ switch (rule.type) {
+ case "budget":
+ return evaluateBudget(rule.condition as BudgetCondition);
+ case "error_rate":
+ return evaluateErrorRate(rule.condition as ErrorRateCondition);
+ case "latency":
+ return evaluateLatency(rule.condition as LatencyCondition);
+ case "quota":
+ return evaluateQuota(rule.condition as QuotaCondition);
+ default:
+ return { triggered: false, currentValue: 0 };
+ }
+}
+
+/**
+ * Main evaluation loop - checks all enabled alert rules
+ */
+async function evaluateAlerts(): Promise {
+ try {
+ // Skip built-in evaluation when Grafana handles alerting
+ if (await isGrafanaConnected()) {
+ return;
+ }
+
+ const rules = await listAlertRules();
+ const channels = await listAlertChannels();
+ const enabledRules = rules.filter((r) => r.enabled);
+
+ for (const rule of enabledRules) {
+ try {
+ const inCooldown = await isInCooldown(rule.id);
+ const { triggered, currentValue } = await evaluateRule(rule);
+
+ if (triggered) {
+ const payload = buildPayload(rule, currentValue);
+
+ if (inCooldown) {
+ await insertAlertHistory({
+ ruleId: rule.id,
+ payload,
+ status: "suppressed",
+ });
+ } else {
+ await dispatchAlert(rule, channels, payload);
+ }
+ }
+ } catch (error) {
+ logger.error("Error evaluating alert rule", {
+ ruleId: rule.id,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ } catch (error) {
+ logger.error("Error in alert evaluation loop", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
+
+/**
+ * Start the alert evaluation engine
+ */
+export function startAlertEngine(): void {
+ if (intervalId) {
+ logger.warn("Alert engine already running");
+ return;
+ }
+
+ logger.info(
+ `Starting alert engine (interval: ${ALERT_CHECK_INTERVAL_MS}ms)`,
+ );
+ intervalId = setInterval(() => {
+ void evaluateAlerts();
+ }, ALERT_CHECK_INTERVAL_MS);
+}
+
+/**
+ * Stop the alert evaluation engine
+ */
+export function stopAlertEngine(): void {
+ if (intervalId) {
+ clearInterval(intervalId);
+ intervalId = null;
+ logger.info("Alert engine stopped");
+ }
+}
diff --git a/backend/src/services/grafanaSync.ts b/backend/src/services/grafanaSync.ts
new file mode 100644
index 0000000..262f9f9
--- /dev/null
+++ b/backend/src/services/grafanaSync.ts
@@ -0,0 +1,367 @@
+import { createLogger } from "@/utils/logger";
+import {
+ GrafanaClient,
+ type GrafanaAlertRulePayload,
+ type GrafanaContactPointPayload,
+} from "@/utils/grafanaClient";
+import {
+ listAlertRules,
+ listAlertChannels,
+ getSetting,
+ updateAlertRuleGrafanaSync,
+ updateAlertChannelGrafanaSync,
+ type AlertRule,
+ type AlertChannel,
+} from "@/db";
+import type {
+ BudgetCondition,
+ ErrorRateCondition,
+ LatencyCondition,
+ QuotaCondition,
+ WebhookChannelConfig,
+ EmailChannelConfig,
+ FeishuChannelConfig,
+} from "@/db/schema";
+
+const logger = createLogger("grafanaSync");
+
+const NEXUSGATE_FOLDER = "NexusGate";
+const NEXUSGATE_RULE_GROUP = "nexusgate-alerts";
+const GRAFANA_CONNECTION_KEY = "grafana_connection";
+
+interface GrafanaConnection {
+ apiUrl: string;
+ authToken: string;
+ datasourceUid?: string;
+ verified: boolean;
+ verifiedAt: string | null;
+}
+
+// ============================================
+// Get Grafana Client
+// ============================================
+
+async function getGrafanaClient(): Promise<{
+ client: GrafanaClient;
+ datasourceUid: string;
+} | null> {
+ const setting = await getSetting(GRAFANA_CONNECTION_KEY);
+ if (!setting?.value) {
+ return null;
+ }
+
+ const config = setting.value as GrafanaConnection;
+ if (!config.verified || !config.datasourceUid) {
+ return null;
+ }
+
+ return {
+ client: new GrafanaClient(config.apiUrl, config.authToken),
+ datasourceUid: config.datasourceUid,
+ };
+}
+
+// ============================================
+// PromQL Mapping
+// ============================================
+
+function buildPromQL(rule: AlertRule): {
+ expr: string;
+ threshold: number;
+ forDuration: string;
+} {
+ switch (rule.type) {
+ case "budget": {
+ const c = rule.condition as BudgetCondition;
+ return {
+ expr: `sum(nexusgate_cost_total_usd_total) > ${c.thresholdUsd}`,
+ threshold: c.thresholdUsd,
+ forDuration: "1m",
+ };
+ }
+ case "error_rate": {
+ const c = rule.condition as ErrorRateCondition;
+ const modelFilter = c.model ? `model="${c.model}",` : "";
+ return {
+ expr: `(sum(rate(nexusgate_completions_total{${modelFilter}status="failed"}[${c.windowMinutes}m])) / clamp_min(sum(rate(nexusgate_completions_total{${modelFilter}}[${c.windowMinutes}m])), 1e-10)) * 100 > ${c.thresholdPercent}`,
+ threshold: c.thresholdPercent,
+ forDuration: "1m",
+ };
+ }
+ case "latency": {
+ const c = rule.condition as LatencyCondition;
+ const modelFilter = c.model
+ ? `,model="${c.model}"`
+ : "";
+ const thresholdSec = c.thresholdMs / 1000;
+ return {
+ expr: `histogram_quantile(${c.percentile / 100}, sum(rate(nexusgate_completion_duration_seconds_bucket{${modelFilter}}[${c.windowMinutes}m])) by (le)) > ${thresholdSec}`,
+ threshold: c.thresholdMs,
+ forDuration: "1m",
+ };
+ }
+ case "quota": {
+ const c = rule.condition as QuotaCondition;
+ let expr: string;
+ if (c.limitType === "rpm") {
+ expr = `(nexusgate_api_key_rpm_usage / clamp_min(nexusgate_api_key_rpm_limit, 1)) * 100 > ${c.thresholdPercent}`;
+ } else if (c.limitType === "tpm") {
+ expr = `(nexusgate_api_key_tpm_usage / clamp_min(nexusgate_api_key_tpm_limit, 1)) * 100 > ${c.thresholdPercent}`;
+ } else {
+ expr = `max((nexusgate_api_key_rpm_usage / clamp_min(nexusgate_api_key_rpm_limit, 1)) * 100, (nexusgate_api_key_tpm_usage / clamp_min(nexusgate_api_key_tpm_limit, 1)) * 100) > ${c.thresholdPercent}`;
+ }
+ return {
+ expr,
+ threshold: c.thresholdPercent,
+ forDuration: "1m",
+ };
+ }
+ default:
+ return {
+ expr: "vector(0) > 1",
+ threshold: 0,
+ forDuration: "1m",
+ };
+ }
+}
+
+function buildGrafanaAlertRule(
+ rule: AlertRule,
+ datasourceUid: string,
+ folderUid: string,
+): GrafanaAlertRulePayload {
+ const { expr, forDuration } = buildPromQL(rule);
+
+ return {
+ title: `[NexusGate] ${rule.name}`,
+ ruleGroup: NEXUSGATE_RULE_GROUP,
+ folderUID: folderUid,
+ condition: "B",
+ data: [
+ {
+ refId: "A",
+ relativeTimeRange: { from: 600, to: 0 },
+ datasourceUid,
+ model: {
+ expr,
+ refId: "A",
+ intervalMs: 15000,
+ maxDataPoints: 43200,
+ },
+ },
+ {
+ refId: "B",
+ relativeTimeRange: { from: 600, to: 0 },
+ datasourceUid: "-100",
+ model: {
+ conditions: [
+ {
+ evaluator: { params: [0], type: "gt" },
+ operator: { type: "and" },
+ query: { params: ["A"] },
+ reducer: { params: [], type: "last" },
+ type: "query",
+ },
+ ],
+ datasource: { type: "__expr__", uid: "-100" },
+ expression: "A",
+ type: "threshold",
+ refId: "B",
+ },
+ },
+ ],
+ noDataState: "OK",
+ execErrState: "OK",
+ for: forDuration,
+ labels: {
+ source: "nexusgate",
+ rule_type: rule.type,
+ nexusgate_rule_id: String(rule.id),
+ },
+ annotations: {
+ summary: `NexusGate ${rule.type} alert: ${rule.name}`,
+ },
+ };
+}
+
+// ============================================
+// Channel to Contact Point Mapping
+// ============================================
+
+function buildContactPoint(
+ channel: AlertChannel,
+): GrafanaContactPointPayload {
+ switch (channel.type) {
+ case "webhook": {
+ const c = channel.config as WebhookChannelConfig;
+ return {
+ name: `[NexusGate] ${channel.name}`,
+ type: "webhook",
+ settings: {
+ url: c.url,
+ httpMethod: "POST",
+ ...(c.headers ? { httpHeaders: JSON.stringify(c.headers) } : {}),
+ },
+ };
+ }
+ case "email": {
+ const c = channel.config as EmailChannelConfig;
+ return {
+ name: `[NexusGate] ${channel.name}`,
+ type: "email",
+ settings: {
+ addresses: c.to.join(";"),
+ singleEmail: false,
+ },
+ };
+ }
+ case "feishu": {
+ const c = channel.config as FeishuChannelConfig;
+ return {
+ name: `[NexusGate] ${channel.name}`,
+ type: "webhook",
+ settings: {
+ url: c.webhookUrl,
+ httpMethod: "POST",
+ },
+ };
+ }
+ }
+}
+
+// ============================================
+// Sync Functions
+// ============================================
+
+export interface SyncResult {
+ synced: number;
+ failed: number;
+ errors: Array<{ id: number; name: string; error: string }>;
+}
+
+export async function syncRulesToGrafana(): Promise {
+ const connection = await getGrafanaClient();
+ if (!connection) {
+ throw new Error("Grafana connection not configured or not verified");
+ }
+
+ const { client, datasourceUid } = connection;
+ const folderUid = await client.ensureFolder(NEXUSGATE_FOLDER);
+ const rules = await listAlertRules();
+ const enabledRules = rules.filter((r) => r.enabled);
+
+ const result: SyncResult = { synced: 0, failed: 0, errors: [] };
+
+ for (const rule of enabledRules) {
+ try {
+ const payload = buildGrafanaAlertRule(rule, datasourceUid, folderUid);
+
+ if (rule.grafanaUid) {
+ await client.updateAlertRule(rule.grafanaUid, payload);
+ } else {
+ const created = await client.createAlertRule(payload);
+ await updateAlertRuleGrafanaSync(rule.id, {
+ grafanaUid: created.uid,
+ });
+ }
+
+ await updateAlertRuleGrafanaSync(rule.id, {
+ grafanaSyncedAt: new Date(),
+ grafanaSyncError: null,
+ });
+
+ result.synced++;
+ } catch (error) {
+ const errorMsg =
+ error instanceof Error ? error.message : String(error);
+ await updateAlertRuleGrafanaSync(rule.id, {
+ grafanaSyncError: errorMsg.slice(0, 500),
+ });
+
+ result.failed++;
+ result.errors.push({ id: rule.id, name: rule.name, error: errorMsg });
+ logger.error("Failed to sync rule to Grafana", {
+ ruleId: rule.id,
+ error: errorMsg,
+ });
+ }
+ }
+
+ return result;
+}
+
+export async function syncChannelsToGrafana(): Promise {
+ const connection = await getGrafanaClient();
+ if (!connection) {
+ throw new Error("Grafana connection not configured or not verified");
+ }
+
+ const { client } = connection;
+ const channels = await listAlertChannels();
+ const enabledChannels = channels.filter((c) => c.enabled);
+
+ const result: SyncResult = { synced: 0, failed: 0, errors: [] };
+
+ for (const channel of enabledChannels) {
+ try {
+ const payload = buildContactPoint(channel);
+
+ if (channel.grafanaUid) {
+ await client.updateContactPoint(channel.grafanaUid, payload);
+ } else {
+ const created = await client.createContactPoint(payload);
+ await updateAlertChannelGrafanaSync(channel.id, {
+ grafanaUid: created.uid,
+ });
+ }
+
+ await updateAlertChannelGrafanaSync(channel.id, {
+ grafanaSyncedAt: new Date(),
+ grafanaSyncError: null,
+ });
+
+ result.synced++;
+ } catch (error) {
+ const errorMsg =
+ error instanceof Error ? error.message : String(error);
+ await updateAlertChannelGrafanaSync(channel.id, {
+ grafanaSyncError: errorMsg.slice(0, 500),
+ });
+
+ result.failed++;
+ result.errors.push({
+ id: channel.id,
+ name: channel.name,
+ error: errorMsg,
+ });
+ logger.error("Failed to sync channel to Grafana", {
+ channelId: channel.id,
+ error: errorMsg,
+ });
+ }
+ }
+
+ return result;
+}
+
+export async function syncAllToGrafana(): Promise<{
+ rules: SyncResult;
+ channels: SyncResult;
+}> {
+ const channels = await syncChannelsToGrafana();
+ const rules = await syncRulesToGrafana();
+ return { rules, channels };
+}
+
+/**
+ * Check if Grafana connection is verified.
+ * Used by the alert engine to decide whether to skip built-in evaluation.
+ */
+export async function isGrafanaConnected(): Promise {
+ const setting = await getSetting(GRAFANA_CONNECTION_KEY);
+ if (!setting?.value) {
+ return false;
+ }
+ const config = setting.value as GrafanaConnection;
+ return config.verified;
+}
diff --git a/backend/src/utils/grafanaClient.ts b/backend/src/utils/grafanaClient.ts
new file mode 100644
index 0000000..e222798
--- /dev/null
+++ b/backend/src/utils/grafanaClient.ts
@@ -0,0 +1,208 @@
+import { createLogger } from "./logger";
+
+const logger = createLogger("grafanaClient");
+
+interface GrafanaAlertRulePayload {
+ uid?: string;
+ title: string;
+ ruleGroup: string;
+ folderUID: string;
+ condition: string;
+ data: Array<{
+ refId: string;
+ relativeTimeRange: { from: number; to: number };
+ datasourceUid: string;
+ model: Record;
+ }>;
+ noDataState: "NoData" | "Alerting" | "OK";
+ execErrState: "Alerting" | "OK";
+ for: string;
+ labels: Record;
+ annotations: Record;
+}
+
+interface GrafanaAlertRule extends GrafanaAlertRulePayload {
+ uid: string;
+ id: number;
+ updated: string;
+ provenance: string;
+}
+
+interface GrafanaContactPointPayload {
+ uid?: string;
+ name: string;
+ type: string;
+ settings: Record;
+ disableResolveMessage?: boolean;
+}
+
+interface GrafanaContactPoint extends GrafanaContactPointPayload {
+ uid: string;
+}
+
+interface GrafanaFolder {
+ uid: string;
+ title: string;
+}
+
+export type {
+ GrafanaAlertRulePayload,
+ GrafanaAlertRule,
+ GrafanaContactPointPayload,
+ GrafanaContactPoint,
+};
+
+export class GrafanaClient {
+ constructor(
+ private apiUrl: string,
+ private authToken: string,
+ ) {}
+
+ private async request(
+ path: string,
+ options: Omit & {
+ headers?: Record;
+ } = {},
+ ): Promise {
+ const url = `${this.apiUrl}${path}`;
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.authToken}`,
+ "X-Disable-Provenance": "true",
+ ...options.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Grafana API ${response.status}: ${text}`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return response.json() as Promise;
+ }
+
+ // ============================================
+ // Folders
+ // ============================================
+
+ async ensureFolder(title: string): Promise {
+ // List existing folders and check if one with the title exists
+ const folders = await this.request("/api/folders");
+ const existing = folders.find(
+ (f) => f.title.toLowerCase() === title.toLowerCase(),
+ );
+ if (existing) {
+ return existing.uid;
+ }
+
+ // Create new folder
+ const folder = await this.request("/api/folders", {
+ method: "POST",
+ body: JSON.stringify({ title }),
+ });
+ logger.info(`Created Grafana folder: ${title} (${folder.uid})`);
+ return folder.uid;
+ }
+
+ // ============================================
+ // Alert Rules
+ // ============================================
+
+ async listAlertRules(): Promise {
+ return this.request(
+ "/api/v1/provisioning/alert-rules",
+ );
+ }
+
+ async createAlertRule(
+ rule: GrafanaAlertRulePayload,
+ ): Promise {
+ return this.request(
+ "/api/v1/provisioning/alert-rules",
+ {
+ method: "POST",
+ body: JSON.stringify(rule),
+ },
+ );
+ }
+
+ async updateAlertRule(
+ uid: string,
+ rule: GrafanaAlertRulePayload,
+ ): Promise {
+ await this.request(
+ `/api/v1/provisioning/alert-rules/${uid}`,
+ {
+ method: "PUT",
+ body: JSON.stringify(rule),
+ },
+ );
+ }
+
+ async deleteAlertRule(uid: string): Promise {
+ await this.request(
+ `/api/v1/provisioning/alert-rules/${uid}`,
+ { method: "DELETE" },
+ );
+ }
+
+ // ============================================
+ // Contact Points
+ // ============================================
+
+ async listContactPoints(): Promise {
+ return this.request(
+ "/api/v1/provisioning/contact-points",
+ );
+ }
+
+ async createContactPoint(
+ cp: GrafanaContactPointPayload,
+ ): Promise {
+ return this.request(
+ "/api/v1/provisioning/contact-points",
+ {
+ method: "POST",
+ body: JSON.stringify(cp),
+ },
+ );
+ }
+
+ async updateContactPoint(
+ uid: string,
+ cp: GrafanaContactPointPayload,
+ ): Promise {
+ await this.request(
+ `/api/v1/provisioning/contact-points/${uid}`,
+ {
+ method: "PUT",
+ body: JSON.stringify(cp),
+ },
+ );
+ }
+
+ async deleteContactPoint(uid: string): Promise {
+ await this.request(
+ `/api/v1/provisioning/contact-points/${uid}`,
+ { method: "DELETE" },
+ );
+ }
+
+ // ============================================
+ // Datasources
+ // ============================================
+
+ async findPrometheusDatasource(): Promise {
+ const datasources = await this.request<
+ Array<{ uid: string; type: string; name: string }>
+ >("/api/datasources");
+ const prom = datasources.find((ds) => ds.type === "prometheus");
+ return prom?.uid ?? null;
+ }
+}
diff --git a/bun.lock b/bun.lock
index de961a4..4729978 100644
--- a/bun.lock
+++ b/bun.lock
@@ -24,11 +24,13 @@
"elysia": "^1.4.22",
"ioredis": "^5.9.1",
"loglayer": "^8.4.0",
+ "nodemailer": "^7.0.13",
"zod": "^3.25.76",
},
"devDependencies": {
"@types/bun": "^1.3.6",
"@types/node": "^24.10.9",
+ "@types/nodemailer": "^7.0.9",
"@typescript/native-preview": "7.0.0-dev.20260124.1",
"drizzle-kit": "^0.31.8",
"openai": "^6.16.0",
@@ -719,6 +721,8 @@
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
+ "@types/nodemailer": ["@types/nodemailer@7.0.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow=="],
+
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -1617,6 +1621,8 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
+ "nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="],
+
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
@@ -2233,6 +2239,8 @@
"@types/node-fetch/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
+ "@types/nodemailer/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
+
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -2381,6 +2389,8 @@
"@types/node-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
+ "@types/nodemailer/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
diff --git a/frontend/src/components/app/app-sidebar.tsx b/frontend/src/components/app/app-sidebar.tsx
index c5b8ce6..deed1e4 100644
--- a/frontend/src/components/app/app-sidebar.tsx
+++ b/frontend/src/components/app/app-sidebar.tsx
@@ -8,6 +8,7 @@ import {
LayoutGridIcon,
SettingsIcon,
WaypointsIcon,
+ WrenchIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -51,6 +52,11 @@ const navItems = [
},
{
icon: ,
+ title: i18n.t('components.app.app-sidebar.Models'),
+ href: '/models',
+ },
+ {
+ icon: ,
title: i18n.t('components.app.app-sidebar.Settings'),
href: '/settings',
},
diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1421354
--- /dev/null
+++ b/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/frontend/src/hooks/use-copy.tsx b/frontend/src/hooks/use-copy.tsx
index 3cdef4c..6377516 100644
--- a/frontend/src/hooks/use-copy.tsx
+++ b/frontend/src/hooks/use-copy.tsx
@@ -50,7 +50,7 @@ export function useCopy(opts: UseCopyOptions = {}): UseCopyReturn {
return false
})
},
- [showErrorToast, showSuccessToast, successToastMessage, timeout],
+ [showErrorToast, showSuccessToast, successToastMessage, timeout, t],
)
return { copy, copied }
diff --git a/frontend/src/hooks/use-settings.ts b/frontend/src/hooks/use-settings.ts
index a75c7c2..128b293 100644
--- a/frontend/src/hooks/use-settings.ts
+++ b/frontend/src/hooks/use-settings.ts
@@ -40,3 +40,40 @@ export function useGrafanaDashboardUrl() {
const { data } = useGrafanaDashboards()
return data?.dashboards?.[0]?.url
}
+
+export interface GrafanaConnectionResponse {
+ configured: boolean
+ apiUrl: string | null
+ hasToken: boolean
+ verified: boolean
+ verifiedAt: string | null
+ datasourceUid: string | null
+}
+
+export const grafanaConnectionQueryOptions = queryOptions({
+ queryKey: ['grafanaConnection'],
+ queryFn: async (): Promise => {
+ const { data, error } = await api.admin.grafana.connection.get()
+ if (error) {
+ return { configured: false, apiUrl: null, hasToken: false, verified: false, verifiedAt: null, datasourceUid: null }
+ }
+ return data as GrafanaConnectionResponse
+ },
+ staleTime: 5 * 60 * 1000,
+ retry: false,
+})
+
+export function useGrafanaConnection() {
+ return useQuery(grafanaConnectionQueryOptions)
+}
+
+export const grafanaSyncStatusQueryOptions = () =>
+ queryOptions({
+ queryKey: ['grafanaSyncStatus'],
+ queryFn: async () => {
+ const { data, error } = await api.admin.grafana.sync.status.get()
+ if (error) return null
+ return data
+ },
+ staleTime: 30 * 1000,
+ })
diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json
index 73ab97e..b4874c4 100644
--- a/frontend/src/i18n/locales/en-US.json
+++ b/frontend/src/i18n/locales/en-US.json
@@ -12,7 +12,8 @@
"components.app.app-sidebar.Requests": "Requests",
"components.app.app-sidebar.Embeddings": "Embeddings",
"components.app.app-sidebar.Applications": "Applications",
- "components.app.app-sidebar.Settings": "Models",
+ "components.app.app-sidebar.Models": "Models",
+ "components.app.app-sidebar.Settings": "Settings",
"components.app.app-sidebar.Documentation": "Documentation",
"components.app.app-sidebar.NexusGate": "NexusGate",
"components.app.app-sidebar.LLMGateway": "LLM Gateway",
@@ -326,10 +327,14 @@
"pages.models.registry.WeightConfiguration": "Weight Configuration",
"pages.models.registry.History": "History",
"pages.models.registry.ViewHistory": "View request history",
- "routes.settings.Title": "Models",
- "routes.settings.Description": "Configure model providers and global model integration.",
- "routes.settings.nav.Providers": "Model Providers",
- "routes.settings.nav.Models": "Global Models",
+ "routes.models.Title": "Models",
+ "routes.models.Description": "Configure model providers and global models.",
+ "routes.models.nav.Providers": "Model Providers",
+ "routes.models.nav.Models": "Global Models",
+ "routes.settings.Title": "Settings",
+ "routes.settings.Description": "Configure alerts and integrations.",
+ "routes.settings.nav.Alerts": "Alerts",
+ "routes.settings.nav.Grafana": "Grafana",
"routes.settings.providers.AddNew": "Add New Model Provider",
"routes.settings.providers.ProviderName": "Provider Name",
"routes.settings.providers.ProviderNamePlaceholder": "e.g.: My Local vLLM",
@@ -399,5 +404,113 @@
"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"
+ "pages.overview.grafana.invalidUrl": "Invalid Grafana dashboard URL configured",
+ "pages.settings.alerts.Channels": "Channels",
+ "pages.settings.alerts.Rules": "Rules",
+ "pages.settings.alerts.History": "History",
+ "pages.settings.alerts.AddChannel": "Add Notification Channel",
+ "pages.settings.alerts.ChannelName": "Channel Name",
+ "pages.settings.alerts.ChannelNamePlaceholder": "e.g.: Production Webhook",
+ "pages.settings.alerts.ChannelType": "Channel Type",
+ "pages.settings.alerts.Secret": "Secret (optional)",
+ "pages.settings.alerts.SecretPlaceholder": "HMAC signing secret",
+ "pages.settings.alerts.EmailUser": "Username",
+ "pages.settings.alerts.EmailPassword": "Password",
+ "pages.settings.alerts.EmailFrom": "From Address",
+ "pages.settings.alerts.EmailTo": "To Addresses",
+ "pages.settings.alerts.EmailToPlaceholder": "Comma-separated email addresses",
+ "pages.settings.alerts.Save": "Save",
+ "pages.settings.alerts.ConfiguredChannels": "Configured Channels",
+ "pages.settings.alerts.NoChannels": "No notification channels configured",
+ "pages.settings.alerts.CreatedAt": "Created",
+ "pages.settings.alerts.Test": "Test",
+ "pages.settings.alerts.ChannelCreated": "Channel created",
+ "pages.settings.alerts.CreateChannelFailed": "Failed to create channel",
+ "pages.settings.alerts.ChannelDeleted": "Channel deleted",
+ "pages.settings.alerts.DeleteChannelFailed": "Failed to delete channel",
+ "pages.settings.alerts.TestSent": "Test notification sent",
+ "pages.settings.alerts.TestFailed": "Test notification failed",
+ "pages.settings.alerts.FetchChannelsError": "Failed to fetch alert channels",
+ "pages.settings.alerts.FetchRulesError": "Failed to fetch alert rules",
+ "pages.settings.alerts.FetchHistoryError": "Failed to fetch alert history",
+ "pages.settings.alerts.AddRule": "Add Alert Rule",
+ "pages.settings.alerts.RuleName": "Rule Name",
+ "pages.settings.alerts.RuleNamePlaceholder": "e.g.: High Error Rate",
+ "pages.settings.alerts.RuleType": "Rule Type",
+ "pages.settings.alerts.BudgetThreshold": "Budget Threshold (USD)",
+ "pages.settings.alerts.PeriodDays": "Period (Days)",
+ "pages.settings.alerts.ErrorThreshold": "Error Rate Threshold (%)",
+ "pages.settings.alerts.WindowMinutes": "Window (Minutes)",
+ "pages.settings.alerts.LatencyThreshold": "Latency Threshold (ms)",
+ "pages.settings.alerts.Percentile": "Percentile",
+ "pages.settings.alerts.QuotaThreshold": "Quota Threshold (%)",
+ "pages.settings.alerts.LimitType": "Limit Type",
+ "pages.settings.alerts.NotifyChannels": "Notify Channel",
+ "pages.settings.alerts.SelectChannel": "Select a channel",
+ "pages.settings.alerts.Cooldown": "Cooldown (Minutes)",
+ "pages.settings.alerts.ConfiguredRules": "Configured Rules",
+ "pages.settings.alerts.NoRules": "No alert rules configured",
+ "pages.settings.alerts.Enabled": "Enabled",
+ "pages.settings.alerts.Disabled": "Disabled",
+ "pages.settings.alerts.Enable": "Enable",
+ "pages.settings.alerts.Disable": "Disable",
+ "pages.settings.alerts.CooldownLabel": "Cooldown: {{minutes}} min",
+ "pages.settings.alerts.ChannelsLabel": "Channels",
+ "pages.settings.alerts.RuleCreated": "Rule created",
+ "pages.settings.alerts.CreateRuleFailed": "Failed to create rule",
+ "pages.settings.alerts.RuleDeleted": "Rule deleted",
+ "pages.settings.alerts.DeleteRuleFailed": "Failed to delete rule",
+ "pages.settings.alerts.AlertHistory": "Alert History",
+ "pages.settings.alerts.NoHistory": "No alert history",
+ "pages.settings.grafana.Title": "Grafana Integration",
+ "pages.settings.grafana.Description": "Configure Grafana API connection and dashboard embeds.",
+ "pages.settings.grafana.FetchError": "Failed to fetch Grafana configuration",
+ "pages.settings.grafana.Connection": "API Connection",
+ "pages.settings.grafana.ConnectionDescription": "Connect to your Grafana instance to enable Prometheus-based alerting and dashboard embedding.",
+ "pages.settings.grafana.ApiUrl": "Grafana URL",
+ "pages.settings.grafana.ApiUrlPlaceholder": "e.g.: http://grafana:3000",
+ "pages.settings.grafana.AuthToken": "Service Account Token",
+ "pages.settings.grafana.AuthTokenPlaceholder": "glsa_...",
+ "pages.settings.grafana.Save": "Save",
+ "pages.settings.grafana.TestConnection": "Test Connection",
+ "pages.settings.grafana.Delete": "Remove Connection",
+ "pages.settings.grafana.StatusConnected": "Connected",
+ "pages.settings.grafana.StatusNotConnected": "Not Connected",
+ "pages.settings.grafana.StatusNotConfigured": "Not Configured",
+ "pages.settings.grafana.ConnectionSaved": "Grafana connection saved",
+ "pages.settings.grafana.SaveFailed": "Failed to save Grafana connection",
+ "pages.settings.grafana.TestSuccess": "Connection verified successfully",
+ "pages.settings.grafana.TestFailed": "Connection test failed",
+ "pages.settings.grafana.Deleted": "Grafana connection removed",
+ "pages.settings.grafana.DeleteFailed": "Failed to remove connection",
+ "pages.settings.grafana.DatasourceUid": "Prometheus Datasource",
+ "pages.settings.grafana.VerifiedAt": "Verified",
+ "pages.settings.grafana.Dashboards": "Dashboard Embeds",
+ "pages.settings.grafana.DashboardsDescription": "Configure Grafana dashboard URLs for embedding in the Overview page.",
+ "pages.settings.grafana.DashboardId": "ID",
+ "pages.settings.grafana.DashboardIdPlaceholder": "e.g.: overview",
+ "pages.settings.grafana.DashboardLabel": "Label",
+ "pages.settings.grafana.DashboardLabelPlaceholder": "e.g.: Overview Dashboard",
+ "pages.settings.grafana.DashboardUrl": "URL",
+ "pages.settings.grafana.DashboardUrlPlaceholder": "e.g.: http://grafana:3000/d/nexusgate/overview",
+ "pages.settings.grafana.AddDashboard": "Add Dashboard",
+ "pages.settings.grafana.SaveDashboards": "Save Dashboards",
+ "pages.settings.grafana.ClearDashboards": "Clear All",
+ "pages.settings.grafana.DashboardsSaved": "Dashboard configuration saved",
+ "pages.settings.grafana.DashboardsSaveFailed": "Failed to save dashboard configuration",
+ "pages.settings.grafana.DashboardsCleared": "Dashboard configuration cleared",
+ "pages.settings.grafana.DashboardsClearFailed": "Failed to clear dashboard configuration",
+ "pages.settings.grafana.EnvOverrideWarning": "Dashboard configuration is managed by the GRAFANA_DASHBOARDS environment variable and cannot be modified here.",
+ "pages.settings.grafana.NoDashboards": "No dashboards configured",
+ "pages.settings.alerts.grafana.SyncAll": "Sync All to Grafana",
+ "pages.settings.alerts.grafana.SyncRules": "Sync Rules",
+ "pages.settings.alerts.grafana.SyncChannels": "Sync Channels",
+ "pages.settings.alerts.grafana.Synced": "Synced",
+ "pages.settings.alerts.grafana.NotSynced": "Not Synced",
+ "pages.settings.alerts.grafana.SyncFailed": "Sync Failed",
+ "pages.settings.alerts.grafana.Syncing": "Syncing...",
+ "pages.settings.alerts.grafana.SyncSuccess": "Successfully synced to Grafana",
+ "pages.settings.alerts.grafana.SyncError": "Failed to sync to Grafana",
+ "pages.settings.alerts.grafana.HistoryBanner": "Alert evaluation and history are managed by Grafana when connected.",
+ "pages.settings.alerts.grafana.OpenGrafana": "Open Grafana Alerting"
}
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 2d8c2d8..f91ad2a 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -12,7 +12,8 @@
"components.app.app-sidebar.Requests": "请求",
"components.app.app-sidebar.Embeddings": "向量化",
"components.app.app-sidebar.Applications": "应用",
- "components.app.app-sidebar.Settings": "模型",
+ "components.app.app-sidebar.Models": "模型",
+ "components.app.app-sidebar.Settings": "设置",
"components.app.app-sidebar.Documentation": "文档",
"components.app.app-sidebar.NexusGate": "NexusGate",
"components.app.app-sidebar.LLMGateway": "大语言模型网关",
@@ -327,10 +328,14 @@
"pages.models.registry.WeightConfiguration": "权重配置",
"pages.models.registry.History": "历史",
"pages.models.registry.ViewHistory": "查看请求历史",
- "routes.settings.Title": "模型",
- "routes.settings.Description": "配置模型供应商和全局模型集成。",
- "routes.settings.nav.Providers": "模型供应商",
- "routes.settings.nav.Models": "全局模型",
+ "routes.models.Title": "模型",
+ "routes.models.Description": "配置模型供应商和全局模型。",
+ "routes.models.nav.Providers": "模型供应商",
+ "routes.models.nav.Models": "全局模型",
+ "routes.settings.Title": "设置",
+ "routes.settings.Description": "配置告警和集成。",
+ "routes.settings.nav.Alerts": "告警",
+ "routes.settings.nav.Grafana": "Grafana",
"routes.settings.providers.AddNew": "添加新模型供应商",
"routes.settings.providers.ProviderName": "供应商名称",
"routes.settings.providers.ProviderNamePlaceholder": "例如:My Local vLLM",
@@ -400,5 +405,113 @@
"pages.overview.viewMode.grafana": "Grafana",
"pages.overview.viewMode.label": "视图模式",
"pages.overview.grafana.title": "Grafana 仪表盘",
- "pages.overview.grafana.invalidUrl": "配置的 Grafana 仪表盘 URL 无效"
+ "pages.overview.grafana.invalidUrl": "配置的 Grafana 仪表盘 URL 无效",
+ "pages.settings.alerts.Channels": "通知渠道",
+ "pages.settings.alerts.Rules": "告警规则",
+ "pages.settings.alerts.History": "历史记录",
+ "pages.settings.alerts.AddChannel": "添加通知渠道",
+ "pages.settings.alerts.ChannelName": "渠道名称",
+ "pages.settings.alerts.ChannelNamePlaceholder": "例如:生产环境 Webhook",
+ "pages.settings.alerts.ChannelType": "渠道类型",
+ "pages.settings.alerts.Secret": "密钥(可选)",
+ "pages.settings.alerts.SecretPlaceholder": "HMAC 签名密钥",
+ "pages.settings.alerts.EmailUser": "用户名",
+ "pages.settings.alerts.EmailPassword": "密码",
+ "pages.settings.alerts.EmailFrom": "发件人地址",
+ "pages.settings.alerts.EmailTo": "收件人地址",
+ "pages.settings.alerts.EmailToPlaceholder": "多个地址用逗号分隔",
+ "pages.settings.alerts.Save": "保存",
+ "pages.settings.alerts.ConfiguredChannels": "已配置的通知渠道",
+ "pages.settings.alerts.NoChannels": "暂无通知渠道",
+ "pages.settings.alerts.CreatedAt": "创建于",
+ "pages.settings.alerts.Test": "测试",
+ "pages.settings.alerts.ChannelCreated": "通知渠道已创建",
+ "pages.settings.alerts.CreateChannelFailed": "创建通知渠道失败",
+ "pages.settings.alerts.ChannelDeleted": "通知渠道已删除",
+ "pages.settings.alerts.DeleteChannelFailed": "删除通知渠道失败",
+ "pages.settings.alerts.TestSent": "测试通知已发送",
+ "pages.settings.alerts.TestFailed": "测试通知发送失败",
+ "pages.settings.alerts.FetchChannelsError": "获取告警渠道失败",
+ "pages.settings.alerts.FetchRulesError": "获取告警规则失败",
+ "pages.settings.alerts.FetchHistoryError": "获取告警历史失败",
+ "pages.settings.alerts.AddRule": "添加告警规则",
+ "pages.settings.alerts.RuleName": "规则名称",
+ "pages.settings.alerts.RuleNamePlaceholder": "例如:高错误率告警",
+ "pages.settings.alerts.RuleType": "规则类型",
+ "pages.settings.alerts.BudgetThreshold": "预算阈值 (USD)",
+ "pages.settings.alerts.PeriodDays": "统计周期(天)",
+ "pages.settings.alerts.ErrorThreshold": "错误率阈值 (%)",
+ "pages.settings.alerts.WindowMinutes": "时间窗口(分钟)",
+ "pages.settings.alerts.LatencyThreshold": "延迟阈值 (ms)",
+ "pages.settings.alerts.Percentile": "百分位",
+ "pages.settings.alerts.QuotaThreshold": "配额阈值 (%)",
+ "pages.settings.alerts.LimitType": "限制类型",
+ "pages.settings.alerts.NotifyChannels": "通知渠道",
+ "pages.settings.alerts.SelectChannel": "选择渠道",
+ "pages.settings.alerts.Cooldown": "冷却时间(分钟)",
+ "pages.settings.alerts.ConfiguredRules": "已配置的告警规则",
+ "pages.settings.alerts.NoRules": "暂无告警规则",
+ "pages.settings.alerts.Enabled": "已启用",
+ "pages.settings.alerts.Disabled": "已禁用",
+ "pages.settings.alerts.Enable": "启用",
+ "pages.settings.alerts.Disable": "禁用",
+ "pages.settings.alerts.CooldownLabel": "冷却时间:{{minutes}} 分钟",
+ "pages.settings.alerts.ChannelsLabel": "通知渠道",
+ "pages.settings.alerts.RuleCreated": "告警规则已创建",
+ "pages.settings.alerts.CreateRuleFailed": "创建告警规则失败",
+ "pages.settings.alerts.RuleDeleted": "告警规则已删除",
+ "pages.settings.alerts.DeleteRuleFailed": "删除告警规则失败",
+ "pages.settings.alerts.AlertHistory": "告警历史",
+ "pages.settings.alerts.NoHistory": "暂无告警记录",
+ "pages.settings.grafana.Title": "Grafana 集成",
+ "pages.settings.grafana.Description": "配置 Grafana API 连接和仪表盘嵌入。",
+ "pages.settings.grafana.FetchError": "获取 Grafana 配置失败",
+ "pages.settings.grafana.Connection": "API 连接",
+ "pages.settings.grafana.ConnectionDescription": "连接到您的 Grafana 实例以启用基于 Prometheus 的告警和仪表盘嵌入。",
+ "pages.settings.grafana.ApiUrl": "Grafana 地址",
+ "pages.settings.grafana.ApiUrlPlaceholder": "例如:http://grafana:3000",
+ "pages.settings.grafana.AuthToken": "服务账号令牌",
+ "pages.settings.grafana.AuthTokenPlaceholder": "glsa_...",
+ "pages.settings.grafana.Save": "保存",
+ "pages.settings.grafana.TestConnection": "测试连接",
+ "pages.settings.grafana.Delete": "移除连接",
+ "pages.settings.grafana.StatusConnected": "已连接",
+ "pages.settings.grafana.StatusNotConnected": "未连接",
+ "pages.settings.grafana.StatusNotConfigured": "未配置",
+ "pages.settings.grafana.ConnectionSaved": "Grafana 连接已保存",
+ "pages.settings.grafana.SaveFailed": "保存 Grafana 连接失败",
+ "pages.settings.grafana.TestSuccess": "连接验证成功",
+ "pages.settings.grafana.TestFailed": "连接测试失败",
+ "pages.settings.grafana.Deleted": "Grafana 连接已移除",
+ "pages.settings.grafana.DeleteFailed": "移除连接失败",
+ "pages.settings.grafana.DatasourceUid": "Prometheus 数据源",
+ "pages.settings.grafana.VerifiedAt": "验证时间",
+ "pages.settings.grafana.Dashboards": "仪表盘嵌入",
+ "pages.settings.grafana.DashboardsDescription": "配置 Grafana 仪表盘 URL 以嵌入到概览页面。",
+ "pages.settings.grafana.DashboardId": "ID",
+ "pages.settings.grafana.DashboardIdPlaceholder": "例如:overview",
+ "pages.settings.grafana.DashboardLabel": "标签",
+ "pages.settings.grafana.DashboardLabelPlaceholder": "例如:概览仪表盘",
+ "pages.settings.grafana.DashboardUrl": "地址",
+ "pages.settings.grafana.DashboardUrlPlaceholder": "例如:http://grafana:3000/d/nexusgate/overview",
+ "pages.settings.grafana.AddDashboard": "添加仪表盘",
+ "pages.settings.grafana.SaveDashboards": "保存仪表盘",
+ "pages.settings.grafana.ClearDashboards": "清除全部",
+ "pages.settings.grafana.DashboardsSaved": "仪表盘配置已保存",
+ "pages.settings.grafana.DashboardsSaveFailed": "保存仪表盘配置失败",
+ "pages.settings.grafana.DashboardsCleared": "仪表盘配置已清除",
+ "pages.settings.grafana.DashboardsClearFailed": "清除仪表盘配置失败",
+ "pages.settings.grafana.EnvOverrideWarning": "仪表盘配置由 GRAFANA_DASHBOARDS 环境变量管理,无法在此修改。",
+ "pages.settings.grafana.NoDashboards": "未配置仪表盘",
+ "pages.settings.alerts.grafana.SyncAll": "同步到 Grafana",
+ "pages.settings.alerts.grafana.SyncRules": "同步规则",
+ "pages.settings.alerts.grafana.SyncChannels": "同步渠道",
+ "pages.settings.alerts.grafana.Synced": "已同步",
+ "pages.settings.alerts.grafana.NotSynced": "未同步",
+ "pages.settings.alerts.grafana.SyncFailed": "同步失败",
+ "pages.settings.alerts.grafana.Syncing": "同步中...",
+ "pages.settings.alerts.grafana.SyncSuccess": "已成功同步到 Grafana",
+ "pages.settings.alerts.grafana.SyncError": "同步到 Grafana 失败",
+ "pages.settings.alerts.grafana.HistoryBanner": "连接 Grafana 后,告警评估和历史记录由 Grafana 管理。",
+ "pages.settings.alerts.grafana.OpenGrafana": "打开 Grafana 告警"
}
diff --git a/frontend/src/pages/settings/alerts-settings-page.tsx b/frontend/src/pages/settings/alerts-settings-page.tsx
new file mode 100644
index 0000000..9959434
--- /dev/null
+++ b/frontend/src/pages/settings/alerts-settings-page.tsx
@@ -0,0 +1,1193 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { formatDistanceToNow } from 'date-fns'
+import {
+ BellIcon,
+ CheckCircleIcon,
+ ExternalLinkIcon,
+ InfoIcon,
+ Loader2Icon,
+ MailIcon,
+ MessageSquareIcon,
+ PlusIcon,
+ RefreshCwIcon,
+ SendIcon,
+ TrashIcon,
+ WebhookIcon,
+ XCircleIcon,
+ XIcon,
+} from 'lucide-react'
+import { useForm } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { z } from 'zod'
+
+import { api } from '@/lib/api'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent } from '@/components/ui/card'
+import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { grafanaSyncStatusQueryOptions } from '@/hooks/use-settings'
+
+// ============================================
+// Types
+// ============================================
+
+interface AlertChannel {
+ id: number
+ name: string
+ type: 'webhook' | 'email' | 'feishu'
+ config: Record
+ enabled: boolean
+ createdAt: string
+}
+
+interface AlertRule {
+ id: number
+ name: string
+ type: 'budget' | 'error_rate' | 'latency' | 'quota'
+ condition: Record
+ channelIds: number[]
+ cooldownMinutes: number
+ enabled: boolean
+ createdAt: string
+}
+
+interface AlertHistoryItem {
+ id: number
+ ruleId: number
+ triggeredAt: string
+ payload: {
+ ruleType: string
+ ruleName: string
+ message: string
+ currentValue: number
+ threshold: number
+ }
+ status: 'sent' | 'failed' | 'suppressed'
+}
+
+interface SyncStatusItem {
+ id: number
+ name: string
+ enabled: boolean
+ grafanaUid: string | null
+ grafanaSyncedAt: string | null
+ grafanaSyncError: string | null
+}
+
+interface AlertsSettingsPageProps {
+ channels: AlertChannel[]
+ rules: AlertRule[]
+ history: { data: AlertHistoryItem[]; total: number; from: number }
+ grafanaConnected: boolean
+ grafanaApiUrl: string | null
+}
+
+// ============================================
+// Channel Form
+// ============================================
+
+const CHANNEL_TYPES = ['webhook', 'email', 'feishu'] as const
+
+const channelSchema = z.object({
+ name: z.string().min(1).max(100),
+ type: z.enum(CHANNEL_TYPES),
+ // Webhook fields
+ webhookUrl: z.string().optional(),
+ webhookSecret: z.string().optional(),
+ // Email fields
+ emailHost: z.string().optional(),
+ emailPort: z.coerce.number().optional(),
+ emailUser: z.string().optional(),
+ emailPassword: z.string().optional(),
+ emailFrom: z.string().optional(),
+ emailTo: z.string().optional(),
+ // Feishu fields
+ feishuWebhookUrl: z.string().optional(),
+ feishuSecret: z.string().optional(),
+})
+
+type ChannelFormValues = z.infer
+
+function buildChannelConfig(values: ChannelFormValues): Record {
+ switch (values.type) {
+ case 'webhook':
+ return {
+ url: values.webhookUrl || '',
+ secret: values.webhookSecret || undefined,
+ }
+ case 'email':
+ return {
+ host: values.emailHost || '',
+ port: values.emailPort || 587,
+ user: values.emailUser || '',
+ password: values.emailPassword || '',
+ from: values.emailFrom || '',
+ to: values.emailTo?.split(',').map((s) => s.trim()) || [],
+ }
+ case 'feishu':
+ return {
+ webhookUrl: values.feishuWebhookUrl || '',
+ secret: values.feishuSecret || undefined,
+ }
+ }
+}
+
+// ============================================
+// Rule Form
+// ============================================
+
+const RULE_TYPES = ['budget', 'error_rate', 'latency', 'quota'] as const
+
+const RULE_TYPE_LABELS: Record = {
+ budget: 'Budget',
+ error_rate: 'Error Rate',
+ latency: 'Latency',
+ quota: 'Quota',
+}
+
+const ruleSchema = z.object({
+ name: z.string().min(1).max(100),
+ type: z.enum(RULE_TYPES),
+ channelIds: z.string().min(1),
+ cooldownMinutes: z.coerce.number().min(1).default(60),
+ // Budget
+ thresholdUsd: z.coerce.number().optional(),
+ periodDays: z.coerce.number().optional(),
+ // Error rate
+ errorThresholdPercent: z.coerce.number().optional(),
+ errorWindowMinutes: z.coerce.number().optional(),
+ // Latency
+ latencyThresholdMs: z.coerce.number().optional(),
+ latencyPercentile: z.coerce.number().optional(),
+ latencyWindowMinutes: z.coerce.number().optional(),
+ // Quota
+ quotaThresholdPercent: z.coerce.number().optional(),
+ quotaLimitType: z.string().optional(),
+})
+
+type RuleFormValues = z.infer
+
+function buildRuleCondition(values: RuleFormValues): Record {
+ switch (values.type) {
+ case 'budget':
+ return {
+ thresholdUsd: values.thresholdUsd || 0,
+ periodDays: values.periodDays || 30,
+ }
+ case 'error_rate':
+ return {
+ thresholdPercent: values.errorThresholdPercent || 0,
+ windowMinutes: values.errorWindowMinutes || 5,
+ }
+ case 'latency':
+ return {
+ thresholdMs: values.latencyThresholdMs || 0,
+ percentile: values.latencyPercentile || 95,
+ windowMinutes: values.latencyWindowMinutes || 5,
+ }
+ case 'quota':
+ return {
+ thresholdPercent: values.quotaThresholdPercent || 0,
+ limitType: values.quotaLimitType || 'both',
+ }
+ }
+}
+
+// ============================================
+// Sync Badge Component
+// ============================================
+
+function SyncBadge({ syncStatus }: { syncStatus?: SyncStatusItem }) {
+ const { t } = useTranslation()
+
+ if (!syncStatus) {
+ return (
+
+
+ {t('pages.settings.alerts.grafana.NotSynced')}
+
+ )
+ }
+
+ if (syncStatus.grafanaSyncError) {
+ return (
+
+
+ {t('pages.settings.alerts.grafana.SyncFailed')}
+
+ )
+ }
+
+ if (syncStatus.grafanaSyncedAt) {
+ return (
+
+
+ {t('pages.settings.alerts.grafana.Synced')}
+
+ )
+ }
+
+ return (
+
+
+ {t('pages.settings.alerts.grafana.NotSynced')}
+
+ )
+}
+
+// ============================================
+// Main Page Component
+// ============================================
+
+export function AlertsSettingsPage({ channels, rules, history, grafanaConnected, grafanaApiUrl }: AlertsSettingsPageProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ {grafanaConnected && (
+
+
+
+ {t('pages.settings.alerts.grafana.HistoryBanner')}
+
+
+ )}
+
+
+
+ {t('pages.settings.alerts.Channels')}
+ {t('pages.settings.alerts.Rules')}
+ {t('pages.settings.alerts.History')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ============================================
+// Channels Tab
+// ============================================
+
+function ChannelsTab({ channels, grafanaConnected }: { channels: AlertChannel[]; grafanaConnected: boolean }) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+
+ const { data: syncStatus } = useQuery({
+ ...grafanaSyncStatusQueryOptions(),
+ enabled: grafanaConnected,
+ })
+
+ const form = useForm({
+ resolver: zodResolver(channelSchema),
+ defaultValues: {
+ name: '',
+ type: 'webhook',
+ webhookUrl: '',
+ webhookSecret: '',
+ emailHost: '',
+ emailPort: 587,
+ emailUser: '',
+ emailPassword: '',
+ emailFrom: '',
+ emailTo: '',
+ feishuWebhookUrl: '',
+ feishuSecret: '',
+ },
+ })
+
+ const watchType = form.watch('type')
+
+ const createMutation = useMutation({
+ mutationFn: async (values: ChannelFormValues) => {
+ const config = buildChannelConfig(values)
+ const { data, error } = await api.admin.alerts.channels.post({
+ name: values.name,
+ type: values.type,
+ config,
+ })
+ if (error) throw error
+ return data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['alertChannels'] })
+ toast.success(t('pages.settings.alerts.ChannelCreated'))
+ form.reset()
+ },
+ onError: () => {
+ toast.error(t('pages.settings.alerts.CreateChannelFailed'))
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: number) => {
+ const { error } = await api.admin.alerts.channels({ id }).delete()
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['alertChannels'] })
+ toast.success(t('pages.settings.alerts.ChannelDeleted'))
+ },
+ onError: () => {
+ toast.error(t('pages.settings.alerts.DeleteChannelFailed'))
+ },
+ })
+
+ const testMutation = useMutation({
+ mutationFn: async (id: number) => {
+ const { data, error } = await api.admin.alerts.channels({ id }).test.post()
+ if (error) throw error
+ return data
+ },
+ onSuccess: () => {
+ toast.success(t('pages.settings.alerts.TestSent'))
+ },
+ onError: () => {
+ toast.error(t('pages.settings.alerts.TestFailed'))
+ },
+ })
+
+ const syncChannelsMutation = useMutation({
+ mutationFn: async () => {
+ const { data, error } = await api.admin.grafana.sync.channels.post()
+ if (error) throw error
+ return data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['grafanaSyncStatus'] })
+ toast.success(t('pages.settings.alerts.grafana.SyncSuccess'))
+ },
+ onError: () => {
+ toast.error(t('pages.settings.alerts.grafana.SyncError'))
+ },
+ })
+
+ const onSubmit = (values: ChannelFormValues) => {
+ createMutation.mutate(values)
+ }
+
+ const channelTypeIcon = (type: string) => {
+ switch (type) {
+ case 'webhook':
+ return
+ case 'email':
+ return
+ case 'feishu':
+ return
+ default:
+ return
+ }
+ }
+
+ const getChannelSyncStatus = (id: number): SyncStatusItem | undefined => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (syncStatus as any)?.channels?.find((c: SyncStatusItem) => c.id === id)
+ }
+
+ return (
+
+ {grafanaConnected && (
+
+
+
+ )}
+
+
+
+
+
+
{t('pages.settings.alerts.AddChannel')}
+
+
+
+
+
+
+
+
+
+ {t('pages.settings.alerts.ConfiguredChannels')}
+
+
+ {channels.map((channel) => (
+
+
+
+
+ {channelTypeIcon(channel.type)}
+
+
+
+ {channel.name}
+
+ {channel.type}
+
+ {grafanaConnected && }
+
+
+ {t('pages.settings.alerts.CreatedAt')}{' '}
+ {formatDistanceToNow(new Date(channel.createdAt), { addSuffix: true })}
+
+
+
+
+
+
+
+
+
+ ))}
+ {channels.length === 0 && (
+
+ {t('pages.settings.alerts.NoChannels')}
+
+ )}
+
+
+
+ )
+}
+
+// ============================================
+// Rules Tab
+// ============================================
+
+function RulesTab({
+ rules,
+ channels,
+ grafanaConnected,
+}: {
+ rules: AlertRule[]
+ channels: AlertChannel[]
+ grafanaConnected: boolean
+}) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+
+ const { data: syncStatus } = useQuery({
+ ...grafanaSyncStatusQueryOptions(),
+ enabled: grafanaConnected,
+ })
+
+ const form = useForm({
+ resolver: zodResolver(ruleSchema),
+ defaultValues: {
+ name: '',
+ type: 'error_rate',
+ channelIds: '',
+ cooldownMinutes: 60,
+ thresholdUsd: 100,
+ periodDays: 30,
+ errorThresholdPercent: 10,
+ errorWindowMinutes: 5,
+ latencyThresholdMs: 5000,
+ latencyPercentile: 95,
+ latencyWindowMinutes: 5,
+ quotaThresholdPercent: 80,
+ quotaLimitType: 'both',
+ },
+ })
+
+ const watchType = form.watch('type')
+
+ const createMutation = useMutation({
+ mutationFn: async (values: RuleFormValues) => {
+ const condition = buildRuleCondition(values)
+ const channelIds = values.channelIds.split(',').map((s) => Number(s.trim())).filter(Boolean)
+ const { data, error } = await api.admin.alerts.rules.post({
+ name: values.name,
+ type: values.type,
+ condition,
+ channelIds,
+ cooldownMinutes: values.cooldownMinutes,
+ })
+ if (error) throw error
+ return data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['alertRules'] })
+ toast.success(t('pages.settings.alerts.RuleCreated'))
+ form.reset()
+ },
+ onError: () => {
+ toast.error(t('pages.settings.alerts.CreateRuleFailed'))
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: number) => {
+ const { error } = await api.admin.alerts.rules({ id }).delete()
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['alertRules'] })
+ toast.success(t('pages.settings.alerts.RuleDeleted'))
+ },
+ onError: () => {
+ toast.error(t('pages.settings.alerts.DeleteRuleFailed'))
+ },
+ })
+
+ const toggleMutation = useMutation({
+ mutationFn: async ({ id, enabled }: { id: number; enabled: boolean }) => {
+ const { error } = await api.admin.alerts.rules({ id }).put({ enabled })
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['alertRules'] })
+ },
+ })
+
+ const syncRulesMutation = useMutation({
+ mutationFn: async () => {
+ const { data, error } = await api.admin.grafana.sync.rules.post()
+ if (error) throw error
+ return data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['grafanaSyncStatus'] })
+ toast.success(t('pages.settings.alerts.grafana.SyncSuccess'))
+ },
+ onError: () => {
+ toast.error(t('pages.settings.alerts.grafana.SyncError'))
+ },
+ })
+
+ const onSubmit = (values: RuleFormValues) => {
+ createMutation.mutate(values)
+ }
+
+ const getChannelName = (id: number) => {
+ return channels.find((c) => c.id === id)?.name ?? `#${id}`
+ }
+
+ const getRuleSyncStatus = (id: number): SyncStatusItem | undefined => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (syncStatus as any)?.rules?.find((r: SyncStatusItem) => r.id === id)
+ }
+
+ return (
+
+ {grafanaConnected && (
+
+
+
+ )}
+
+
+
+
+
+
{t('pages.settings.alerts.AddRule')}
+
+
+
+
+
+
+
+
+
+ {t('pages.settings.alerts.ConfiguredRules')}
+
+
+ {rules.map((rule) => (
+
+
+
+
+
+
+
+
+ {rule.name}
+ {RULE_TYPE_LABELS[rule.type] ?? rule.type}
+
+ {rule.enabled ? t('pages.settings.alerts.Enabled') : t('pages.settings.alerts.Disabled')}
+
+ {grafanaConnected && }
+
+
+ {t('pages.settings.alerts.CooldownLabel', { minutes: rule.cooldownMinutes })}
+ ·
+ {t('pages.settings.alerts.ChannelsLabel')}: {rule.channelIds.map(getChannelName).join(', ')}
+
+
+
+
+
+
+
+
+
+ ))}
+ {rules.length === 0 && (
+
+ {t('pages.settings.alerts.NoRules')}
+
+ )}
+
+
+
+ )
+}
+
+// ============================================
+// History Tab
+// ============================================
+
+function HistoryTab({
+ history,
+ rules,
+ grafanaConnected,
+ grafanaApiUrl,
+}: {
+ history: { data: AlertHistoryItem[]; total: number; from: number }
+ rules: AlertRule[]
+ grafanaConnected: boolean
+ grafanaApiUrl: string | null
+}) {
+ const { t } = useTranslation()
+
+ const getRuleName = (ruleId: number) => {
+ return rules.find((r) => r.id === ruleId)?.name ?? `Rule #${ruleId}`
+ }
+
+ const statusBadgeVariant = (status: string): 'default' | 'destructive' | 'secondary' => {
+ switch (status) {
+ case 'sent':
+ return 'default'
+ case 'failed':
+ return 'destructive'
+ case 'suppressed':
+ return 'secondary'
+ default:
+ return 'secondary'
+ }
+ }
+
+ return (
+
+ {grafanaConnected && grafanaApiUrl && (
+
+
+
+ {t('pages.settings.alerts.grafana.HistoryBanner')}
+
+
+
+ )}
+
+
+ {t('pages.settings.alerts.AlertHistory')} ({history.total})
+
+ {history.data.length === 0 ? (
+
+ {t('pages.settings.alerts.NoHistory')}
+
+ ) : (
+
+ {history.data.map((item) => (
+
+
+
+
+ {item.payload.ruleName || getRuleName(item.ruleId)}
+ {item.status}
+ {item.payload.ruleType}
+
+
{item.payload.message}
+
+
+ {formatDistanceToNow(new Date(item.triggeredAt), { addSuffix: true })}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/settings/grafana-settings-page.tsx b/frontend/src/pages/settings/grafana-settings-page.tsx
new file mode 100644
index 0000000..3befea9
--- /dev/null
+++ b/frontend/src/pages/settings/grafana-settings-page.tsx
@@ -0,0 +1,398 @@
+import { useState } from 'react'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { formatDistanceToNow } from 'date-fns'
+import { CheckCircle2Icon, PlusIcon, Trash2Icon, XCircleIcon, CircleDashedIcon } from 'lucide-react'
+import { useFieldArray, useForm } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { z } from 'zod'
+
+import { api } from '@/lib/api'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import type { GrafanaConnectionResponse, GrafanaDashboard } from '@/hooks/use-settings'
+
+// ============================================
+// Connection Form
+// ============================================
+
+const connectionSchema = z.object({
+ apiUrl: z.string().url('Must be a valid URL'),
+ authToken: z.string().min(1, 'Token is required'),
+})
+
+type ConnectionFormValues = z.infer
+
+// ============================================
+// Dashboard Form
+// ============================================
+
+const dashboardEntrySchema = z.object({
+ id: z.string().min(1, 'ID is required'),
+ label: z.string().min(1, 'Label is required'),
+ url: z.string().url('Must be a valid URL'),
+})
+
+const dashboardsFormSchema = z.object({
+ dashboards: z.array(dashboardEntrySchema),
+})
+
+type DashboardsFormValues = z.infer
+
+// ============================================
+// Main Page Component
+// ============================================
+
+interface GrafanaSettingsPageProps {
+ connection: GrafanaConnectionResponse
+ dashboards: GrafanaDashboard[]
+ envOverride: boolean
+}
+
+export function GrafanaSettingsPage({ connection, dashboards, envOverride }: GrafanaSettingsPageProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
{t('pages.settings.grafana.Title')}
+
{t('pages.settings.grafana.Description')}
+
+
+
+
+ )
+}
+
+// ============================================
+// Connection Card
+// ============================================
+
+function ConnectionCard({ connection }: { connection: GrafanaConnectionResponse }) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const [isTesting, setIsTesting] = useState(false)
+
+ const form = useForm({
+ resolver: zodResolver(connectionSchema),
+ defaultValues: {
+ apiUrl: connection.apiUrl ?? '',
+ authToken: '',
+ },
+ })
+
+ const saveMutation = useMutation({
+ mutationFn: async (values: ConnectionFormValues) => {
+ const { error } = await api.admin.grafana.connection.put(values)
+ if (error) {
+ throw new Error(typeof error === 'object' && 'error' in error ? String(error.error) : 'Save failed')
+ }
+ },
+ onSuccess: () => {
+ toast.success(t('pages.settings.grafana.ConnectionSaved'))
+ queryClient.invalidateQueries({ queryKey: ['grafanaConnection'] })
+ },
+ onError: () => {
+ toast.error(t('pages.settings.grafana.SaveFailed'))
+ },
+ })
+
+ const testMutation = useMutation({
+ mutationFn: async () => {
+ setIsTesting(true)
+ const { error } = await api.admin.grafana.connection.test.post()
+ if (error) {
+ throw new Error(typeof error === 'object' && 'error' in error ? String(error.error) : 'Test failed')
+ }
+ },
+ onSuccess: () => {
+ toast.success(t('pages.settings.grafana.TestSuccess'))
+ queryClient.invalidateQueries({ queryKey: ['grafanaConnection'] })
+ },
+ onError: (error) => {
+ toast.error(t('pages.settings.grafana.TestFailed'), { description: error.message })
+ },
+ onSettled: () => {
+ setIsTesting(false)
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: async () => {
+ const { error } = await api.admin.grafana.connection.delete()
+ if (error) {
+ throw new Error('Delete failed')
+ }
+ },
+ onSuccess: () => {
+ toast.success(t('pages.settings.grafana.Deleted'))
+ form.reset({ apiUrl: '', authToken: '' })
+ queryClient.invalidateQueries({ queryKey: ['grafanaConnection'] })
+ },
+ onError: () => {
+ toast.error(t('pages.settings.grafana.DeleteFailed'))
+ },
+ })
+
+ return (
+
+
+
+
+ {t('pages.settings.grafana.Connection')}
+ {t('pages.settings.grafana.ConnectionDescription')}
+
+
+
+
+
+ {connection.verified && (
+
+ {connection.datasourceUid && (
+
+ {t('pages.settings.grafana.DatasourceUid')}: {connection.datasourceUid}
+
+ )}
+ {connection.verifiedAt && (
+
+ {t('pages.settings.grafana.VerifiedAt')}: {formatDistanceToNow(new Date(connection.verifiedAt), { addSuffix: true })}
+
+ )}
+
+ )}
+
+
+
+
+
+ )
+}
+
+function ConnectionStatusBadge({ connection }: { connection: GrafanaConnectionResponse }) {
+ const { t } = useTranslation()
+
+ if (!connection.configured) {
+ return (
+
+
+ {t('pages.settings.grafana.StatusNotConfigured')}
+
+ )
+ }
+ if (connection.verified) {
+ return (
+
+
+ {t('pages.settings.grafana.StatusConnected')}
+
+ )
+ }
+ return (
+
+
+ {t('pages.settings.grafana.StatusNotConnected')}
+
+ )
+}
+
+// ============================================
+// Dashboards Card
+// ============================================
+
+function DashboardsCard({ dashboards, envOverride }: { dashboards: GrafanaDashboard[]; envOverride: boolean }) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+
+ const form = useForm({
+ resolver: zodResolver(dashboardsFormSchema),
+ defaultValues: {
+ dashboards: dashboards.length > 0 ? dashboards : [],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: 'dashboards',
+ })
+
+ const saveMutation = useMutation({
+ mutationFn: async (values: DashboardsFormValues) => {
+ const { error } = await api.admin.dashboards.put({ dashboards: values.dashboards })
+ if (error) {
+ throw new Error(typeof error === 'object' && 'error' in error ? String(error.error) : 'Save failed')
+ }
+ },
+ onSuccess: () => {
+ toast.success(t('pages.settings.grafana.DashboardsSaved'))
+ queryClient.invalidateQueries({ queryKey: ['dashboards'] })
+ },
+ onError: () => {
+ toast.error(t('pages.settings.grafana.DashboardsSaveFailed'))
+ },
+ })
+
+ const clearMutation = useMutation({
+ mutationFn: async () => {
+ const { error } = await api.admin.dashboards.delete()
+ if (error) {
+ throw new Error('Clear failed')
+ }
+ },
+ onSuccess: () => {
+ toast.success(t('pages.settings.grafana.DashboardsCleared'))
+ form.reset({ dashboards: [] })
+ queryClient.invalidateQueries({ queryKey: ['dashboards'] })
+ },
+ onError: () => {
+ toast.error(t('pages.settings.grafana.DashboardsClearFailed'))
+ },
+ })
+
+ return (
+
+
+ {t('pages.settings.grafana.Dashboards')}
+ {t('pages.settings.grafana.DashboardsDescription')}
+
+
+ {envOverride && (
+
+ {t('pages.settings.grafana.EnvOverrideWarning')}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 41a350c..3e8e61d 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -9,18 +9,22 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as DashboardIndexRouteImport } from './routes/_dashboard/index'
-import { Route as DashboardRouteRouteImport } from './routes/_dashboard/route'
-import { Route as AppsIndexRouteImport } from './routes/apps/index'
-import { Route as AppsRouteRouteImport } from './routes/apps/route'
-import { Route as EmbeddingsIndexRouteImport } from './routes/embeddings/index'
-import { Route as EmbeddingsRouteRouteImport } from './routes/embeddings/route'
-import { Route as RequestsIndexRouteImport } from './routes/requests/index'
+import { Route as SettingsRouteRouteImport } from './routes/settings/route'
import { Route as RequestsRouteRouteImport } from './routes/requests/route'
+import { Route as ModelsRouteRouteImport } from './routes/models/route'
+import { Route as EmbeddingsRouteRouteImport } from './routes/embeddings/route'
+import { Route as AppsRouteRouteImport } from './routes/apps/route'
+import { Route as DashboardRouteRouteImport } from './routes/_dashboard/route'
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
-import { Route as SettingsModelsRouteImport } from './routes/settings/models'
-import { Route as SettingsProvidersRouteImport } from './routes/settings/providers'
-import { Route as SettingsRouteRouteImport } from './routes/settings/route'
+import { Route as RequestsIndexRouteImport } from './routes/requests/index'
+import { Route as ModelsIndexRouteImport } from './routes/models/index'
+import { Route as EmbeddingsIndexRouteImport } from './routes/embeddings/index'
+import { Route as AppsIndexRouteImport } from './routes/apps/index'
+import { Route as DashboardIndexRouteImport } from './routes/_dashboard/index'
+import { Route as SettingsGrafanaRouteImport } from './routes/settings/grafana'
+import { Route as SettingsAlertsRouteImport } from './routes/settings/alerts'
+import { Route as ModelsRegistryRouteImport } from './routes/models/registry'
+import { Route as ModelsProvidersRouteImport } from './routes/models/providers'
const SettingsRouteRoute = SettingsRouteRouteImport.update({
id: '/settings',
@@ -32,6 +36,11 @@ const RequestsRouteRoute = RequestsRouteRouteImport.update({
path: '/requests',
getParentRoute: () => rootRouteImport,
} as any)
+const ModelsRouteRoute = ModelsRouteRouteImport.update({
+ id: '/models',
+ path: '/models',
+ getParentRoute: () => rootRouteImport,
+} as any)
const EmbeddingsRouteRoute = EmbeddingsRouteRouteImport.update({
id: '/embeddings',
path: '/embeddings',
@@ -56,6 +65,11 @@ const RequestsIndexRoute = RequestsIndexRouteImport.update({
path: '/',
getParentRoute: () => RequestsRouteRoute,
} as any)
+const ModelsIndexRoute = ModelsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => ModelsRouteRoute,
+} as any)
const EmbeddingsIndexRoute = EmbeddingsIndexRouteImport.update({
id: '/',
path: '/',
@@ -71,36 +85,53 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
path: '/',
getParentRoute: () => DashboardRouteRoute,
} as any)
-const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
- id: '/providers',
- path: '/providers',
+const SettingsGrafanaRoute = SettingsGrafanaRouteImport.update({
+ id: '/grafana',
+ path: '/grafana',
getParentRoute: () => SettingsRouteRoute,
} as any)
-const SettingsModelsRoute = SettingsModelsRouteImport.update({
- id: '/models',
- path: '/models',
+const SettingsAlertsRoute = SettingsAlertsRouteImport.update({
+ id: '/alerts',
+ path: '/alerts',
getParentRoute: () => SettingsRouteRoute,
} as any)
+const ModelsRegistryRoute = ModelsRegistryRouteImport.update({
+ id: '/registry',
+ path: '/registry',
+ getParentRoute: () => ModelsRouteRoute,
+} as any)
+const ModelsProvidersRoute = ModelsProvidersRouteImport.update({
+ id: '/providers',
+ path: '/providers',
+ getParentRoute: () => ModelsRouteRoute,
+} as any)
export interface FileRoutesByFullPath {
'/apps': typeof AppsRouteRouteWithChildren
'/embeddings': typeof EmbeddingsRouteRouteWithChildren
+ '/models': typeof ModelsRouteRouteWithChildren
'/requests': typeof RequestsRouteRouteWithChildren
'/settings': typeof SettingsRouteRouteWithChildren
- '/settings/models': typeof SettingsModelsRoute
- '/settings/providers': typeof SettingsProvidersRoute
+ '/models/providers': typeof ModelsProvidersRoute
+ '/models/registry': typeof ModelsRegistryRoute
+ '/settings/alerts': typeof SettingsAlertsRoute
+ '/settings/grafana': typeof SettingsGrafanaRoute
'/': typeof DashboardIndexRoute
'/apps/': typeof AppsIndexRoute
'/embeddings/': typeof EmbeddingsIndexRoute
+ '/models/': typeof ModelsIndexRoute
'/requests/': typeof RequestsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRoutesByTo {
- '/settings/models': typeof SettingsModelsRoute
- '/settings/providers': typeof SettingsProvidersRoute
+ '/models/providers': typeof ModelsProvidersRoute
+ '/models/registry': typeof ModelsRegistryRoute
+ '/settings/alerts': typeof SettingsAlertsRoute
+ '/settings/grafana': typeof SettingsGrafanaRoute
'/': typeof DashboardIndexRoute
'/apps': typeof AppsIndexRoute
'/embeddings': typeof EmbeddingsIndexRoute
+ '/models': typeof ModelsIndexRoute
'/requests': typeof RequestsIndexRoute
'/settings': typeof SettingsIndexRoute
}
@@ -109,13 +140,17 @@ export interface FileRoutesById {
'/_dashboard': typeof DashboardRouteRouteWithChildren
'/apps': typeof AppsRouteRouteWithChildren
'/embeddings': typeof EmbeddingsRouteRouteWithChildren
+ '/models': typeof ModelsRouteRouteWithChildren
'/requests': typeof RequestsRouteRouteWithChildren
'/settings': typeof SettingsRouteRouteWithChildren
- '/settings/models': typeof SettingsModelsRoute
- '/settings/providers': typeof SettingsProvidersRoute
+ '/models/providers': typeof ModelsProvidersRoute
+ '/models/registry': typeof ModelsRegistryRoute
+ '/settings/alerts': typeof SettingsAlertsRoute
+ '/settings/grafana': typeof SettingsGrafanaRoute
'/_dashboard/': typeof DashboardIndexRoute
'/apps/': typeof AppsIndexRoute
'/embeddings/': typeof EmbeddingsIndexRoute
+ '/models/': typeof ModelsIndexRoute
'/requests/': typeof RequestsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
@@ -124,29 +159,47 @@ export interface FileRouteTypes {
fullPaths:
| '/apps'
| '/embeddings'
+ | '/models'
| '/requests'
| '/settings'
- | '/settings/models'
- | '/settings/providers'
+ | '/models/providers'
+ | '/models/registry'
+ | '/settings/alerts'
+ | '/settings/grafana'
| '/'
| '/apps/'
| '/embeddings/'
+ | '/models/'
| '/requests/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
- to: '/settings/models' | '/settings/providers' | '/' | '/apps' | '/embeddings' | '/requests' | '/settings'
+ to:
+ | '/models/providers'
+ | '/models/registry'
+ | '/settings/alerts'
+ | '/settings/grafana'
+ | '/'
+ | '/apps'
+ | '/embeddings'
+ | '/models'
+ | '/requests'
+ | '/settings'
id:
| '__root__'
| '/_dashboard'
| '/apps'
| '/embeddings'
+ | '/models'
| '/requests'
| '/settings'
- | '/settings/models'
- | '/settings/providers'
+ | '/models/providers'
+ | '/models/registry'
+ | '/settings/alerts'
+ | '/settings/grafana'
| '/_dashboard/'
| '/apps/'
| '/embeddings/'
+ | '/models/'
| '/requests/'
| '/settings/'
fileRoutesById: FileRoutesById
@@ -155,6 +208,7 @@ export interface RootRouteChildren {
DashboardRouteRoute: typeof DashboardRouteRouteWithChildren
AppsRouteRoute: typeof AppsRouteRouteWithChildren
EmbeddingsRouteRoute: typeof EmbeddingsRouteRouteWithChildren
+ ModelsRouteRoute: typeof ModelsRouteRouteWithChildren
RequestsRouteRoute: typeof RequestsRouteRouteWithChildren
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
}
@@ -175,6 +229,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RequestsRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/models': {
+ id: '/models'
+ path: '/models'
+ fullPath: '/models'
+ preLoaderRoute: typeof ModelsRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/embeddings': {
id: '/embeddings'
path: '/embeddings'
@@ -210,6 +271,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RequestsIndexRouteImport
parentRoute: typeof RequestsRouteRoute
}
+ '/models/': {
+ id: '/models/'
+ path: '/'
+ fullPath: '/models/'
+ preLoaderRoute: typeof ModelsIndexRouteImport
+ parentRoute: typeof ModelsRouteRoute
+ }
'/embeddings/': {
id: '/embeddings/'
path: '/'
@@ -231,20 +299,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexRouteImport
parentRoute: typeof DashboardRouteRoute
}
- '/settings/providers': {
- id: '/settings/providers'
- path: '/providers'
- fullPath: '/settings/providers'
- preLoaderRoute: typeof SettingsProvidersRouteImport
+ '/settings/grafana': {
+ id: '/settings/grafana'
+ path: '/grafana'
+ fullPath: '/settings/grafana'
+ preLoaderRoute: typeof SettingsGrafanaRouteImport
parentRoute: typeof SettingsRouteRoute
}
- '/settings/models': {
- id: '/settings/models'
- path: '/models'
- fullPath: '/settings/models'
- preLoaderRoute: typeof SettingsModelsRouteImport
+ '/settings/alerts': {
+ id: '/settings/alerts'
+ path: '/alerts'
+ fullPath: '/settings/alerts'
+ preLoaderRoute: typeof SettingsAlertsRouteImport
parentRoute: typeof SettingsRouteRoute
}
+ '/models/registry': {
+ id: '/models/registry'
+ path: '/registry'
+ fullPath: '/models/registry'
+ preLoaderRoute: typeof ModelsRegistryRouteImport
+ parentRoute: typeof ModelsRouteRoute
+ }
+ '/models/providers': {
+ id: '/models/providers'
+ path: '/providers'
+ fullPath: '/models/providers'
+ preLoaderRoute: typeof ModelsProvidersRouteImport
+ parentRoute: typeof ModelsRouteRoute
+ }
}
}
@@ -256,7 +338,9 @@ const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
DashboardIndexRoute: DashboardIndexRoute,
}
-const DashboardRouteRouteWithChildren = DashboardRouteRoute._addFileChildren(DashboardRouteRouteChildren)
+const DashboardRouteRouteWithChildren = DashboardRouteRoute._addFileChildren(
+ DashboardRouteRouteChildren,
+)
interface AppsRouteRouteChildren {
AppsIndexRoute: typeof AppsIndexRoute
@@ -266,7 +350,9 @@ const AppsRouteRouteChildren: AppsRouteRouteChildren = {
AppsIndexRoute: AppsIndexRoute,
}
-const AppsRouteRouteWithChildren = AppsRouteRoute._addFileChildren(AppsRouteRouteChildren)
+const AppsRouteRouteWithChildren = AppsRouteRoute._addFileChildren(
+ AppsRouteRouteChildren,
+)
interface EmbeddingsRouteRouteChildren {
EmbeddingsIndexRoute: typeof EmbeddingsIndexRoute
@@ -276,7 +362,25 @@ const EmbeddingsRouteRouteChildren: EmbeddingsRouteRouteChildren = {
EmbeddingsIndexRoute: EmbeddingsIndexRoute,
}
-const EmbeddingsRouteRouteWithChildren = EmbeddingsRouteRoute._addFileChildren(EmbeddingsRouteRouteChildren)
+const EmbeddingsRouteRouteWithChildren = EmbeddingsRouteRoute._addFileChildren(
+ EmbeddingsRouteRouteChildren,
+)
+
+interface ModelsRouteRouteChildren {
+ ModelsProvidersRoute: typeof ModelsProvidersRoute
+ ModelsRegistryRoute: typeof ModelsRegistryRoute
+ ModelsIndexRoute: typeof ModelsIndexRoute
+}
+
+const ModelsRouteRouteChildren: ModelsRouteRouteChildren = {
+ ModelsProvidersRoute: ModelsProvidersRoute,
+ ModelsRegistryRoute: ModelsRegistryRoute,
+ ModelsIndexRoute: ModelsIndexRoute,
+}
+
+const ModelsRouteRouteWithChildren = ModelsRouteRoute._addFileChildren(
+ ModelsRouteRouteChildren,
+)
interface RequestsRouteRouteChildren {
RequestsIndexRoute: typeof RequestsIndexRoute
@@ -286,27 +390,34 @@ const RequestsRouteRouteChildren: RequestsRouteRouteChildren = {
RequestsIndexRoute: RequestsIndexRoute,
}
-const RequestsRouteRouteWithChildren = RequestsRouteRoute._addFileChildren(RequestsRouteRouteChildren)
+const RequestsRouteRouteWithChildren = RequestsRouteRoute._addFileChildren(
+ RequestsRouteRouteChildren,
+)
interface SettingsRouteRouteChildren {
- SettingsModelsRoute: typeof SettingsModelsRoute
- SettingsProvidersRoute: typeof SettingsProvidersRoute
+ SettingsAlertsRoute: typeof SettingsAlertsRoute
+ SettingsGrafanaRoute: typeof SettingsGrafanaRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
- SettingsModelsRoute: SettingsModelsRoute,
- SettingsProvidersRoute: SettingsProvidersRoute,
+ SettingsAlertsRoute: SettingsAlertsRoute,
+ SettingsGrafanaRoute: SettingsGrafanaRoute,
SettingsIndexRoute: SettingsIndexRoute,
}
-const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(SettingsRouteRouteChildren)
+const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
+ SettingsRouteRouteChildren,
+)
const rootRouteChildren: RootRouteChildren = {
DashboardRouteRoute: DashboardRouteRouteWithChildren,
AppsRouteRoute: AppsRouteRouteWithChildren,
EmbeddingsRouteRoute: EmbeddingsRouteRouteWithChildren,
+ ModelsRouteRoute: ModelsRouteRouteWithChildren,
RequestsRouteRoute: RequestsRouteRouteWithChildren,
SettingsRouteRoute: SettingsRouteRouteWithChildren,
}
-export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes()
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
diff --git a/frontend/src/routes/models/index.tsx b/frontend/src/routes/models/index.tsx
new file mode 100644
index 0000000..6ea7523
--- /dev/null
+++ b/frontend/src/routes/models/index.tsx
@@ -0,0 +1,5 @@
+import { createFileRoute, Navigate } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/models/')({
+ component: () => ,
+})
diff --git a/frontend/src/routes/settings/providers.tsx b/frontend/src/routes/models/providers.tsx
similarity index 94%
rename from frontend/src/routes/settings/providers.tsx
rename to frontend/src/routes/models/providers.tsx
index 9947453..0288f3f 100644
--- a/frontend/src/routes/settings/providers.tsx
+++ b/frontend/src/routes/models/providers.tsx
@@ -18,7 +18,7 @@ const providersQueryOptions = () =>
},
})
-export const Route = createFileRoute('/settings/providers')({
+export const Route = createFileRoute('/models/providers')({
loader: () => queryClient.ensureQueryData(providersQueryOptions()),
component: RouteComponent,
errorComponent: AppErrorComponent,
diff --git a/frontend/src/routes/settings/models.tsx b/frontend/src/routes/models/registry.tsx
similarity index 94%
rename from frontend/src/routes/settings/models.tsx
rename to frontend/src/routes/models/registry.tsx
index b42f390..7baabab 100644
--- a/frontend/src/routes/settings/models.tsx
+++ b/frontend/src/routes/models/registry.tsx
@@ -18,7 +18,7 @@ const systemNamesQueryOptions = () =>
},
})
-export const Route = createFileRoute('/settings/models')({
+export const Route = createFileRoute('/models/registry')({
loader: () => queryClient.ensureQueryData(systemNamesQueryOptions()),
component: RouteComponent,
errorComponent: AppErrorComponent,
diff --git a/frontend/src/routes/models/route.tsx b/frontend/src/routes/models/route.tsx
new file mode 100644
index 0000000..046bd01
--- /dev/null
+++ b/frontend/src/routes/models/route.tsx
@@ -0,0 +1,105 @@
+import { createFileRoute, Link, Outlet, useMatchRoute } from '@tanstack/react-router'
+import { BoxesIcon, CpuIcon, MenuIcon } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import { cn } from '@/lib/utils'
+import {
+ AppHeader,
+ AppHeaderPart,
+ AppHeaderTitle,
+ AppSidebarSeparator,
+ AppSidebarTrigger,
+} from '@/components/app/app-header'
+import { Button } from '@/components/ui/button'
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
+import { useIsMobile } from '@/hooks/use-mobile'
+
+export const Route = createFileRoute('/models')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ const { t } = useTranslation()
+ const matchRoute = useMatchRoute()
+ const isMobile = useIsMobile()
+
+ const navItems = [
+ {
+ icon: ,
+ title: t('routes.models.nav.Providers'),
+ href: '/models/providers',
+ },
+ {
+ icon: ,
+ title: t('routes.models.nav.Models'),
+ href: '/models/registry',
+ },
+ ]
+
+ const NavContent = () => (
+
+ )
+
+ return (
+
+
+
+
+
+ {isMobile && (
+ <>
+
+
+
+
+
+
+ Models Navigation
+ Navigate between model pages
+
+
+
+
+
+
+
+ >
+ )}
+ {t('routes.models.Title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/routes/settings/alerts.tsx b/frontend/src/routes/settings/alerts.tsx
new file mode 100644
index 0000000..3d6ee5c
--- /dev/null
+++ b/frontend/src/routes/settings/alerts.tsx
@@ -0,0 +1,75 @@
+import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
+import { createFileRoute } from '@tanstack/react-router'
+
+import { api } from '@/lib/api'
+import { formatError } from '@/lib/error'
+import { AppErrorComponent } from '@/components/app/app-error'
+import { queryClient } from '@/components/app/query-provider'
+import i18n from '@/i18n'
+import { grafanaConnectionQueryOptions, type GrafanaConnectionResponse } from '@/hooks/use-settings'
+import { AlertsSettingsPage } from '@/pages/settings/alerts-settings-page'
+
+const alertChannelsQueryOptions = () =>
+ queryOptions({
+ queryKey: ['alertChannels'],
+ queryFn: async () => {
+ const { data, error } = await api.admin.alerts.channels.get()
+ if (error) throw formatError(error, i18n.t('pages.settings.alerts.FetchChannelsError'))
+ return data
+ },
+ })
+
+const alertRulesQueryOptions = () =>
+ queryOptions({
+ queryKey: ['alertRules'],
+ queryFn: async () => {
+ const { data, error } = await api.admin.alerts.rules.get()
+ if (error) throw formatError(error, i18n.t('pages.settings.alerts.FetchRulesError'))
+ return data
+ },
+ })
+
+const alertHistoryQueryOptions = () =>
+ queryOptions({
+ queryKey: ['alertHistory'],
+ queryFn: async () => {
+ const { data, error } = await api.admin.alerts.history.get({ query: { limit: 50 } })
+ if (error) throw formatError(error, i18n.t('pages.settings.alerts.FetchHistoryError'))
+ return data
+ },
+ })
+
+export const Route = createFileRoute('/settings/alerts')({
+ loader: async () => {
+ await Promise.all([
+ queryClient.ensureQueryData(alertChannelsQueryOptions()),
+ queryClient.ensureQueryData(alertRulesQueryOptions()),
+ queryClient.ensureQueryData(alertHistoryQueryOptions()),
+ queryClient.ensureQueryData(grafanaConnectionQueryOptions),
+ ])
+ },
+ component: RouteComponent,
+ errorComponent: AppErrorComponent,
+})
+
+function RouteComponent() {
+ const { data: channels } = useSuspenseQuery(alertChannelsQueryOptions())
+ const { data: rules } = useSuspenseQuery(alertRulesQueryOptions())
+ const { data: history } = useSuspenseQuery(alertHistoryQueryOptions())
+ const { data: grafanaConnection } = useSuspenseQuery(grafanaConnectionQueryOptions)
+
+ const grafanaConnected = (grafanaConnection as GrafanaConnectionResponse | undefined)?.verified ?? false
+ const grafanaApiUrl = (grafanaConnection as GrafanaConnectionResponse | undefined)?.apiUrl ?? null
+
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ return (
+
+ )
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+}
diff --git a/frontend/src/routes/settings/grafana.tsx b/frontend/src/routes/settings/grafana.tsx
new file mode 100644
index 0000000..7cc896d
--- /dev/null
+++ b/frontend/src/routes/settings/grafana.tsx
@@ -0,0 +1,54 @@
+import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
+import { createFileRoute } from '@tanstack/react-router'
+
+import { api } from '@/lib/api'
+import { formatError } from '@/lib/error'
+import { AppErrorComponent } from '@/components/app/app-error'
+import { queryClient } from '@/components/app/query-provider'
+import i18n from '@/i18n'
+import { GrafanaSettingsPage } from '@/pages/settings/grafana-settings-page'
+import type { GrafanaConnectionResponse, DashboardsResponse } from '@/hooks/use-settings'
+
+const grafanaConnectionQueryOptions = () =>
+ queryOptions({
+ queryKey: ['grafanaConnection'],
+ queryFn: async () => {
+ const { data, error } = await api.admin.grafana.connection.get()
+ if (error) throw formatError(error, i18n.t('pages.settings.grafana.FetchError'))
+ return data as GrafanaConnectionResponse
+ },
+ })
+
+const dashboardsQueryOptions = () =>
+ queryOptions({
+ queryKey: ['dashboards'],
+ queryFn: async () => {
+ const { data, error } = await api.admin.dashboards.get()
+ if (error) throw formatError(error, i18n.t('pages.settings.grafana.FetchError'))
+ return data as DashboardsResponse
+ },
+ })
+
+export const Route = createFileRoute('/settings/grafana')({
+ loader: async () => {
+ await Promise.all([
+ queryClient.ensureQueryData(grafanaConnectionQueryOptions()),
+ queryClient.ensureQueryData(dashboardsQueryOptions()),
+ ])
+ },
+ component: RouteComponent,
+ errorComponent: AppErrorComponent,
+})
+
+function RouteComponent() {
+ const { data: connection } = useSuspenseQuery(grafanaConnectionQueryOptions())
+ const { data: dashboardsData } = useSuspenseQuery(dashboardsQueryOptions())
+
+ return (
+
+ )
+}
diff --git a/frontend/src/routes/settings/index.tsx b/frontend/src/routes/settings/index.tsx
index 72d61ea..5458853 100644
--- a/frontend/src/routes/settings/index.tsx
+++ b/frontend/src/routes/settings/index.tsx
@@ -1,5 +1,5 @@
import { createFileRoute, Navigate } from '@tanstack/react-router'
export const Route = createFileRoute('/settings/')({
- component: () => ,
+ component: () => ,
})
diff --git a/frontend/src/routes/settings/route.tsx b/frontend/src/routes/settings/route.tsx
index b4177d4..7568ef5 100644
--- a/frontend/src/routes/settings/route.tsx
+++ b/frontend/src/routes/settings/route.tsx
@@ -1,5 +1,5 @@
import { createFileRoute, Link, Outlet, useMatchRoute } from '@tanstack/react-router'
-import { BoxesIcon, CpuIcon, MenuIcon } from 'lucide-react'
+import { BarChart3Icon, BellIcon, MenuIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
@@ -25,14 +25,14 @@ function RouteComponent() {
const navItems = [
{
- icon: ,
- title: t('routes.settings.nav.Providers'),
- href: '/settings/providers',
+ icon: ,
+ title: t('routes.settings.nav.Alerts'),
+ href: '/settings/alerts',
},
{
- icon: ,
- title: t('routes.settings.nav.Models'),
- href: '/settings/models',
+ icon: ,
+ title: t('routes.settings.nav.Grafana'),
+ href: '/settings/grafana',
},
]
@@ -61,7 +61,6 @@ function RouteComponent() {
return (
- {/* Fixed header */}
@@ -92,14 +91,11 @@ function RouteComponent() {
- {/* Main content area - fills remaining height */}
- {/* Desktop sidebar - fixed height, hidden on mobile */}
- {/* Content area - scrollable */}
From 8350de6acd07430857f7849af32d24d221dd217c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?=
Date: Sat, 31 Jan 2026 23:46:33 +0800
Subject: [PATCH 2/5] fix: address PR review comments
- Fix PromQL label selector syntax (no leading/trailing commas)
- Add fetch timeouts (AbortSignal.timeout) to all external HTTP calls
- Add ON DELETE CASCADE to alert_history FK referencing alert_rules
- Deduplicate grafanaConnectionQueryOptions (reuse from use-settings hook)
- Add default case to dispatchToChannel switch statement
- Fix duplicate listApiKeys() call in evaluateQuota
- Add missing i18n key pages.settings.grafana.Testing
- Fix wrong i18n key reference in grafana-settings-page.tsx
- Clear datasourceUid on connection test failure
Co-Authored-By: Claude Opus 4.5
---
backend/drizzle/0015_green_kate_bishop.sql | 3 +
backend/drizzle/meta/0015_snapshot.json | 1325 +++++++++++++++++
backend/drizzle/meta/_journal.json | 7 +
backend/src/api/admin/grafana.ts | 3 +
backend/src/db/schema.ts | 2 +-
backend/src/services/alertDispatcher.ts | 4 +
backend/src/services/alertEngine.ts | 5 +-
backend/src/services/grafanaSync.ts | 19 +-
backend/src/utils/grafanaClient.ts | 1 +
frontend/src/i18n/locales/en-US.json | 1 +
frontend/src/i18n/locales/zh-CN.json | 1 +
.../pages/settings/grafana-settings-page.tsx | 2 +-
frontend/src/routes/settings/grafana.tsx | 16 +-
13 files changed, 1365 insertions(+), 24 deletions(-)
create mode 100644 backend/drizzle/0015_green_kate_bishop.sql
create mode 100644 backend/drizzle/meta/0015_snapshot.json
diff --git a/backend/drizzle/0015_green_kate_bishop.sql b/backend/drizzle/0015_green_kate_bishop.sql
new file mode 100644
index 0000000..3a46948
--- /dev/null
+++ b/backend/drizzle/0015_green_kate_bishop.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "alert_history" DROP CONSTRAINT "alert_history_rule_id_alert_rules_id_fk";
+--> statement-breakpoint
+ALTER TABLE "alert_history" ADD CONSTRAINT "alert_history_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/backend/drizzle/meta/0015_snapshot.json b/backend/drizzle/meta/0015_snapshot.json
new file mode 100644
index 0000000..0186b8e
--- /dev/null
+++ b/backend/drizzle/meta/0015_snapshot.json
@@ -0,0 +1,1325 @@
+{
+ "id": "fa45eb3e-7bca-4be9-9142-78bf48f320e2",
+ "prevId": "5baa8f77-48e3-4e1d-9078-d979c2312412",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.alert_channels": {
+ "name": "alert_channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_channels_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "alert_channel_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "grafana_uid": {
+ "name": "grafana_uid",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_synced_at": {
+ "name": "grafana_synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_sync_error": {
+ "name": "grafana_sync_error",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_history": {
+ "name": "alert_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_history_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "triggered_at": {
+ "name": "triggered_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "alert_history_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "alert_history_rule_id_alert_rules_id_fk": {
+ "name": "alert_history_rule_id_alert_rules_id_fk",
+ "tableFrom": "alert_history",
+ "tableTo": "alert_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.alert_rules": {
+ "name": "alert_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "alert_rules_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "alert_rule_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "condition": {
+ "name": "condition",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_ids": {
+ "name": "channel_ids",
+ "type": "integer[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cooldown_minutes": {
+ "name": "cooldown_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "grafana_uid": {
+ "name": "grafana_uid",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_synced_at": {
+ "name": "grafana_synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grafana_sync_error": {
+ "name": "grafana_sync_error",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "api_keys_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rpm_limit": {
+ "name": "rpm_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 50
+ },
+ "tpm_limit": {
+ "name": "tpm_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 50000
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "api_key_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_keys_key_unique": {
+ "name": "api_keys_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ },
+ "api_keys_external_id_unique": {
+ "name": "api_keys_external_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.completions": {
+ "name": "completions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "completions_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_id": {
+ "name": "upstream_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prompt": {
+ "name": "prompt",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prompt_tokens": {
+ "name": "prompt_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completion": {
+ "name": "completion",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completion_tokens": {
+ "name": "completion_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "completions_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "ttft": {
+ "name": "ttft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "req_id": {
+ "name": "req_id",
+ "type": "varchar(127)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_completion_id": {
+ "name": "source_completion_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_format": {
+ "name": "api_format",
+ "type": "varchar(31)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cached_response": {
+ "name": "cached_response",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "completions_api_key_id_api_keys_id_fk": {
+ "name": "completions_api_key_id_api_keys_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "completions_upstream_id_upstreams_id_fk": {
+ "name": "completions_upstream_id_upstreams_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "upstreams",
+ "columnsFrom": [
+ "upstream_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "completions_source_completion_id_completions_id_fk": {
+ "name": "completions_source_completion_id_completions_id_fk",
+ "tableFrom": "completions",
+ "tableTo": "completions",
+ "columnsFrom": [
+ "source_completion_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "completions_id_unique": {
+ "name": "completions_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.embeddings": {
+ "name": "embeddings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "embeddings_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input": {
+ "name": "input",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dimensions": {
+ "name": "dimensions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "completions_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "embeddings_api_key_id_api_keys_id_fk": {
+ "name": "embeddings_api_key_id_api_keys_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "embeddings_model_id_models_id_fk": {
+ "name": "embeddings_model_id_models_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "models",
+ "columnsFrom": [
+ "model_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "embeddings_id_unique": {
+ "name": "embeddings_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.models": {
+ "name": "models",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "models_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "system_name": {
+ "name": "system_name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_id": {
+ "name": "remote_id",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_type": {
+ "name": "model_type",
+ "type": "model_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'chat'"
+ },
+ "context_length": {
+ "name": "context_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_price": {
+ "name": "input_price",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output_price": {
+ "name": "output_price",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "models_provider_id_providers_id_fk": {
+ "name": "models_provider_id_providers_id_fk",
+ "tableFrom": "models",
+ "tableTo": "providers",
+ "columnsFrom": [
+ "provider_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "models_provider_system_name_unique": {
+ "name": "models_provider_system_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "provider_id",
+ "system_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.providers": {
+ "name": "providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "providers_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "provider_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'openai'"
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_version": {
+ "name": "api_version",
+ "type": "varchar(31)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "settings_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "settings_key_unique": {
+ "name": "settings_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.srv_logs": {
+ "name": "srv_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "srv_logs_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "related_api_key_id": {
+ "name": "related_api_key_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "related_upstream_id": {
+ "name": "related_upstream_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "related_completion_id": {
+ "name": "related_completion_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "srv_logs_level",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "acknowledged": {
+ "name": "acknowledged",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ack_at": {
+ "name": "ack_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "srv_logs_related_api_key_id_api_keys_id_fk": {
+ "name": "srv_logs_related_api_key_id_api_keys_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "api_keys",
+ "columnsFrom": [
+ "related_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "srv_logs_related_upstream_id_upstreams_id_fk": {
+ "name": "srv_logs_related_upstream_id_upstreams_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "upstreams",
+ "columnsFrom": [
+ "related_upstream_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "srv_logs_related_completion_id_completions_id_fk": {
+ "name": "srv_logs_related_completion_id_completions_id_fk",
+ "tableFrom": "srv_logs",
+ "tableTo": "completions",
+ "columnsFrom": [
+ "related_completion_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "srv_logs_id_unique": {
+ "name": "srv_logs_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upstreams": {
+ "name": "upstreams",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "upstreams_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_model": {
+ "name": "upstream_model",
+ "type": "varchar(63)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted": {
+ "name": "deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.alert_channel_type": {
+ "name": "alert_channel_type",
+ "schema": "public",
+ "values": [
+ "webhook",
+ "email",
+ "feishu"
+ ]
+ },
+ "public.alert_history_status": {
+ "name": "alert_history_status",
+ "schema": "public",
+ "values": [
+ "sent",
+ "failed",
+ "suppressed"
+ ]
+ },
+ "public.alert_rule_type": {
+ "name": "alert_rule_type",
+ "schema": "public",
+ "values": [
+ "budget",
+ "error_rate",
+ "latency",
+ "quota"
+ ]
+ },
+ "public.api_key_source": {
+ "name": "api_key_source",
+ "schema": "public",
+ "values": [
+ "manual",
+ "operator",
+ "init"
+ ]
+ },
+ "public.completions_status": {
+ "name": "completions_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "completed",
+ "failed",
+ "aborted",
+ "cache_hit"
+ ]
+ },
+ "public.model_type": {
+ "name": "model_type",
+ "schema": "public",
+ "values": [
+ "chat",
+ "embedding"
+ ]
+ },
+ "public.provider_type": {
+ "name": "provider_type",
+ "schema": "public",
+ "values": [
+ "openai",
+ "openai-responses",
+ "anthropic",
+ "azure",
+ "ollama"
+ ]
+ },
+ "public.srv_logs_level": {
+ "name": "srv_logs_level",
+ "schema": "public",
+ "values": [
+ "unspecific",
+ "info",
+ "warn",
+ "error"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json
index a7b8915..8c1ce13 100644
--- a/backend/drizzle/meta/_journal.json
+++ b/backend/drizzle/meta/_journal.json
@@ -106,6 +106,13 @@
"when": 1769870349819,
"tag": "0014_opposite_dragon_man",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "7",
+ "when": 1769874323194,
+ "tag": "0015_green_kate_bishop",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/backend/src/api/admin/grafana.ts b/backend/src/api/admin/grafana.ts
index 733bde9..8e6463b 100644
--- a/backend/src/api/admin/grafana.ts
+++ b/backend/src/api/admin/grafana.ts
@@ -105,6 +105,7 @@ export const adminGrafana = new Elysia({ prefix: "/grafana" })
// Step 1: Test health endpoint
const healthRes = await fetch(`${config.apiUrl}/api/health`, {
headers: { Authorization: `Bearer ${config.authToken}` },
+ signal: AbortSignal.timeout(10_000),
});
if (!healthRes.ok) {
throw new Error(
@@ -117,6 +118,7 @@ export const adminGrafana = new Elysia({ prefix: "/grafana" })
try {
const dsRes = await fetch(`${config.apiUrl}/api/datasources`, {
headers: { Authorization: `Bearer ${config.authToken}` },
+ signal: AbortSignal.timeout(10_000),
});
if (dsRes.ok) {
const datasources = (await dsRes.json()) as Array<{
@@ -155,6 +157,7 @@ export const adminGrafana = new Elysia({ prefix: "/grafana" })
key: GRAFANA_CONNECTION_KEY,
value: {
...config,
+ datasourceUid: undefined,
verified: false,
verifiedAt: null,
} satisfies GrafanaConnection,
diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts
index 7332bc1..080aa5d 100644
--- a/backend/src/db/schema.ts
+++ b/backend/src/db/schema.ts
@@ -475,7 +475,7 @@ export const AlertHistoryTable = pgTable("alert_history", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
ruleId: integer("rule_id")
.notNull()
- .references((): AnyPgColumn => AlertRulesTable.id),
+ .references((): AnyPgColumn => AlertRulesTable.id, { onDelete: "cascade" }),
triggeredAt: timestamp("triggered_at").notNull().defaultNow(),
resolvedAt: timestamp("resolved_at"),
payload: jsonb("payload").notNull().$type(),
diff --git a/backend/src/services/alertDispatcher.ts b/backend/src/services/alertDispatcher.ts
index d1ed017..9aefc72 100644
--- a/backend/src/services/alertDispatcher.ts
+++ b/backend/src/services/alertDispatcher.ts
@@ -58,6 +58,7 @@ async function dispatchWebhook(
method: "POST",
headers,
body,
+ signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
@@ -146,6 +147,7 @@ async function dispatchFeishu(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
@@ -169,6 +171,8 @@ export async function dispatchToChannel(
return dispatchEmail(config as EmailChannelConfig, payload);
case "feishu":
return dispatchFeishu(config as FeishuChannelConfig, payload);
+ default:
+ throw new Error(`Unsupported channel type: ${channelType as string}`);
}
}
diff --git a/backend/src/services/alertEngine.ts b/backend/src/services/alertEngine.ts
index 8d47115..a45a596 100644
--- a/backend/src/services/alertEngine.ts
+++ b/backend/src/services/alertEngine.ts
@@ -104,10 +104,10 @@ async function evaluateLatency(
async function evaluateQuota(
condition: QuotaCondition,
): Promise<{ triggered: boolean; currentValue: number }> {
+ const apiKeys = await listApiKeys();
+
// If a specific API key is specified, check just that one
if (condition.apiKeyId) {
- // Need to get the key's limits from the DB
- const apiKeys = await listApiKeys();
const apiKey = apiKeys.find((k) => k.id === condition.apiKeyId);
if (!apiKey) {
return { triggered: false, currentValue: 0 };
@@ -149,7 +149,6 @@ async function evaluateQuota(
}
// Check all active API keys, trigger if any exceed threshold
- const apiKeys = await listApiKeys();
let maxUsagePercent = 0;
for (const key of apiKeys) {
diff --git a/backend/src/services/grafanaSync.ts b/backend/src/services/grafanaSync.ts
index 262f9f9..b5966a5 100644
--- a/backend/src/services/grafanaSync.ts
+++ b/backend/src/services/grafanaSync.ts
@@ -81,21 +81,28 @@ function buildPromQL(rule: AlertRule): {
}
case "error_rate": {
const c = rule.condition as ErrorRateCondition;
- const modelFilter = c.model ? `model="${c.model}",` : "";
+ const labels: string[] = [];
+ if (c.model) {
+ labels.push(`model="${c.model}"`);
+ }
+ const baseSelector = labels.join(",");
+ const failedSelector = [...labels, `status="failed"`].join(",");
return {
- expr: `(sum(rate(nexusgate_completions_total{${modelFilter}status="failed"}[${c.windowMinutes}m])) / clamp_min(sum(rate(nexusgate_completions_total{${modelFilter}}[${c.windowMinutes}m])), 1e-10)) * 100 > ${c.thresholdPercent}`,
+ expr: `(sum(rate(nexusgate_completions_total{${failedSelector}}[${c.windowMinutes}m])) / clamp_min(sum(rate(nexusgate_completions_total{${baseSelector}}[${c.windowMinutes}m])), 1e-10)) * 100 > ${c.thresholdPercent}`,
threshold: c.thresholdPercent,
forDuration: "1m",
};
}
case "latency": {
const c = rule.condition as LatencyCondition;
- const modelFilter = c.model
- ? `,model="${c.model}"`
- : "";
+ const labels: string[] = [];
+ if (c.model) {
+ labels.push(`model="${c.model}"`);
+ }
+ const labelSelector = labels.join(",");
const thresholdSec = c.thresholdMs / 1000;
return {
- expr: `histogram_quantile(${c.percentile / 100}, sum(rate(nexusgate_completion_duration_seconds_bucket{${modelFilter}}[${c.windowMinutes}m])) by (le)) > ${thresholdSec}`,
+ expr: `histogram_quantile(${c.percentile / 100}, sum(rate(nexusgate_completion_duration_seconds_bucket{${labelSelector}}[${c.windowMinutes}m])) by (le)) > ${thresholdSec}`,
threshold: c.thresholdMs,
forDuration: "1m",
};
diff --git a/backend/src/utils/grafanaClient.ts b/backend/src/utils/grafanaClient.ts
index e222798..d33a491 100644
--- a/backend/src/utils/grafanaClient.ts
+++ b/backend/src/utils/grafanaClient.ts
@@ -73,6 +73,7 @@ export class GrafanaClient {
"X-Disable-Provenance": "true",
...options.headers,
},
+ signal: options.signal ?? AbortSignal.timeout(15_000),
});
if (!response.ok) {
diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json
index b4874c4..e9dbc6a 100644
--- a/frontend/src/i18n/locales/en-US.json
+++ b/frontend/src/i18n/locales/en-US.json
@@ -502,6 +502,7 @@
"pages.settings.grafana.DashboardsClearFailed": "Failed to clear dashboard configuration",
"pages.settings.grafana.EnvOverrideWarning": "Dashboard configuration is managed by the GRAFANA_DASHBOARDS environment variable and cannot be modified here.",
"pages.settings.grafana.NoDashboards": "No dashboards configured",
+ "pages.settings.grafana.Testing": "Testing...",
"pages.settings.alerts.grafana.SyncAll": "Sync All to Grafana",
"pages.settings.alerts.grafana.SyncRules": "Sync Rules",
"pages.settings.alerts.grafana.SyncChannels": "Sync Channels",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index f91ad2a..ea5ef6b 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -503,6 +503,7 @@
"pages.settings.grafana.DashboardsClearFailed": "清除仪表盘配置失败",
"pages.settings.grafana.EnvOverrideWarning": "仪表盘配置由 GRAFANA_DASHBOARDS 环境变量管理,无法在此修改。",
"pages.settings.grafana.NoDashboards": "未配置仪表盘",
+ "pages.settings.grafana.Testing": "测试中...",
"pages.settings.alerts.grafana.SyncAll": "同步到 Grafana",
"pages.settings.alerts.grafana.SyncRules": "同步规则",
"pages.settings.alerts.grafana.SyncChannels": "同步渠道",
diff --git a/frontend/src/pages/settings/grafana-settings-page.tsx b/frontend/src/pages/settings/grafana-settings-page.tsx
index 3befea9..585ee0b 100644
--- a/frontend/src/pages/settings/grafana-settings-page.tsx
+++ b/frontend/src/pages/settings/grafana-settings-page.tsx
@@ -203,7 +203,7 @@ function ConnectionCard({ connection }: { connection: GrafanaConnectionResponse
{connection.configured && (
<>