=== 1. 日志列表:去掉页码分页,改「加载更多」+ 触底自动加载(节流)===
为什么不用"上一页/下一页/共 N 页":
- Loki 是**游标分页**,不支持 offset,页码天生跳不了页
- 规模上没意义:15 分钟 4000 万条 → 按 200/页算 40 万页
- 带 |= / |~ 行过滤时总数根本算不出来(index/stats 只接受 label matcher)
日志排查的真实交互是"继续往下看",所以改为:
- 列表底部:「已加载 N 条 · 约 M 条」+「加载更多」按钮
- 滚动到底**自动加载**,但 2 秒节流 —— 每批都要后端扫 Loki,
快速滚动连续触发会撞并发护栏(429),反而比手动点更卡
- 到 5000 条停止并提示"缩小时间范围或加过滤条件"(虚拟滚动只省 DOM,
不省数组内存,无上限会一直涨)
- 修正语义 bug:分页原来是 setEntries(result.entries) **整页替换**,
改成追加(否则"加载更多"等于翻页)
- 删除 pageCursors 翻页栈与 prevPage/nextPage/pageOf/pageOfTotal 文案
=== 2. /logs/stats 错误映射:Loki 400 → 我们的 400 + 人话 ===
之前带行过滤时 Loki 回 400 "only label matchers are supported",
被统一包装成 502「Kubernetes API 代理返回 HTTP 400…」,
前端只能当失败静默 → 用户看到"总页数没了"却不知道原因。
现在返回 400 +「含过滤条件(|= / |~)时无法统计总页数:Loki 只支持按标签统计」,
前端据此在条数旁给出提示。
=== 3. 分析页:修"延迟加载永远不触发"(数据一直不出来)===
现象:应用排行/流图/饼图/活跃应用全是"没有应用维度的数据",每日量"已有 0 天",
但接口实测正常(by-app 返回 75 条序列、daily-volume 返回 2 天)。
根因:这三块用 useInView 门控,而 ref 挂在 <Card> 上,观察器不触发 → 永远 false
→ hook 直接 return → 页面永远空。
修法:
- 改用普通 <div ref> 包裹,不依赖组件是否转发 ref
- useInView 加**几何兜底**:1 秒后若元素已在视口内就直接置 true
(有些容器里 IntersectionObserver 的 root 判定会和预期不一致)
=== 4. 分析页指标短缓存(26s → 11ms)===
`sum by (app)(count_over_time(...))` 扫 15 分钟 astralx(约 6.6GB)实测 16~28 秒,
Loki 自带结果缓存不命中(时间窗每次都在变)。点刷新/切页都要再等半分钟。
现在按 (数据源, 查询, 时间窗) 缓存 90 秒,时间窗按 2 分钟取整当 key:
实测第 1 次 23514ms → 第 2 次 **11ms**
代价:命中缓存时数据最多旧 ~2 分钟,对聚合视图可接受。
=== 5. 仪表盘 ===
去掉「平台自检」「N 项需关注」两段文字:卡片本身就是自检结果,
标题重复一遍没信息量;状态改由图标+边框颜色表达。
=== 6. 其它文案修正 ===
饼图空态从「没有命名空间维度的数据」改为「没有应用维度的数据」
(饼图上一版已改成应用维度,文案没跟上)。
=== 实测(1da8a148d756)===
Pod = Hub latest = v0.1 = 1da8a148d756
stats:纯 selector → 200(7005 万条);带 |= error → 400 + 人话
分析指标缓存:23514ms → 11ms
每日量:2 天(10-27 32.5GB / 10-28 356.5GB)
文案:加载更多 / 已到末尾 / 达到上限 均进包;代码中已无 prevPage/nextPage 引用
110 lines
4.0 KiB
TypeScript
110 lines
4.0 KiB
TypeScript
import { memo, useCallback, useEffect, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Activity, AlertTriangle, CheckCircle2, RefreshCw, XCircle } from 'lucide-react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { systemApi, type SelfCheckResult } from '@/api/system'
|
|
|
|
/** 单项状态 → 图标与配色 */
|
|
function StatusIcon({ status }: { status: 'ok' | 'warn' | 'error' }) {
|
|
if (status === 'ok') return <CheckCircle2 className="h-4 w-4 text-success" />
|
|
if (status === 'warn') return <AlertTriangle className="h-4 w-4 text-warning" />
|
|
return <XCircle className="h-4 w-4 text-destructive" />
|
|
}
|
|
|
|
/**
|
|
* 一键自检 — 仪表盘顶部。
|
|
*
|
|
* 为什么放在仪表盘:新人接手/故障时第一眼看这里,不必在
|
|
* 「集群/主机/存储/部署/采集器」五个页面之间来回跳着判断"到底哪坏了"。
|
|
* 后端 /system/self-check 会真正探活(Loki /ready、集群凭证解密、磁盘空间),
|
|
* 不是只读配置推断。
|
|
*/
|
|
export const SelfCheckCard = memo(function SelfCheckCard() {
|
|
const { t } = useTranslation('dashboard')
|
|
const [data, setData] = useState<SelfCheckResult | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [failed, setFailed] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
setFailed(false)
|
|
try {
|
|
setData(await systemApi.selfCheck())
|
|
} catch {
|
|
// 自检失败本身不代表平台坏了(可能是权限或接口异常),
|
|
// 这里静默降级,不打断仪表盘其它内容
|
|
setFailed(true)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [load])
|
|
|
|
if (loading && !data) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
{t('selfCheck.loading')}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
if (failed || !data) return null
|
|
|
|
const borderClass =
|
|
data.overall === 'ok'
|
|
? 'border-success/30'
|
|
: data.overall === 'warn'
|
|
? 'border-warning/40'
|
|
: 'border-destructive/40'
|
|
|
|
return (
|
|
<Card className={borderClass}>
|
|
<CardHeader className="pb-2">
|
|
{/* 按使用反馈去掉「平台自检 / N 项需关注」这两段文字:
|
|
卡片本身就是自检结果,标题重复一遍没有信息量。
|
|
只保留状态图标着色(边框颜色已经能表达 overall)+ 时间 + 刷新。 */}
|
|
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
|
<Activity className={`h-4 w-4 ${
|
|
data.overall === 'ok'
|
|
? 'text-success'
|
|
: data.overall === 'warn'
|
|
? 'text-warning'
|
|
: 'text-destructive'
|
|
}`} />
|
|
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
|
{new Date(data.checkedAt).toLocaleTimeString()}
|
|
</span>
|
|
<Button variant="ghost" size="sm" onClick={() => void load()} disabled={loading}>
|
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-wrap gap-x-6 gap-y-2 pt-0">
|
|
{data.items.map(item => (
|
|
<div key={item.name} className="flex items-start gap-1.5 text-xs" title={item.detail || ''}>
|
|
<StatusIcon status={item.status} />
|
|
<span className="text-muted-foreground">{item.name}</span>
|
|
<span
|
|
className={
|
|
item.status === 'ok'
|
|
? 'text-foreground'
|
|
: item.status === 'warn'
|
|
? 'text-warning'
|
|
: 'text-destructive'
|
|
}
|
|
>
|
|
{item.message}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})
|