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:
1 parent
46e73d3d17
commit
4dda6e5ba8
7 files changed
+119
-16
No files matched your search
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"opsxloki/models"
|
||||||
"opsxloki/pkg/crypto"
|
"opsxloki/pkg/crypto"
|
||||||
@@ -82,10 +83,55 @@ func Seed() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内置默认查询策略:所有人可查询 default 命名空间。
|
||||||
|
// 目的:新环境/新账号开箱即有可查范围,快捷 LogQL 的 namespace 下拉不会空。
|
||||||
|
seedDefaultPolicy()
|
||||||
|
|
||||||
// 内置告警规则:有 managed 数据源(部署过 Loki)时补齐通用模板(默认禁用)
|
// 内置告警规则:有 managed 数据源(部署过 Loki)时补齐通用模板(默认禁用)
|
||||||
SeedAlertRules()
|
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 首次修改密码后调用,删除明文密码文件。
|
// CleanupInitPassword 在 admin 首次修改密码后调用,删除明文密码文件。
|
||||||
// 幂等:文件不存在时静默返回。
|
// 幂等:文件不存在时静默返回。
|
||||||
func CleanupInitPassword() {
|
func CleanupInitPassword() {
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ func policyGroupAccess(groups []models.LogPolicyGroup, sourceID, userID uint) *m
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, id := range group.UserIDs {
|
for _, id := range group.UserIDs {
|
||||||
if id == userID {
|
// 0 是哨兵值:表示「所有人」(anyone / *),匹配任意用户。
|
||||||
|
// 用于默认策略——让所有账号开箱即可查询业务命名空间。
|
||||||
|
if id == 0 || id == userID {
|
||||||
return &models.LogSourceAccess{UserID: userID, Labels: group.Labels}
|
return &models.LogSourceAccess{UserID: userID, Labels: group.Labels}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,12 @@ func LokiLabels(ctx context.Context, src *models.LogSource, start, end int64) ([
|
|||||||
if err := json.Unmarshal(body, &resp); err != nil {
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
return nil, err
|
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
|
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 {
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 同上:label 无值时 Loki 返回 {"data":null},归一化为空切片,
|
||||||
|
// 避免前端 .map() 遇到 null 崩溃(快捷 LogQL 报错的直接原因)。
|
||||||
|
if resp.Data == nil {
|
||||||
|
resp.Data = []string{}
|
||||||
|
}
|
||||||
return resp.Data, nil
|
return resp.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !sourceId) return
|
if (!open || !sourceId) return
|
||||||
let active = true
|
let active = true
|
||||||
logsApi.labels(sourceId, startNs, endNs).then(next => {
|
logsApi.labels(sourceId, startNs, endNs).then(raw => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
// 后端历史版本可能返回 null(Loki 无值),兜底成数组,
|
||||||
|
// 否则 [...null] / values.filter() 会抛 "Cannot read properties of null"
|
||||||
|
const next = Array.isArray(raw) ? raw : []
|
||||||
// namespace 优先,其次 app/pod/container,其余按字母序
|
// namespace 优先,其次 app/pod/container,其余按字母序
|
||||||
const order = ['namespace', 'app', 'service_name', 'pod', 'container', 'job']
|
const order = ['namespace', 'app', 'service_name', 'pod', 'container', 'job']
|
||||||
const ordered = [...next].sort((a, b) => {
|
const ordered = [...next].sort((a, b) => {
|
||||||
@@ -55,7 +58,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
|
|||||||
const scope = { ...selected }
|
const scope = { ...selected }
|
||||||
delete scope[activeLabel]
|
delete scope[activeLabel]
|
||||||
logsApi.labelValues(sourceId, activeLabel, scoped && Object.keys(scope).length ? streamSelector(scope) : '', startNs, endNs)
|
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 }
|
return () => { active = false }
|
||||||
}, [open, sourceId, activeLabel, selected, scoped, startNs, endNs])
|
}, [open, sourceId, activeLabel, selected, scoped, startNs, endNs])
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import { AccessRuleEditor } from './AccessRuleEditor'
|
|||||||
type PolicyForm = Omit<LogPolicyGroup, 'id'> & { id?: number }
|
type PolicyForm = Omit<LogPolicyGroup, 'id'> & { id?: number }
|
||||||
const emptyForm = (sourceId: number): PolicyForm => ({ name: '', sourceId, labels: {}, userIds: [] })
|
const emptyForm = (sourceId: number): PolicyForm => ({ name: '', sourceId, labels: {}, userIds: [] })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* userIds 里的哨兵值:表示「所有人」(anyone / *)。
|
||||||
|
* 后端 policyGroupAccess 遇到 id === 0 会匹配任意用户,
|
||||||
|
* 用于默认策略——让所有账号开箱即可查询业务命名空间。
|
||||||
|
*/
|
||||||
|
const ANYONE = 0
|
||||||
|
|
||||||
export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||||
const [groups, setGroups] = useState<LogPolicyGroup[]>([])
|
const [groups, setGroups] = useState<LogPolicyGroup[]>([])
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
@@ -43,7 +50,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.name.trim() || !form.sourceId || form.userIds.length === 0) {
|
if (!form.name.trim() || !form.sourceId || form.userIds.length === 0) {
|
||||||
toast.error('请填写策略组名称,选择数据源和至少一位用户')
|
toast.error('请填写策略组名称,选择数据源,并选择「所有人」或至少一位用户')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -81,14 +88,14 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
|||||||
{error && <ErrorState message={error} onRetry={() => void reload()} />}
|
{error && <ErrorState message={error} onRetry={() => void reload()} />}
|
||||||
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">还没有策略组</p>}
|
{!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'}`}>
|
<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>
|
</button>)}</div>
|
||||||
</aside>
|
</aside>
|
||||||
<section className="rounded-xl border bg-card p-5 space-y-6">
|
<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="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 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><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>
|
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? '保存中…' : '保存策略组'}</Button></div>
|
||||||
</section>
|
</section>
|
||||||
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title="删除查询策略组" description={`删除「${deleteTarget?.name ?? ''}」后,关联用户将失去由该组授予的数据源访问权限。`} loading={saving} onConfirm={() => void remove()} />
|
<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(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return
|
if (!sourceId) return
|
||||||
let active = true
|
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 }
|
return () => { active = false }
|
||||||
}, [sourceId])
|
}, [sourceId])
|
||||||
|
|
||||||
@@ -65,7 +65,11 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
|
|||||||
const version = ++requestVersion.current
|
const version = ++requestVersion.current
|
||||||
const selector = text.match(/\{[^}]*\}/)?.[0] ?? ''
|
const selector = text.match(/\{[^}]*\}/)?.[0] ?? ''
|
||||||
logsApi.labelValues(sourceId, label, selector).then(values => {
|
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([]) })
|
}).catch(() => { if (version === requestVersion.current) setHints([]) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,25 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
|||||||
const [namespaces, setNamespaces] = useState<string[]>([])
|
const [namespaces, setNamespaces] = useState<string[]>([])
|
||||||
const [apps, setApps] = useState<string[]>([])
|
const [apps, setApps] = useState<string[]>([])
|
||||||
const [pods, setPods] = 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 列表只加载一次
|
// namespace 列表只加载一次
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return
|
if (!sourceId) return
|
||||||
let active = true
|
let active = true
|
||||||
logsApi.labelValues(sourceId, 'namespace')
|
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([]) })
|
.catch(() => { if (active) setNamespaces([]) })
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [sourceId])
|
}, [sourceId])
|
||||||
@@ -40,9 +52,26 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
|||||||
if (!sourceId) return
|
if (!sourceId) return
|
||||||
let active = true
|
let active = true
|
||||||
const selector = selectorOf({ namespace })
|
const selector = selectorOf({ namespace })
|
||||||
logsApi.labelValues(sourceId, 'app', selector)
|
const fetchLabel = (name: string) =>
|
||||||
.then(next => { if (active) setApps(next) })
|
logsApi.labelValues(sourceId, name, selector)
|
||||||
.catch(() => { if (active) setApps([]) })
|
.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 }
|
return () => { active = false }
|
||||||
}, [sourceId, namespace])
|
}, [sourceId, namespace])
|
||||||
|
|
||||||
@@ -50,12 +79,13 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return
|
if (!sourceId) return
|
||||||
let active = true
|
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)
|
logsApi.labelValues(sourceId, 'pod', selector)
|
||||||
.then(next => { if (active) setPods(next) })
|
.then(next => { if (active) setPods(Array.isArray(next) ? next : []) })
|
||||||
.catch(() => { if (active) setPods([]) })
|
.catch(() => { if (active) setPods([]) })
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [sourceId, namespace, app])
|
}, [sourceId, namespace, app, appLabel])
|
||||||
|
|
||||||
function chooseNamespace(value: string) {
|
function chooseNamespace(value: string) {
|
||||||
setNamespace(value)
|
setNamespace(value)
|
||||||
@@ -73,7 +103,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
|||||||
let next = extractSelector(query) || '{}'
|
let next = extractSelector(query) || '{}'
|
||||||
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
|
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
|
||||||
else if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim())
|
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 (pod.trim()) next = applyLabelFilter(next, 'pod', pod.trim())
|
||||||
if (next === '{}') return
|
if (next === '{}') return
|
||||||
if (template === 'error') next += ' |= "error"'
|
if (template === 'error') next += ' |= "error"'
|
||||||
|
|||||||
Reference in new issue
Block a user