fix(hosts,collectors,usage): 主机验证结果失真 + 主机模式系统日志解析 + metrics 单位

=== 一、主机「测试连接」永远显示旧错误(真实 bug)===
HostService.Validate 的失败分支也写 status=active,且从不写 statusMessage:
    if info, err := client.GetHostInfo(ctx); err == nil { 更新 os/arch }
    else { s.Update(id, {status:"active"}) }   // ← 失败也标记成功
结果:连不上也显示正常,页面永远停在几分钟前的旧错误文案,
用户改了 IP 再点测试,还以为"改了还是不行"。

改为区分成功/失败并写清原因:
    成功 → status=active + os/arch + statusMessage="连接成功"
    失败 → status=error + statusMessage=FormatSSHError(err)

=== 二、回环地址在平台里必然连不上 ===
平台跑在容器/Pod 内,127.0.0.1 指向平台自身。
- 新建主机时拦截:hostIP 为 127.0.0.1/localhost/::1 直接报错并解释原因
- 前端表单同步校验 + 文案
- SSH 错误映射补充:连接被拒绝时提示可能是地址填了回环

=== 三、主机模式采集系统日志(两处会导致采集失败)===
1. pipeline 一律套 cri 阶段:cri 只认容器运行时格式,
   套在 /var/log/messages 这类 syslog 上会取不到时间戳、整行被丢。
   → appPipelineStages() 按路径判断:容器日志用 cri,普通文本日志只做限流。
2. promtailExternalLabels 写死 8 空格缩进:主机模板 external_labels 只有 4 空格,
   一旦给采集器配置了标签就生成非法 YAML,Promtail 装不上。
   → 增加 indent 参数,主机 6 空格 / DaemonSet 8 空格。
3. 新增 system 采集任务:
   /var/log/{messages,secure,cron,maillog,syslog,dmesg}
   正则解析 syslog 时间戳(Jan _2 15:04:05,Asia/Shanghai)+ 限流

测试:TestHostPromtailConfigValid / CRIPathKeepsCRIStage / PlainFileHasNoCRIStage
      TestPromtailManifestParses 全部通过

=== 四、资源用量 CPU 解析(nanocores)===
metrics-server 返回的 CPU 常是纳核(如 "194533645n"=194.5m)。
原解析只处理 "m",导致 194 亿核、页面出现"CPU 已达 limit 的 4863341125%"。
→ parseQuantityMilli 支持 n/u/m/整核
实测:CPU 当前=178m,峰值=3300m,建议"峰值占 limit 82%,可考虑提前扩容"

=== 五、其他 ===
- 添加集群默认改用 Token 认证(最小权限、可随时吊销)
- RBAC 片段补 metrics.k8s.io(资源面板需要,独立 API 组常被漏授)
- 文案去掉 Grafana 需求描述(平台自身不部署 Grafana)

镜像:lghost/lokistack:latest + v0.1 = sha256:7bddd0f4d1f1
This commit is contained in:
opsx committed 2026-10-28 17:58:30 +00:00
1 parent a32f93f533
commit bad2a1e624
33 files changed
+1124 -52

No files matched your search

