fix(logs): 「所有人」策略保存被拒 + 快捷 LogQL namespace 空且不认默认策略

=== 一、保存策略组报「用户ID无效或重复」===
validatePolicyGroup 里 `if id == 0 || seen[id]` 直接把「所有人」哨兵当非法。
修复:
  - 新增常量 AnyoneUserID = 0(与 policyGroupAccess、前端 ANYONE 对齐)
  - 0 跳过用户存在性校验,但必须独占:与具体用户混选时报
    「「所有人」不能与具体用户同时选择」
  - 重复检查独立出来,报错带具体 ID

=== 二、快捷 LogQL 的 namespace 下拉总是空的 ===
两个叠加原因:
  1. 用的是原生 <datalist>,只在输入时才提示、样式弱,看着像"下拉是空的"
  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 三个 <datalist> 全部换成 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
This commit is contained in:
opsx committed 2026-10-27 02:59:33 +00:00
1 parent 4dda6e5ba8
commit cf54be3f7d
6 files changed
+131 -16

No files matched your search

+33
View File
@@ -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)
+1
View File
@@ -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)
}
+14 -2
View File
@@ -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" {
+26
View File
@@ -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 {
+2
View File
@@ -25,6 +25,8 @@ export const logsApi = {
histogram: (data: LogQuery) => http.post<Array<Record<string, unknown>>>('/logs/histogram', data, { timeout: 125000 }),
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/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<string, string>; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`),
policyGroups: () => http.get<LogPolicyGroup[]>('/logs/policy-groups'),
createPolicyGroup: (data: Omit<LogPolicyGroup, 'id'>) => http.post<LogPolicyGroup>('/logs/policy-groups', data),
updatePolicyGroup: (id: number, data: Omit<LogPolicyGroup, 'id'>) => http.put<LogPolicyGroup>(`/logs/policy-groups/${id}`, data),
@@ -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 && <h2 className="text-sm font-medium">快捷生成 LogQL</h2>}
{!compact && <p className="text-xs text-muted-foreground">先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。</p>}
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-36 flex-1 text-xs text-muted-foreground">namespace
<Input className="mt-1 h-8" list="quick-log-namespaces" value={namespace} onChange={event => chooseNamespace(event.target.value)} placeholder="先选命名空间" />
</label>
<datalist id="quick-log-namespaces">{namespaces.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">app
<Input className="mt-1 h-8" list="quick-log-apps" value={app} onChange={event => chooseApp(event.target.value)} placeholder={namespace ? '搜索该命名空间下的 app' : '先选 namespace 可缩小范围'} />
</label>
<datalist id="quick-log-apps">{apps.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">副本 pod
<Input className="mt-1 h-8" list="quick-log-pods" value={pod} onChange={event => setPod(event.target.value)} placeholder={app ? '搜索该 app 的副本' : '可选'} />
</label>
<datalist id="quick-log-pods">{pods.map(value => <option value={value} key={value} />)}</datalist>
{/* 用 SearchableValueSelect(点击即展开列表 + 可搜索),
原生 datalist 只在输入时才提示、样式弱,容易让人以为「下拉是空的」 */}
<SearchableValueSelect
label="命名空间"
values={namespaceOptions}
value={namespace}
onChange={chooseNamespace}
placeholder={`请选择(共 ${namespaceOptions.length} 个)`}
allowEmpty
loading={!namespaceOptions.length}
/>
<SearchableValueSelect
label={appLabel === 'app' ? 'App' : `App(${appLabel})`}
values={apps}
value={app}
onChange={chooseApp}
placeholder={apps.length ? `请选择(共 ${apps.length} 个)` : namespace ? '该范围内无 app' : '先选命名空间可缩小范围'}
allowEmpty
disabled={!sourceId}
/>
<SearchableValueSelect
label="副本 / Pod"
values={pods}
value={pod}
onChange={setPod}
placeholder={pods.length ? `请选择(共 ${pods.length} 个)` : app ? '该 app 下无副本' : '可选'}
allowEmpty
disabled={!sourceId}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>查询该范围日志</Button>