Files
LokiStack/frontend/src/pages/logs/components/QuickQueryTemplates.tsx
T
opsx f3733f0bfc feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂
- i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏
- 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条
- 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器
- UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正
- 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简
- 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

202 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { logsApi } from '@/api/logs'
import { SearchableValueSelect } from './SearchableValueSelect'
import { applyLabelFilter, extractSelector } from '../query-utils'
type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
/** Ingress 访问日志所在命名空间候选(按顺序取第一个实际存在的) */
const INGRESS_NAMESPACE_CANDIDATES = ['ingress-nginx', 'ingress']
function selectorOf(labels: Record<string, string>) {
const entries = Object.entries(labels).filter(([, value]) => value.trim())
if (entries.length === 0) return ''
return `{${entries.map(([key, value]) => `${key}=${JSON.stringify(value.trim())}`).join(',')}}`
}
export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {
sourceId: number
query: string
onQueryChange: (query: string) => void
compact?: boolean
}) {
const { t } = useTranslation('logs')
const [namespace, setNamespace] = useState('')
const [app, setApp] = useState('')
const [pod, setPod] = useState('')
const [namespaces, setNamespaces] = useState<string[]>([])
const [apps, setApps] = useState<string[]>([])
const [pods, setPods] = useState<string[]>([])
/**
* 实际用来标识「应用」的标签名。
*
* Kubernetes 惯例是 app 标签,但很多集群(如本环境)Deployment 模板没打 app,
* 只有 Kuboard 注入的 k8s.kuboard.cn/name,此时 Loki 里 app 只剩零星几个值。
* Promtail 会从容器名生成 service_name / container,覆盖度更高,
* 因此按 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(() => {
if (!sourceId) return
let active = true
logsApi.labelValues(sourceId, 'namespace')
// 后端历史版本可能返回 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])
// app 候选:选了 namespace 就只在该命名空间内搜索
useEffect(() => {
if (!sourceId) return
let active = true
const selector = selectorOf({ namespace })
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])
// pod 候选:按 namespace + app 收窄
useEffect(() => {
if (!sourceId) return
let active = true
// 用实际生效的 appLabel 收窄;否则在没打 app 标签的集群上筛不出东西
const selector = selectorOf(appLabel === 'app' ? { namespace, app } : { namespace, [appLabel]: app })
logsApi.labelValues(sourceId, 'pod', selector)
.then(next => { if (active) setPods(Array.isArray(next) ? next : []) })
.catch(() => { if (active) setPods([]) })
return () => { active = false }
}, [sourceId, namespace, app, appLabel])
function chooseNamespace(value: string) {
setNamespace(value)
// 换命名空间后旧的 app / pod 条件失效
setApp('')
setPod('')
}
function chooseApp(value: string) {
setApp(value)
setPod('')
}
function generate(template: Template) {
// Ingress 访问日志与当前选中的业务命名空间无关:
// ingress controller 的 Pod 只在它自己的命名空间(ingress-nginx / ingress)里,
// 所以这里固定用该命名空间,并且不叠加 app / pod 过滤(那些属于业务服务)。
// 原实现 `namespace.trim() || 'ingress-nginx'` 会导致选了 monitor 就变成
// {namespace="monitor"} |= "HTTP/",一条都查不到。
if (template === 'ingress') {
const ingressNs =
INGRESS_NAMESPACE_CANDIDATES.find((n) => namespaces.includes(n)) ??
INGRESS_NAMESPACE_CANDIDATES[0]
onQueryChange(`{namespace="${ingressNs}"} |= "HTTP/"`)
return
}
let next = extractSelector(query) || '{}'
if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.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"'
if (template === 'warn') next += ' |= "warn"'
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)
return <section className="space-y-2" aria-label={t('templates.aria')}>
{!compact && <h2 className="text-sm font-medium">{t('templates.title')}</h2>}
{!compact && <p className="text-xs text-muted-foreground">{t('templates.hint')}</p>}
<div className="flex flex-wrap items-end gap-2">
{/* 用 SearchableValueSelect(点击即展开列表 + 可搜索),
原生 datalist 只在输入时才提示、样式弱,容易让人以为「下拉是空的」 */}
<SearchableValueSelect
label={t('templates.namespace')}
values={namespaceOptions}
value={namespace}
onChange={chooseNamespace}
placeholder={t('templates.selectCount', { count: namespaceOptions.length })}
allowEmpty
loading={!namespaceOptions.length}
/>
<SearchableValueSelect
label={appLabel === 'app' ? t('templates.app') : t('templates.appWithKey', { key: appLabel })}
values={apps}
value={app}
onChange={chooseApp}
placeholder={apps.length ? t('templates.selectCount', { count: apps.length }) : namespace ? t('templates.noApp') : t('templates.pickNsFirst')}
allowEmpty
disabled={!sourceId}
/>
<SearchableValueSelect
label={t('templates.pod')}
values={pods}
value={pod}
onChange={setPod}
placeholder={pods.length ? t('templates.selectCount', { count: pods.length }) : app ? t('templates.noPod') : t('templates.optional')}
allowEmpty
disabled={!sourceId}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>{t('templates.scopeLogs')}</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>{t('templates.errorLogs')}</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>{t('templates.warnLogs')}</Button>
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>{t('templates.podLogs')}</Button>
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>{t('templates.ingressLogs')}</Button>
{!hasScope && <span className="self-center text-xs text-muted-foreground">{t('templates.scopeHint')}</span>}
</div>
</section>
}