fix(logs,analysis,alerts): 数据源不可达、分析页超时、告警规则混入基础设施项

=== 1. 主机模式数据源地址 0.0.0.0 → 主机真实 IP(查询页"永远加载中"的根因)===
现象:切换数据源后命名空间/App 下拉不动,一直显示"加载中…",控制台报
    Get "http://0.0.0.0:3100/loki/api/v1/labels": dial tcp 0.0.0.0:3100: connect: connection refused

根因:主机模式部署的 endpoint 记的是**容器监听地址** 0.0.0.0:3100,
而平台进程不在被采集主机上,0.0.0.0 只在本机有意义。
EnsureLogSourceForDeployment 把它原样写成了数据源 URL。

修法:
  - 新增 reachableEndpoint():把 0.0.0.0 / [::] 换成关联主机的真实 IP
  - schemaVersion v3→v4 迁移 fixUnreachableLogSourceURLs(),修正已有脏数据
  - 实测:http://0.0.0.0:3100 → http://172.19.16.8:3100,该源 label 查询由 502 变 200

=== 2. 前端:区分"加载中"与"加载失败" ===
原来用 `loading={!options.length}` 表示加载中 —— 数据源不可达时列表永远为空,
于是界面永远转圈,把真实错误完全藏起来了(这次的 0.0.0.0 就是这样被掩盖的)。
改为独立的 namespaceLoading / namespaceError 两个状态:
  - 切换数据源先清空旧 options(否则显示上一个源的命名空间,看着像"没反应")
  - 失败显示"加载失败"并在旁边给「重试」按钮,不用刷新整页

=== 3. 日志分析页:主图改走索引接口(原来 5 条 count_over_time 全超时)===
实测 astralx(约 370GB/天)15 分钟窗口:
  count_over_time   → exec 29s + 排队 95s,超过 30s 护栏 → 502
  index/volume_range → 0s
改为两阶段渲染:
  - 总量趋势改用 /logs/histogram(内部 index/volume_range),**0s 出图**
  - 错误/警告/app 维度仍需读日志内容,串行执行,先渲染主图再更新
  - heavyQueryTimeout 30s → 90s(实测 error 维度查询 14s 可完成)

=== 4. 告警规则按日志平台定位收敛 ===
  - seed 里删掉「Kubernetes 节点 NotReady」「容器 OOMKilled」:
    这类属于 kube-state-metrics / Prometheus 的职责,用日志字符串匹配不可靠
  - 迁移里清掉线上已存在的同类规则(3 条)
  - 修 AlertRule / NotifyChannel 重复 ID(同 logSources 一样漏在 sequences 重建外)

=== 5. 告警页 UI ===
  - 规则列表由"每条一张卡"改为紧凑表格(一屏看全,阈值可对齐比较)
  - 通知通道(飞书/Lark/Webhook)从「系统设置」搬到告警页折叠区:
    规则和"发到哪"是一件事的两半,分两页配容易漏配导致告警发不出去

=== 6. 权限收敛 ===
  - /alerts 加 adminOnly:普通用户按查询授权只看「日志查询」,不再看到告警菜单
  - 系统设置从侧栏移到右上角齿轮(低频平台配置,不占侧栏一位)

=== 7. 迁移设计教训(写进代码注释)===
v2 时把"告警去重 + 清基础设施规则"塞进 case 1,但线上库已到 v2,
那段代码永远不执行。**结构变更只要逻辑变了、目标库已达该版本,就必须再抬一版**,
不能改历史迁移分支。

=== 实测(v4-1028-2210 / 979bdb4a48e8)===
  v4 迁移:数据源 URL 修正 http://0.0.0.0:3100 → http://172.19.16.8:3100
  source 1 label:200,0s(astralx / log)
  source 2 label:200,0s(不再 connection refused)
  分析页 histogram astralx 15m:0s
  分析页 metrics  error  astralx 15m:14s
  告警规则:10 条(已无基础设施类)、ID 唯一
  另清理 172.19.16.8 上 13 个残留容器,回收 6.1GB
This commit is contained in:
opsx committed 2026-10-28 22:11:48 +00:00
1 parent 1a4c75e28c
commit 95dcd9408c
20 files changed
+941 -87

No files matched your search

