From 4dda6e5ba8ad03e9700975ccdbc8ef5bd54feba3 Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 27 Oct 2026 01:19:06 +0000 Subject: [PATCH] =?UTF-8?q?fix(logs):=20=E5=BF=AB=E6=8D=B7=20LogQL=20null?= =?UTF-8?q?=20=E5=B4=A9=E6=BA=83=20+=20app=20=E6=A0=87=E7=AD=BE=E9=94=99?= =?UTF-8?q?=E4=BD=8D=20+=20=E6=9F=A5=E8=AF=A2=E6=8E=88=E6=9D=83=E3=80=8C?= =?UTF-8?q?=E6=89=80=E6=9C=89=E4=BA=BA=E3=80=8D=E4=B8=8E=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 一、快捷 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 --- backend/seed/seed.go | 46 +++++++++++++++++++ backend/services/log_policy.go | 4 +- backend/services/log_query.go | 11 +++++ .../logs/components/LokiLabelBrowser.tsx | 7 ++- .../logs/components/PolicyGroupsPage.tsx | 13 ++++-- .../src/pages/logs/components/QueryInput.tsx | 8 +++- .../logs/components/QuickQueryTemplates.tsx | 46 +++++++++++++++---- 7 files changed, 119 insertions(+), 16 deletions(-) diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 1e4ea9f..7faf219 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -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() { diff --git a/backend/services/log_policy.go b/backend/services/log_policy.go index 6865e87..419ac99 100644 --- a/backend/services/log_policy.go +++ b/backend/services/log_policy.go @@ -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} } } diff --git a/backend/services/log_query.go b/backend/services/log_query.go index d0fb8dc..8a03ea9 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -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 } diff --git a/frontend/src/pages/logs/components/LokiLabelBrowser.tsx b/frontend/src/pages/logs/components/LokiLabelBrowser.tsx index 7171e49..8e4f58a 100644 --- a/frontend/src/pages/logs/components/LokiLabelBrowser.tsx +++ b/frontend/src/pages/logs/components/LokiLabelBrowser.tsx @@ -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]) diff --git a/frontend/src/pages/logs/components/PolicyGroupsPage.tsx b/frontend/src/pages/logs/components/PolicyGroupsPage.tsx index 3a5e078..6902878 100644 --- a/frontend/src/pages/logs/components/PolicyGroupsPage.tsx +++ b/frontend/src/pages/logs/components/PolicyGroupsPage.tsx @@ -12,6 +12,13 @@ import { AccessRuleEditor } from './AccessRuleEditor' type PolicyForm = Omit & { 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([]) const [users, setUsers] = useState([]) @@ -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 && void reload()} />} {!loading && !error && groups.length === 0 &&

还没有策略组

}
{groups.map(group => )}

{form.id ? '编辑策略组' : '新建策略组'}

选择数据源及允许的日志范围,再关联需要复用此策略的用户。

{form.id && }

允许查询的日志范围

setForm(current => ({ ...current, labels }))} />

可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。

-

关联用户 已选 {form.userIds.length} 位

同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。

setFilter(event.target.value)} placeholder="搜索用户名或昵称" />
{visibleUsers.map(user => )}{visibleUsers.length === 0 &&

没有匹配用户

}
+

关联用户 已选 {form.userIds.includes(ANYONE) ? '所有人(anyone)' : `${form.userIds.length} 位`}

同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。

setFilter(event.target.value)} placeholder="搜索用户名或昵称" />
{visibleUsers.map(user => )}{visibleUsers.length === 0 &&

没有匹配用户

}
{ if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title="删除查询策略组" description={`删除「${deleteTarget?.name ?? ''}」后,关联用户将失去由该组授予的数据源访问权限。`} loading={saving} onConfirm={() => void remove()} /> diff --git a/frontend/src/pages/logs/components/QueryInput.tsx b/frontend/src/pages/logs/components/QueryInput.tsx index 7575c3c..60d55c0 100644 --- a/frontend/src/pages/logs/components/QueryInput.tsx +++ b/frontend/src/pages/logs/components/QueryInput.tsx @@ -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 } diff --git a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx index f988ca5..646a9e3 100644 --- a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx +++ b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx @@ -24,13 +24,25 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: const [namespaces, setNamespaces] = useState([]) const [apps, setApps] = useState([]) const [pods, setPods] = useState([]) + /** + * 实际用来标识「应用」的标签名。 + * + * 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"'