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

@@ -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) }} />