- 品牌: 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 未翻译、词典双逗号等
202 lines
8.8 KiB
TypeScript
202 lines
8.8 KiB
TypeScript
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>
|
||
}
|