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.ChannelName')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.ChannelType')} + + + )} + /> +
+ + {watchType === 'webhook' && ( +
+ ( + + Webhook URL + + + + + )} + /> + ( + + {t('pages.settings.alerts.Secret')} + + + + + )} + /> +
+ )} + + {watchType === 'email' && ( +
+
+ ( + + SMTP Host + + + + + )} + /> + ( + + SMTP Port + + + + + )} + /> +
+
+ ( + + {t('pages.settings.alerts.EmailUser')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.EmailPassword')} + + + + + )} + /> +
+ ( + + {t('pages.settings.alerts.EmailFrom')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.EmailTo')} + + + + + )} + /> +
+ )} + + {watchType === 'feishu' && ( +
+ ( + + Feishu Webhook URL + + + + + )} + /> + ( + + {t('pages.settings.alerts.Secret')} + + + + + )} + /> +
+ )} + +
+ +
+
+ +
+
+ +
+

+ {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.RuleName')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.RuleType')} + + + )} + /> +
+ + {watchType === 'budget' && ( +
+ ( + + {t('pages.settings.alerts.BudgetThreshold')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.PeriodDays')} + + + + + )} + /> +
+ )} + + {watchType === 'error_rate' && ( +
+ ( + + {t('pages.settings.alerts.ErrorThreshold')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.WindowMinutes')} + + + + + )} + /> +
+ )} + + {watchType === 'latency' && ( +
+ ( + + {t('pages.settings.alerts.LatencyThreshold')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.Percentile')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.WindowMinutes')} + + + + + )} + /> +
+ )} + + {watchType === 'quota' && ( +
+ ( + + {t('pages.settings.alerts.QuotaThreshold')} + + + + + )} + /> + ( + + {t('pages.settings.alerts.LimitType')} + + + )} + /> +
+ )} + +
+ ( + + {t('pages.settings.alerts.NotifyChannels')} + + + )} + /> + ( + + {t('pages.settings.alerts.Cooldown')} + + + + + )} + /> +
+ +
+ +
+
+ +
+
+ +
+

+ {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 })} + + )} +
+ )} + +
+ saveMutation.mutate(v))} className="space-y-4"> + ( + + {t('pages.settings.grafana.ApiUrl')} + + + + + )} + /> + ( + + {t('pages.settings.grafana.AuthToken')} + + + + + )} + /> +
+ + {connection.configured && ( + <> + + + + )} +
+ + +
+
+ ) +} + +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')} + + )} + +
+ saveMutation.mutate(v))} className="space-y-4"> + {fields.length === 0 && ( +

{t('pages.settings.grafana.NoDashboards')}

+ )} + + {fields.map((field, index) => ( +
+ ( + + {index === 0 && {t('pages.settings.grafana.DashboardId')}} + + + + + )} + /> + ( + + {index === 0 && {t('pages.settings.grafana.DashboardLabel')}} + + + + + )} + /> + ( + + {index === 0 && {t('pages.settings.grafana.DashboardUrl')}} + + + + + )} + /> + {!envOverride && ( + + )} +
+ ))} + + {!envOverride && ( +
+ + + {fields.length > 0 && ( + + )} +
+ )} +
+ +
+
+ ) +} 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 && ( <>