@@ -0,0 +1,246 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { useTranslation } from 'react-i18next'
import { BellRing, Plus, Pencil, Trash2, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { PasswordInput } from '@/components/PasswordInput'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { alertsApi, type NotifyChannel } from '@/api/alerts'
import { toast } from 'sonner'
interface ChannelForm {
name: string
webhook: string
signingSecret: string
enabled: boolean
}
/**
* 通知通道(飞书/Lark)— 多 webhook 对应不同告警群。
* webhook 加密存储(留空=保留旧值),每通道可独立测试/禁用/删除(有关联规则时禁删)。
*/
export const NotifySection = memo(function NotifySection() {
const { t } = useTranslation('settings')
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
const [form, setForm] = useState<ChannelForm>({ name: '', webhook: '', signingSecret: '', enabled: true })
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<NotifyChannel | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
try {
const list = await alertsApi.listChannels()
setChannels(Array.isArray(list) ? list : [])
} catch (err) {
toast.error(err instanceof Error ? err.message : t('notify.errLoad'))
}
}, [t])
useEffect(() => {
void refresh()
}, [refresh])
const openCreate = () => {
setEditing(null)
setForm({ name: '', webhook: '', signingSecret: '', enabled: true })
setOpen(true)
}
const openEdit = (ch: NotifyChannel) => {
setEditing(ch)
setForm({ name: ch.name, webhook: '', signingSecret: '', enabled: ch.enabled })
setOpen(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
toast.error(t('notify.errNameRequired'))
return
}
if (!editing && !form.webhook) {
toast.error(t('notify.errWebhookRequired'))
return
}
setSaving(true)
try {
if (editing) {
await alertsApi.updateChannel(editing.id, {
name: form.name,
webhook: form.webhook || undefined,
signingSecret: form.signingSecret || undefined,
enabled: form.enabled,
})
} else {
await alertsApi.saveChannel({
name: form.name,
webhook: form.webhook,
signingSecret: form.signingSecret || undefined,
enabled: form.enabled,
})
}
setOpen(false)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('notify.errSave'))
} finally {
setSaving(false)
}
}
const handleTest = async (ch: NotifyChannel) => {
setTesting(true)
try {
await alertsApi.testChannel({ id: ch.id })
toast.success(t('notify.testSent', { name: ch.name }))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('notify.errSend'))
} finally {
setTesting(false)
}
}
const handleToggle = async (ch: NotifyChannel, enabled: boolean) => {
try {
await alertsApi.updateChannel(ch.id, { name: ch.name, enabled })
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('notify.errToggle'))
}
}
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await alertsApi.deleteChannel(deleteTarget.id)
setDeleteTarget(null)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('notify.errDelete'))
} finally {
setDeleting(false)
}
}
return (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<BellRing className="w-4 h-4" />
{t('notify.cardTitle')}
</span>
<Button size="sm" variant="outline" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
{t('notify.create')}
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-2 text-center">
{t('notify.emptyBefore')}
<Link to="/alerts" className="text-primary mx-1">{t('notify.emptyLink')}</Link>
{t('notify.emptyAfter')}
</p>
) : (
channels.map((ch) => (
<div key={ch.id} className="flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
<Switch checked={ch.enabled} onCheckedChange={(v) => void handleToggle(ch, v)} />
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">{ch.name}</p>
<p className="text-xs text-muted-foreground">
{ch.hasWebhook ? t('notify.webhookConfigured') : t('notify.webhookMissing')}
{ch.hasSecret ? t('notify.secretConfigured') : ''}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" aria-label={t('notify.testAria', { name: ch.name })} disabled={testing} onClick={() => void handleTest(ch)}>
<Send className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" aria-label={t('notify.editAria', { name: ch.name })} onClick={() => openEdit(ch)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={t('notify.deleteAria', { name: ch.name })}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(ch)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))
)}
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editing ? t('notify.editTitle', { name: editing.name }) : t('notify.createTitle')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('notify.nameLabel')}</Label>
<Input
placeholder={t('notify.namePlaceholder')}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>
{t('notify.webhookLabel')}
{editing ? t('notify.webhookKeepHint') : ' *'}
</Label>
<PasswordInput
placeholder={editing?.hasWebhook ? t('notify.webhookSavedPlaceholder') : 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'}
value={form.webhook}
onChange={(e) => setForm((f) => ({ ...f, webhook: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('notify.signingLabel')}</Label>
<PasswordInput
placeholder={editing?.hasSecret ? t('notify.signingSavedPlaceholder') : t('notify.signingPlaceholder')}
value={form.signingSecret}
onChange={(e) => setForm((f) => ({ ...f, signingSecret: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
<Label>{t('notify.enable')}</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>{t('actions.cancel')}</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t('actions.saving') : editing ? t('notify.saveChanges') : t('notify.createChannel')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title={t('notify.deleteTitle')}
loading={deleting}
onConfirm={handleDelete}
/>
</Card>
)
})
+83 -55
View File
@@ -1,11 +1,11 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import { NotifySection } from './components/NotifySection'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
@@ -216,55 +216,72 @@ const AlertsPage = memo(function AlertsPage() {
description={t('emptyDesc')}
/>
) : (
<div className="space-y-3">
{rules.map((rule) => {
const src = sources.find((s) => s.id === rule.sourceId)
return (
<Card key={rule.id} className="border-border">
<CardContent className="p-4 flex items-center gap-4 flex-wrap">
<Switch checked={rule.enabled} onCheckedChange={(v) => void toggleRule(rule, v)} />
<div className="min-w-0 flex-1">
<p className="font-medium">{rule.name}</p>
<p className="text-xs text-muted-foreground truncate font-mono mt-0.5">{rule.query}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{src?.name ?? t('sourceFallback', { id: rule.sourceId })} →{' '}
{(rule.channelIds || [])
.map((id) => channels.find((c) => c.id === id)?.name || `#${id}`)
.join(t('channelSep')) || t('noChannels')}{' '}
· {t('ruleMeta', {
window: rule.windowMin,
threshold: rule.threshold,
cooldown: rule.cooldownMin,
})}
{rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · ${t('lastFired', {
time: new Date(rule.lastFiredAt).toLocaleString(),
})}`}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
aria-label={t('editAria', { name: rule.name })}
onClick={() => openEdit(rule)}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline"
size="sm"
aria-label={t('deleteAria', { name: rule.name })}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(rule)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
/* 紧凑表格:原来每条规则一张 Card(p-4 + 三行文字),10 条规则要滚很久。
规则本质是同构的行数据,用表格能在一屏内看全,也便于对齐比较阈值。 */
<Card className="border-border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
<tr>
<th className="w-10 px-3 py-1.5 text-left font-medium">{t('col.enabled')}</th>
<th className="px-3 py-1.5 text-left font-medium">{t('col.name')}</th>
<th className="px-3 py-1.5 text-left font-medium">{t('col.condition')}</th>
<th className="px-3 py-1.5 text-left font-medium">{t('col.notify')}</th>
<th className="w-16 px-3 py-1.5" />
</tr>
</thead>
<tbody>
{rules.map((rule) => {
const src = sources.find((s) => s.id === rule.sourceId)
const chan = (rule.channelIds || [])
.map((id) => channels.find((c) => c.id === id)?.name || `#${id}`)
.join(t('channelSep')) || t('noChannels')
return (
<tr key={rule.id} className="border-b border-border/50 last:border-0 hover:bg-muted/20">
<td className="px-3 py-1.5">
<Switch checked={rule.enabled} onCheckedChange={(v) => void toggleRule(rule, v)} />
</td>
<td className="max-w-[13rem] px-3 py-1.5">
<div className="truncate font-medium" title={rule.name}>{rule.name}</div>
<div className="truncate text-[11px] text-muted-foreground" title={rule.query}>
{src?.name ?? t('sourceFallback', { id: rule.sourceId })}
</div>
</td>
<td className="px-3 py-1.5">
<div className="truncate font-mono text-[11px] text-muted-foreground" title={rule.query}>
{rule.query}
</div>
<div className="text-[11px] text-muted-foreground">
{t('ruleMeta', {
window: rule.windowMin,
threshold: rule.threshold,
cooldown: rule.cooldownMin,
})}
{rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · ${t('lastFired', {
time: new Date(rule.lastFiredAt).toLocaleString(),
})}`}
</div>
</td>
<td className="max-w-[10rem] px-3 py-1.5">
<div className="truncate text-xs" title={chan}>{chan}</div>
</td>
<td className="px-3 py-1.5">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" aria-label={t('editAria', { name: rule.name })} onClick={() => openEdit(rule)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10" aria-label={t('deleteAria', { name: rule.name })} onClick={() => setDeleteTarget(rule)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
)}
{/* 发送历史 */}
@@ -301,11 +318,22 @@ const AlertsPage = memo(function AlertsPage() {
</Card>
)}
<p className="text-xs text-muted-foreground">
{t('notifyHintPre')}
<Link to="/settings" className="text-primary mx-1">{t('common:menu.settings')}</Link>
{t('notifyHintPost')}
</p>
{/* 通知通道配置:从「系统设置」搬到这里。
告警规则和"告警发到哪"是同一件事的两半,分两个页面配很容易漏配导致告警发不出去。
默认折叠,不影响规则列表的紧凑度。 */}
<details className="rounded-lg border border-border" open={channels.length === 0}>
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium">
{t('notifySectionTitle')}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{channels.length > 0
? t('notifySectionConfigured', { count: channels.length })
: t('notifySectionEmpty')}
</span>
</summary>
<div className="border-t border-border p-3">
<NotifySection />
</div>
</details>
{/* 规则弹窗 */}
<Dialog open={open} onOpenChange={setOpen}>
@@ -111,7 +111,10 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
// 分批(每批 2 条)而不是一次性 5 条并发:
// Loki 单实例被 5 条 count_over_time 同时打会互相拖慢,
// 而且会撞后端并发护栏。分批后总耗时接近,但不会互相踩。
const runInBatches = async <T,>(tasks: Array<() => Promise<T>>, size = 2): Promise<T[]> => {
// 串行执行(size=1):分析页一次要跑 5 条重查询(count_over_time),
// 后端并发护栏是 4 且共享给所有用户。并发 2 时多人同时打开分析页
// 很容易撞 429,表现为"加载不出来"。串行慢一点但稳。
const runInBatches = async <T,>(tasks: Array<() => Promise<T>>, size = 1): Promise<T[]> => {
const out: T[] = []
for (let index = 0; index < tasks.length; index += size) {
out.push(...await Promise.all(tasks.slice(index, index + size).map(task => task())))
@@ -123,8 +126,42 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
setError('')
void (async () => {
try {
const [totalSeries, errorSeries, warnSeries, byAppSeries, errByAppSeries] = await runInBatches([
() => fetchRetry(`sum(count_over_time(${selector}[$step]))`),
// ── 第一步:总量趋势走 Loki 的索引接口(index/volume_range)──
//
// 原来第一张图也用 count_over_time,实测 astralx(约 370GB/天)15 分钟窗口
// 单条要 exec 29s + 排队,5 条串行下来两三分钟都出不来,页面就是"死在转圈"。
// /logs/histogram 内部用 index/volume_range,只读 TSDB 的 chunk 元数据,
// 不解码日志内容,同一条件毫秒级返回(对比实测 ~1300 倍差距)。
const hist = await logsApi.histogram(
{ sourceId, query: selector, start: Number(ns(new Date(start).getTime())), end: Number(ns(new Date(end).getTime())) },
{ signal: controller.signal },
)
const totalSeries: MetricSeries[] = [{
labels: {},
points: (hist || []).map(bucket => ({
timestamp: String((bucket as { timestamp: string }).timestamp ?? ''),
value: Number((bucket as { count: number }).count ?? 0),
})),
}]
if (version !== versionRef.current) return
// 主图先渲染:即使后面的 app 维度还在算,用户也能立刻看到总量趋势
setLoading(false)
setData(prev => ({
trend: buildTrend(totalSeries, [], []),
namespaceShares: prev?.namespaceShares ?? [],
apps: prev?.apps ?? [],
appTimes: prev?.appTimes ?? [],
stats: {
total: seriesTotal(totalSeries),
errorCount: prev?.stats.errorCount ?? 0,
warnCount: prev?.stats.warnCount ?? 0,
appCount: prev?.stats.appCount ?? 0,
},
}))
// ── 第二步:错误/警告/app 维度仍需读日志内容,串行执行 ──
const [errorSeries, warnSeries, byAppSeries, errByAppSeries] = await runInBatches([
() => fetchRetry(`sum(count_over_time(${selector} |= "error"[$step]))`),
() => fetchRetry(`sum(count_over_time(${selector} |= "warn"[$step]))`),
() => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`),
+34 -10
View File
@@ -39,7 +39,9 @@ const AnalysisPage = memo(function AnalysisPage() {
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000)))
// 默认 15 分钟:分析页一次性要跑 5 条 count_over_time,
// 1 小时窗口在日志量大的命名空间要十几秒;15 分钟既能看出趋势又能快速出结果。
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000)))
const [end, setEnd] = useState(() => toLocal(new Date()))
/**
@@ -49,7 +51,9 @@ const AnalysisPage = memo(function AnalysisPage() {
* 现在默认只查业务命名空间,用户可下拉切换;"全部命名空间"作为显式选项并列提示较慢。
*/
const [namespaces, setNamespaces] = useState<string[]>([])
const [namespace, setNamespace] = useState<string>(() => localStorage.getItem('analysisNamespace') ?? '')
// 初始为空,等策略返回后再定(不要用 localStorage 覆盖策略:
// 策略是服务端下发的权威值,本地记忆会把管理员改过的策略"顶掉")。
const [namespace, setNamespace] = useState<string>('')
const [namespaceReady, setNamespaceReady] = useState(false)
useEffect(() => {
@@ -65,20 +69,35 @@ const AnalysisPage = memo(function AnalysisPage() {
.catch(() => setSources([]))
}, [])
// 拉取可用命名空间;默认优先业务命名空间 astralx,其次列表第一个
// 拉取可用命名空间,并把**查询授权策略里配置的命名空间**作为默认值。
//
// 之前这里硬编码"优先 astralx",等于把环境特征写进代码:
// 换环境(策略配的是 default / 别的 ns)就默认选错,用户还得手动改。
// 现在默认值来源依次为:URL 记忆 → 授权策略 labels.namespace → default → 列表第一个。
useEffect(() => {
if (!sourceId) return
let cancelled = false
logsApi.labelValues(sourceId, 'namespace')
.then(list => {
Promise.all([
logsApi.labelValues(sourceId, 'namespace'),
// 策略里配的命名空间(后台「查询授权」可改);失败不影响主流程
logsApi.myScope(sourceId).catch(() => null),
])
.then(([list, scope]) => {
if (cancelled) return
const values = (list || []).filter(Boolean).sort()
const scopeNs = scope?.labels?.namespace ?? ''
setNamespaces(values)
setNamespace(current => {
if (current && (current === ALL_NAMESPACES || values.includes(current))) return current
const saved = localStorage.getItem('analysisNamespace')
if (saved && (saved === ALL_NAMESPACES || values.includes(saved))) return saved
return values.includes('astralx') ? 'astralx' : values[0] ?? ''
// 优先级:用户当前已选(同一数据源内不跳变)→ 授权策略 → default → 第一个
const candidates = [current, scopeNs, 'default', values[0]]
for (const candidate of candidates) {
if (!candidate) continue
if (candidate === ALL_NAMESPACES) return candidate
if (values.includes(candidate)) return candidate
}
// 策略里配了但 Loki 里暂时没数据时,仍按策略显示(用户能看到"为什么没数据")
if (scopeNs) return scopeNs
return ''
})
})
.catch(() => { if (!cancelled) setNamespaces([]) })
@@ -86,6 +105,11 @@ const AnalysisPage = memo(function AnalysisPage() {
return () => { cancelled = true }
}, [sourceId])
// 记住用户手选,下次进入沿用(但不会覆盖策略下发的值)
useEffect(() => {
if (namespace) localStorage.setItem('analysisNamespace', namespace)
}, [namespace])
// hook 内部把空串当作 {namespace=~".+"}(全部),这里做哨兵转换
const scopedNamespace = namespace === ALL_NAMESPACES ? '' : namespace
const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId, start, end, namespace: scopedNamespace })
@@ -106,7 +130,7 @@ const AnalysisPage = memo(function AnalysisPage() {
title={t('title')}
description={t('subtitle')}
action={
<Button variant="outline" onClick={refresh} disabled={loading || !sourceId}>
<Button variant="outline" onClick={refresh} disabled={!sourceId}>
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <RefreshCw className="mr-1 size-4" />}
{t('filter.refresh')}
</Button>
@@ -42,6 +42,12 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
* Promtail 会从容器名生成 service_name / container,覆盖度更高,
* 因此按 app → service_name → container 逐级回退。
*/
/** 命名空间加载中(与"加载失败"要分开,否则空列表会一直显示"加载中") */
const [namespaceLoading, setNamespaceLoading] = useState(false)
/** 命名空间加载失败原因(如数据源地址不可达 connection refused) */
const [namespaceError, setNamespaceError] = useState('')
/** 重试计数:失败后点「重试」时 +1 触发重新拉取 */
const [reloadKey, setReloadKey] = useState(0)
const [appLabel, setAppLabel] = useState('app')
/** 授权策略里配置的 namespace(如默认策略的 default),用作默认选中值 */
const [scopeNamespace, setScopeNamespace] = useState('')
@@ -61,18 +67,41 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
return () => { active = false }
}, [sourceId])
// namespace 列表只加载一次
// namespace 列表:**每次切换数据源都重新加载**。
//
// 三个必须点:
// 1) 先清空旧值:否则切换数据源后仍显示上一个源的命名空间(看着像"没反应")
// 2) 记录 loading 与 error 两个状态:原来用 !options.length 表示 loading,
// 数据源不可达时列表永远为空 → 界面永远"加载中…",把真实错误藏起来了
// (典型错误:主机模式数据源地址是 0.0.0.0 → connection refused)
// 3) 失败后可点重试,不用刷新整页
useEffect(() => {
if (!sourceId) return
if (!sourceId) {
setNamespaces([])
setApps([])
setPods([])
setNamespaceError('')
return
}
let active = true
setNamespaceLoading(true)
setNamespaceError('')
setNamespaces([])
setApps([])
setPods([])
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([]) })
.catch(err => {
if (!active) return
setNamespaces([])
setNamespaceError(err instanceof Error ? err.message : String(err))
})
.finally(() => { if (active) setNamespaceLoading(false) })
return () => { active = false }
}, [sourceId])
}, [sourceId, reloadKey])
// app 候选:选了 namespace 就只在该命名空间内搜索
useEffect(() => {
@@ -204,10 +233,26 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert,
values={namespaceOptions}
value={namespace}
onChange={chooseNamespace}
placeholder={t('templates.selectCount', { count: namespaceOptions.length })}
placeholder={
namespaceError
? t('templates.loadFailed')
: namespaceLoading
? t('templates.loading')
: t('templates.selectCount', { count: namespaceOptions.length })
}
allowEmpty
loading={!namespaceOptions.length}
loading={namespaceLoading}
/>
{namespaceError && (
<button
type="button"
onClick={() => setReloadKey(k => k + 1)}
className="self-center text-xs text-destructive underline underline-offset-2"
title={namespaceError}
>
{t('templates.retry')}
</button>
)}
<SearchableValueSelect
label={appLabel === 'app' ? t('templates.app') : t('templates.appWithKey', { key: appLabel })}
values={apps}
+19 -2
View File
@@ -63,6 +63,23 @@ export default function LogsPage() {
const admin = getUser()?.role === 'admin'
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
/**
* 按 id 去重的数据源选项。
*
* 后端曾因 sequences 漏建导致两个数据源 ID 都是 1,
* 于是 <SelectItem> 出现两个相同 value —— 用户点第二个时 sourceId
* 仍是 1,state 不变、effect 不触发,界面上表现为"数据源切换不了"。
* 后端已修 + 做了迁移去重,这里再兜一层:即使历史数据仍有重复,
* 下拉也不会因相同 value 而卡死。
*/
const sourceOptions = useMemo(() => {
const seen = new Set<number>()
return sources.filter(source => {
if (seen.has(source.id)) return false
seen.add(source.id)
return true
})
}, [sources])
const [query, setQuery] = useState('')
const [executedQuery, setExecutedQuery] = useState('')
const [executedRange, setExecutedRange] = useState<{ start: number; end: number } | null>(null)
@@ -263,12 +280,12 @@ export default function LogsPage() {
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Select value={sourceId ? String(sourceId) : undefined} onValueChange={value => setSourceId(Number(value))} disabled={sources.length === 0}>
<Select value={sourceId ? String(sourceId) : undefined} onValueChange={value => setSourceId(Number(value))} disabled={sourceOptions.length === 0}>
<SelectTrigger className="h-9 min-w-40" aria-label={t('toolbar.sourceAria')}>
<SelectValue placeholder={t('toolbar.sourceEmptyOption')} />
</SelectTrigger>
<SelectContent>
{sources.map(source => <SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>)}
{sourceOptions.map(source => <SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>)}
</SelectContent>
</Select>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
+1 -2
View File
@@ -14,7 +14,6 @@ import { LdapSection } from './components/LdapSection'
import { ThemeSection } from './components/ThemeSection'
import { LanguageSection } from '@/i18n/LanguageSection'
import { Palette } from 'lucide-react'
import { NotifySection } from './components/NotifySection'
import { ImageRegistrySection } from './components/ImageRegistrySection'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -180,7 +179,7 @@ const SettingsPage = memo(function SettingsPage() {
</TabsContent>
<TabsContent value="notify" className="space-y-6">
<NotifySection />
{/* 告警通知通道已移到「日志告警 → 通知通道」,避免配置入口分裂 */}
</TabsContent>
<TabsContent value="ldap">