fix(logs): 快捷 LogQL null 崩溃 + app 标签错位 + 查询授权「所有人」与默认策略

=== 一、快捷 LogQL 报 Cannot read properties of null (reading 'map') ===
根因:Loki 对无值的 label 返回 {"data":null},Go nil slice 序列化成 JSON null,
     前端 setState(null) 后再 .map() / [...null] / .filter() 直接抛错。
     表现为「不稳定」——切到某个没有该 label 值的范围就崩。

修复(前后端双保险):
  后端 LokiLabels / LokiLabelValues 归一化 nil → []string{}
    (LokiHistogram 原本就返回空切片,无需改)
  前端 6 处调用点加 Array.isArray 防御:
    QuickQueryTemplates ×3、QueryInput ×2、LokiLabelBrowser ×2

=== 二、选 astralx 只显示 2 个 app(不是权限问题)===
实测:
  astralx 117 个 Deployment / 497 个 Pod
  带 app 标签的 Pod 只有 2 个(cex-promotion-web / cex-web-h5)
  其余 115 个 Deployment 的 Pod 模板没打 app,Kuboard 打的是 k8s.kuboard.cn/name
  Loki 里 service_name / container 各有 79 个值(Promtail 从容器名生成)

修复:QuickQueryTemplates 的「app」候选改为三级回退 app → service_name → container,
     并用 appLabel 状态记住实际生效的标签名,pod 收窄与 generate() 都用它,
     保证生成的 LogQL selector 真能查到数据。

=== 三、查询授权:支持「所有人」+ 内置默认策略 ===
1. 后端 policyGroupAccess:userIds 里的 0 作为哨兵值表示「所有人」(anyone / *),
   匹配任意用户(原来只能逐个指定用户 ID)
2. 种子 seedDefaultPolicy():首次启动且尚无任何策略组时,创建
   「默认策略(所有人 · default 命名空间)」(labels: namespace=default,userIds: [0])。
   管理员可在「查询授权」里把命名空间改成实际业务命名空间(如 astralx),
   对该组所有用户立即生效。
3. 前端 PolicyGroupsPage:新增「所有人(anyone)」一键按钮(与具体用户互斥),
   列表与编辑区显示「所有人」,校验文案同步更新。

验证:
  GET /logs/label/nonexistent_xyz/values            → {"data":[]}
  GET /logs/label/service_name/values?query={namespace="astralx"} → 79 个
  GET /logs/policy-groups → 1 条:默认策略(所有人 · default 命名空间)所有人

镜像:lghost/lokistack:latest + v0.1 = sha256:88a4ba626f62
This commit is contained in:
opsx committed 2026-10-27 01:19:06 +00:00
1 parent 46e73d3d17
commit 4dda6e5ba8
7 files changed
+119 -16

No files matched your search

