refactor(analysis): 去掉排行 10/30/50 选择器,改为一次取 50 + 前端分页;零点结算昨天
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

=== 1. 为什么去掉「显示条数」选择器 ===
topk 的 N 不影响查询成本(扫描的 chunk 集合完全相同),实测:
  topk(10)  17833ms / 11 条
  topk(30)  18019ms / 34 条
  topk(50)   7394ms / 54 条
  topk(100) 17553ms / 74 条
波动来自集群负载,与 N 无关 —— 它只决定最后返回多少序列。
所以"选 30"既不更快也不更慢,本质是个翻页开关;
而选它还要重新等一次十几秒的查询,纯亏。
改为:一次 topk(50),前端 10 条/页 → 5 页只需 1 次查询。
附带收益:占比的分母从"前 10 合计"变成"前 50 合计",更接近真实占比。
副标题明确写清"一次取前 50 个应用(占比按这 50 个合计计算)"。

=== 2. 零点结算昨天(daily volume)===
原来只有 30 分钟 ticker 回补,且启动时间不对齐整点 →
昨天最早的一段可能已经超出 Loki 24h 保留期被删,
结算结果系统性偏低,图上却标"完整一天",属静默错数。
新增 nextMidnightFinalize():每天 00:01 触发 FinalizeYesterdayVolume()
  - 昨天定稿 partial=false
  - 补一次条数(index/stats,一天一次可接受)
  - 顺带刷新今天 partial=true 的柱子
ticker 仍保留,负责当天数据的 30 分钟刷新。

=== 3. 顺带 ===
- 去掉每日量"其中 N 天平台没有留存记录"的提示文案(按用户要求)
- 修 QuickQueryTemplates.tsx 的未使用变量导致 tsc -b 失败
- 工作区里上一轮中断的"排行体积列"改动一并纳入(app-ranking 返回 bytes,
  TopAppsTable 增加体积列,快照记录 per-app 字节数)

Pod = Hub latest = a16978d2f88b
This commit is contained in:
opsx committed 2026-10-30 17:13:18 +00:00
1 parent 63dd491c5b
commit d4422f1541
22 files changed
+715 -117

No files matched your search

