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 只在输入时才提示、样式弱,容易让人以为「下拉是空的」 */} + + +