From cf54be3f7de0c2b6a484fdf13be70b0bb8aad3df Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 27 Oct 2026 02:59:33 +0000 Subject: [PATCH] =?UTF-8?q?fix(logs):=20=E3=80=8C=E6=89=80=E6=9C=89?= =?UTF-8?q?=E4=BA=BA=E3=80=8D=E7=AD=96=E7=95=A5=E4=BF=9D=E5=AD=98=E8=A2=AB?= =?UTF-8?q?=E6=8B=92=20+=20=E5=BF=AB=E6=8D=B7=20LogQL=20namespace=20?= =?UTF-8?q?=E7=A9=BA=E4=B8=94=E4=B8=8D=E8=AE=A4=E9=BB=98=E8=AE=A4=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 一、保存策略组报「用户ID无效或重复」=== validatePolicyGroup 里 `if id == 0 || seen[id]` 直接把「所有人」哨兵当非法。 修复: - 新增常量 AnyoneUserID = 0(与 policyGroupAccess、前端 ANYONE 对齐) - 0 跳过用户存在性校验,但必须独占:与具体用户混选时报 「「所有人」不能与具体用户同时选择」 - 重复检查独立出来,报错带具体 ID === 二、快捷 LogQL 的 namespace 下拉总是空的 === 两个叠加原因: 1. 用的是原生 ,只在输入时才提示、样式弱,看着像"下拉是空的" 2. 候选只来自 Loki 实际值 —— 而默认策略配的 default 命名空间里没有业务 Pod, Loki 里根本没有这个 label 值,所以永远不出现;前端也不会拿策略值当默认选中 修复: 后端新增 GET /api/v1/logs/my-scope?sourceId= → {labels, unrestricted} (services.UserAccessLabels 暴露当前用户在数据源上的授权 labels; admin 返回 unrestricted=true 但仍带上策略 labels 供 UI 做默认值) 前端: - logsApi.myScope() - QuickQueryTemplates 用策略里的 namespace 作为初始选中值 - namespaceOptions = 策略值 ∪ Loki 实际值(去重,策略值优先) - namespace/app/pod 三个 全部换成 SearchableValueSelect (点击即展开、可搜索,placeholder 显示「请选择(共 N 个)」) 验证: PUT /logs/policy-groups/1 {userIds:[0]} → 200 ✅ POST userIds:[0,2](混选) → 400「「所有人」不能与具体用户同时选择」✅ GET /logs/my-scope?sourceId=1 → {"labels":{"namespace":"default"},"unrestricted":true} ✅ bundle: my-scope 1 处、datalist 残留 0 处 ✅ 镜像:lghost/lokistack:latest + v0.1 = sha256:ad659f564665 --- backend/controllers/logs.go | 33 +++++++++ backend/routers/router.go | 1 + backend/services/log_policy.go | 16 ++++- backend/services/log_source.go | 26 +++++++ frontend/src/api/logs/index.ts | 2 + .../logs/components/QuickQueryTemplates.tsx | 69 +++++++++++++++---- 6 files changed, 131 insertions(+), 16 deletions(-) diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 0e91c4e..a012c0d 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -245,6 +245,39 @@ func (ctrl *LogsController) LabelValues(c *gin.Context) { utils.Success(c, values) } +// MyScope 返回当前登录用户在某数据源上的授权范围。 +// +// 快捷 LogQL 用它做两件事: +// 1. 把授权策略里配置的 namespace 作为默认选中值(管理员配了 default/astralx 后开箱即用) +// 2. 把该值合并进 namespace 候选列表 —— 否则像 default 这种「策略里有、Loki 里没日志」 +// 的命名空间不会出现在下拉里,用户会以为策略没生效 +func (ctrl *LogsController) MyScope(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64) + src := services.GetLogSource(uint(id)) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + if c.GetString("role") == "admin" { + // 管理员的查询范围不受策略限制(unrestricted=true), + // 但仍返回策略里的 labels 作为快捷 LogQL 的默认选中值—— + // 否则管理员在小黑框里看到的下拉永远没有策略配的那个 namespace, + // 会误以为「默认策略没生效」。 + labels := services.UserAccessLabels(src, c.GetUint("userId")) + if labels == nil { + labels = map[string]string{} + } + utils.Success(c, gin.H{"labels": labels, "unrestricted": true}) + return + } + labels := services.UserAccessLabels(src, c.GetUint("userId")) + if labels == nil { + utils.Forbidden(c, "无权查询此数据源") + return + } + utils.Success(c, gin.H{"labels": labels, "unrestricted": len(labels) == 0}) +} + // Health Loki /ready 探活(查询页状态点) func (ctrl *LogsController) Health(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64) diff --git a/backend/routers/router.go b/backend/routers/router.go index 97a4da5..497c771 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -84,6 +84,7 @@ func SetupRouter() *gin.Engine { logs.POST("/context", logsCtrl.Context) logs.GET("/labels", logsCtrl.Labels) logs.GET("/label/:name/values", logsCtrl.LabelValues) + logs.GET("/my-scope", logsCtrl.MyScope) logs.POST("/histogram", logsCtrl.Histogram) logs.GET("/health", logsCtrl.Health) } diff --git a/backend/services/log_policy.go b/backend/services/log_policy.go index 419ac99..827561b 100644 --- a/backend/services/log_policy.go +++ b/backend/services/log_policy.go @@ -25,6 +25,10 @@ func ListLogPolicyGroups() []models.LogPolicyGroup { return groups } +// AnyoneUserID 是 userIds 里的哨兵值,表示「所有人」(anyone / *)。 +// 与 policyGroupAccess 的匹配逻辑、前端 ANYONE 常量保持一致。 +const AnyoneUserID uint = 0 + func validatePolicyGroup(db *repository.JsonDB, group models.LogPolicyGroup, excludeID uint) error { group.Name = strings.TrimSpace(group.Name) if group.Name == "" { @@ -53,10 +57,18 @@ func validatePolicyGroup(db *repository.JsonDB, group models.LogPolicyGroup, exc } seen := map[uint]bool{} for _, id := range group.UserIDs { - if id == 0 || seen[id] { - return fmt.Errorf("用户 ID 无效或重复") + if seen[id] { + return fmt.Errorf("用户 ID 重复: %d", id) } seen[id] = true + // AnyoneUserID(0) 是「所有人」哨兵,不对应真实用户记录, + // 跳过存在性校验;但它必须独占,不能与其他具体用户混选。 + if id == AnyoneUserID { + if len(group.UserIDs) > 1 { + return fmt.Errorf("「所有人」不能与具体用户同时选择") + } + continue + } found := false for _, user := range db.Users { if user.ID == id && user.Role != "admin" { diff --git a/backend/services/log_source.go b/backend/services/log_source.go index 13ed2ad..c21fd21 100644 --- a/backend/services/log_source.go +++ b/backend/services/log_source.go @@ -58,6 +58,32 @@ func ListVisibleLogSources(userID uint, admin bool) []map[string]interface{} { return out } +// UserAccessLabels 返回某用户在指定数据源上的授权 labels。 +// +// 返回值语义: +// - nil → 该用户对此数据源完全无权限 +// - 空 map → 有权限但不限定范围(可查全部) +// - 非空 map → 只能查匹配这些 label 的日志(如 {"namespace":"astralx"}) +func UserAccessLabels(src *models.LogSource, userID uint) map[string]string { + db := repository.GetDB() + var access *models.LogSourceAccess + if db != nil { + db.RLock() + access = policyGroupAccess(db.LogPolicyGroups, src.ID, userID) + db.RUnlock() + } + if access == nil { + access = sourceAccess(src, userID) + } + if access == nil { + return nil + } + if access.Labels == nil { + return map[string]string{} + } + return access.Labels +} + func sourceAccess(src *models.LogSource, userID uint) *models.LogSourceAccess { for i := range src.Access { if src.Access[i].UserID == userID { diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index 7379a36..163bcd2 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -25,6 +25,8 @@ export const logsApi = { histogram: (data: LogQuery) => http.post>>('/logs/histogram', data, { timeout: 125000 }), labels: (sourceId: number, start?: number, end?: number) => http.get(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`), labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`), + /** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */ + myScope: (sourceId: number) => http.get<{ labels: Record; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`), policyGroups: () => http.get('/logs/policy-groups'), createPolicyGroup: (data: Omit) => http.post('/logs/policy-groups', data), updatePolicyGroup: (id: number, data: Omit) => http.put(`/logs/policy-groups/${id}`, data), diff --git a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx index 646a9e3..4c4c499 100644 --- a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx +++ b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { logsApi } from '@/api/logs' +import { SearchableValueSelect } from './SearchableValueSelect' import { applyLabelFilter, extractSelector } from '../query-utils' type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress' @@ -33,6 +33,23 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: * 因此按 app → service_name → container 逐级回退。 */ const [appLabel, setAppLabel] = useState('app') + /** 授权策略里配置的 namespace(如默认策略的 default),用作默认选中值 */ + const [scopeNamespace, setScopeNamespace] = useState('') + + // 授权策略的 namespace:作为默认选中值(管理员在「查询授权」里配好后开箱即用) + useEffect(() => { + if (!sourceId) return + let active = true + logsApi.myScope(sourceId) + .then(scope => { + if (!active) return + const ns = (scope?.labels?.namespace ?? '').trim() + setScopeNamespace(ns) + if (ns) setNamespace(current => current || ns) + }) + .catch(() => { /* 无权限或无策略时静默 */ }) + return () => { active = false } + }, [sourceId]) // namespace 列表只加载一次 useEffect(() => { @@ -112,6 +129,13 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: onQueryChange(next) } + // 候选 = 策略里配的 namespace ∪ Loki 实际有的 namespace + // (default 这类「策略配了但没日志」的命名空间也要能选到,否则用户以为策略没生效) + const namespaceOptions = useMemo( + () => (scopeNamespace && !namespaces.includes(scopeNamespace) ? [scopeNamespace, ...namespaces] : namespaces), + [namespaces, scopeNamespace], + ) + const hasSelector = extractSelector(query).replace(/[{}\s]/g, '').length > 0 const hasScope = Boolean(app.trim() || namespace.trim() || pod.trim() || hasSelector) @@ -119,18 +143,35 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {!compact &&

快捷生成 LogQL

} {!compact &&

先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。

}
- - {namespaces.map(value => - - {apps.map(value => - - {pods.map(value => + {/* 用 SearchableValueSelect(点击即展开列表 + 可搜索), + 原生 datalist 只在输入时才提示、样式弱,容易让人以为「下拉是空的」 */} + + +