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:
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',
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user