diff --git a/backend/repository/db.go b/backend/repository/db.go
index 596f95c..3015523 100644
--- a/backend/repository/db.go
+++ b/backend/repository/db.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
+ "strings"
"path/filepath"
"sync"
"time"
@@ -61,12 +62,239 @@ func GetDB() *JsonDB {
return db
}
+// fixUnreachableLogSourceURLs 把数据源里 0.0.0.0 / [::] 的监听地址换成主机真实 IP。
+//
+// 平台进程不在被采集主机上,0.0.0.0 只在本机有意义,用它必然连不上。
+func (d *JsonDB) fixUnreachableLogSourceURLs() {
+ hostIPByID := map[uint]string{}
+ for _, h := range d.Hosts {
+ hostIPByID[h.ID] = strings.TrimSpace(h.HostIP)
+ }
+ // 数据源按名字对应部署名,用于推断该源属于哪台主机
+ hostByName := map[string]string{}
+ for _, dep := range d.Deployments {
+ if dep.HostID == nil {
+ continue
+ }
+ if ip, ok := hostIPByID[*dep.HostID]; ok && ip != "" {
+ hostByName[dep.Name] = ip
+ }
+ }
+
+ for i := range d.LogSources {
+ src := &d.LogSources[i]
+ if !strings.Contains(src.URL, "0.0.0.0") && !strings.Contains(src.URL, "[::]") {
+ continue
+ }
+ ip := hostByName[src.Name]
+ if ip == "" && len(d.Hosts) == 1 {
+ // 只有一台主机时可以直接推断(常见于单机试用)
+ ip = strings.TrimSpace(d.Hosts[0].HostIP)
+ }
+ if ip == "" {
+ log.Printf("[schema] 数据源 %q 的 URL 是不可达的监听地址 %s,但无法推断主机 IP,请手动修正", src.Name, src.URL)
+ continue
+ }
+ old := src.URL
+ src.URL = strings.Replace(src.URL, "0.0.0.0", ip, 1)
+ src.URL = strings.Replace(src.URL, "[::]", ip, 1)
+ log.Printf("[schema] 数据源 %q URL 修正:%s → %s", src.Name, old, src.URL)
+ }
+}
+
+// dedupeCollectionIDs 对所有带 ID 的集合做重复 ID 修复。
+//
+// 注意:实际迁移里按版本分步调用(见 migrateSchema),
+// 不要直接用它替代分步逻辑 —— 那会让"已迁移库"漏掉后加的清理步骤。
+//
+// 背景:这些集合曾经都漏在 InitDB 的 sequences 重建之外,导致 NextID 每次
+// 进程重启都从 1 开始,于是不同时期创建的对象会撞号。撞号后的表现各不相同:
+// LogSource : 查询页数据源下拉选不动(两个 option 同 value)
+// AlertRule : 规则列表出现两条同 id,编辑/删除操作命中错的那条
+// NotifyChannel: 通知渠道覆盖错对象
+func (d *JsonDB) dedupeCollectionIDs() {
+ d.dedupeLogSourceIDs()
+ d.dedupeAlertRuleIDs()
+ d.dedupeNotifyChannelIDs()
+}
+
+// dedupeAlertRuleIDs 重新编号重复的告警规则 ID。
+// 同名规则(seed 重复执行导致)只保留第一条,其余删除。
+func (d *JsonDB) dedupeAlertRuleIDs() {
+ if len(d.AlertRules) == 0 {
+ return
+ }
+ seenID := map[uint]bool{}
+ seenName := map[string]bool{}
+ next := uint(0)
+ for _, r := range d.AlertRules {
+ if r.ID > next {
+ next = r.ID
+ }
+ }
+ fixed := make([]models.AlertRule, 0, len(d.AlertRules))
+ for i := range d.AlertRules {
+ rule := d.AlertRules[i]
+ if rule.Name != "" && seenName[rule.Name] {
+ log.Printf("[schema] 删除重复的告警规则 %q(同名)", rule.Name)
+ continue
+ }
+ seenName[rule.Name] = true
+ if rule.ID == 0 || seenID[rule.ID] {
+ next++
+ log.Printf("[schema] 告警规则 %q 重复 ID %d → 重新分配为 %d", rule.Name, rule.ID, next)
+ rule.ID = next
+ }
+ seenID[rule.ID] = true
+ fixed = append(fixed, rule)
+ }
+ d.AlertRules = fixed
+ for _, r := range d.AlertRules {
+ if r.ID > d.sequences["alertRules"] {
+ d.sequences["alertRules"] = r.ID
+ }
+ }
+}
+
+// dedupeNotifyChannelIDs 重新编号重复的通知渠道 ID。
+func (d *JsonDB) dedupeNotifyChannelIDs() {
+ if len(d.NotifyChannels) == 0 {
+ return
+ }
+ seen := map[uint]bool{}
+ next := uint(0)
+ for _, c := range d.NotifyChannels {
+ if c.ID > next {
+ next = c.ID
+ }
+ }
+ for i := range d.NotifyChannels {
+ ch := &d.NotifyChannels[i]
+ if ch.ID == 0 || seen[ch.ID] {
+ next++
+ log.Printf("[schema] 通知渠道 %q 重复 ID %d → 重新分配为 %d", ch.Name, ch.ID, next)
+ ch.ID = next
+ }
+ seen[ch.ID] = true
+ if ch.ID > d.sequences["notifyChannels"] {
+ d.sequences["notifyChannels"] = ch.ID
+ }
+ }
+}
+
+// removeInfraAlertRules 删除误配置的「基础设施类」告警规则。
+//
+// 判据:查询指向 kube-state-metrics / event-exporter,或在日志里匹配
+// 节点 NotReady / 容器 OOMKilled。这些是 Prometheus 指标监控的职责:
+// - 日志里有没有这行取决于采集器是否采到那些命名空间,不可靠
+// - 指标侧(kube_node_status_condition / kube_pod_container_status_terminated_reason)
+// 才是权威数据源
+func (d *JsonDB) removeInfraAlertRules() {
+ infraMarkers := []string{"kube-state-metrics", "event-exporter"}
+ infraNames := []string{"Kubernetes 节点 NotReady", "K8s 节点 NotReady", "容器 OOMKilled"}
+
+ kept := make([]models.AlertRule, 0, len(d.AlertRules))
+ for _, r := range d.AlertRules {
+ isInfra := false
+ for _, marker := range infraMarkers {
+ if strings.Contains(r.Query, marker) {
+ isInfra = true
+ }
+ }
+ for _, name := range infraNames {
+ if strings.TrimSpace(r.Name) == name {
+ isInfra = true
+ }
+ }
+ if isInfra {
+ log.Printf("[schema] 移除基础设施类告警规则 %q(应交给 Prometheus)", r.Name)
+ continue
+ }
+ kept = append(kept, r)
+ }
+ d.AlertRules = kept
+}
+
+// dedupeLogSourceIDs 修复历史遗留的重复 ID,并同步更新引用它的策略组。
+//
+// 策略组通过 SourceID 关联数据源。重复 ID 时无法判断分组到底指向哪一个,
+// 这里按"保留第一个、其余顺延"处理,并让原本指向该 ID 的策略组继续指向
+// 编号最小的那个(即重建前第一次创建的数据源)。
+func (d *JsonDB) dedupeLogSourceIDs() {
+ if len(d.LogSources) < 2 {
+ return
+ }
+ seen := map[uint]bool{}
+ remap := map[uint]uint{} // 旧ID → 新ID(仅记录需要改的)
+ next := uint(0)
+ for _, s := range d.LogSources {
+ if s.ID > next {
+ next = s.ID
+ }
+ }
+
+ fixed := make([]models.LogSource, 0, len(d.LogSources))
+ kept := map[uint]bool{} // 已被占用的 ID(首次出现时占位)
+ for i := range d.LogSources {
+ src := d.LogSources[i]
+ if src.ID == 0 || kept[src.ID] {
+ // 重复或非法 ID:分配新号
+ next++
+ old := src.ID
+ src.ID = next
+ if _, ok := remap[old]; !ok && old != 0 {
+ // 同一个旧 ID 只记录一次映射(指向保留的那个)
+ remap[old] = 0 // 0 表示"不需要改引用"
+ }
+ log.Printf("[schema] 数据源 %q 重复 ID %d → 重新分配为 %d", src.Name, old, src.ID)
+ } else {
+ kept[src.ID] = true
+ }
+ seen[src.ID] = true
+ fixed = append(fixed, src)
+ }
+ d.LogSources = fixed
+
+ // 同步策略组引用(只在旧 ID 已不存在时改写)
+ for i := range d.LogPolicyGroups {
+ sid := d.LogPolicyGroups[i].SourceID
+ if sid == 0 || seen[sid] {
+ continue
+ }
+ if nv, ok := remap[sid]; ok && nv != 0 {
+ d.LogPolicyGroups[i].SourceID = nv
+ }
+ }
+
+ // 迁移发生在 sequences 重建**之后**,重新分配可能抬高最大 ID,
+ // 必须同步提升计数器,否则紧接着创建的数据源会撞号。
+ for _, s := range d.LogSources {
+ if s.ID > d.sequences["logSources"] {
+ d.sequences["logSources"] = s.ID
+ }
+ }
+
+ // 保证只有一个默认数据源
+ defaultSeen := false
+ for i := range d.LogSources {
+ if d.LogSources[i].IsDefault {
+ if defaultSeen {
+ d.LogSources[i].IsDefault = false
+ }
+ defaultSeen = true
+ }
+ }
+ if !defaultSeen && len(d.LogSources) > 0 {
+ d.LogSources[0].IsDefault = true
+ }
+}
+
// CurrentSchemaVersion 当前数据结构版本。
// 每次做**不兼容**的结构变更时 +1,并在 migrateSchema 里补一段迁移逻辑。
//
// v0/v1(无字段)→ 1:新增 auditLogs / schemaVersion(老库走零值兜底即可,无需处理)
// v1 → 2:预留示例(如把 ScrapeJobs 从 logPath 拆分时,需按旧结构生成默认 job)
-const CurrentSchemaVersion = 1
+const CurrentSchemaVersion = 4
// migrateSchema 按版本号顺序迁移老库。返回是否发生了迁移(需要回写)。
func (d *JsonDB) migrateSchema() bool {
@@ -86,6 +314,31 @@ func (d *JsonDB) migrateSchema() bool {
d.AuditLogs = []models.AuditLog{}
}
d.SchemaVersion = 1
+ case 1:
+ // v1 → v2:修复 LogSource 重复 ID(曾漏在 sequences 重建之外,
+ // NextID 每次重启从 1 开始,线上出现两个数据源都是 id=1)。
+ d.dedupeLogSourceIDs()
+ d.SchemaVersion = 2
+ case 2:
+ // v2 → v3:修复 AlertRule / NotifyChannel 重复 ID + 清掉基础设施类规则。
+ //
+ // ⚠️ 教训:v2 那次我把"告警去重 + 清基础设施规则"塞进了 case 1,
+ // 但当时线上库已迁移到 v2,于是这段代码永远不执行(DB 里 schemaVersion
+ // 已经等于当前版本)。结构变更只要改了逻辑、且目标库已到该版本,
+ // 就必须**再抬一版**,不能改历史迁移分支。
+ d.dedupeAlertRuleIDs()
+ d.dedupeNotifyChannelIDs()
+ d.removeInfraAlertRules()
+ d.SchemaVersion = 3
+ case 3:
+ // v3 → v4:修复主机模式数据源的不可达 URL。
+ //
+ // 主机模式部署的 endpoint 存的是容器监听地址 0.0.0.0:3100,
+ // 平台把它原样当数据源 URL → 查询页对该数据源的所有请求
+ // 都报 dial tcp 0.0.0.0:3100: connection refused,
+ // 前端表现为「命名空间/App 下拉永远加载中」。
+ d.fixUnreachableLogSourceURLs()
+ d.SchemaVersion = 4
default:
// 防御:出现未知版本(比当前代码还新)时不乱动,避免降级把数据写坏
log.Printf("[schema] 数据库版本 %d 高于当前代码支持版本 %d,跳过迁移", d.SchemaVersion, CurrentSchemaVersion)
@@ -126,6 +379,11 @@ func InitDB() error {
"collectors": 0,
"logs": 0,
"logPolicyGroups": 0,
+ "logSources": 0,
+ "notifyChannels": 0,
+ "alertRules": 0,
+ "alertDeliveries": 0,
+ "auditLogs": 0,
},
saveChan: make(chan saveRequest, 10),
stopChan: make(chan struct{}),
@@ -174,6 +432,35 @@ func InitDB() error {
d.sequences["logPolicyGroups"] = group.ID
}
}
+ // ⚠️ 以下集合曾经漏在重建之外,导致 NextID 每次重启都从 1 开始,
+ // 于是不同进程生命周期里创建的对象 ID 重复(线上实际出现过
+ // 两个 LogSource 都是 id=1 → 前端数据源下拉无法切换)。
+ // 以后新增任何带 ID 的集合,都必须在这里补一段。
+ for _, s := range d.LogSources {
+ if s.ID > d.sequences["logSources"] {
+ d.sequences["logSources"] = s.ID
+ }
+ }
+ for _, ch := range d.NotifyChannels {
+ if ch.ID > d.sequences["notifyChannels"] {
+ d.sequences["notifyChannels"] = ch.ID
+ }
+ }
+ for _, rule := range d.AlertRules {
+ if rule.ID > d.sequences["alertRules"] {
+ d.sequences["alertRules"] = rule.ID
+ }
+ }
+ for _, del := range d.AlertDeliveries {
+ if del.ID > d.sequences["alertDeliveries"] {
+ d.sequences["alertDeliveries"] = del.ID
+ }
+ }
+ for _, a := range d.AuditLogs {
+ if a.ID > d.sequences["auditLogs"] {
+ d.sequences["auditLogs"] = a.ID
+ }
+ }
}
}
diff --git a/backend/repository/dedupe_test.go b/backend/repository/dedupe_test.go
new file mode 100644
index 0000000..4edafc0
--- /dev/null
+++ b/backend/repository/dedupe_test.go
@@ -0,0 +1,83 @@
+package repository
+
+import (
+ "testing"
+
+ "lokistack/models"
+)
+
+// 回归:nextSequence / 去重逻辑。
+//
+// 线上事故:logSources 漏在 sequences 重建之外,NextID 每次重启从 1 开始,
+// 导致两个数据源都是 id=1 → 前端数据源下拉两个 option value 相同,
+// 选中后 sourceId 不变,界面表现为"无法切换数据源"。
+func TestDedupeLogSourceIDs(t *testing.T) {
+ db := &JsonDB{
+ sequences: map[string]uint{"logSources": 1},
+ LogSources: []models.LogSource{
+ {ID: 1, Name: "loki-pre", IsDefault: true},
+ {ID: 1, Name: "host-verify-loki"}, // 重复 ID
+ },
+ LogPolicyGroups: []models.LogPolicyGroup{
+ {ID: 1, Name: "g1", SourceID: 1},
+ },
+ }
+
+ db.dedupeLogSourceIDs()
+
+ if len(db.LogSources) != 2 {
+ t.Fatalf("数据源数量变化了:%d", len(db.LogSources))
+ }
+ ids := map[uint]string{}
+ for _, s := range db.LogSources {
+ if _, dup := ids[s.ID]; dup {
+ t.Fatalf("仍然存在重复 ID %d", s.ID)
+ }
+ ids[s.ID] = s.Name
+ }
+ if ids[1] != "loki-pre" {
+ t.Fatalf("第一个数据源应保留 ID=1,实际 %v", ids)
+ }
+ // 被重排的那个必须拿到新号
+ if _, ok := ids[2]; !ok {
+ t.Fatalf("重复项应被重排为 ID=2,实际 %v", ids)
+ }
+ // 策略组仍指向保留下来的那个
+ if db.LogPolicyGroups[0].SourceID != 1 {
+ t.Fatalf("策略组引用被误改:%d", db.LogPolicyGroups[0].SourceID)
+ }
+ // 计数器必须已提升,防止紧接着创建又撞号
+ if db.sequences["logSources"] < 2 {
+ t.Fatalf("sequences 未提升:%d", db.sequences["logSources"])
+ }
+}
+
+// 只有一个数据源时不应被改动
+func TestDedupeLogSourceIDsNoop(t *testing.T) {
+ db := &JsonDB{
+ sequences: map[string]uint{"logSources": 1},
+ LogSources: []models.LogSource{{ID: 1, Name: "only"}},
+ }
+ db.dedupeLogSourceIDs()
+ if len(db.LogSources) != 1 || db.LogSources[0].ID != 1 {
+ t.Fatalf("单数据源不应被改动:%+v", db.LogSources)
+ }
+}
+
+// 多个默认数据源只保留第一个
+func TestDedupeKeepsSingleDefault(t *testing.T) {
+ db := &JsonDB{
+ sequences: map[string]uint{"logSources": 2},
+ LogSources: []models.LogSource{
+ {ID: 1, Name: "a", IsDefault: true},
+ {ID: 2, Name: "b", IsDefault: true},
+ },
+ }
+ db.dedupeLogSourceIDs()
+ if !db.LogSources[0].IsDefault {
+ t.Fatal("第一个应保持默认")
+ }
+ if db.LogSources[1].IsDefault {
+ t.Fatal("第二个不应再是默认")
+ }
+}
diff --git a/backend/seed/alert_rules.go b/backend/seed/alert_rules.go
index 26668c9..fc87250 100644
--- a/backend/seed/alert_rules.go
+++ b/backend/seed/alert_rules.go
@@ -26,8 +26,10 @@ var builtinRules = []builtinRule{
{name: "Go panic", query: `{namespace=~".+"} |= "panic:"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "JVM OutOfMemoryError", query: `{namespace=~".+"} |~ "(^|[^a-zA-Z])OutOfMemoryError"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "StackOverflowError", query: `{namespace=~".+"} |= "StackOverflowError"`, windowMin: 15, threshold: 0, cooldown: 60},
- {name: "Kubernetes 节点 NotReady", query: `{app="kube-state-metrics"} |= "NotReady"`, windowMin: 15, threshold: 0, cooldown: 120},
- {name: "容器 OOMKilled", query: `{app=~"kube-state-metrics|event-exporter"} |= "OOMKilled"`, windowMin: 15, threshold: 0, cooldown: 120},
+ // 注意:这里**只放日志类告警**。
+ // 「节点 NotReady」「容器 OOMKilled」属于基础设施指标监控(kube-state-metrics /
+ // Prometheus)的职责,用日志去匹配这些字符串既不可靠(日志没开或轮转就漏),
+ // 又与日志平台的定位混淆。这类告警请交给 Prometheus + Alertmanager。
// —— 突增类:超过基线阈值才报 ——
{name: "应用错误过多(error > 1000)", query: `{namespace=~".+"} |~ "(?i)\berror\b"`, windowMin: 15, threshold: 1000, cooldown: 60},
{name: "ingress-nginx 5xx 错误", query: `{app=~"ingress-nginx|nginx-ingress-controller"} |~ " (50[0-4]|5[2-9][0-9]) `, windowMin: 15, threshold: 100, cooldown: 60},
diff --git a/backend/services/log_query.go b/backend/services/log_query.go
index 07f8964..e9af305 100644
--- a/backend/services/log_query.go
+++ b/backend/services/log_query.go
@@ -417,7 +417,10 @@ const (
// 日志量统计最多统计 7 天;更长跨度请分段查(前端会提示缩小范围)
maxHistogramRange = 7 * 24 * time.Hour
// 重查询(histogram/metrics)单独的更短超时,避免长时间占用 CPU
- heavyQueryTimeout = 30 * time.Second
+ // 90s:实测 astralx(约 370GB/天)15 分钟窗口单条 count_over_time
+ // exec 约 29s、外加排队时间,30s 必超时 → 分析页 502。
+ // 索引类查询(index/volume_range、index/stats)不受此限制,仍是毫秒级。
+ heavyQueryTimeout = 90 * time.Second
// 全局并发重查询上限。取 4 而不是 2:
// 日志分析页一次会并发 5 条 count_over_time,限制 2 会让大部分请求拿不到令牌。
maxConcurrentHeavyQueries = 4
diff --git a/backend/services/log_source.go b/backend/services/log_source.go
index bdbc2e9..df5ec3b 100644
--- a/backend/services/log_source.go
+++ b/backend/services/log_source.go
@@ -345,6 +345,45 @@ func DeleteLogSource(id uint) error {
return fmt.Errorf("数据源不存在")
}
+// reachableEndpoint 把部署记录里的访问地址转换成「平台侧能真正连上」的地址。
+//
+// 主机模式创建部署时存的是容器的监听地址(0.0.0.0:3100 / [::]:3100),
+// 那是容器内部视角,平台进程在别的机器/集群里,用它必然 connection refused。
+// 换成该主机的真实 IP 再返回;主机信息缺失时原样返回(至少不会更差)。
+func reachableEndpoint(d *models.LokiDeployment) string {
+ endpoint := strings.TrimSpace(d.Endpoint)
+ if endpoint == "" {
+ return endpoint
+ }
+ if !strings.Contains(endpoint, "0.0.0.0") && !strings.Contains(endpoint, "[::]") {
+ return endpoint
+ }
+ hostIP := ""
+ if d.Host != nil {
+ hostIP = strings.TrimSpace(d.Host.HostIP)
+ }
+ if hostIP == "" {
+ // 没带 Host 关联时回库查一次,避免调用方漏预加载
+ db := repository.GetDB()
+ if db != nil && d.HostID != nil {
+ db.RLock()
+ for i := range db.Hosts {
+ if db.Hosts[i].ID == *d.HostID {
+ hostIP = strings.TrimSpace(db.Hosts[i].HostIP)
+ break
+ }
+ }
+ db.RUnlock()
+ }
+ }
+ if hostIP == "" {
+ return endpoint
+ }
+ endpoint = strings.Replace(endpoint, "0.0.0.0", hostIP, 1)
+ endpoint = strings.Replace(endpoint, "[::]", hostIP, 1)
+ return endpoint
+}
+
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host→url;k8s→cluster-svc)。
// 已存在同名数据源时:平台托管的自动刷新连接信息,用户手填的不覆盖。
func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
@@ -360,7 +399,11 @@ func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
}
if d.Mode == "host-binary" || d.Mode == "host-docker" {
target.TargetType = "url"
- target.URL = d.Endpoint
+ // 主机模式部署的 endpoint 记的是**容器监听地址**(0.0.0.0:3100),
+ // 直接拿来当数据源 URL 会得到 dial tcp 0.0.0.0:3100: connection refused ——
+ // 平台进程不在那台主机上,0.0.0.0 只在本机有意义。
+ // 这里换成主机真实 IP,平台才能连上。
+ target.URL = reachableEndpoint(d)
target.ClusterID = nil
}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index ed89bab..91164be 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -16,6 +16,7 @@ import {
Menu,
Sun,
Moon,
+ Settings,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
@@ -237,6 +238,17 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
+ {/* 系统设置入口:低频平台配置,放右上角比占侧栏一位更合适 */}
+ {user?.role === 'admin' && (
+
+
+
+ )}
{user && (
diff --git a/frontend/src/i18n/locales/en/alerts.ts b/frontend/src/i18n/locales/en/alerts.ts
index 3bc1ff8..ccde8e0 100644
--- a/frontend/src/i18n/locales/en/alerts.ts
+++ b/frontend/src/i18n/locales/en/alerts.ts
@@ -7,6 +7,15 @@ export default {
checking: 'Checking...',
create: 'New Rule',
emptyTitle: 'No alert rules yet',
+ notifySectionTitle: 'Notification channels (Feishu / Lark / Webhook)',
+ notifySectionConfigured: '{{count}} configured',
+ notifySectionEmpty: 'Not configured — alerts cannot be delivered',
+ col: {
+ enabled: 'On',
+ name: 'Rule',
+ condition: 'Condition',
+ notify: 'Notify',
+ },
emptyDesc:
'e.g. {namespace=~".+"} |= "FATAL" — any match within 15 minutes pushes a Feishu (Lark) card',
sourceFallback: 'Data source {{id}}',
diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts
index c1f57b5..3b93a2d 100644
--- a/frontend/src/i18n/locales/en/common.ts
+++ b/frontend/src/i18n/locales/en/common.ts
@@ -5,7 +5,7 @@ export default {
groups: {
resources: 'Resources',
services: 'Log Service',
- system: 'System',
+ system: 'Users & Audit',
},
dashboard: 'Dashboard',
wizard: 'Deploy Wizard',
diff --git a/frontend/src/i18n/locales/en/logs.ts b/frontend/src/i18n/locales/en/logs.ts
index 4336548..6d72126 100644
--- a/frontend/src/i18n/locales/en/logs.ts
+++ b/frontend/src/i18n/locales/en/logs.ts
@@ -94,6 +94,9 @@ export default {
appWithKey: 'App ({{key}})',
pod: 'Pod',
selectCount: 'Select ({{count}} total)',
+ loading: 'Loading…',
+ loadFailed: 'Load failed',
+ retry: 'Retry',
noApp: 'No app in this scope',
pickNsFirst: 'Pick a namespace first to narrow down',
noPod: 'No pods under this app',
diff --git a/frontend/src/i18n/locales/zh/alerts.ts b/frontend/src/i18n/locales/zh/alerts.ts
index 53763cc..9e780d3 100644
--- a/frontend/src/i18n/locales/zh/alerts.ts
+++ b/frontend/src/i18n/locales/zh/alerts.ts
@@ -6,6 +6,15 @@ export default {
checking: '检查中...',
create: '新建规则',
emptyTitle: '暂无告警规则',
+ notifySectionTitle: '通知通道(飞书 / Lark / Webhook)',
+ notifySectionConfigured: '已配置 {{count}} 个',
+ notifySectionEmpty: '尚未配置,告警发不出去',
+ col: {
+ enabled: '启用',
+ name: '规则',
+ condition: '条件',
+ notify: '通知',
+ },
emptyDesc: '例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书',
sourceFallback: '数据源 {{id}}',
noChannels: '未关联通道',
diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts
index a775138..31a5d83 100644
--- a/frontend/src/i18n/locales/zh/common.ts
+++ b/frontend/src/i18n/locales/zh/common.ts
@@ -5,7 +5,7 @@ export default {
groups: {
resources: '资源接入',
services: '日志服务',
- system: '系统管理',
+ system: '用户与审计',
},
dashboard: '仪表盘',
wizard: '部署向导',
diff --git a/frontend/src/i18n/locales/zh/logs.ts b/frontend/src/i18n/locales/zh/logs.ts
index 56f6e71..c3005bb 100644
--- a/frontend/src/i18n/locales/zh/logs.ts
+++ b/frontend/src/i18n/locales/zh/logs.ts
@@ -93,6 +93,9 @@ export default {
appWithKey: 'App({{key}})',
pod: '副本 / Pod',
selectCount: '请选择(共 {{count}} 个)',
+ loading: '加载中…',
+ loadFailed: '加载失败',
+ retry: '重试',
noApp: '该范围内无 app',
pickNsFirst: '先选命名空间可缩小范围',
noPod: '该 app 下无副本',
diff --git a/frontend/src/pages/alerts/components/NotifySection.tsx b/frontend/src/pages/alerts/components/NotifySection.tsx
new file mode 100644
index 0000000..9e9ecf5
--- /dev/null
+++ b/frontend/src/pages/alerts/components/NotifySection.tsx
@@ -0,0 +1,246 @@
+import { memo, useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router'
+import { useTranslation } from 'react-i18next'
+import { BellRing, Plus, Pencil, Trash2, Send } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle,
+} from '@/components/ui/dialog'
+import { PasswordInput } from '@/components/PasswordInput'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
+import { alertsApi, type NotifyChannel } from '@/api/alerts'
+import { toast } from 'sonner'
+
+interface ChannelForm {
+ name: string
+ webhook: string
+ signingSecret: string
+ enabled: boolean
+}
+
+/**
+ * 通知通道(飞书/Lark)— 多 webhook 对应不同告警群。
+ * webhook 加密存储(留空=保留旧值),每通道可独立测试/禁用/删除(有关联规则时禁删)。
+ */
+export const NotifySection = memo(function NotifySection() {
+ const { t } = useTranslation('settings')
+ const [channels, setChannels] = useState
([])
+ const [open, setOpen] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [form, setForm] = useState({ name: '', webhook: '', signingSecret: '', enabled: true })
+ const [saving, setSaving] = useState(false)
+ const [testing, setTesting] = useState(false)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+
+ const refresh = useCallback(async () => {
+ try {
+ const list = await alertsApi.listChannels()
+ setChannels(Array.isArray(list) ? list : [])
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : t('notify.errLoad'))
+ }
+ }, [t])
+
+ useEffect(() => {
+ void refresh()
+ }, [refresh])
+
+ const openCreate = () => {
+ setEditing(null)
+ setForm({ name: '', webhook: '', signingSecret: '', enabled: true })
+ setOpen(true)
+ }
+
+ const openEdit = (ch: NotifyChannel) => {
+ setEditing(ch)
+ setForm({ name: ch.name, webhook: '', signingSecret: '', enabled: ch.enabled })
+ setOpen(true)
+ }
+
+ const handleSave = async () => {
+ if (!form.name.trim()) {
+ toast.error(t('notify.errNameRequired'))
+ return
+ }
+ if (!editing && !form.webhook) {
+ toast.error(t('notify.errWebhookRequired'))
+ return
+ }
+ setSaving(true)
+ try {
+ if (editing) {
+ await alertsApi.updateChannel(editing.id, {
+ name: form.name,
+ webhook: form.webhook || undefined,
+ signingSecret: form.signingSecret || undefined,
+ enabled: form.enabled,
+ })
+ } else {
+ await alertsApi.saveChannel({
+ name: form.name,
+ webhook: form.webhook,
+ signingSecret: form.signingSecret || undefined,
+ enabled: form.enabled,
+ })
+ }
+ setOpen(false)
+ void refresh()
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : t('notify.errSave'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleTest = async (ch: NotifyChannel) => {
+ setTesting(true)
+ try {
+ await alertsApi.testChannel({ id: ch.id })
+ toast.success(t('notify.testSent', { name: ch.name }))
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : t('notify.errSend'))
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ const handleToggle = async (ch: NotifyChannel, enabled: boolean) => {
+ try {
+ await alertsApi.updateChannel(ch.id, { name: ch.name, enabled })
+ void refresh()
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : t('notify.errToggle'))
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleting(true)
+ try {
+ await alertsApi.deleteChannel(deleteTarget.id)
+ setDeleteTarget(null)
+ void refresh()
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : t('notify.errDelete'))
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ {t('notify.cardTitle')}
+
+
+
+
+
+ {channels.length === 0 ? (
+
+ {t('notify.emptyBefore')}
+ {t('notify.emptyLink')}
+ {t('notify.emptyAfter')}
+
+ ) : (
+ channels.map((ch) => (
+
+
void handleToggle(ch, v)} />
+
+
{ch.name}
+
+ {ch.hasWebhook ? t('notify.webhookConfigured') : t('notify.webhookMissing')}
+ {ch.hasSecret ? t('notify.secretConfigured') : ''}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ { if (!o) setDeleteTarget(null) }}
+ name={deleteTarget?.name}
+ title={t('notify.deleteTitle')}
+ loading={deleting}
+ onConfirm={handleDelete}
+ />
+
+ )
+})
diff --git a/frontend/src/pages/alerts/index.tsx b/frontend/src/pages/alerts/index.tsx
index 26ac0cf..17b5ba1 100644
--- a/frontend/src/pages/alerts/index.tsx
+++ b/frontend/src/pages/alerts/index.tsx
@@ -1,11 +1,11 @@
import { memo, useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router'
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
+import { NotifySection } from './components/NotifySection'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
@@ -216,55 +216,72 @@ const AlertsPage = memo(function AlertsPage() {
description={t('emptyDesc')}
/>
) : (
-
- {rules.map((rule) => {
- const src = sources.find((s) => s.id === rule.sourceId)
- return (
-
-
- void toggleRule(rule, v)} />
-
-
{rule.name}
-
{rule.query}
-
- {src?.name ?? t('sourceFallback', { id: rule.sourceId })} →{' '}
- {(rule.channelIds || [])
- .map((id) => channels.find((c) => c.id === id)?.name || `#${id}`)
- .join(t('channelSep')) || t('noChannels')}{' '}
- · {t('ruleMeta', {
- window: rule.windowMin,
- threshold: rule.threshold,
- cooldown: rule.cooldownMin,
- })}
- {rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · ${t('lastFired', {
- time: new Date(rule.lastFiredAt).toLocaleString(),
- })}`}
-
-
-
-
-
-
-
-
- )
- })}
-
+ /* 紧凑表格:原来每条规则一张 Card(p-4 + 三行文字),10 条规则要滚很久。
+ 规则本质是同构的行数据,用表格能在一屏内看全,也便于对齐比较阈值。 */
+
+
+
+
+
+ | {t('col.enabled')} |
+ {t('col.name')} |
+ {t('col.condition')} |
+ {t('col.notify')} |
+ |
+
+
+
+ {rules.map((rule) => {
+ const src = sources.find((s) => s.id === rule.sourceId)
+ const chan = (rule.channelIds || [])
+ .map((id) => channels.find((c) => c.id === id)?.name || `#${id}`)
+ .join(t('channelSep')) || t('noChannels')
+ return (
+
+ |
+ void toggleRule(rule, v)} />
+ |
+
+ {rule.name}
+
+ {src?.name ?? t('sourceFallback', { id: rule.sourceId })}
+
+ |
+
+
+ {rule.query}
+
+
+ {t('ruleMeta', {
+ window: rule.windowMin,
+ threshold: rule.threshold,
+ cooldown: rule.cooldownMin,
+ })}
+ {rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · ${t('lastFired', {
+ time: new Date(rule.lastFiredAt).toLocaleString(),
+ })}`}
+
+ |
+
+ {chan}
+ |
+
+
+
+
+
+ |
+
+ )
+ })}
+
+
+
+
)}
{/* 发送历史 */}
@@ -301,11 +318,22 @@ const AlertsPage = memo(function AlertsPage() {
)}
-
- {t('notifyHintPre')}
- {t('common:menu.settings')}
- {t('notifyHintPost')}
-
+ {/* 通知通道配置:从「系统设置」搬到这里。
+ 告警规则和"告警发到哪"是同一件事的两半,分两个页面配很容易漏配导致告警发不出去。
+ 默认折叠,不影响规则列表的紧凑度。 */}
+
+
+ {t('notifySectionTitle')}
+
+ {channels.length > 0
+ ? t('notifySectionConfigured', { count: channels.length })
+ : t('notifySectionEmpty')}
+
+
+
+
+
+
{/* 规则弹窗 */}
)}
-