@@ -67,7 +67,9 @@ type ClusterFormValues = z.infer<ReturnType<typeof makeClusterFormSchema>>
const DEFAULT_VALUES: ClusterFormValues = {
name: '',
description: '',
configType: 'kubeconfig',
// 默认用 Token:最小权限、可随时吊销,不需要把整份 kubeconfig(含集群 CA 私钥路径、
// 其他用户的凭证)交给平台。kubeconfig 模式保留给需要严格 TLS 校验的场景。
configType: 'token',
kubeconfig: '',
apiServer: '',
token: '',
@@ -106,7 +108,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
if (open) {
form.reset(editing ? {
name: editing.name, description: editing.description || '',
configType: (editing.configType as 'kubeconfig' | 'token') || 'kubeconfig',
configType: (editing.configType as 'kubeconfig' | 'token') || 'token',
kubeconfig: '', apiServer: editing.apiServer || '', token: '', context: editing.context || '',
} as never : DEFAULT_VALUES)
}
@@ -305,6 +307,11 @@ rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# 资源用量面板需要。metrics.k8s.io 是独立 API 组,
# 内置只读角色(view)在国内云上通常不含它,漏了只能看到限额、看不到实时用量。
- apiGroups: ["metrics.k8s.io"]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
@@ -1,6 +1,6 @@
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Play, Square, RotateCw, Trash2, Terminal, Settings, Rocket } from 'lucide-react'
import { Activity, Play, Square, RotateCw, Trash2, Terminal, Settings, Rocket } from 'lucide-react'
import { StatusBadge } from '@/components/StatusBadge'
import { deploymentApi } from '@/api'
import { ResourceCard } from '@/components/ResourceCard'
@@ -17,6 +17,7 @@ interface DeploymentCardProps {
onDelete: (d: LokiDeployment) => void
onViewLogs: (d: LokiDeployment) => void
onEditRetention: (d: LokiDeployment) => void
onViewUsage: (d: LokiDeployment) => void
}
/** 部署卡片 — 列表项展示与操作入口(配置/部署/停止/重启/日志/删除) */
@@ -29,6 +30,7 @@ export const DeploymentCard = memo(function DeploymentCard({
onDelete,
onViewLogs,
onEditRetention,
onViewUsage,
}: DeploymentCardProps) {
const { t } = useTranslation('deployments')
// Loki 就绪探测(运行中时经数据源代理 /ready)
@@ -117,6 +119,7 @@ export const DeploymentCard = memo(function DeploymentCard({
<CardActionButton icon={RotateCw} label={t('card.restart')} spinning={isBusy} disabled={isBusy} onClick={() => onRestart(d.id)} />
</>
)}
<CardActionButton icon={Activity} label={t('card.usage')} onClick={() => onViewUsage(d)} />
<CardActionButton icon={Terminal} label={t('card.logs')} onClick={() => onViewLogs(d)} />
<CardActionButton icon={Trash2} ariaLabel={t('card.deleteAria', { name: d.name })} destructive onClick={() => onDelete(d)} />
</>
@@ -375,7 +375,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>CPU</FormLabel>
<FormControl>
<Input placeholder="1" {...field} />
<Input placeholder="4" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -388,7 +388,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>{t('form.memory')}</FormLabel>
<FormControl>
<Input placeholder="2Gi" {...field} />
<Input placeholder="8Gi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -0,0 +1,183 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Activity, RefreshCw, TriangleAlert } from 'lucide-react'
import { deploymentApi } from '@/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { LokiDeployment } from '@/types'
interface Usage {
mode: string
source: string
namespace?: string
pod?: string
node?: string
ready: boolean
restarts: number
limitsConfigured: boolean
metricsNote?: string
cpuCurrentMilli: number
cpuRequestMilli: number
cpuLimitMilli: number
memCurrentBytes: number
memRequestBytes: number
memLimitBytes: number
cpuPeakMilli?: number
memPeakBytes?: number
throttlePercent?: number
cpuSuggest?: string
memSuggest?: string
summary: string
}
const gb = (bytes: number) => (bytes / 1024 ** 3).toFixed(2)
const cores = (milli: number) => (milli / 1000).toFixed(2)
/** 用条形容使用率;超 90% 红、70% 黄、其余绿 */
function UsageBar({ label, current, limit, format, peak }: {
label: string
current: number
limit: number
format: (v: number) => string
peak?: number
}) {
const ratio = limit > 0 ? Math.min(1, current / limit) : 0
const tone = ratio >= 0.9 ? 'bg-destructive' : ratio >= 0.7 ? 'bg-warning' : 'bg-success'
return (
<div className="space-y-1">
<div className="flex items-baseline justify-between text-xs">
<span className="font-medium text-foreground">{label}</span>
<span className="tabular-nums text-muted-foreground">
{format(current)} / {format(limit)}({(ratio * 100).toFixed(0)}%)
{peak !== undefined && peak > 0 && <span className="ml-2">峰值 {format(peak)}</span>}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full rounded-full ${tone}`} style={{ width: `${Math.max(2, ratio * 100)}%` }} />
</div>
</div>
)
}
/**
* 资源用量弹框 —— 给"要不要扩容"提供依据:
* - 当前 CPU / 内存 对 limit 的占比(进度条颜色分级)
* - 24 小时峰值(需后端配置 LOKI_USAGE_PROMETHEUS_URL)、CPU 限流率
* - 就绪状态、重启次数
* - 后端算好的扩容建议(峰值 ≥ limit 90% 时给出具体目标值)
* 打开期间每 15 秒自动刷新。
*/
export const DeploymentUsageDialog = memo(function DeploymentUsageDialog({ deployment, onClose }: {
deployment: LokiDeployment | null
onClose: () => void
}) {
const { t } = useTranslation('deployments')
const [usage, setUsage] = useState<Usage | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const load = useCallback(async (id: number) => {
setLoading(true)
setError('')
try {
setUsage(await deploymentApi.usage(id) as Usage)
} catch (e) {
setError(e instanceof Error ? e.message : t('usage.errLoad'))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
if (!deployment) return
void load(deployment.id)
const timer = window.setInterval(() => void load(deployment.id), 15000)
return () => window.clearInterval(timer)
}, [deployment, load])
return (
<Dialog open={deployment !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity className="size-4" />{t('usage.title', { name: deployment?.name ?? '' })}
</DialogTitle>
<DialogDescription className="text-xs">
{usage
? [usage.namespace && usage.pod ? `${usage.namespace}/${usage.pod}` : usage.pod, usage.node].filter(Boolean).join(' · ')
: t('usage.subtitle')}
</DialogDescription>
</DialogHeader>
{error && <p className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">{error}</p>}
{!error && !usage && <p className="py-8 text-center text-sm text-muted-foreground">{t('usage.loading')}</p>}
{usage && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-xs">
<span className={usage.ready ? 'rounded bg-success/15 px-2 py-0.5 text-success' : 'rounded bg-destructive/15 px-2 py-0.5 text-destructive'}>
{usage.ready ? t('usage.ready') : t('usage.notReady')}
</span>
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">{usage.source}</span>
<span className="text-muted-foreground">{t('usage.restarts', { count: usage.restarts })}</span>
{usage.throttlePercent !== undefined && (
<span className={usage.throttlePercent >= 10 ? 'rounded bg-warning/15 px-2 py-0.5 text-warning' : 'text-muted-foreground'}>
{t('usage.throttle', { percent: usage.throttlePercent.toFixed(1) })}
</span>
)}
{usage.throttlePercent === undefined && <span className="text-muted-foreground">{t('usage.promMissing')}</span>}
</div>
{usage.limitsConfigured ? (
<>
<UsageBar
label={t('usage.cpu')}
current={usage.cpuCurrentMilli}
limit={usage.cpuLimitMilli}
peak={usage.cpuPeakMilli}
format={v => `${cores(v)} 核`}
/>
<UsageBar
label={t('usage.memory')}
current={usage.memCurrentBytes}
limit={usage.memLimitBytes}
peak={usage.memPeakBytes}
format={v => `${gb(v)} GiB`}
/>
</>
) : (
<div className="space-y-1 rounded-md border border-border px-3 py-2 text-xs">
<p className="font-medium text-foreground">{t('usage.noLimits')}</p>
<p className="text-muted-foreground">
{t('usage.cpu')} {cores(usage.cpuCurrentMilli)} 核 · {t('usage.memory')} {gb(usage.memCurrentBytes)} GiB
</p>
</div>
)}
{usage.metricsNote && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-foreground">{usage.metricsNote}</p>
)}
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
<p className="font-medium text-foreground">{usage.summary}</p>
{usage.cpuSuggest && <p className="mt-1 text-muted-foreground">· {usage.cpuSuggest}</p>}
{usage.memSuggest && <p className="mt-1 text-muted-foreground">· {usage.memSuggest}</p>}
{(usage.cpuSuggest || usage.memSuggest) && (
<p className="mt-2 flex items-center gap-1 text-warning">
<TriangleAlert className="size-3" />{t('usage.upgradeHint')}
</p>
)}
</div>
<div className="flex items-center justify-between">
<Button type="button" variant="outline" size="sm" onClick={() => deployment && void load(deployment.id)} disabled={loading}>
<RefreshCw className={`mr-1 size-3.5 ${loading ? 'animate-spin' : ''}`} />{t('usage.refresh')}
</Button>
<Button type="button" variant="outline" onClick={onClose}>{t('retention.cancel')}</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
})
@@ -89,7 +89,9 @@ export const DEFAULT_VALUES: DeploymentFormValues = {
authEnabled: false,
retentionDays: '30',
replicas: '1',
resourcesCPU: '1',
resourcesMem: '2Gi',
// 按实测调:Loki 峰值 CPU ≈3.3 核、峰值内存 ≈4.96 GiB,
// 原来的 1 核 / 2Gi 会同时触发 CPU 限流和 OOMKilled。
resourcesCPU: '4',
resourcesMem: '8Gi',
storageSize: '20Gi',
}
+4
View File
@@ -16,6 +16,7 @@ import { DeploymentCard } from './components/DeploymentCard'
import { DeploymentFormDialog } from './components/DeploymentFormDialog'
import { RetentionDialog } from './components/RetentionDialog'
import { LogViewerDialog } from './components/LogViewerDialog'
import { DeploymentUsageDialog } from './components/DeploymentUsageDialog'
/**
* Loki 部署页 — 只做编排:部署列表走 useListQuery(取消+版本号),
@@ -36,6 +37,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
const [formOpen, setFormOpen] = useState(false)
const [logsDeployment, setLogsDeployment] = useState<LokiDeployment | null>(null)
const [retentionDeployment, setRetentionDeployment] = useState<LokiDeployment | null>(null)
const [usageDeployment, setUsageDeployment] = useState<LokiDeployment | null>(null)
const [deleteTarget, setDeleteTarget] = useState<LokiDeployment | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/重启进行中的部署 ID(按钮 loading + 防重复点击) */
@@ -168,6 +170,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
onDelete={setDeleteTarget}
onViewLogs={setLogsDeployment}
onEditRetention={setRetentionDeployment}
onViewUsage={setUsageDeployment}
/>
))}
</div>
@@ -194,6 +197,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
onClose={() => setLogsDeployment(null)}
/>
<DeploymentUsageDialog deployment={usageDeployment} onClose={() => setUsageDeployment(null)} />
<RetentionDialog
deployment={retentionDeployment}
onClose={() => setRetentionDeployment(null)}
@@ -35,7 +35,16 @@ const makeHostFormSchema = (t: TFunction<'hosts'>) =>
z
.object({
name: z.string().min(1, t('form.validation.nameRequired')),
hostIP: z.string().min(1, t('form.validation.hostIpRequired')),
hostIP: z
.string()
.min(1, t('form.validation.hostIpRequired'))
.refine(
value => {
const v = value.trim().toLowerCase().replace(/^\[|\]$/g, '')
return !(v === 'localhost' || v === '::1' || v === '127.0.0.1' || v.startsWith('127.'))
},
t('form.validation.hostIpLoopback'),
),
port: z
.string()
.min(1, t('form.validation.portRequired'))