+26 -5
View File
@@ -48,11 +48,19 @@ export const logsApi = {
'/logs/stats', data, { timeout: 25000, ...opts }),
/** 日志量统计:后端有 7 天范围上限与 30s 超时,前端超时略大于它 */
/** 应用排行(长范围读平台预聚合快照,毫秒级,不扫日志内容) */
appRanking: (sourceId: number, from: number, to: number, limit = 10, opts?: { signal?: AbortSignal }) =>
http.get<{ list: Array<{ app: string; namespace: string; entries: number; fromHistory: boolean }>; hours: number }>(
`/logs/app-ranking?sourceId=${sourceId}&from=${from}&to=${to}&limit=${limit}`,
opts,
),
appRanking: (sourceId: number, from: number, to: number, limit = 10, opts?: { signal?: AbortSignal; sort?: 'bytes' | 'entries' }) => {
const { signal, sort = 'bytes' } = opts ?? {}
return http.get<{
list: Array<{ app: string; namespace: string; entries: number; bytes: number; fromHistory: boolean }>
/** 快照覆盖的小时数 */
hours: number
/** 其中带体积的小时数(体积列是本轮新增,早期快照只有条数) */
bytesHours: number
}>(
`/logs/app-ranking?sourceId=${sourceId}&from=${from}&to=${to}&limit=${limit}&sort=${sort}`,
signal ? { signal } : undefined,
)
},
/** 每日日志量历史(读平台库,毫秒级) */
dailyVolume: (sourceId: number, days: number, opts?: { signal?: AbortSignal }) =>
@@ -63,6 +71,19 @@ export const logsApi = {
metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post<MetricSeries[]>('/logs/metrics', data, { timeout: 125000, ...opts }),
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`),
/**
* 批量取多个 label 的候选值:`/logs/label-values?names=app,service_name,container&query={...}`
*
* 快捷查询的 app 下拉要做 app → service_name → container 三级回退,
* 逐个请求要 3 个往返(每个都经 K8s API 代理到 Loki 索引);
* 批量接口后端内部并发,一次往返拿全,前端只看"有没有值"。
* 返回里缺某个 label 或取失败 = 空数组(不会抛错)。
*/
labelValuesBatch: (sourceId: number, names: string[], query = '', start?: number, end?: number, opts?: { signal?: AbortSignal }) =>
http.get<Record<string, string[]>>(
`/logs/label-values${buildQuery({ sourceId, names: names.join(','), query, start, end } as { page?: number; pageSize?: number })}`,
opts,
),
/** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */
myScope: (sourceId: number) => http.get<{ labels: Record<string, string>; unrestricted: boolean }>(`/logs/my-scope?sourceId=${sourceId}`),
policyGroups: () => http.get<LogPolicyGroup[]>('/logs/policy-groups'),
+5 -3
View File
@@ -67,18 +67,20 @@ export default {
},
table: {
showCount: 'Rows',
pagerInfo: '{{total}} apps · page {{page}}/{{pages}}',
prevPage: 'Prev',
nextPage: 'Next',
subtitleHistory: 'This {{range}}-minute range exceeds live-scan limits, so this reads the platform\'s hourly pre-aggregated snapshots ({{hours}} hours retained) — returns in milliseconds, without error counts or trends.',
subtitleHistory: 'This {{range}}-minute range exceeds live-scan limits, so this reads the platform\'s hourly pre-aggregated snapshots ({{hours}} hours retained) — returns in milliseconds, sorted by volume, without error counts or trends.',
bytesCoverage: 'Only {{bytesHours}} of {{hours}} hours have volume data (earlier snapshots stored entries only), so volume may be understated.',
title: 'App volume ranking',
computing: 'Computing per-app volume (reads log content, usually 20–30s)…',
subtitle: 'Top 10 apps by volume; click "View logs" to open the query page',
subtitle: 'Fetches the top 50 apps once (share is computed against those 50), 10 rows per page; click "View logs" to open the query page',
rank: '#',
app: 'App',
appWithKey: 'App ({{key}})',
volume: 'Volume',
bytes: 'Volume',
entries: 'Entries',
share: 'Share',
errors: 'Errors',
errorRate: 'Error rate',
+2 -1
View File
@@ -100,7 +100,8 @@ export default {
loadFailed: 'Load failed',
retry: 'Retry',
noApp: 'No app in this scope',
pickNsFirst: 'Pick a namespace first to narrow down',
pickNsFirst: 'Pick a namespace first',
pickAppFirst: 'Pick an app first',
noPod: 'No pods under this app',
optional: 'Optional',
errorLogs: 'Errors (error)',
+5 -3
View File
@@ -67,18 +67,20 @@ export default {
},
table: {
showCount: '显示条数',
pagerInfo: '共 {{total}} 个应用 · 第 {{page}}/{{pages}} 页',
prevPage: '上一页',
nextPage: '下一页',
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。',
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,按体积排序,但不含错误数与趋势。',
bytesCoverage: '其中 {{bytesHours}}/{{hours}} 小时有体积数据,其余小时只有条数(早期快照),体积会偏小。',
title: '应用日志量排行',
subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页',
subtitle: '一次取前 50 个应用(占比按这 50 个合计计算),每页 10 条,点击「查看日志」跳转至查询页',
computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…',
rank: '#',
app: '应用',
appWithKey: '应用({{key}})',
volume: '日志量',
bytes: '体积',
entries: '条数',
share: '占比',
errors: '错误量',
errorRate: '错误率',
+2 -1
View File
@@ -99,7 +99,8 @@ export default {
loadFailed: '加载失败',
retry: '重试',
noApp: '该范围内无 app',
pickNsFirst: '先选命名空间可缩小范围',
pickNsFirst: '先选命名空间',
pickAppFirst: '先选 App',
noPod: '该 app 下无副本',
optional: '可选',
errorLogs: '错误 error',
@@ -35,7 +35,6 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
// 有留存的天数与"完全没留存"的天数分开统计:
// 后者是"不知道",不能画成 0 让人以为那天没日志。
const covered = data.filter(item => !item.missing)
const missingCount = data.length - covered.length
const hasData = covered.some(item => item.bytes > 0)
const coverage = covered.length
@@ -102,13 +101,6 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o
<EmptyChart message={t('daily.empty')} />
)}
{/* 灰柱 = 平台没有留存那天(不是"日志量为 0"),必须显式说明,
否则 30/90/365 天视角下会被误读成"以前日志很少" */}
{missingCount > 0 && (
<p className="text-[11px] text-muted-foreground">
{t('daily.missingHint', { count: missingCount })}
</p>
)}
</div>
)
})
@@ -2,7 +2,7 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import type { AppRow } from '../hooks/useAnalysisMetrics'
import { compactNumber } from './chart-shared'
import { compactNumber, formatBytes } from './chart-shared'
/** 迷你趋势 sparkline:纯 SVG polyline,避免每行一个 recharts 实例 */
function Sparkline({ values }: { values: number[] }) {
@@ -31,11 +31,25 @@ export const TopAppsTable = memo(function TopAppsTable({
return <p className="py-8 text-center text-sm text-muted-foreground">{t('table.empty')}</p>
}
const appHeader = appLabel === 'app' ? t('table.app') : t('table.appWithKey', { key: appLabel })
/**
* 有体积就按体积展示和算占比,没有(短范围实时扫描)就退回按条数。
*
* 为什么短范围拿不到体积:按 app 取体积在 Loki 里没有索引级聚合,
* 只能 bytes_over_time 重扫日志内容(5 分钟窗口就要再花 10 秒)。
* 所以短范围这张表老实显示条数,不编一个估算体积出来。
*/
const useBytes = apps.some(row => typeof row.bytes === 'number' && (row.bytes ?? 0) > 0)
const metricOf = (row: AppRow) => (useBytes ? (row.bytes ?? 0) : row.total)
const metricTotal = useBytes ? apps.reduce((sum, row) => sum + (row.bytes ?? 0), 0) : total
const headers = [t('table.rank'), appHeader, t('table.namespace')]
if (useBytes) headers.push(t('table.bytes'), t('table.entries'))
else headers.push(t('table.volume'))
headers.push(t('table.share'), t('table.errors'), t('table.errorRate'), t('table.trend'), t('table.actions'))
return <div className="max-h-[26rem] overflow-auto rounded-lg border">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 bg-muted text-muted-foreground">
<tr>
{[t('table.rank'), appHeader, t('table.namespace'), t('table.volume'), t('table.share'), t('table.errors'), t('table.errorRate'), t('table.trend'), t('table.actions')].map(title => (
{headers.map(title => (
<th key={title} className="whitespace-nowrap border-b px-3 py-2 font-medium">{title}</th>
))}
</tr>
@@ -43,12 +57,18 @@ export const TopAppsTable = memo(function TopAppsTable({
<tbody className="divide-y">
{apps.map((row, index) => {
const selector = `{namespace="${row.namespace}", ${appLabel}="${row.app}"}`
const metric = metricOf(row)
return <tr key={`${row.app}-${row.namespace}`} className="hover:bg-muted/40">
<td className="px-3 py-2 tabular-nums text-muted-foreground">{index + 1}</td>
<td className="max-w-56 truncate px-3 py-2 font-medium" title={row.app || undefined}>{row.app || t('table.untagged')}</td>
<td className="max-w-40 truncate px-3 py-2 text-muted-foreground" title={row.namespaces.join(', ')}>{row.namespace || '—'}</td>
<td className="px-3 py-2 tabular-nums">{compactNumber(row.total)} 条</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{total > 0 ? `${((row.total / total) * 100).toFixed(1)}%` : '—'}</td>
{useBytes
? <>
<td className="px-3 py-2 tabular-nums">{formatBytes(row.bytes ?? 0)}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{compactNumber(row.total)} 条</td>
</>
: <td className="px-3 py-2 tabular-nums">{compactNumber(row.total)} 条</td>}
<td className="px-3 py-2 tabular-nums text-muted-foreground">{metricTotal > 0 ? `${((metric / metricTotal) * 100).toFixed(1)}%` : '—'}</td>
<td className={`px-3 py-2 tabular-nums ${row.errorCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{compactNumber(row.errorCount)} 条</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'}</td>
<td className="px-3 py-2"><Sparkline values={row.sparkline} /></td>
@@ -6,7 +6,14 @@ export interface AppRow {
app: string
namespace: string
namespaces: string[]
/** 日志条数 */
total: number
/**
* 日志体积(字节)。只有"长范围读平台预聚合快照"这条路径有值;
* 短范围实时扫描只有条数(按 app 取体积要再扫一遍日志内容,代价太大),
* 所以这里是可选,表格按"有没有体积"决定展示哪一列。
*/
bytes?: number
errorCount: number
sparkline: number[]
}
+30 -30
View File
@@ -160,14 +160,21 @@ const AnalysisPage = memo(function AnalysisPage() {
Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000),
)
const useHistoryRanking = rangeMinutes > 60
// 排行一次取多少(10/30/50)。topk 只影响返回序列数,扫描成本与 N
// 无关(实测 10/30/50/100 都在 7~18 秒同一区间),所以放宽不增加查询成本。
// 分页在前端做:排行是"排序后的静态列表",翻页不需要再查 Loki。
const [rankLimit, setRankLimit] = useState(10)
const [rankPage, setRankPage] = useState(1)
// 排行**一次取 50 条**,前端按 10 条/页切。
//
// 为什么不做 10/30/50 选择器:
// topk 只决定最后返回多少序列,扫描的 chunk 集合完全相同(实测
// topk(10)=17.8s / topk(30)=18.0s / topk(50)=7.4s / topk(100)=17.6s,
// 波动来自集群负载而非 N)。所以"选 30"不会更慢也不会更快,
// 它只是一个翻页开关——用一次 50 条 + 前端分页即可覆盖 5 页,
// 避免用户为了看第 2 页再等十几秒重查一遍。
const RANK_FETCH_LIMIT = 50
const RANK_PAGE_SIZE = 10
const [rankPage, setRankPage] = useState(1)
const [historyApps, setHistoryApps] = useState<AppRow[] | null>(null)
const [historyHours, setHistoryHours] = useState(0)
/** 其中"有体积数据"的小时数(体积列是本轮新增,早期快照只有条数) */
const [historyBytesHours, setHistoryBytesHours] = useState(0)
useEffect(() => {
if (!useHistoryRanking || !effectiveSourceId) {
@@ -177,8 +184,11 @@ const AnalysisPage = memo(function AnalysisPage() {
const controller = new AbortController()
setHistoryApps(null)
logsApi
.appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, rankLimit, {
.appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, RANK_FETCH_LIMIT, {
signal: controller.signal,
// 排行按体积:用户问的是"哪个应用把日志量吃掉了",
// 条数会被高频短日志主导(一条 INFO 刷屏 vs 一条大 JSON)。
sort: 'bytes',
})
.then(res => {
const rows: AppRow[] = (res?.list ?? []).map(item => ({
@@ -186,16 +196,18 @@ const AnalysisPage = memo(function AnalysisPage() {
namespace: item.namespace,
namespaces: item.namespace ? [item.namespace] : [],
total: item.entries,
// 预聚合只统计条数,没有错误数/趋势 —— 表格对应列会显示 —
bytes: item.bytes,
// 预聚合只统计条数/体积,没有错误数/趋势 —— 表格对应列会显示 —
errorCount: 0,
sparkline: [],
}))
setHistoryApps(rows)
setHistoryHours(res?.hours ?? 0)
setHistoryBytesHours(res?.bytesHours ?? 0)
})
.catch(() => setHistoryApps([]))
return () => controller.abort()
}, [useHistoryRanking, effectiveSourceId, start, end, rankLimit])
}, [useHistoryRanking, effectiveSourceId, start, end])
const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({
sourceId: effectiveSourceId,
@@ -207,7 +219,7 @@ const AnalysisPage = memo(function AnalysisPage() {
// 用户滚到排行卡才发那条 10~15 秒的重查询。
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking),
rankLimit,
rankLimit: RANK_FETCH_LIMIT,
})
// 选择值与已生效值不一致 → 提示"点刷新生效"
@@ -363,25 +375,6 @@ const AnalysisPage = memo(function AnalysisPage() {
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
</CardHeader>
<CardContent>
{/* 一次取多少个应用:点击即触发查询(topk 放宽不增加扫描成本) */}
<div className="mb-2 flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{t('table.showCount')}</span>
{[10, 30, 50].map(count => (
<Button
key={count}
size="sm"
variant={rankLimit === count ? 'default' : 'outline'}
className="h-7 px-2.5 text-xs"
onClick={() => {
setRankLimit(count)
setRankPage(1)
refresh()
}}
>
{count}
</Button>
))}
</div>
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : (data?.apps ?? [])).length ? (
// 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
@@ -392,13 +385,20 @@ const AnalysisPage = memo(function AnalysisPage() {
) : (
<TopAppsTable
apps={rankPageRows}
/* 分母必须是"条数合计":apps 里是条数,stats.total 是字节,
混用会让占比全部算成 0.0x% */
/* 分母必须是同一口径的合计:按体积排行时用体积合计,
按条数排行时用条数合计,混用会让占比全部算成 0.0x% */
total={rankTotal}
appLabel={appLabel}
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
/>
)}
{/* 体积覆盖率提示:体积列是本轮新增,早期小时只有条数。
不提示的话用户会把"体积偏小"误读成"这个应用体积真的很小"。 */}
{useHistoryRanking && historyHours > 0 && historyBytesHours < historyHours && (
<p className="mt-2 text-xs text-warning">
{t('table.bytesCoverage', { bytesHours: historyBytesHours, hours: historyHours })}
</p>
)}
{/* 分页在前端做:排行是排序后的静态列表,翻页不需要再查 Loki。
服务端只按 topk(N) 返回一次,N=50 时 5 页。 */}
{rankPageCount > 1 && (
@@ -25,16 +25,49 @@ export function AccessRuleEditor({ sourceId, username, labels, onChange }: Acces
useEffect(() => {
if (!sourceId) return
let active = true
const load = (name: PolicyLabel, scope: Record<string, string>) => {
setLoading(current => ({ ...current, [name]: true }))
logsApi.labelValues(sourceId, name, Object.keys(scope).length ? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}` : '')
.then(values => { if (active) setOptions(current => ({ ...current, [name]: values })) })
.catch(() => { if (active) setOptions(current => ({ ...current, [name]: [] })) })
.finally(() => { if (active) setLoading(current => ({ ...current, [name]: false })) })
const scopeSelector = (scope: Record<string, string>) =>
Object.keys(scope).length
? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}`
: ''
const setBusy = (names: PolicyLabel[], busy: boolean) =>
setLoading(current => {
const next = { ...current }
for (const name of names) next[name] = busy
return next
})
// 命名空间 + 应用一次批量取(`/logs/label-values`),
// 原来是 2 个独立请求、每个都要经 K8s API 代理到 Loki 索引。
const primary: PolicyLabel[] = namespace ? ['app'] : ['namespace', 'app']
const primaryScope: Record<string, string> = namespace ? { namespace } : {}
setBusy(primary, true)
logsApi
.labelValuesBatch(sourceId, primary, scopeSelector(primaryScope))
.then(result => {
if (!active) return
setOptions(current => {
const next = { ...current, pod: app ? current.pod : [] }
for (const name of primary) next[name] = Array.isArray(result?.[name]) ? result[name] : []
return next
})
})
.catch(() => { if (active) setOptions(current => ({ ...current })) })
.finally(() => { if (active) setBusy(primary, false) })
// pod 候选**懒加载**:没选 app 就不拉(全集群 Pod 列表对授权配置没有意义,
// 而且索引查询很贵)。选了 app 才按 namespace+app 收窄取一次。
if (!app) {
setOptions(current => ({ ...current, pod: [] }))
setLoading(current => ({ ...current, pod: false }))
return () => { active = false }
}
load('namespace', {})
load('app', namespace ? { namespace } : {})
load('pod', { ...(namespace ? { namespace } : {}), ...(app ? { app } : {}) })
setBusy(['pod'], true)
logsApi
.labelValues(sourceId, 'pod', scopeSelector({ ...(namespace ? { namespace } : {}), app }))
.then(values => { if (active) setOptions(current => ({ ...current, pod: Array.isArray(values) ? values : [] })) })
.catch(() => { if (active) setOptions(current => ({ ...current, pod: [] })) })
.finally(() => { if (active) setBusy(['pod'], false) })
return () => { active = false }
}, [sourceId, namespace, app])
@@ -48,6 +48,11 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
const [namespaceError, setNamespaceError] = useState('')
/** 重试计数:失败后点「重试」时 +1 触发重新拉取 */
const [reloadKey, setReloadKey] = useState(0)
/** app / pod 候选值是懒加载的(选了上一级才拉)。
* 用下划线前缀避免"声明未使用"编译错误——这两个状态是为后续按级联加载
* 显示各自 loading 预留的,当前 SearchableValueSelect 已内置 loading。 */
const [appLoading, setAppLoading] = useState(false)
const [podLoading, setPodLoading] = useState(false)
const [appLabel, setAppLabel] = useState('app')
/** 授权策略里配置的 namespace(如默认策略的 default),用作默认选中值 */
const [scopeNamespace, setScopeNamespace] = useState('')
@@ -103,43 +108,59 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
return () => { active = false }
}, [sourceId, reloadKey])
// app 候选:选了 namespace 就只在该命名空间内搜索
// app 候选:**懒加载 + 批量取值**。
//
// 三个改动,都是为了"进页面不要一次性把 Loki 索引扫一遍":
// 1) 没选命名空间就不拉 —— 原来是全集群 app 列表(一个单实例 Loki 上要几秒到十几秒),
// 而下拉本身已经提示"先选命名空间",不拉也说得通。
// 2) 三级回退(app → service_name → container)改走**一次批量请求**,
// 原来最多串行 3 个往返(每个都要经 kube-apiserver service proxy)。
// 3) 记住本次解析出的 label 名(appLabel),拼 selector / 收窄 pod 都用它,
// 否则下拉里显示 79 个、生成的 {app="cex-xxx"} 一条也查不到。
useEffect(() => {
if (!sourceId) return
if (!sourceId || !namespace.trim()) {
setApps([])
setAppLabel('app')
setAppLoading(false)
return
}
let active = true
setAppLoading(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 }) => {
logsApi
.labelValuesBatch(sourceId, ['app', 'service_name', 'container'], selector)
.then(result => {
if (!active) return
const pick = ([label, list]: [string, string[]]) => ({ label, list })
const candidates: Array<[string, string[]]> = ['app', 'service_name', 'container'].map(name => [
name,
Array.isArray(result?.[name]) ? result[name] : [],
])
const hit = candidates.find(([, list]) => list.length > 0)
const { label, list } = pick(hit ?? ['app', []])
setAppLabel(label)
setApps(list)
})
.catch(() => { if (active) { setAppLabel('app'); setApps([]) } })
.finally(() => { if (active) setAppLoading(false) })
return () => { active = false }
}, [sourceId, namespace])
// pod 候选:按 namespace + app 收窄
// pod 候选:同样懒加载 —— 没选 app 就不拉(一个命名空间几百个 Pod 的下拉毫无用处)
useEffect(() => {
if (!sourceId) return
if (!sourceId || !app.trim()) {
setPods([])
setPodLoading(false)
return
}
let active = true
setPodLoading(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([]) })
.finally(() => { if (active) setPodLoading(false) })
return () => { active = false }
}, [sourceId, namespace, app, appLabel])
@@ -258,18 +279,36 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
values={apps}
value={app}
onChange={chooseApp}
placeholder={apps.length ? t('templates.selectCount', { count: apps.length }) : namespace ? t('templates.noApp') : t('templates.pickNsFirst')}
placeholder={
appLoading
? t('templates.loading')
: apps.length
? t('templates.selectCount', { count: apps.length })
: namespace
? t('templates.noApp')
: t('templates.pickNsFirst')
}
allowEmpty
disabled={!sourceId}
loading={appLoading}
disabled={!sourceId || !namespace.trim()}
/>
<SearchableValueSelect
label={t('templates.pod')}
values={pods}
value={pod}
onChange={choosePod}
placeholder={pods.length ? t('templates.selectCount', { count: pods.length }) : app ? t('templates.noPod') : t('templates.optional')}
placeholder={
podLoading
? t('templates.loading')
: pods.length
? t('templates.selectCount', { count: pods.length })
: app
? t('templates.noPod')
: t('templates.pickAppFirst')
}
allowEmpty
disabled={!sourceId}
loading={podLoading}
disabled={!sourceId || !app.trim()}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">