按需求:所有 lokistack 相关资源(管理平台副本、Loki 实例、Promtail 采集器) 固定使用 log 命名空间,不提供覆盖开关。 改动: 1. 后端 services/deployment.go Create():k8s-yaml 模式下 namespace 为空时 兜底为 "log",前端漏传也不会跑偏。 2. 前端 DeploymentCard.tsx:命名空间展示 fallback 'loki' → 'log' 3. 前端 DeploymentFormDialog.tsx:输入框 placeholder 'loki' → 'log' (form-schema.ts 的 DEFAULT_VALUES.namespace 原本已是 'log') 未动: kubernetes/install.sh 的 NS="log" 早就是固定值 collector_deployer.go 已自动跟随 loki.Namespace(collector.Namespace 为空时) 资源名不冲突: 平台 lokistack / lokistack-data / lokistack-secrets / lokistack-nodeport Loki 实例 loki / loki-config / loki-data / loki-headless / loki-storage-credentials Promtail promtail-* (DaemonSet) 镜像:lghost/lokistack:latest + v0.1 = sha256:9d15b7ffbb3d
150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
import { memo, useEffect, useState } from 'react'
|
|
import { Link } from 'react-router'
|
|
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink, Pencil, Rocket } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { StatusBadge } from '@/components/StatusBadge'
|
|
import { deploymentApi } from '@/api'
|
|
import { ResourceCard } from '@/components/ResourceCard'
|
|
import type { LokiDeployment } from '@/types'
|
|
|
|
/** 部署目标展示:优先集群,其次主机 */
|
|
const getTargetLabel = (d: LokiDeployment) => {
|
|
if (d.cluster) return `集群:${d.cluster.name}`
|
|
if (d.host) return `主机:${d.host.name}`
|
|
return '未知'
|
|
}
|
|
|
|
interface DeploymentCardProps {
|
|
deployment: LokiDeployment
|
|
/** 部署/重启进行中的部署 ID(按钮 loading 态) */
|
|
operating: number | null
|
|
onDeploy: (id: number) => void
|
|
onStop: (id: number) => void
|
|
onRestart: (id: number) => void
|
|
onDelete: (d: LokiDeployment) => void
|
|
onViewLogs: (d: LokiDeployment) => void
|
|
onEditRetention: (d: LokiDeployment) => void
|
|
}
|
|
|
|
/** 部署卡片 — 列表项展示与操作入口(部署/停止/重启/日志/访问/删除) */
|
|
export const DeploymentCard = memo(function DeploymentCard({
|
|
deployment: d,
|
|
operating,
|
|
onDeploy,
|
|
onStop,
|
|
onRestart,
|
|
onDelete,
|
|
onViewLogs,
|
|
onEditRetention,
|
|
}: DeploymentCardProps) {
|
|
// Loki 就绪探测(运行中时经数据源代理 /ready)
|
|
const [ready, setReady] = useState<boolean | null>(null)
|
|
useEffect(() => {
|
|
if (d.status !== 'running') return
|
|
let cancelled = false
|
|
deploymentApi
|
|
.ready(d.id)
|
|
.then((r) => {
|
|
if (!cancelled) setReady(r.ready)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setReady(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [d.id, d.status])
|
|
|
|
const isDeploying = d.status === 'deploying'
|
|
const isBusy = operating === d.id
|
|
const canDeploy = d.status === 'pending' || d.status === 'failed' || d.status === 'stopped'
|
|
|
|
return (
|
|
<ResourceCard
|
|
icon={Rocket}
|
|
iconClassName={d.status === 'running' ? 'text-success' : d.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}
|
|
title={d.name}
|
|
titleExtra={
|
|
d.status === 'running' && ready !== null ? (
|
|
<span
|
|
className={`w-2 h-2 rounded-full shrink-0 ${ready ? 'bg-success' : 'bg-destructive'}`}
|
|
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
|
|
/>
|
|
) : undefined
|
|
}
|
|
badges={<StatusBadge status={d.status} />}
|
|
description={d.description || d.uuid}
|
|
fields={[
|
|
{ label: '方式', value: d.mode === 'host-docker' ? '主机 Docker' : d.mode === 'k8s-yaml' ? 'Kubernetes' : d.mode },
|
|
{ label: '版本', value: d.version },
|
|
{ label: '目标', value: getTargetLabel(d) },
|
|
...(d.mode !== 'host-docker' ? [
|
|
{ label: '命名空间', value: d.namespace || 'log' },
|
|
{ label: '副本', value: `${d.replicas} · 保留 ${d.retentionDays || 30} 天` },
|
|
] : [{ label: '保留', value: `${d.retentionDays || 30} 天` }]),
|
|
{ label: '存储', value: d.storageConfig?.name || '本地' },
|
|
...(d.endpoint ? [{ label: '访问', value: (
|
|
<span className="text-primary">{d.endpoint.replace(/^https?:\/\//, '')}</span>
|
|
), title: d.endpoint }] : []),
|
|
...(d.grafanaEnabled && d.grafanaEndpoint ? [{ label: 'Grafana', value: (
|
|
<a href={d.grafanaEndpoint} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" title={`${d.grafanaEndpoint}(admin/admin)`}>
|
|
{d.grafanaEndpoint.replace(/^https?:\/\//, '')}
|
|
</a>
|
|
) }] : []),
|
|
]}
|
|
message={
|
|
d.statusMessage ||
|
|
(d.status === 'running'
|
|
? '提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。'
|
|
: undefined)
|
|
}
|
|
actions={
|
|
<>
|
|
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
|
|
<Pencil className="size-3 mr-1" />配置
|
|
</Button>
|
|
{canDeploy ? (
|
|
<Button variant="outline" size="sm" className="flex-1 text-xs bg-success/10 text-primary hover:bg-success/20 border-success/30" onClick={() => onDeploy(d.id)} disabled={isBusy}>
|
|
<Play className="size-3 mr-1" />{isBusy ? '部署中...' : '部署'}
|
|
</Button>
|
|
) : isDeploying ? (
|
|
<Button variant="outline" size="sm" className="flex-1 text-xs" disabled>
|
|
<RotateCw className="size-3 mr-1 animate-spin" />部署中...
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onStop(d.id)}>
|
|
<Square className="size-3 mr-1" />停止
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onRestart(d.id)} disabled={isBusy}>
|
|
<RotateCw className={`size-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />重启
|
|
</Button>
|
|
</>
|
|
)}
|
|
{d.status === 'running' && (
|
|
<>
|
|
<Button variant="outline" size="sm" className="text-xs" asChild>
|
|
<Link to="/logs">日志查询</Link>
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="text-xs" asChild>
|
|
<Link to="/collectors">采集器</Link>
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
|
|
<Terminal className="size-3 mr-1" />日志
|
|
</Button>
|
|
{d.endpoint && (
|
|
<Button variant="outline" size="sm" className="text-xs text-primary hover:text-primary/80" onClick={() => window.open(d.endpoint, '_blank', 'noopener,noreferrer')} title="浏览器访问">
|
|
<ExternalLink className="size-3" />
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="sm" aria-label={`删除部署 ${d.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(d)}>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
)
|
|
})
|