+46
View File
@@ -5,6 +5,7 @@ import (
"log"
"os"
"path/filepath"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
@@ -82,10 +83,55 @@ func Seed() {
}
}
// 内置默认查询策略:所有人可查询 default 命名空间。
// 目的:新环境/新账号开箱即有可查范围,快捷 LogQL 的 namespace 下拉不会空。
seedDefaultPolicy()
// 内置告警规则:有 managed 数据源(部署过 Loki)时补齐通用模板(默认禁用)
SeedAlertRules()
}
// seedDefaultPolicy 创建「默认策略」:所有用户可查询 default 命名空间。
//
// 设计要点:
// - UserIDs 里的 0 是哨兵值,表示「所有人」(anyone / *),见 policyGroupAccess
// - 只在当前还没有任何策略组时创建,避免覆盖管理员的自定义配置
// - 命名空间取 default 作为占位;管理员可在「查询授权」里改成实际业务命名空间
// (如 astralx / prod),改动对该策略组所有用户立即生效
func seedDefaultPolicy() {
db := repository.GetDB()
if db == nil {
return
}
db.RLock()
hasGroup := len(db.LogPolicyGroups) > 0
var sourceID uint
if len(db.LogSources) > 0 {
sourceID = db.LogSources[0].ID
}
db.RUnlock()
if hasGroup || sourceID == 0 {
return
}
id := db.NextID("logPolicyGroups")
now := time.Now()
group := models.LogPolicyGroup{
ID: id,
Name: "默认策略(所有人 · default 命名空间)",
SourceID: sourceID,
Labels: map[string]string{"namespace": "default"},
UserIDs: []uint{0}, // 0 = 所有人
CreatedAt: now,
UpdatedAt: now,
}
db.Lock()
db.LogPolicyGroups = append(db.LogPolicyGroups, group)
db.Unlock()
db.RequestSave()
log.Printf("[SEED] 已创建默认查询策略:所有人可查 default 命名空间(可在「查询授权」改为实际业务命名空间)")
}
// CleanupInitPassword 在 admin 首次修改密码后调用,删除明文密码文件。
// 幂等:文件不存在时静默返回。
func CleanupInitPassword() {
+3 -1
View File
@@ -166,7 +166,9 @@ func policyGroupAccess(groups []models.LogPolicyGroup, sourceID, userID uint) *m
continue
}
for _, id := range group.UserIDs {
if id == userID {
// 0 是哨兵值:表示「所有人」(anyone / *),匹配任意用户。
// 用于默认策略——让所有账号开箱即可查询业务命名空间。
if id == 0 || id == userID {
return &models.LogSourceAccess{UserID: userID, Labels: group.Labels}
}
}
+11
View File
@@ -351,6 +351,12 @@ func LokiLabels(ctx context.Context, src *models.LogSource, start, end int64) ([
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
// Loki 在某 label 无值时会返回 {"data":null},Go nil slice 序列化成 JSON null,
// 前端 setState(null) 后 .map() 直接抛 "Cannot read properties of null"。
// 统一归一化为空切片,前端拿到的永远是数组。
if resp.Data == nil {
resp.Data = []string{}
}
return resp.Data, nil
}
@@ -376,6 +382,11 @@ func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
// 同上:label 无值时 Loki 返回 {"data":null},归一化为空切片,
// 避免前端 .map() 遇到 null 崩溃(快捷 LogQL 报错的直接原因)。
if resp.Data == nil {
resp.Data = []string{}
}
return resp.Data, nil
}
@@ -32,8 +32,11 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
useEffect(() => {
if (!open || !sourceId) return
let active = true
logsApi.labels(sourceId, startNs, endNs).then(next => {
logsApi.labels(sourceId, startNs, endNs).then(raw => {
if (!active) return
// 后端历史版本可能返回 null(Loki 无值),兜底成数组,
// 否则 [...null] / values.filter() 会抛 "Cannot read properties of null"
const next = Array.isArray(raw) ? raw : []
// namespace 优先,其次 app/pod/container,其余按字母序
const order = ['namespace', 'app', 'service_name', 'pod', 'container', 'job']
const ordered = [...next].sort((a, b) => {
@@ -55,7 +58,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
const scope = { ...selected }
delete scope[activeLabel]
logsApi.labelValues(sourceId, activeLabel, scoped && Object.keys(scope).length ? streamSelector(scope) : '', startNs, endNs)
.then(next => { if (active) setValues(next) }).catch(() => { if (active) setValues([]) }).finally(() => { if (active) setLoadingValues(false) })
.then(next => { if (active) setValues(Array.isArray(next) ? next : []) }).catch(() => { if (active) setValues([]) }).finally(() => { if (active) setLoadingValues(false) })
return () => { active = false }
}, [open, sourceId, activeLabel, selected, scoped, startNs, endNs])
@@ -12,6 +12,13 @@ import { AccessRuleEditor } from './AccessRuleEditor'
type PolicyForm = Omit<LogPolicyGroup, 'id'> & { id?: number }
const emptyForm = (sourceId: number): PolicyForm => ({ name: '', sourceId, labels: {}, userIds: [] })
/**
* userIds 里的哨兵值:表示「所有人」(anyone / *)。
* 后端 policyGroupAccess 遇到 id === 0 会匹配任意用户,
* 用于默认策略——让所有账号开箱即可查询业务命名空间。
*/
const ANYONE = 0
export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
const [groups, setGroups] = useState<LogPolicyGroup[]>([])
const [users, setUsers] = useState<User[]>([])
@@ -43,7 +50,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
async function save() {
if (!form.name.trim() || !form.sourceId || form.userIds.length === 0) {
toast.error('请填写策略组名称,选择数据源和至少一位用户')
toast.error('请填写策略组名称,选择数据源,并选择「所有人」或至少一位用户')
return
}
setSaving(true)
@@ -81,14 +88,14 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
{error && <ErrorState message={error} onRetry={() => void reload()} />}
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">还没有策略组</p>}
<div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}>
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.length} 位用户</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? '所有人' : `${group.userIds.length} 位用户`}</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
</button>)}</div>
</aside>
<section className="rounded-xl border bg-card p-5 space-y-6">
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? '编辑策略组' : '新建策略组'}</h2><p className="mt-1 text-sm text-muted-foreground">选择数据源及允许的日志范围,再关联需要复用此策略的用户。</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>删除</Button>}</div>
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">策略组名称<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder="例如:生产环境 Agent 日志" /></label><label className="text-sm font-medium">数据源<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>请先添加数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
<div><h3 className="mb-2 text-sm font-medium">允许查询的日志范围</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || '策略组'} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。</p></div>
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">关联用户 <span className="text-muted-foreground">已选 {form.userIds.length} 位</span></h3><div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>选择当前筛选</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>清空</Button></div></div><p className="text-xs text-muted-foreground">同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。</p><Input aria-label="搜索用户" value={filter} onChange={event => setFilter(event.target.value)} placeholder="搜索用户名或昵称" /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">已分组</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">没有匹配用户</p>}</div></div>
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">关联用户 <span className="text-muted-foreground">已选 {form.userIds.includes(ANYONE) ? '所有人(anyone)' : `${form.userIds.length} 位`}</span></h3><div className="flex gap-2"><Button size="sm" variant={form.userIds.includes(ANYONE) ? 'default' : 'outline'} onClick={() => setForm(current => current.userIds.includes(ANYONE) ? { ...current, userIds: [] } : { ...current, userIds: [ANYONE] })}>{form.userIds.includes(ANYONE) ? '✓ 所有人(anyone)' : '所有人(anyone)'}</Button><Button size="sm" variant="outline" disabled={form.userIds.includes(ANYONE)} onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>选择当前筛选</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>清空</Button></div></div><p className="text-xs text-muted-foreground">同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。</p><Input aria-label="搜索用户" value={filter} onChange={event => setFilter(event.target.value)} placeholder="搜索用户名或昵称" /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id) || form.userIds.includes(ANYONE)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">已分组</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">没有匹配用户</p>}</div></div>
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? '保存中…' : '保存策略组'}</Button></div>
</section>
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title="删除查询策略组" description={`删除「${deleteTarget?.name ?? ''}」后,关联用户将失去由该组授予的数据源访问权限。`} loading={saving} onConfirm={() => void remove()} />
@@ -50,7 +50,7 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
useEffect(() => {
if (!sourceId) return
let active = true
logsApi.labels(sourceId).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) })
logsApi.labels(sourceId).then(next => { if (active) setLabels(Array.isArray(next) ? next : []) }).catch(() => { if (active) setLabels([]) })
return () => { active = false }
}, [sourceId])
@@ -65,7 +65,11 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
const version = ++requestVersion.current
const selector = text.match(/\{[^}]*\}/)?.[0] ?? ''
logsApi.labelValues(sourceId, label, selector).then(values => {
if (version === requestVersion.current) { setHints(values.filter(item => item.toLowerCase().includes(typed)).slice(0, 12)); setActive(0) }
if (version === requestVersion.current) {
// 后端历史版本可能返回 null(Loki 无值),兜底成数组再 filter
const list = Array.isArray(values) ? values : []
setHints(list.filter(item => item.toLowerCase().includes(typed)).slice(0, 12)); setActive(0)
}
}).catch(() => { if (version === requestVersion.current) setHints([]) })
return
}
@@ -24,13 +24,25 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
const [namespaces, setNamespaces] = useState<string[]>([])
const [apps, setApps] = useState<string[]>([])
const [pods, setPods] = useState<string[]>([])
/**
* 实际用来标识「应用」的标签名。
*
* K8s 惯例是 app 标签,但很多集群(如本环境)Deployment 模板没打 app,
* 只有 Kuboard 注入的 k8s.kuboard.cn/name,此时 Loki 里 app 只剩零星几个值。
* Promtail 会从容器名生成 service_name / container,覆盖度更高,
* 因此按 app → service_name → container 逐级回退。
*/
const [appLabel, setAppLabel] = useState('app')
// namespace 列表只加载一次
useEffect(() => {
if (!sourceId) return
let active = true
logsApi.labelValues(sourceId, 'namespace')
.then(next => { if (active) setNamespaces(next) })
// 后端历史版本可能返回 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([]) })
return () => { active = false }
}, [sourceId])
@@ -40,9 +52,26 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
if (!sourceId) return
let active = true
const selector = selectorOf({ namespace })
logsApi.labelValues(sourceId, 'app', selector)
.then(next => { if (active) setApps(next) })
.catch(() => { if (active) setApps([]) })
const fetchLabel = (name: string) =>
logsApi.labelValues(sourceId, name, selector)
.then(list => (Array.isArray(list) ? list : []))
.catch(() => [] as string[])
// 逐级回退:app → service_name → container
fetchLabel('app')
.then(list => {
if (list.length > 0) return { label: 'app', list }
return fetchLabel('service_name').then(s =>
s.length > 0
? { label: 'service_name', list: s }
: fetchLabel('container').then(c => ({ label: 'container', list: c })),
)
})
.then(({ label, list }) => {
if (!active) return
setAppLabel(label)
setApps(list)
})
.catch(() => { if (active) { setAppLabel('app'); setApps([]) } })
return () => { active = false }
}, [sourceId, namespace])
@@ -50,12 +79,13 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
useEffect(() => {
if (!sourceId) return
let active = true
const selector = selectorOf({ namespace, app })
// 用实际生效的 appLabel 收窄;否则在没打 app 标签的集群上筛不出东西
const selector = selectorOf(appLabel === 'app' ? { namespace, app } : { namespace, [appLabel]: app })
logsApi.labelValues(sourceId, 'pod', selector)
.then(next => { if (active) setPods(next) })
.then(next => { if (active) setPods(Array.isArray(next) ? next : []) })
.catch(() => { if (active) setPods([]) })
return () => { active = false }
}, [sourceId, namespace, app])
}, [sourceId, namespace, app, appLabel])
function chooseNamespace(value: string) {
setNamespace(value)
@@ -73,7 +103,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
let next = extractSelector(query) || '{}'
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
else if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim())
if (app.trim()) next = applyLabelFilter(next, 'app', app.trim())
if (app.trim()) next = applyLabelFilter(next, appLabel, app.trim())
if (pod.trim()) next = applyLabelFilter(next, 'pod', pod.trim())
if (next === '{}') return
if (template === 'error') next += ' |= "error"'