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') : ''} +

+
+
+ + + +
+
+ )) + )} +
+ + + + + + {editing ? t('notify.editTitle', { name: editing.name }) : t('notify.createTitle')} + + +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, webhook: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, signingSecret: e.target.value }))} + /> +
+
+ setForm((f) => ({ ...f, enabled: v }))} /> + +
+
+ + +
+
+
+
+ + { 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 条规则要滚很久。 + 规则本质是同构的行数据,用表格能在一屏内看全,也便于对齐比较阈值。 */ + +
+ + + + + + + + + + + {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 ( + + + + + + + + ) + })} + +
{t('col.enabled')}{t('col.name')}{t('col.condition')}{t('col.notify')} +
+ 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')} + + +
+ +
+
{/* 规则弹窗 */} diff --git a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts index c9bd66a..ff9d9f7 100644 --- a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts +++ b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts @@ -111,7 +111,10 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis // 分批(每批 2 条)而不是一次性 5 条并发: // Loki 单实例被 5 条 count_over_time 同时打会互相拖慢, // 而且会撞后端并发护栏。分批后总耗时接近,但不会互相踩。 - const runInBatches = async (tasks: Array<() => Promise>, size = 2): Promise => { + // 串行执行(size=1):分析页一次要跑 5 条重查询(count_over_time), + // 后端并发护栏是 4 且共享给所有用户。并发 2 时多人同时打开分析页 + // 很容易撞 429,表现为"加载不出来"。串行慢一点但稳。 + const runInBatches = async (tasks: Array<() => Promise>, size = 1): Promise => { const out: T[] = [] for (let index = 0; index < tasks.length; index += size) { out.push(...await Promise.all(tasks.slice(index, index + size).map(task => task()))) @@ -123,8 +126,42 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis setError('') void (async () => { try { - const [totalSeries, errorSeries, warnSeries, byAppSeries, errByAppSeries] = await runInBatches([ - () => fetchRetry(`sum(count_over_time(${selector}[$step]))`), + // ── 第一步:总量趋势走 Loki 的索引接口(index/volume_range)── + // + // 原来第一张图也用 count_over_time,实测 astralx(约 370GB/天)15 分钟窗口 + // 单条要 exec 29s + 排队,5 条串行下来两三分钟都出不来,页面就是"死在转圈"。 + // /logs/histogram 内部用 index/volume_range,只读 TSDB 的 chunk 元数据, + // 不解码日志内容,同一条件毫秒级返回(对比实测 ~1300 倍差距)。 + const hist = await logsApi.histogram( + { sourceId, query: selector, start: Number(ns(new Date(start).getTime())), end: Number(ns(new Date(end).getTime())) }, + { signal: controller.signal }, + ) + const totalSeries: MetricSeries[] = [{ + labels: {}, + points: (hist || []).map(bucket => ({ + timestamp: String((bucket as { timestamp: string }).timestamp ?? ''), + value: Number((bucket as { count: number }).count ?? 0), + })), + }] + if (version !== versionRef.current) return + + // 主图先渲染:即使后面的 app 维度还在算,用户也能立刻看到总量趋势 + setLoading(false) + setData(prev => ({ + trend: buildTrend(totalSeries, [], []), + namespaceShares: prev?.namespaceShares ?? [], + apps: prev?.apps ?? [], + appTimes: prev?.appTimes ?? [], + stats: { + total: seriesTotal(totalSeries), + errorCount: prev?.stats.errorCount ?? 0, + warnCount: prev?.stats.warnCount ?? 0, + appCount: prev?.stats.appCount ?? 0, + }, + })) + + // ── 第二步:错误/警告/app 维度仍需读日志内容,串行执行 ── + const [errorSeries, warnSeries, byAppSeries, errByAppSeries] = await runInBatches([ () => fetchRetry(`sum(count_over_time(${selector} |= "error"[$step]))`), () => fetchRetry(`sum(count_over_time(${selector} |= "warn"[$step]))`), () => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`), diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index d509788..0f2accd 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -39,7 +39,9 @@ const AnalysisPage = memo(function AnalysisPage() { const [sources, setSources] = useState([]) const [sourceId, setSourceId] = useState(0) - const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000))) + // 默认 15 分钟:分析页一次性要跑 5 条 count_over_time, + // 1 小时窗口在日志量大的命名空间要十几秒;15 分钟既能看出趋势又能快速出结果。 + const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000))) const [end, setEnd] = useState(() => toLocal(new Date())) /** @@ -49,7 +51,9 @@ const AnalysisPage = memo(function AnalysisPage() { * 现在默认只查业务命名空间,用户可下拉切换;"全部命名空间"作为显式选项并列提示较慢。 */ const [namespaces, setNamespaces] = useState([]) - const [namespace, setNamespace] = useState(() => localStorage.getItem('analysisNamespace') ?? '') + // 初始为空,等策略返回后再定(不要用 localStorage 覆盖策略: + // 策略是服务端下发的权威值,本地记忆会把管理员改过的策略"顶掉")。 + const [namespace, setNamespace] = useState('') const [namespaceReady, setNamespaceReady] = useState(false) useEffect(() => { @@ -65,20 +69,35 @@ const AnalysisPage = memo(function AnalysisPage() { .catch(() => setSources([])) }, []) - // 拉取可用命名空间;默认优先业务命名空间 astralx,其次列表第一个 + // 拉取可用命名空间,并把**查询授权策略里配置的命名空间**作为默认值。 + // + // 之前这里硬编码"优先 astralx",等于把环境特征写进代码: + // 换环境(策略配的是 default / 别的 ns)就默认选错,用户还得手动改。 + // 现在默认值来源依次为:URL 记忆 → 授权策略 labels.namespace → default → 列表第一个。 useEffect(() => { if (!sourceId) return let cancelled = false - logsApi.labelValues(sourceId, 'namespace') - .then(list => { + Promise.all([ + logsApi.labelValues(sourceId, 'namespace'), + // 策略里配的命名空间(后台「查询授权」可改);失败不影响主流程 + logsApi.myScope(sourceId).catch(() => null), + ]) + .then(([list, scope]) => { if (cancelled) return const values = (list || []).filter(Boolean).sort() + const scopeNs = scope?.labels?.namespace ?? '' setNamespaces(values) setNamespace(current => { - if (current && (current === ALL_NAMESPACES || values.includes(current))) return current - const saved = localStorage.getItem('analysisNamespace') - if (saved && (saved === ALL_NAMESPACES || values.includes(saved))) return saved - return values.includes('astralx') ? 'astralx' : values[0] ?? '' + // 优先级:用户当前已选(同一数据源内不跳变)→ 授权策略 → default → 第一个 + const candidates = [current, scopeNs, 'default', values[0]] + for (const candidate of candidates) { + if (!candidate) continue + if (candidate === ALL_NAMESPACES) return candidate + if (values.includes(candidate)) return candidate + } + // 策略里配了但 Loki 里暂时没数据时,仍按策略显示(用户能看到"为什么没数据") + if (scopeNs) return scopeNs + return '' }) }) .catch(() => { if (!cancelled) setNamespaces([]) }) @@ -86,6 +105,11 @@ const AnalysisPage = memo(function AnalysisPage() { return () => { cancelled = true } }, [sourceId]) + // 记住用户手选,下次进入沿用(但不会覆盖策略下发的值) + useEffect(() => { + if (namespace) localStorage.setItem('analysisNamespace', namespace) + }, [namespace]) + // hook 内部把空串当作 {namespace=~".+"}(全部),这里做哨兵转换 const scopedNamespace = namespace === ALL_NAMESPACES ? '' : namespace const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId, start, end, namespace: scopedNamespace }) @@ -106,7 +130,7 @@ const AnalysisPage = memo(function AnalysisPage() { title={t('title')} description={t('subtitle')} action={ - diff --git a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx index 056779b..f3ddf80 100644 --- a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx +++ b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx @@ -42,6 +42,12 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, * Promtail 会从容器名生成 service_name / container,覆盖度更高, * 因此按 app → service_name → container 逐级回退。 */ + /** 命名空间加载中(与"加载失败"要分开,否则空列表会一直显示"加载中") */ + const [namespaceLoading, setNamespaceLoading] = useState(false) + /** 命名空间加载失败原因(如数据源地址不可达 connection refused) */ + const [namespaceError, setNamespaceError] = useState('') + /** 重试计数:失败后点「重试」时 +1 触发重新拉取 */ + const [reloadKey, setReloadKey] = useState(0) const [appLabel, setAppLabel] = useState('app') /** 授权策略里配置的 namespace(如默认策略的 default),用作默认选中值 */ const [scopeNamespace, setScopeNamespace] = useState('') @@ -61,18 +67,41 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, return () => { active = false } }, [sourceId]) - // namespace 列表只加载一次 + // namespace 列表:**每次切换数据源都重新加载**。 + // + // 三个必须点: + // 1) 先清空旧值:否则切换数据源后仍显示上一个源的命名空间(看着像"没反应") + // 2) 记录 loading 与 error 两个状态:原来用 !options.length 表示 loading, + // 数据源不可达时列表永远为空 → 界面永远"加载中…",把真实错误藏起来了 + // (典型错误:主机模式数据源地址是 0.0.0.0 → connection refused) + // 3) 失败后可点重试,不用刷新整页 useEffect(() => { - if (!sourceId) return + if (!sourceId) { + setNamespaces([]) + setApps([]) + setPods([]) + setNamespaceError('') + return + } let active = true + setNamespaceLoading(true) + setNamespaceError('') + setNamespaces([]) + setApps([]) + setPods([]) logsApi.labelValues(sourceId, 'namespace') // 后端历史版本可能返回 null(Loki 无值时 data:null),这里兜底成数组, // 否则 setState(null) 后 datalist 的 .map() 会抛 // "Cannot read properties of null (reading 'map')" .then(next => { if (active) setNamespaces(Array.isArray(next) ? next : []) }) - .catch(() => { if (active) setNamespaces([]) }) + .catch(err => { + if (!active) return + setNamespaces([]) + setNamespaceError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { if (active) setNamespaceLoading(false) }) return () => { active = false } - }, [sourceId]) + }, [sourceId, reloadKey]) // app 候选:选了 namespace 就只在该命名空间内搜索 useEffect(() => { @@ -204,10 +233,26 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, values={namespaceOptions} value={namespace} onChange={chooseNamespace} - placeholder={t('templates.selectCount', { count: namespaceOptions.length })} + placeholder={ + namespaceError + ? t('templates.loadFailed') + : namespaceLoading + ? t('templates.loading') + : t('templates.selectCount', { count: namespaceOptions.length }) + } allowEmpty - loading={!namespaceOptions.length} + loading={namespaceLoading} /> + {namespaceError && ( + + )} ([]) const [sourceId, setSourceId] = useState(0) + /** + * 按 id 去重的数据源选项。 + * + * 后端曾因 sequences 漏建导致两个数据源 ID 都是 1, + * 于是 出现两个相同 value —— 用户点第二个时 sourceId + * 仍是 1,state 不变、effect 不触发,界面上表现为"数据源切换不了"。 + * 后端已修 + 做了迁移去重,这里再兜一层:即使历史数据仍有重复, + * 下拉也不会因相同 value 而卡死。 + */ + const sourceOptions = useMemo(() => { + const seen = new Set() + return sources.filter(source => { + if (seen.has(source.id)) return false + seen.add(source.id) + return true + }) + }, [sources]) const [query, setQuery] = useState('') const [executedQuery, setExecutedQuery] = useState('') const [executedRange, setExecutedRange] = useState<{ start: number; end: number } | null>(null) @@ -263,12 +280,12 @@ export default function LogsPage() {
)}
- setSourceId(Number(value))} disabled={sourceOptions.length === 0}> - {sources.map(source => {source.name})} + {sourceOptions.map(source => {source.name})} { setStart(nextStart); setEnd(nextEnd); setTail(false) }} /> diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx index eebb456..ab21688 100644 --- a/frontend/src/pages/settings/index.tsx +++ b/frontend/src/pages/settings/index.tsx @@ -14,7 +14,6 @@ import { LdapSection } from './components/LdapSection' import { ThemeSection } from './components/ThemeSection' import { LanguageSection } from '@/i18n/LanguageSection' import { Palette } from 'lucide-react' -import { NotifySection } from './components/NotifySection' import { ImageRegistrySection } from './components/ImageRegistrySection' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -180,7 +179,7 @@ const SettingsPage = memo(function SettingsPage() { - + {/* 告警通知通道已移到「日志告警 → 通知通道」,避免配置入口分裂 */} diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx index 1c5fb5e..7315f06 100644 --- a/frontend/src/router/routes.tsx +++ b/frontend/src/router/routes.tsx @@ -68,7 +68,9 @@ export const routes: AppRoute[] = [ { path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true } }, // ── 告警:独立入口(要盯的,不能被折叠) ── - { path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 7 } }, + // 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。 + // 告警规则涉及阈值/通知通道等平台级配置,不属于日志查询者的职责。 + { path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 7, adminOnly: true } }, // ── 系统管理(用户 / 审计 / 设置):低频管理动作 ── { @@ -79,7 +81,9 @@ export const routes: AppRoute[] = [ }, }, { path: '/audit', component: AuditPage, auth: true, meta: { title: 'menu.audit', icon: History, order: 13, adminOnly: true, groupKey: 'system' } }, - { path: '/settings', component: SettingsPage, auth: true, meta: { title: 'menu.settings', icon: Settings, order: 12, adminOnly: true, groupKey: 'system' } }, + // 系统设置从侧栏移到右上角齿轮入口:属于低频平台配置, + // 和「用户/审计」这类日常安全管理不是一类东西,混在一个分组里太乱。 + { path: '/settings', component: SettingsPage, auth: true, meta: { title: 'menu.settings', icon: Settings, order: 12, adminOnly: true, hideInMenu: true } }, { path: '*', component: NotFound, auth: false, meta: { title: 'menu.notFound', hideInMenu: true } }, ];