refactor: 前后端目录结构模块化 + v2 优化移植与缺陷修复
前端: - src/srv/api → src/api,按域分文件夹(auth/cluster/host/storage/deployment/collector) - 新增 http.ts 引擎(http.get/post/put/delete),域模块改用语义化调用 - index.html 增加 favicon(匹配应用 logo 的 emerald + Activity 脉冲) 后端分层增强(保留包名): - 新增 repository/:从 models 拆出 JsonDB 持久化层(InitDB/GetDB/Shutdown/RequestSave) - 新增 dto/:从 controllers 抽出各域请求契约(带 binding 校验) - 新增 pkg/crypto、pkg/jwt:从 utils 拆出安全原语(外部 jwt 别名 jwtv5 避免冲突) - models 只留领域实体 + sanitizer;utils 只留 gin/应用相关(response/errors) v2 优化移植与缺陷修复: - 移植密钥分离/SSH TOFU/并发写安全/Loki 生产级配置/Docker 加固 - 修复 deployment.go 并发丢更新、Helm values 重复键、EncryptField 哨兵、loki-gateway 端点等审计缺陷 - 实现真正的优雅关停(main.go http.Server + signal + repository.Shutdown) - 修复登录契约:后端返回 accessToken,前端 auth.ts/Login.tsx 对齐 - 补 crypto/jwt/repository 单元测试(含并发写回归) 验证: go build/vet/test 全绿;npm run build 通过;登录→鉴权→优雅关停冒烟通过 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
fb8d10231a
commit
327e282356
93 files changed
+8433
-3089
No files matched your search
+435
-309
@@ -1,5 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Server, Plus, RefreshCw, Trash2, Eye, Key, FileKey } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Eye,
|
||||
Key,
|
||||
FileKey,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -13,8 +21,12 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { clusterApi } from '@/srv/api'
|
||||
import { statusLabel } from '@/lib/utils'
|
||||
import { clusterApi } from '@/api'
|
||||
import { StatusBadge } from '@/components/StatusBadge'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { toast } from 'sonner'
|
||||
import type { Cluster, ClusterInfo } from '@/types'
|
||||
|
||||
// 从 kubeconfig 文本自动提取 server 地址
|
||||
@@ -23,7 +35,18 @@ const parseApiServer = (kubeconfig: string): string => {
|
||||
return m ? m[1] : ''
|
||||
}
|
||||
|
||||
export default function Clusters() {
|
||||
/**
|
||||
* Clusters 页面 - 优化版本
|
||||
*
|
||||
* 优化点:
|
||||
* 1. React.memo 包裹组件
|
||||
* 2. 所有操作函数使用 useCallback 缓存
|
||||
* 3. alert() 替换为 sonner toast
|
||||
* 4. setTimeout 使用 ref 管理,添加清理函数
|
||||
* 5. 使用 StatusBadge 公共组件
|
||||
* 6. 使用 PageHeader、EmptyState、LoadingSpinner 公共组件
|
||||
*/
|
||||
const Clusters = memo(function Clusters() {
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -42,55 +65,38 @@ export default function Clusters() {
|
||||
const [context, setContext] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 定时器管理ref
|
||||
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadClusters()
|
||||
return () => {
|
||||
timeoutsRef.current.forEach((id) => clearTimeout(id))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadClusters = async () => {
|
||||
const addTimeout = useCallback((cb: () => void, delay: number) => {
|
||||
const id = setTimeout(cb, delay)
|
||||
timeoutsRef.current.push(id)
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res = await clusterApi.list(1, 100)
|
||||
setClusters(res.list)
|
||||
} catch (err) {
|
||||
console.error('Failed to load clusters:', err)
|
||||
toast.error('加载集群数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 校验
|
||||
if (configType === 'kubeconfig' && !kubeconfig.trim()) {
|
||||
alert('请填写 kubeconfig 内容')
|
||||
return
|
||||
}
|
||||
if (configType === 'token' && (!apiServer.trim() || !token.trim())) {
|
||||
alert('Token 接入方式需要填写 API Server 和 Token')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await clusterApi.create({
|
||||
name,
|
||||
description,
|
||||
configType,
|
||||
apiServer: configType === 'token' ? apiServer : undefined,
|
||||
kubeconfig: configType === 'kubeconfig' ? kubeconfig : undefined,
|
||||
token: configType === 'token' ? token : undefined,
|
||||
context,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadClusters()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '创建集群失败'
|
||||
alert(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
loadClusters()
|
||||
}, [loadClusters])
|
||||
|
||||
const resetForm = () => {
|
||||
const resetForm = useCallback(() => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setConfigType('kubeconfig')
|
||||
@@ -98,249 +104,230 @@ export default function Clusters() {
|
||||
setApiServer('')
|
||||
setToken('')
|
||||
setContext('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此集群?')) return
|
||||
try {
|
||||
await clusterApi.remove(id)
|
||||
loadClusters()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete cluster:', err)
|
||||
}
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 校验
|
||||
if (configType === 'kubeconfig' && !kubeconfig.trim()) {
|
||||
toast.error('请填写 kubeconfig 内容')
|
||||
return
|
||||
}
|
||||
if (configType === 'token' && (!apiServer.trim() || !token.trim())) {
|
||||
toast.error('Token 接入方式需要填写 API Server 和 Token')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await clusterApi.create({
|
||||
name,
|
||||
description,
|
||||
configType,
|
||||
apiServer: configType === 'token' ? apiServer : undefined,
|
||||
kubeconfig: configType === 'kubeconfig' ? kubeconfig : undefined,
|
||||
token: configType === 'token' ? token : undefined,
|
||||
context,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadClusters()
|
||||
toast.success('集群添加成功')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '创建集群失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[name, description, configType, kubeconfig, apiServer, token, context, resetForm, loadClusters]
|
||||
)
|
||||
|
||||
const handleValidate = async (id: number) => {
|
||||
setValidating(id)
|
||||
try {
|
||||
await clusterApi.validate(id)
|
||||
setTimeout(loadClusters, 3000)
|
||||
} catch (err) {
|
||||
console.error('Failed to validate cluster:', err)
|
||||
} finally {
|
||||
setTimeout(() => setValidating(null), 3000)
|
||||
}
|
||||
}
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
if (!confirm('确定删除此集群?')) return
|
||||
try {
|
||||
await clusterApi.remove(id)
|
||||
loadClusters()
|
||||
toast.success('集群已删除')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete cluster:', err)
|
||||
toast.error('删除集群失败')
|
||||
}
|
||||
},
|
||||
[loadClusters]
|
||||
)
|
||||
|
||||
const viewClusterInfo = async (cluster: Cluster) => {
|
||||
const handleValidate = useCallback(
|
||||
async (id: number) => {
|
||||
setValidating(id)
|
||||
try {
|
||||
await clusterApi.validate(id)
|
||||
addTimeout(loadClusters, 3000)
|
||||
toast.success('验证指令已发送')
|
||||
} catch (err) {
|
||||
console.error('Failed to validate cluster:', err)
|
||||
toast.error('验证失败')
|
||||
} finally {
|
||||
addTimeout(() => setValidating(null), 3000)
|
||||
}
|
||||
},
|
||||
[loadClusters, addTimeout]
|
||||
)
|
||||
|
||||
const viewClusterInfo = useCallback(async (cluster: Cluster) => {
|
||||
setSelectedCluster(cluster)
|
||||
setClusterInfo(null)
|
||||
setDetailOpen(true)
|
||||
try {
|
||||
const info = await clusterApi.getInfo(cluster.id)
|
||||
setClusterInfo(info)
|
||||
} catch (err) {
|
||||
console.error('Failed to get cluster info:', err)
|
||||
toast.error('加载集群详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-emerald-100 text-emerald-700'
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'pending':
|
||||
return 'bg-amber-100 text-amber-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">集群管理</h2>
|
||||
<p className="text-sm text-slate-500">管理用于 Loki 部署的 Kubernetes 集群</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加集群
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 Kubernetes 集群</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">集群名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="production-cluster"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="生产环境日志集群"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>接入方式</Label>
|
||||
<Tabs value={configType} onValueChange={(v) => setConfigType(v as 'kubeconfig' | 'token')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="kubeconfig"><FileKey className="w-3.5 h-3.5" /> Kubeconfig</TabsTrigger>
|
||||
<TabsTrigger value="token"><Key className="w-3.5 h-3.5" /> Token</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="kubeconfig" className="space-y-2 pt-2">
|
||||
<Label htmlFor="kubeconfig">Kubeconfig 内容 *</Label>
|
||||
<Textarea
|
||||
id="kubeconfig"
|
||||
value={kubeconfig}
|
||||
onChange={(e) => setKubeconfig(e.target.value)}
|
||||
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
/>
|
||||
{kubeconfig && parseApiServer(kubeconfig) && (
|
||||
<p className="text-xs text-emerald-600">已识别 API Server:{parseApiServer(kubeconfig)}</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="token" className="space-y-2 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiServer">API Server *</Label>
|
||||
<Input
|
||||
id="apiServer"
|
||||
value={apiServer}
|
||||
onChange={(e) => setApiServer(e.target.value)}
|
||||
placeholder="https://10.0.0.1:6443"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token">Token *</Label>
|
||||
<Textarea
|
||||
id="token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="粘贴 Service Account Token..."
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">采用 insecure 模式,便于内网/自签证书集群接入</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{configType === 'kubeconfig' && (
|
||||
<PageHeader
|
||||
title="集群管理"
|
||||
description="管理用于 Loki 部署的 Kubernetes 集群"
|
||||
action={
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加集群
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 Kubernetes 集群</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context">上下文(可选)</Label>
|
||||
<Label htmlFor="name">集群名称 *</Label>
|
||||
<Input
|
||||
id="context"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder="留空则使用默认上下文"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="production-cluster"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="生产环境日志集群"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
||||
{submitting ? '添加中...' : '添加集群'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>接入方式</Label>
|
||||
<Tabs
|
||||
value={configType}
|
||||
onValueChange={(v) => setConfigType(v as 'kubeconfig' | 'token')}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="kubeconfig">
|
||||
<FileKey className="w-3.5 h-3.5 mr-1" /> Kubeconfig
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="token">
|
||||
<Key className="w-3.5 h-3.5 mr-1" /> Token
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="kubeconfig" className="space-y-2 pt-2">
|
||||
<Label htmlFor="kubeconfig">Kubeconfig 内容 *</Label>
|
||||
<Textarea
|
||||
id="kubeconfig"
|
||||
value={kubeconfig}
|
||||
onChange={(e) => setKubeconfig(e.target.value)}
|
||||
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
/>
|
||||
{kubeconfig && parseApiServer(kubeconfig) && (
|
||||
<p className="text-xs text-emerald-600">
|
||||
已识别 API Server:{parseApiServer(kubeconfig)}
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="token" className="space-y-2 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiServer">API Server *</Label>
|
||||
<Input
|
||||
id="apiServer"
|
||||
value={apiServer}
|
||||
onChange={(e) => setApiServer(e.target.value)}
|
||||
placeholder="https://10.0.0.1:6443"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token">Token *</Label>
|
||||
<Textarea
|
||||
id="token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="粘贴 Service Account Token..."
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
采用 insecure 模式,便于内网/自签证书集群接入
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{configType === 'kubeconfig' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context">上下文(可选)</Label>
|
||||
<Input
|
||||
id="context"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder="留空则使用默认上下文"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '添加中...' : '添加集群'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Clusters Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner className="h-64" />
|
||||
) : clusters.length === 0 ? (
|
||||
<Card className="border-dashed border-slate-300">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Server className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 font-medium">暂无集群</p>
|
||||
<p className="text-sm text-slate-400 mt-1">添加第一个 Kubernetes 集群开始使用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
title="暂无集群"
|
||||
description="添加第一个 Kubernetes 集群开始使用"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<Card key={cluster.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white">{cluster.name}</h3>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(cluster.status)}`}>
|
||||
{statusLabel(cluster.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{cluster.description || '无描述'}</p>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span>接入方式</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{cluster.configType === 'token' ? 'Token' : 'Kubeconfig'}
|
||||
</span>
|
||||
</div>
|
||||
{cluster.apiServer && (
|
||||
<div className="flex justify-between">
|
||||
<span>API Server</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[150px]">{cluster.apiServer}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>版本</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{cluster.version || '未知'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>节点数</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{cluster.nodesCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>最后连接</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{cluster.lastConnected ? new Date(cluster.lastConnected).toLocaleString() : '从未'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => viewClusterInfo(cluster)}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleValidate(cluster.id)}
|
||||
disabled={validating === cluster.id}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 mr-1 ${validating === cluster.id ? 'animate-spin' : ''}`} />
|
||||
验证
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleDelete(cluster.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ClusterCard
|
||||
key={cluster.id}
|
||||
cluster={cluster}
|
||||
validating={validating}
|
||||
onViewInfo={viewClusterInfo}
|
||||
onValidate={handleValidate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -356,62 +343,201 @@ export default function Clusters() {
|
||||
</DialogHeader>
|
||||
|
||||
{clusterInfo ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">版本</p>
|
||||
<p className="text-sm font-medium">{clusterInfo.version || '未知'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">当前上下文</p>
|
||||
<p className="text-sm font-medium">{clusterInfo.currentContext || '默认'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">节点({clusterInfo.nodes.length})</h4>
|
||||
<div className="space-y-2">
|
||||
{clusterInfo.nodes.map((node, idx) => (
|
||||
<div key={idx} className="p-3 border border-slate-200 dark:border-slate-700 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{node.name}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${node.status === 'Ready' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{node.status === 'Ready' ? '就绪' : '未就绪'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 text-xs text-slate-500">
|
||||
<span>IP:{node.internalIP}</span>
|
||||
<span>系统:{node.os}/{node.arch}</span>
|
||||
<span>版本:{node.version}</span>
|
||||
</div>
|
||||
{node.roles.length > 0 && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{node.roles.map((role, ridx) => (
|
||||
<span key={ridx} className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{role}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">命名空间</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clusterInfo.namespaces.map((ns, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">{ns}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClusterDetail info={clusterInfo} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner className="h-32" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:集群卡片
|
||||
interface ClusterCardProps {
|
||||
cluster: Cluster
|
||||
validating: number | null
|
||||
onViewInfo: (c: Cluster) => void
|
||||
onValidate: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
}
|
||||
|
||||
const ClusterCard = memo(function ClusterCard({
|
||||
cluster,
|
||||
validating,
|
||||
onViewInfo,
|
||||
onValidate,
|
||||
onDelete,
|
||||
}: ClusterCardProps) {
|
||||
return (
|
||||
<Card className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
|
||||
{cluster.name}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusBadge status={cluster.status} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500 mb-3 line-clamp-2">
|
||||
{cluster.description || '无描述'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
||||
<InfoRow
|
||||
label="接入方式"
|
||||
value={cluster.configType === 'token' ? 'Token' : 'Kubeconfig'}
|
||||
/>
|
||||
{cluster.apiServer && (
|
||||
<InfoRow
|
||||
label="API Server"
|
||||
value={cluster.apiServer}
|
||||
valueClass="truncate max-w-[150px]"
|
||||
/>
|
||||
)}
|
||||
<InfoRow label="版本" value={cluster.version || '未知'} />
|
||||
<InfoRow label="节点数" value={String(cluster.nodesCount || 0)} />
|
||||
<InfoRow
|
||||
label="最后连接"
|
||||
value={
|
||||
cluster.lastConnected
|
||||
? new Date(cluster.lastConnected).toLocaleString()
|
||||
: '从未'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => onViewInfo(cluster)}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => onValidate(cluster.id)}
|
||||
disabled={validating === cluster.id}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3 h-3 mr-1 ${validating === cluster.id ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
验证
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => onDelete(cluster.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:信息行
|
||||
const InfoRow = memo(function InfoRow({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
valueClass?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span>{label}</span>
|
||||
<span className={`font-medium text-slate-700 dark:text-slate-300 ${valueClass || ''}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:集群详情
|
||||
const ClusterDetail = memo(function ClusterDetail({ info }: { info: ClusterInfo }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">版本</p>
|
||||
<p className="text-sm font-medium">{info.version || '未知'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">当前上下文</p>
|
||||
<p className="text-sm font-medium">{info.currentContext || '默认'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">节点({info.nodes.length})</h4>
|
||||
<div className="space-y-2">
|
||||
{info.nodes.map((node, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 border border-slate-200 dark:border-slate-700 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{node.name}</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
node.status === 'Ready'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{node.status === 'Ready' ? '就绪' : '未就绪'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 text-xs text-slate-500">
|
||||
<span>IP:{node.internalIP}</span>
|
||||
<span>系统:{node.os}/{node.arch}</span>
|
||||
<span>版本:{node.version}</span>
|
||||
</div>
|
||||
{node.roles.length > 0 && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{node.roles.map((role, ridx) => (
|
||||
<span
|
||||
key={ridx}
|
||||
className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-xs"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">命名空间</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.namespaces.map((ns, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs"
|
||||
>
|
||||
{ns}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Clusters
|
||||
+395
-254
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
RadioTower,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
Download,
|
||||
@@ -28,30 +27,40 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { collectorApi, deploymentApi, clusterApi, hostApi } from '@/srv/api'
|
||||
import { collectorApi, deploymentApi, clusterApi, hostApi } from '@/api'
|
||||
import { statusLabel } from '@/lib/utils'
|
||||
import { StatusBadge } from '@/components/StatusBadge'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { toast } from 'sonner'
|
||||
import type { Collector, LokiDeployment, Cluster, Host } from '@/types'
|
||||
|
||||
const typeMeta: Record<string, { label: string; icon: typeof Server; color: string }> = {
|
||||
// 采集器类型元数据 - 常量提取
|
||||
const COLLECTOR_TYPE_META: Record<string, { label: string; icon: typeof Server; color: string }> = {
|
||||
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'bg-purple-100 text-purple-700' },
|
||||
'k8s-daemonset': { label: 'K8s DaemonSet', icon: Cloud, color: 'bg-blue-100 text-blue-700' },
|
||||
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-emerald-100 text-emerald-700'
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'pending':
|
||||
return 'bg-amber-100 text-amber-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
const COLLECTOR_TYPES = [
|
||||
{ value: 'host-promtail' as const, label: '主机 Promtail(SSH)' },
|
||||
{ value: 'k8s-daemonset' as const, label: 'K8s Promtail DaemonSet' },
|
||||
{ value: 'k8s-fluentbit-sidecar' as const, label: 'K8s Fluent Bit Sidecar' },
|
||||
]
|
||||
|
||||
export default function Collectors() {
|
||||
/**
|
||||
* Collectors 页面 - 优化版本
|
||||
*
|
||||
* 优化点:
|
||||
* 1. React.memo 包裹组件
|
||||
* 2. 所有操作函数使用 useCallback 缓存
|
||||
* 3. alert() 替换为 sonner toast
|
||||
* 4. setTimeout 使用 ref 管理,添加清理
|
||||
* 5. 使用公共组件替代重复代码
|
||||
* 6. 常量映射提取到组件外部
|
||||
*/
|
||||
const Collectors = memo(function Collectors() {
|
||||
const [collectors, setCollectors] = useState<Collector[]>([])
|
||||
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
@@ -60,9 +69,9 @@ export default function Collectors() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
|
||||
// Form
|
||||
// Form state
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'>('host-promtail')
|
||||
const [collectorType, setCollectorType] = useState<'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'>('host-promtail')
|
||||
const [lokiID, setLokiID] = useState('')
|
||||
const [hostID, setHostID] = useState('')
|
||||
const [clusterID, setClusterID] = useState('')
|
||||
@@ -72,11 +81,21 @@ export default function Collectors() {
|
||||
const [labels, setLabels] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
return () => {
|
||||
timeoutsRef.current.forEach((id) => clearTimeout(id))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
const addTimeout = useCallback((cb: () => void, delay: number) => {
|
||||
const id = setTimeout(cb, delay)
|
||||
timeoutsRef.current.push(id)
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
const [cRes, dRes, clRes, hRes] = await Promise.all([
|
||||
collectorApi.list(1, 100),
|
||||
@@ -90,14 +109,19 @@ export default function Collectors() {
|
||||
setHosts(hRes.list)
|
||||
} catch (err) {
|
||||
console.error('Failed to load collectors:', err)
|
||||
toast.error('加载采集器数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetForm = () => {
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [loadAll])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName('')
|
||||
setType('host-promtail')
|
||||
setCollectorType('host-promtail')
|
||||
setLokiID('')
|
||||
setHostID('')
|
||||
setClusterID('')
|
||||
@@ -105,258 +129,375 @@ export default function Collectors() {
|
||||
setNamespace('')
|
||||
setLogPath('')
|
||||
setLabels('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!lokiID) {
|
||||
alert('请选择关联的 Loki 部署')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await collectorApi.create({
|
||||
name,
|
||||
type,
|
||||
lokiDeploymentID: Number(lokiID),
|
||||
hostID: type === 'host-promtail' && hostID ? Number(hostID) : undefined,
|
||||
clusterID: type !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
|
||||
targetDeployment: type === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
|
||||
namespace: type !== 'host-promtail' ? namespace : undefined,
|
||||
logPath,
|
||||
labels,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadAll()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '创建采集器失败'
|
||||
alert(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!lokiID) {
|
||||
toast.error('请选择关联的 Loki 部署')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await collectorApi.create({
|
||||
name,
|
||||
type: collectorType,
|
||||
lokiDeploymentID: Number(lokiID),
|
||||
hostID: collectorType === 'host-promtail' && hostID ? Number(hostID) : undefined,
|
||||
clusterID: collectorType !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
|
||||
targetDeployment: collectorType === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
|
||||
namespace: collectorType !== 'host-promtail' ? namespace : undefined,
|
||||
logPath,
|
||||
labels,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadAll()
|
||||
toast.success('采集器创建成功')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '创建采集器失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[name, collectorType, lokiID, hostID, clusterID, targetDeployment, namespace, logPath, labels, resetForm, loadAll]
|
||||
)
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此采集器?(将自动卸载)')) return
|
||||
try {
|
||||
await collectorApi.remove(id)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collector:', err)
|
||||
}
|
||||
}
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
if (!confirm('确定删除此采集器?(将自动卸载)')) return
|
||||
try {
|
||||
await collectorApi.remove(id)
|
||||
loadAll()
|
||||
toast.success('采集器已删除')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collector:', err)
|
||||
toast.error('删除采集器失败')
|
||||
}
|
||||
},
|
||||
[loadAll]
|
||||
)
|
||||
|
||||
const handleAction = async (id: number, action: 'install' | 'uninstall') => {
|
||||
setBusy(id)
|
||||
try {
|
||||
await collectorApi[action](id)
|
||||
setTimeout(loadAll, 3000)
|
||||
} catch (err) {
|
||||
console.error('Collector action failed:', err)
|
||||
} finally {
|
||||
setTimeout(() => setBusy(null), 3000)
|
||||
}
|
||||
}
|
||||
const handleAction = useCallback(
|
||||
async (id: number, action: 'install' | 'uninstall') => {
|
||||
setBusy(id)
|
||||
try {
|
||||
await collectorApi[action](id)
|
||||
addTimeout(loadAll, 3000)
|
||||
toast.success(action === 'install' ? '部署指令已发送' : '卸载指令已发送')
|
||||
} catch (err) {
|
||||
console.error('Collector action failed:', err)
|
||||
toast.error(action === 'install' ? '部署失败' : '卸载失败')
|
||||
} finally {
|
||||
addTimeout(() => setBusy(null), 3000)
|
||||
}
|
||||
},
|
||||
[loadAll, addTimeout]
|
||||
)
|
||||
|
||||
const isHostType = collectorType === 'host-promtail'
|
||||
const isK8sType = collectorType !== 'host-promtail'
|
||||
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署客户端</h2>
|
||||
<p className="text-sm text-slate-500">部署日志采集器,将日志推送到关联的 Loki</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建采集器
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建日志采集器</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-name">名称 *</Label>
|
||||
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="app-logs-collector" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>采集器类型</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="host-promtail">主机 Promtail(SSH)</SelectItem>
|
||||
<SelectItem value="k8s-daemonset">K8s Promtail DaemonSet</SelectItem>
|
||||
<SelectItem value="k8s-fluentbit-sidecar">K8s Fluent Bit Sidecar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>关联 Loki 部署 *</Label>
|
||||
<Select value={lokiID} onValueChange={setLokiID}>
|
||||
<SelectTrigger><SelectValue placeholder="选择已部署的 Loki(推送目标)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{deployments.map((d) => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>{d.name}({statusLabel(d.status)})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{type === 'host-promtail' && (
|
||||
<div className="space-y-2">
|
||||
<Label>目标主机 *</Label>
|
||||
<Select value={hostID} onValueChange={setHostID}>
|
||||
<SelectTrigger><SelectValue placeholder="选择主机" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{hosts.map((h) => (
|
||||
<SelectItem key={h.id} value={String(h.id)}>{h.name}({h.hostIP})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== 'host-promtail' && (
|
||||
<>
|
||||
<PageHeader
|
||||
title="部署客户端"
|
||||
description="部署日志采集器,将日志推送到关联的 Loki"
|
||||
action={
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建采集器
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建日志采集器</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>目标集群 *</Label>
|
||||
<Select value={clusterID} onValueChange={setClusterID}>
|
||||
<SelectTrigger><SelectValue placeholder="选择集群" /></SelectTrigger>
|
||||
<Label htmlFor="c-name">名称 *</Label>
|
||||
<Input
|
||||
id="c-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="app-logs-collector"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>采集器类型</Label>
|
||||
<Select
|
||||
value={collectorType}
|
||||
onValueChange={(v) =>
|
||||
setCollectorType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}({statusLabel(c.status)})</SelectItem>
|
||||
{COLLECTOR_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-ns">命名空间</Label>
|
||||
<Input id="c-ns" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={type === 'k8s-daemonset' ? 'logging' : 'default'} />
|
||||
</div>
|
||||
{type === 'k8s-fluentbit-sidecar' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-deploy">目标 Deployment *</Label>
|
||||
<Input id="c-deploy" value={targetDeployment} onChange={(e) => setTargetDeployment(e.target.value)} placeholder="my-app" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>关联 Loki 部署 *</Label>
|
||||
<Select value={lokiID} onValueChange={setLokiID}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择已部署的 Loki(推送目标)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deployments.map((d) => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>
|
||||
{d.name}({statusLabel(d.status)})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isHostType && (
|
||||
<div className="space-y-2">
|
||||
<Label>目标主机 *</Label>
|
||||
<Select value={hostID} onValueChange={setHostID}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择主机" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hosts.map((h) => (
|
||||
<SelectItem key={h.id} value={String(h.id)}>
|
||||
{h.name}({h.hostIP})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-log">采集日志路径</Label>
|
||||
<Input id="c-log" value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder={type === 'host-promtail' ? '/var/log/*.log' : '/var/log/app/*.log'} />
|
||||
</div>
|
||||
{isK8sType && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>目标集群 *</Label>
|
||||
<Select value={clusterID} onValueChange={setClusterID}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}({statusLabel(c.status)})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-ns">命名空间</Label>
|
||||
<Input
|
||||
id="c-ns"
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
{isSidecarType && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-deploy">目标 Deployment *</Label>
|
||||
<Input
|
||||
id="c-deploy"
|
||||
value={targetDeployment}
|
||||
onChange={(e) => setTargetDeployment(e.target.value)}
|
||||
placeholder="my-app"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建并部署'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-log">采集日志路径</Label>
|
||||
<Input
|
||||
id="c-log"
|
||||
value={logPath}
|
||||
onChange={(e) => setLogPath(e.target.value)}
|
||||
placeholder={isHostType ? '/var/log/*.log' : '/var/log/app/*.log'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '创建中...' : '创建并部署'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Collectors List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner className="h-64" />
|
||||
) : collectors.length === 0 ? (
|
||||
<Card className="border-dashed border-slate-300">
|
||||
<CardContent className="p-12 text-center">
|
||||
<RadioTower className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 font-medium">暂无采集器</p>
|
||||
<p className="text-sm text-slate-400 mt-1">新建一个日志采集器,关联已部署的 Loki</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={RadioTower}
|
||||
title="暂无采集器"
|
||||
description="新建一个日志采集器,关联已部署的 Loki"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{collectors.map((c) => {
|
||||
const meta = typeMeta[c.type] || { label: c.type, icon: RadioTower, color: 'bg-slate-100 text-slate-600' }
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<Card key={c.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 text-emerald-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white">{c.name}</h3>
|
||||
<p className="text-xs text-slate-500">关联 Loki:{c.lokiDeployment?.name || '未知'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(c.status)}`}>
|
||||
{statusLabel(c.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
|
||||
<div className="flex justify-between">
|
||||
<span>类型</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>{meta.label}</span>
|
||||
</div>
|
||||
{c.host && (
|
||||
<div className="flex justify-between">
|
||||
<span>主机</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.host.name}({c.host.hostIP})</span>
|
||||
</div>
|
||||
)}
|
||||
{c.cluster && (
|
||||
<div className="flex justify-between">
|
||||
<span>集群</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.cluster.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.targetDeployment && (
|
||||
<div className="flex justify-between">
|
||||
<span>目标 Deployment</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.targetDeployment}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.namespace && (
|
||||
<div className="flex justify-between">
|
||||
<span>命名空间</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.namespace}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.logPath && (
|
||||
<div className="flex justify-between">
|
||||
<span>日志路径</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[200px]">{c.logPath}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.statusMessage && (
|
||||
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">{c.statusMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'install')} disabled={busy === c.id}>
|
||||
<Upload className="w-3 h-3 mr-1" />
|
||||
{busy === c.id ? '处理中...' : '部署'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'uninstall')} disabled={busy === c.id}>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
卸载
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(c.id)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{collectors.map((c) => (
|
||||
<CollectorCard
|
||||
key={c.id}
|
||||
collector={c}
|
||||
busy={busy}
|
||||
onInstall={(id) => handleAction(id, 'install')}
|
||||
onUninstall={(id) => handleAction(id, 'uninstall')}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:采集器卡片
|
||||
interface CollectorCardProps {
|
||||
collector: Collector
|
||||
busy: number | null
|
||||
onInstall: (id: number) => void
|
||||
onUninstall: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
}
|
||||
|
||||
const CollectorCard = memo(function CollectorCard({
|
||||
collector,
|
||||
busy,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onDelete,
|
||||
}: CollectorCardProps) {
|
||||
const meta = COLLECTOR_TYPE_META[collector.type] || {
|
||||
label: collector.type,
|
||||
icon: RadioTower,
|
||||
color: 'bg-slate-100 text-slate-600',
|
||||
}
|
||||
const Icon = meta.icon
|
||||
const isBusy = busy === collector.id
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="w-5 h-5 text-emerald-600 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
|
||||
{collector.name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
关联 Loki:{collector.lokiDeployment?.name || '未知'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={collector.status} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
|
||||
<div className="flex justify-between">
|
||||
<span>类型</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
{collector.host && (
|
||||
<InfoRow label="主机" value={`${collector.host.name}(${collector.host.hostIP})`} />
|
||||
)}
|
||||
{collector.cluster && (
|
||||
<InfoRow label="集群" value={collector.cluster.name} />
|
||||
)}
|
||||
{collector.targetDeployment && (
|
||||
<InfoRow label="目标 Deployment" value={collector.targetDeployment} />
|
||||
)}
|
||||
{collector.namespace && <InfoRow label="命名空间" value={collector.namespace} />}
|
||||
{collector.logPath && (
|
||||
<InfoRow label="日志路径" value={collector.logPath} />
|
||||
)}
|
||||
{collector.statusMessage && (
|
||||
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">
|
||||
{collector.statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => onInstall(collector.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Upload className="w-3 h-3 mr-1" />
|
||||
{isBusy ? '处理中...' : '部署'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => onUninstall(collector.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
卸载
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => onDelete(collector.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:信息行
|
||||
const InfoRow = memo(function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span>{label}</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[200px]">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Collectors
|
||||
+337
-236
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Rocket,
|
||||
@@ -6,17 +6,85 @@ import {
|
||||
Monitor,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
PauseCircle,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { deploymentApi, clusterApi, hostApi } from '@/srv/api'
|
||||
import { deploymentApi, clusterApi, hostApi } from '@/api'
|
||||
import { statusLabel } from '@/lib/utils'
|
||||
import { StatusIcon, getStatusColorClass } from '@/components/StatusBadge'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import type { DashboardStats, LokiDeployment, Cluster, Host } from '@/types'
|
||||
|
||||
export default function Dashboard() {
|
||||
// 统计卡片数据常量
|
||||
const STATS_CONFIG = [
|
||||
{
|
||||
key: 'deployments' as const,
|
||||
label: '部署总数',
|
||||
icon: Rocket,
|
||||
iconColor: 'text-emerald-600',
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
getValue: (s: DashboardStats | null) => s?.totalDeployments || 0,
|
||||
subLabel: (s: DashboardStats | null) => `${s?.running || 0} 个运行中`,
|
||||
subIcon: TrendingUp,
|
||||
subColor: 'text-emerald-500',
|
||||
subTextColor: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
key: 'clusters' as const,
|
||||
label: '集群',
|
||||
icon: Server,
|
||||
iconColor: 'text-blue-600',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
getValue: (s: DashboardStats | null) => s?.clusterCount || 0,
|
||||
subLabel: () => '已接入',
|
||||
subIcon: CheckCircle,
|
||||
subColor: 'text-emerald-500',
|
||||
subTextColor: 'text-slate-500',
|
||||
},
|
||||
{
|
||||
key: 'hosts' as const,
|
||||
label: '主机',
|
||||
icon: Monitor,
|
||||
iconColor: 'text-purple-600',
|
||||
iconBg: 'bg-purple-100 dark:bg-purple-900/30',
|
||||
getValue: (s: DashboardStats | null) => s?.hostCount || 0,
|
||||
subLabel: () => 'SSH 就绪',
|
||||
subIcon: CheckCircle,
|
||||
subColor: 'text-emerald-500',
|
||||
subTextColor: 'text-slate-500',
|
||||
},
|
||||
{
|
||||
key: 'status' as const,
|
||||
label: '状态',
|
||||
icon: Activity,
|
||||
iconColor: 'text-orange-600',
|
||||
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
getValue: () => 0, // 状态卡片使用特殊渲染
|
||||
isStatusCard: true,
|
||||
},
|
||||
]
|
||||
|
||||
const STATUS_ITEMS = [
|
||||
{ key: 'running', label: '运行中', colorClass: 'text-emerald-600' },
|
||||
{ key: 'failed', label: '失败', colorClass: 'text-red-600' },
|
||||
{ key: 'pending', label: '等待中', colorClass: 'text-amber-600' },
|
||||
] as const
|
||||
|
||||
const REFRESH_INTERVAL = 10000 // 10秒自动刷新
|
||||
|
||||
/**
|
||||
* Dashboard 页面 - 全面优化版本
|
||||
*
|
||||
* 优化点:
|
||||
* 1. 使用 React.memo 包裹组件,避免不必要的重渲染
|
||||
* 2. 状态函数使用 useMemo 缓存(替代原来的getStatusIcon/getStatusColor)
|
||||
* 3. 数据加载使用 useCallback 缓存引用
|
||||
* 4. 自动刷新定时器添加清理函数,防止内存泄漏
|
||||
* 5. 使用 StatusBadge/StatusIcon 公共组件替代重复的状态渲染逻辑
|
||||
* 6. 提取常量配置避免重复创建
|
||||
*/
|
||||
const Dashboard = memo(function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
@@ -24,13 +92,16 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
const interval = setInterval(loadDashboard, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
// 使用ref追踪组件是否已卸载,防止内存泄漏
|
||||
const isMountedRef = useRef(true)
|
||||
// 使用ref存储定时器ID以便清理
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// 数据加载函数 - useCallback缓存引用
|
||||
const loadDashboard = useCallback(async () => {
|
||||
// 如果组件已卸载,不执行状态更新
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const [statsRes, deployRes, clusterRes, hostRes] = await Promise.all([
|
||||
deploymentApi.stats(),
|
||||
@@ -38,145 +109,107 @@ export default function Dashboard() {
|
||||
clusterApi.list(1, 5),
|
||||
hostApi.list(1, 5),
|
||||
])
|
||||
setStats(statsRes)
|
||||
setDeployments(deployRes.list)
|
||||
setClusters(clusterRes.list)
|
||||
setHosts(hostRes.list)
|
||||
|
||||
// 再次检查组件是否仍挂载
|
||||
if (isMountedRef.current) {
|
||||
setStats(statsRes)
|
||||
setDeployments(deployRes.list)
|
||||
setClusters(clusterRes.list)
|
||||
setHosts(hostRes.list)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
case 'failed':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-amber-500" />
|
||||
case 'stopped':
|
||||
return <PauseCircle className="w-4 h-4 text-slate-400" />
|
||||
case 'deploying':
|
||||
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-slate-400" />
|
||||
}
|
||||
}
|
||||
// 初始化加载 + 自动刷新定时器
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'active':
|
||||
return 'bg-emerald-100 text-emerald-700'
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'pending':
|
||||
return 'bg-amber-100 text-amber-700'
|
||||
case 'stopped':
|
||||
case 'inactive':
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
case 'deploying':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
// 立即加载数据
|
||||
loadDashboard()
|
||||
|
||||
// 设置自动刷新定时器
|
||||
intervalRef.current = setInterval(loadDashboard, REFRESH_INTERVAL)
|
||||
|
||||
// 清理函数:组件卸载时清除定时器和标记
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [loadDashboard])
|
||||
|
||||
// 缓存导航处理函数
|
||||
const navigateTo = useCallback(
|
||||
(path: string) => () => navigate(path),
|
||||
[navigate]
|
||||
)
|
||||
|
||||
// 渲染状态概览数值
|
||||
const statusSummary = useMemo(() => {
|
||||
if (!stats) return null
|
||||
return STATUS_ITEMS.map((item) => (
|
||||
<div key={item.key} className="text-center">
|
||||
<p className={`text-lg font-bold ${item.colorClass}`}>
|
||||
{(stats[item.key as keyof DashboardStats] as number) || 0}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400">{item.label}</p>
|
||||
</div>
|
||||
))
|
||||
}, [stats])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Activity className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
return <LoadingSpinner className="h-64" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">部署总数</p>
|
||||
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
|
||||
{stats?.totalDeployments || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<Rocket className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-xs text-emerald-600">{stats?.running || 0} 个运行中</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">集群</p>
|
||||
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
|
||||
{stats?.clusterCount || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Server className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-xs text-slate-500">已接入</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">主机</p>
|
||||
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
|
||||
{stats?.hostCount || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Monitor className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-xs text-slate-500">SSH 就绪</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{STATS_CONFIG.slice(0, 3).map((config) => {
|
||||
const Icon = config.icon
|
||||
const SubIcon = config.subIcon
|
||||
return (
|
||||
<Card key={config.key} className="border-slate-200 dark:border-slate-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{config.label}</p>
|
||||
<p className="text-3xl font-bold text-slate-900 dark:text-white mt-1">
|
||||
{config.getValue(stats)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl ${config.iconBg} flex items-center justify-center`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${config.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{SubIcon && <SubIcon className={`w-4 h-4 ${config.subColor}`} />}
|
||||
<span className={`text-xs ${config.subTextColor}`}>
|
||||
{config.subLabel?.(stats)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Status Overview Card */}
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">状态</p>
|
||||
<div className="flex gap-3 mt-1">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-emerald-600">{stats?.running || 0}</p>
|
||||
<p className="text-[10px] text-slate-400">运行中</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-red-600">{stats?.failed || 0}</p>
|
||||
<p className="text-[10px] text-slate-400">失败</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-amber-600">{stats?.pending || 0}</p>
|
||||
<p className="text-[10px] text-slate-400">等待中</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1">{statusSummary}</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
|
||||
<Activity className="w-6 h-6 text-orange-600" />
|
||||
@@ -186,118 +219,186 @@ export default function Dashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Deployments */}
|
||||
{/* Recent Deployments & Quick Resources */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">最近部署</CardTitle>
|
||||
<button
|
||||
onClick={() => navigate('/deployments')}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{deployments.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-8">暂无部署</p>
|
||||
) : (
|
||||
deployments.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
|
||||
onClick={() => navigate('/deployments')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(d.status)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{d.name}</p>
|
||||
<p className="text-xs text-slate-500">{d.mode} · {d.namespace || 'loki'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(d.status)}`}>
|
||||
{statusLabel(d.status)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Recent Deployments */}
|
||||
<RecentDeploymentsCard
|
||||
deployments={deployments}
|
||||
onViewAll={navigateTo('/deployments')}
|
||||
/>
|
||||
|
||||
{/* Quick Resources */}
|
||||
<div className="space-y-4">
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">集群</CardTitle>
|
||||
<button
|
||||
onClick={() => navigate('/clusters')}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
管理
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{clusters.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-4">暂无集群</p>
|
||||
) : (
|
||||
clusters.slice(0, 3).map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between p-2 rounded-md hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm">{c.name}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(c.status)}`}>
|
||||
{statusLabel(c.status)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">主机</CardTitle>
|
||||
<button
|
||||
onClick={() => navigate('/hosts')}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
管理
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{hosts.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-4">暂无主机</p>
|
||||
) : (
|
||||
hosts.slice(0, 3).map((h) => (
|
||||
<div key={h.id} className="flex items-center justify-between p-2 rounded-md hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm">{h.name}</span>
|
||||
<span className="text-xs text-slate-400">{h.hostIP}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(h.status)}`}>
|
||||
{statusLabel(h.status)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResourceListCard
|
||||
title="集群"
|
||||
icon={<Server className="w-4 h-4 text-blue-500" />}
|
||||
items={clusters.slice(0, 3)}
|
||||
onManage={navigateTo('/clusters')}
|
||||
getItemIcon={() => <Server className="w-4 h-4 text-blue-500" />}
|
||||
getItemLabel={(c) => c.name}
|
||||
getItemStatus={(c) => c.status}
|
||||
/>
|
||||
<ResourceListCard
|
||||
title="主机"
|
||||
icon={<Monitor className="w-4 h-4 text-purple-500" />}
|
||||
items={hosts.slice(0, 3)}
|
||||
onManage={navigateTo('/hosts')}
|
||||
getItemIcon={() => <Monitor className="w-4 h-4 text-purple-500" />}
|
||||
getItemLabel={(h) => h.name}
|
||||
getItemStatus={(h) => h.status}
|
||||
getItemExtra={(h) => h.hostIP}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:最近部署卡片 - 独立memo优化
|
||||
const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
|
||||
deployments,
|
||||
onViewAll,
|
||||
}: {
|
||||
deployments: LokiDeployment[]
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">最近部署</CardTitle>
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{deployments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Rocket}
|
||||
title="暂无部署"
|
||||
description="创建你的第一个 Loki 部署"
|
||||
/>
|
||||
) : (
|
||||
deployments.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
|
||||
onClick={() => navigate('/deployments')}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<StatusIcon status={d.status} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
|
||||
{d.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{d.mode} · {d.namespace || 'loki'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColorClass(
|
||||
d.status
|
||||
)}`}
|
||||
>
|
||||
{statusLabel(d.status)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:资源列表卡片 - 独立memo优化
|
||||
// 用具体结构类型而非泛型:memo 包裹泛型组件会导致 TS 无法从 props 推断 T,
|
||||
// 退化为约束类型 {id:number},使 getItemLabel/Status 中访问 name/status 报错。
|
||||
interface ResourceItem {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
hostIP?: string
|
||||
}
|
||||
|
||||
interface ResourceListCardProps {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
items: ResourceItem[]
|
||||
onManage: () => void
|
||||
getItemIcon: (item: ResourceItem) => React.ReactNode
|
||||
getItemLabel: (item: ResourceItem) => string
|
||||
getItemStatus: (item: ResourceItem) => string
|
||||
getItemExtra?: (item: ResourceItem) => string | undefined
|
||||
}
|
||||
|
||||
const ResourceListCard = memo(function ResourceListCard({
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
onManage,
|
||||
getItemIcon,
|
||||
getItemLabel,
|
||||
getItemStatus,
|
||||
getItemExtra,
|
||||
}: ResourceListCardProps) {
|
||||
return (
|
||||
<Card className="border-slate-200 dark:border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onManage}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
管理
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-4">暂无{title}</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{getItemIcon(item)}
|
||||
<span className="text-sm truncate">{getItemLabel(item)}</span>
|
||||
{getItemExtra?.(item) && (
|
||||
<span className="text-xs text-slate-400 flex-shrink-0">
|
||||
{getItemExtra(item)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs flex-shrink-0 ${getStatusColorClass(
|
||||
getItemStatus(item)
|
||||
)}`}
|
||||
>
|
||||
{statusLabel(getItemStatus(item))}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
export default Dashboard
|
||||
+639
-466
File diff suppressed because it is too large.
Load diff
@@ -1,20 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import '../App.css'
|
||||
|
||||
export default function Home() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+476
-348
@@ -1,5 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Monitor, Plus, RefreshCw, Trash2, Eye, Terminal, Key, Lock } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Monitor,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Eye,
|
||||
Terminal,
|
||||
Key,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,14 +21,29 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { hostApi } from '@/srv/api'
|
||||
import { statusLabel } from '@/lib/utils'
|
||||
import { hostApi } from '@/api'
|
||||
import { StatusBadge } from '@/components/StatusBadge'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { toast } from 'sonner'
|
||||
import type { Host, HostInfo } from '@/types'
|
||||
|
||||
// 使用率颜色阈值
|
||||
const usageColor = (v: number) => (v >= 90 ? 'bg-red-500' : v >= 70 ? 'bg-amber-500' : 'bg-emerald-500')
|
||||
// 使用率颜色阈值 - 常量提取
|
||||
const usageColor = (v: number) =>
|
||||
v >= 90 ? 'bg-red-500' : v >= 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
|
||||
export default function Hosts() {
|
||||
/**
|
||||
* Hosts 页面 - 优化版本
|
||||
*
|
||||
* 优化点:
|
||||
* 1. React.memo 包裹组件
|
||||
* 2. 所有操作函数使用 useCallback 缓存
|
||||
* 3. alert() 替换为 sonner toast
|
||||
* 4. setTimeout 使用 ref 管理
|
||||
* 5. 使用公共组件替代重复代码
|
||||
*/
|
||||
const Hosts = memo(function Hosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -46,48 +70,37 @@ export default function Hosts() {
|
||||
const [labels, setLabels] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadHosts()
|
||||
return () => {
|
||||
timeoutsRef.current.forEach((id) => clearTimeout(id))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadHosts = async () => {
|
||||
const addTimeout = useCallback((cb: () => void, delay: number) => {
|
||||
const id = setTimeout(cb, delay)
|
||||
timeoutsRef.current.push(id)
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const loadHosts = useCallback(async () => {
|
||||
try {
|
||||
const res = await hostApi.list(1, 100)
|
||||
setHosts(res.list)
|
||||
} catch (err) {
|
||||
console.error('Failed to load hosts:', err)
|
||||
toast.error('加载主机数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await hostApi.create({
|
||||
name,
|
||||
hostIP,
|
||||
port,
|
||||
user,
|
||||
authType,
|
||||
password: authType === 'password' ? password : undefined,
|
||||
privateKey: authType === 'key' ? privateKey : undefined,
|
||||
keyPhrase: authType === 'key' ? keyPhrase : undefined,
|
||||
labels,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadHosts()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '添加主机失败'
|
||||
alert(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
loadHosts()
|
||||
}, [loadHosts])
|
||||
|
||||
const resetForm = () => {
|
||||
const resetForm = useCallback(() => {
|
||||
setName('')
|
||||
setHostIP('')
|
||||
setPort(22)
|
||||
@@ -97,49 +110,94 @@ export default function Hosts() {
|
||||
setPrivateKey('')
|
||||
setKeyPhrase('')
|
||||
setLabels('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此主机?')) return
|
||||
try {
|
||||
await hostApi.remove(id)
|
||||
loadHosts()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete host:', err)
|
||||
}
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await hostApi.create({
|
||||
name,
|
||||
hostIP,
|
||||
port,
|
||||
user,
|
||||
authType,
|
||||
password: authType === 'password' ? password : undefined,
|
||||
privateKey: authType === 'key' ? privateKey : undefined,
|
||||
keyPhrase: authType === 'key' ? keyPhrase : undefined,
|
||||
labels,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadHosts()
|
||||
toast.success('主机添加成功')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '添加主机失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[name, hostIP, port, user, authType, password, privateKey, keyPhrase, labels, resetForm, loadHosts]
|
||||
)
|
||||
|
||||
const handleValidate = async (id: number) => {
|
||||
setValidating(id)
|
||||
try {
|
||||
await hostApi.validate(id)
|
||||
setTimeout(loadHosts, 3000)
|
||||
} catch (err) {
|
||||
console.error('Failed to validate host:', err)
|
||||
} finally {
|
||||
setTimeout(() => setValidating(null), 3000)
|
||||
}
|
||||
}
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
if (!confirm('确定删除此主机?')) return
|
||||
try {
|
||||
await hostApi.remove(id)
|
||||
loadHosts()
|
||||
toast.success('主机已删除')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete host:', err)
|
||||
toast.error('删除主机失败')
|
||||
}
|
||||
},
|
||||
[loadHosts]
|
||||
)
|
||||
|
||||
const viewHostInfo = async (host: Host) => {
|
||||
setSelectedHost(host)
|
||||
setDetailOpen(true)
|
||||
setHostInfo(null)
|
||||
try {
|
||||
const info = await hostApi.getInfo(host.id)
|
||||
setHostInfo(info)
|
||||
} catch (err) {
|
||||
console.error('Failed to get host info:', err)
|
||||
}
|
||||
}
|
||||
const handleValidate = useCallback(
|
||||
async (id: number) => {
|
||||
setValidating(id)
|
||||
try {
|
||||
await hostApi.validate(id)
|
||||
addTimeout(loadHosts, 3000)
|
||||
toast.success('验证指令已发送')
|
||||
} catch (err) {
|
||||
console.error('Failed to validate host:', err)
|
||||
toast.error('验证失败')
|
||||
} finally {
|
||||
addTimeout(() => setValidating(null), 3000)
|
||||
}
|
||||
},
|
||||
[loadHosts, addTimeout]
|
||||
)
|
||||
|
||||
const openTerminal = (host: Host) => {
|
||||
const viewHostInfo = useCallback(
|
||||
async (host: Host) => {
|
||||
setSelectedHost(host)
|
||||
setHostInfo(null)
|
||||
setDetailOpen(true)
|
||||
try {
|
||||
const info = await hostApi.getInfo(host.id)
|
||||
setHostInfo(info)
|
||||
} catch (err) {
|
||||
console.error('Failed to get host info:', err)
|
||||
toast.error('加载主机详情失败')
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const openTerminal = useCallback((host: Host) => {
|
||||
setSelectedHost(host)
|
||||
setTerminalOpen(true)
|
||||
setTerminalOutput(`已连接到 ${host.name} (${host.hostIP})\n输入命令后按"执行"...\n`)
|
||||
}
|
||||
setCommand('')
|
||||
}, [])
|
||||
|
||||
const executeCommand = async () => {
|
||||
const executeCommand = useCallback(async () => {
|
||||
if (!selectedHost || !command.trim()) return
|
||||
setExecuting(true)
|
||||
setTerminalOutput((prev) => prev + `\n$ ${command}\n`)
|
||||
@@ -153,239 +211,188 @@ export default function Hosts() {
|
||||
setExecuting(false)
|
||||
setCommand('')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-emerald-100 text-emerald-700'
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'pending':
|
||||
return 'bg-amber-100 text-amber-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
}, [selectedHost, command])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">主机管理</h2>
|
||||
<p className="text-sm text-slate-500">管理用于二进制方式部署 Loki 的 SSH 主机</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加主机
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 SSH 主机</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-name">名称 *</Label>
|
||||
<Input id="h-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="loki-server-1" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-ip">主机 IP *</Label>
|
||||
<Input id="h-ip" value={hostIP} onChange={(e) => setHostIP(e.target.value)} placeholder="192.168.1.100" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-port">端口</Label>
|
||||
<Input id="h-port" type="number" value={port} onChange={(e) => setPort(Number(e.target.value))} placeholder="22" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-user">用户名 *</Label>
|
||||
<Input id="h-user" value={user} onChange={(e) => setUser(e.target.value)} placeholder="root" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>认证方式</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="authType"
|
||||
value="password"
|
||||
checked={authType === 'password'}
|
||||
onChange={() => setAuthType('password')}
|
||||
<PageHeader
|
||||
title="主机管理"
|
||||
description="管理用于二进制方式部署 Loki 的 SSH 主机"
|
||||
action={
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加主机
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 SSH 主机</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-name">名称 *</Label>
|
||||
<Input
|
||||
id="h-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="loki-server-1"
|
||||
required
|
||||
/>
|
||||
<Lock className="w-4 h-4" />
|
||||
<span className="text-sm">密码</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="authType"
|
||||
value="key"
|
||||
checked={authType === 'key'}
|
||||
onChange={() => setAuthType('key')}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-ip">主机 IP *</Label>
|
||||
<Input
|
||||
id="h-ip"
|
||||
value={hostIP}
|
||||
onChange={(e) => setHostIP(e.target.value)}
|
||||
placeholder="192.168.1.100"
|
||||
required
|
||||
/>
|
||||
<Key className="w-4 h-4" />
|
||||
<span className="text-sm">SSH 密钥</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-port">端口</Label>
|
||||
<Input
|
||||
id="h-port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(Number(e.target.value))}
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-user">用户名 *</Label>
|
||||
<Input
|
||||
id="h-user"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
placeholder="root"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authType === 'password' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-password">密码 *</Label>
|
||||
<Label>认证方式</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="authType"
|
||||
value="password"
|
||||
checked={authType === 'password'}
|
||||
onChange={() => setAuthType('password')}
|
||||
/>
|
||||
<Lock className="w-4 h-4" />
|
||||
<span className="text-sm">密码</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="authType"
|
||||
value="key"
|
||||
checked={authType === 'key'}
|
||||
onChange={() => setAuthType('key')}
|
||||
/>
|
||||
<Key className="w-4 h-4" />
|
||||
<span className="text-sm">SSH 密钥</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authType === 'password' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-password">密码 *</Label>
|
||||
<Input
|
||||
id="h-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="SSH 登录密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-key">私钥 *</Label>
|
||||
<Textarea
|
||||
id="h-key"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
required
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-phrase">密钥口令(可选)</Label>
|
||||
<Input
|
||||
id="h-phrase"
|
||||
type="password"
|
||||
value={keyPhrase}
|
||||
onChange={(e) => setKeyPhrase(e.target.value)}
|
||||
placeholder="无口令则留空"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-labels">标签(JSON)</Label>
|
||||
<Input
|
||||
id="h-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="SSH 登录密码"
|
||||
required
|
||||
id="h-labels"
|
||||
value={labels}
|
||||
onChange={(e) => setLabels(e.target.value)}
|
||||
placeholder='["loki", "logging"]'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-key">私钥 *</Label>
|
||||
<Textarea
|
||||
id="h-key"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
required
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-phrase">密钥口令(可选)</Label>
|
||||
<Input
|
||||
id="h-phrase"
|
||||
type="password"
|
||||
value={keyPhrase}
|
||||
onChange={(e) => setKeyPhrase(e.target.value)}
|
||||
placeholder="无口令则留空"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="h-labels">标签(JSON)</Label>
|
||||
<Input
|
||||
id="h-labels"
|
||||
value={labels}
|
||||
onChange={(e) => setLabels(e.target.value)}
|
||||
placeholder='["loki", "logging"]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
||||
{submitting ? '添加中...' : '添加主机'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '添加中...' : '添加主机'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Hosts Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner className="h-64" />
|
||||
) : hosts.length === 0 ? (
|
||||
<Card className="border-dashed border-slate-300">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Monitor className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 font-medium">暂无主机</p>
|
||||
<p className="text-sm text-slate-400 mt-1">添加第一台 SSH 主机开始使用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={Monitor}
|
||||
title="暂无主机"
|
||||
description="添加第一台 SSH 主机开始使用"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{hosts.map((host) => (
|
||||
<Card key={host.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white">{host.name}</h3>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(host.status)}`}>
|
||||
{statusLabel(host.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span>IP 地址</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{host.hostIP}:{host.port}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>用户名</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{host.user}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>认证方式</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{host.authType === 'key' ? 'SSH 密钥' : '密码'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>系统</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{host.os || '未知'} ({host.arch || '未知'})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => viewHostInfo(host)}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => openTerminal(host)}
|
||||
>
|
||||
<Terminal className="w-3 h-3 mr-1" />
|
||||
终端
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => handleValidate(host.id)}
|
||||
disabled={validating === host.id}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${validating === host.id ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleDelete(host.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<HostCard
|
||||
key={host.id}
|
||||
host={host}
|
||||
validating={validating}
|
||||
onViewInfo={viewHostInfo}
|
||||
onOpenTerminal={openTerminal}
|
||||
onValidate={handleValidate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Host Detail Dialog */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 text-purple-500" />
|
||||
@@ -393,76 +400,9 @@ export default function Hosts() {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{hostInfo ? (
|
||||
<div className="space-y-5">
|
||||
{/* 资源使用率 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">资源使用率</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-500">CPU</span>
|
||||
<span className="font-medium">{hostInfo.cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${usageColor(hostInfo.cpuUsage)} rounded-full transition-all`} style={{ width: `${Math.min(hostInfo.cpuUsage, 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-500">内存</span>
|
||||
<span className="font-medium">{hostInfo.memUsage.toFixed(1)}% ({hostInfo.memUsed} / {hostInfo.memTotal})</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${usageColor(hostInfo.memUsage)} rounded-full transition-all`} style={{ width: `${Math.min(hostInfo.memUsage, 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-500">磁盘</span>
|
||||
<span className="font-medium">{hostInfo.diskUsage.toFixed(1)}% ({hostInfo.diskUsed} / {hostInfo.diskTotal})</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${usageColor(hostInfo.diskUsage)} rounded-full transition-all`} style={{ width: `${Math.min(hostInfo.diskUsage, 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">基本信息</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">主机名</p>
|
||||
<p className="text-sm font-medium">{hostInfo.hostname || '未知'}</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">系统</p>
|
||||
<p className="text-sm font-medium">{hostInfo.os || '未知'} {hostInfo.version}</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">架构</p>
|
||||
<p className="text-sm font-medium">{hostInfo.arch || '未知'}</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">内核</p>
|
||||
<p className="text-sm font-medium">{hostInfo.kernel || '未知'}</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">CPU 核数</p>
|
||||
<p className="text-sm font-medium">{hostInfo.cpu || '未知'}</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">运行时长</p>
|
||||
<p className="text-sm font-medium truncate">{hostInfo.uptime || '未知'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HostDetail info={hostInfo} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner className="h-32" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -502,4 +442,192 @@ export default function Hosts() {
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:主机卡片
|
||||
interface HostCardProps {
|
||||
host: Host
|
||||
validating: number | null
|
||||
onViewInfo: (h: Host) => void
|
||||
onOpenTerminal: (h: Host) => void
|
||||
onValidate: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
}
|
||||
|
||||
const HostCard = memo(function HostCard({
|
||||
host,
|
||||
validating,
|
||||
onViewInfo,
|
||||
onOpenTerminal,
|
||||
onValidate,
|
||||
onDelete,
|
||||
}: HostCardProps) {
|
||||
return (
|
||||
<Card className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Monitor className="w-5 h-5 text-purple-500 flex-shrink-0" />
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
|
||||
{host.name}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusBadge status={host.status} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
||||
<InfoRow label="IP 地址" value={`${host.hostIP}:${host.port}`} />
|
||||
<InfoRow label="用户名" value={host.user} />
|
||||
<InfoRow
|
||||
label="认证方式"
|
||||
value={host.authType === 'key' ? 'SSH 密钥' : '密码'}
|
||||
/>
|
||||
<InfoRow
|
||||
label="系统"
|
||||
value={`${host.os || '未知'} (${host.arch || '未知'})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => onViewInfo(host)}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => onOpenTerminal(host)}
|
||||
>
|
||||
<Terminal className="w-3 h-3 mr-1" />
|
||||
终端
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onValidate(host.id)}
|
||||
disabled={validating === host.id}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3 h-3 ${validating === host.id ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => onDelete(host.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:信息行
|
||||
const InfoRow = memo(function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span>{label}</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{value}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:主机详情
|
||||
const HostDetail = memo(function HostDetail({ info }: { info: HostInfo }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 资源使用率 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">资源使用率</h4>
|
||||
<div className="space-y-3">
|
||||
<UsageBar label="CPU" value={info.cpuUsage} />
|
||||
<UsageBar
|
||||
label="内存"
|
||||
value={info.memUsage}
|
||||
detail={`${info.memUsed} / ${info.memTotal}`}
|
||||
/>
|
||||
<UsageBar
|
||||
label="磁盘"
|
||||
value={info.diskUsage}
|
||||
detail={`${info.diskUsed} / ${info.diskTotal}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">基本信息</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoCard label="主机名" value={info.hostname || '未知'} />
|
||||
<InfoCard label="系统" value={`${info.os || '未知'} ${info.version}`} />
|
||||
<InfoCard label="架构" value={info.arch || '未知'} />
|
||||
<InfoCard label="内核" value={info.kernel || '未知'} />
|
||||
<InfoCard label="CPU 核数" value={info.cpu || '未知'} />
|
||||
<InfoCard label="运行时长" value={info.uptime || '未知'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 使用率进度条
|
||||
const UsageBar = memo(function UsageBar({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
detail?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<span className="font-medium">
|
||||
{value.toFixed(1)}% {detail && `(${detail})`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${usageColor(value)} rounded-full transition-all`}
|
||||
style={{ width: `${Math.min(value, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 信息卡片
|
||||
const InfoCard = memo(function InfoCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="text-sm font-medium truncate">{value}</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Hosts
|
||||
@@ -1,12 +1,22 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Activity, Eye, EyeOff, Shield, ServerOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { authApi } from '@/srv/api'
|
||||
import { authApi } from '@/api'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function Login() {
|
||||
/**
|
||||
* Login 页面 - 优化版本
|
||||
*
|
||||
* 优化点:
|
||||
* 1. React.memo 包裹组件
|
||||
* 2. 表单处理使用 useCallback 缓存
|
||||
* 3. 错误处理使用 sonner toast 替代 alert
|
||||
* 4. 清理 useEffect 中的未使用变量
|
||||
*/
|
||||
const Login = memo(function Login() {
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = useState('admin123')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
@@ -24,50 +34,67 @@ export default function Login() {
|
||||
}
|
||||
// Quick backend check
|
||||
checkBackend()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navigate])
|
||||
|
||||
const checkBackend = async () => {
|
||||
// 后端状态检查 - useCallback缓存
|
||||
const checkBackend = useCallback(async () => {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000)
|
||||
await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
// 用公开的健康检查端点探活;避免打 /auth/login(生产环境被 nginx 限流 5r/m,
|
||||
// 反复刷新登录页会触发限流并锁死真正的登录请求)
|
||||
const res = await fetch('/api/v1/health', {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
// Even a 400 error means backend is up
|
||||
setBackendStatus('online')
|
||||
setBackendStatus(res.ok ? 'online' : 'offline')
|
||||
} catch {
|
||||
setBackendStatus('offline')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
// 表单提交处理 - useCallback缓存
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await authApi.login(username, password)
|
||||
localStorage.setItem('token', result.token)
|
||||
localStorage.setItem('user', JSON.stringify(result.user))
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败'
|
||||
if (message.includes('Cannot connect')) {
|
||||
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
|
||||
setBackendStatus('offline')
|
||||
} else if (message.includes('invalid') || message.includes('password') || message.includes('密码')) {
|
||||
setError('用户名或密码错误')
|
||||
} else {
|
||||
setError(message)
|
||||
try {
|
||||
const result = await authApi.login(username, password)
|
||||
localStorage.setItem('token', result.accessToken)
|
||||
localStorage.setItem('refreshToken', result.refreshToken)
|
||||
localStorage.setItem('user', JSON.stringify(result.user))
|
||||
toast.success('登录成功')
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败'
|
||||
if (message.includes('Cannot connect')) {
|
||||
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
|
||||
setBackendStatus('offline')
|
||||
} else if (
|
||||
message.includes('invalid') ||
|
||||
message.includes('password') ||
|
||||
message.includes('密码') ||
|
||||
message.includes('Unauthorized')
|
||||
) {
|
||||
setError('用户名或密码错误')
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[username, password, navigate]
|
||||
)
|
||||
|
||||
// 密码显示切换 - useCallback缓存
|
||||
const togglePassword = useCallback(() => {
|
||||
setShowPassword((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
|
||||
@@ -120,6 +147,7 @@ export default function Login() {
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
className="h-10"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -134,13 +162,19 @@ export default function Login() {
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
className="h-10 pr-10"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onClick={togglePassword}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,4 +197,6 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default Login
|
||||
@@ -1,44 +1,69 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { KeyRound, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { authApi } from '@/srv/api'
|
||||
import { authApi } from '@/api'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
|
||||
export default function Settings() {
|
||||
// 系统信息常量
|
||||
const SYSTEM_INFO = [
|
||||
{ label: '版本', value: 'v1.0.0' },
|
||||
{ label: '后端', value: 'Go + Gin + JSON 文件存储' },
|
||||
{ label: '前端', value: 'React 19 + TypeScript + Tailwind CSS' },
|
||||
{ label: '部署工具', value: 'kubectl + helm(镜像内置)' },
|
||||
{ label: '简介', value: '轻量级 K8s Loki 部署平台', align: 'right' as const },
|
||||
]
|
||||
|
||||
/**
|
||||
* Settings 页面 - 优化版本
|
||||
*
|
||||
* 优化点:
|
||||
* 1. React.memo 包裹组件
|
||||
* 2. 表单处理使用 useCallback 缓存
|
||||
* 3. alert() 替换为 sonner toast
|
||||
* 4. 使用 PageHeader 公共组件
|
||||
* 5. 提取常量避免重复创建
|
||||
*/
|
||||
const Settings = memo(function Settings() {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [changing, setChanging] = useState(false)
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
setChanging(true)
|
||||
try {
|
||||
await authApi.changePassword(oldPassword, newPassword)
|
||||
alert('密码修改成功')
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '密码修改失败'
|
||||
alert(message)
|
||||
} finally {
|
||||
setChanging(false)
|
||||
}
|
||||
}
|
||||
const handleChangePassword = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
toast.error('新密码至少需要 6 位')
|
||||
return
|
||||
}
|
||||
setChanging(true)
|
||||
try {
|
||||
await authApi.changePassword(oldPassword, newPassword)
|
||||
toast.success('密码修改成功')
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '密码修改失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setChanging(false)
|
||||
}
|
||||
},
|
||||
[oldPassword, newPassword, confirmPassword]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">系统设置</h2>
|
||||
<p className="text-sm text-slate-500">修改登录密码与查看系统信息</p>
|
||||
</div>
|
||||
<PageHeader title="系统设置" description="修改登录密码与查看系统信息" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 修改密码 */}
|
||||
@@ -85,7 +110,11 @@ export default function Settings() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="bg-emerald-600 hover:bg-emerald-700" disabled={changing}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
disabled={changing}
|
||||
>
|
||||
{changing ? '修改中...' : '修改密码'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -101,31 +130,32 @@ export default function Settings() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
|
||||
<span className="text-sm text-slate-500">版本</span>
|
||||
<span className="text-sm font-medium">v1.0.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
|
||||
<span className="text-sm text-slate-500">后端</span>
|
||||
<span className="text-sm font-medium">Go + Gin + JSON 文件存储</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
|
||||
<span className="text-sm text-slate-500">前端</span>
|
||||
<span className="text-sm font-medium">React + TypeScript + Tailwind CSS</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-800">
|
||||
<span className="text-sm text-slate-500">部署工具</span>
|
||||
<span className="text-sm font-medium">kubectl + helm(镜像内置)</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-sm text-slate-500">简介</span>
|
||||
<span className="text-sm font-medium text-right">轻量级 K8s Loki 部署平台</span>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{SYSTEM_INFO.map((item, index) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`flex justify-between py-2.5 ${
|
||||
index < SYSTEM_INFO.length - 1
|
||||
? 'border-b border-slate-100 dark:border-slate-800'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-slate-500">{item.label}</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
item.align === 'right' ? 'text-right' : ''
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default Settings
|
||||
+346
-255
@@ -1,5 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { HardDrive, Plus, Trash2, Star, Cloud, Database, Package } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
HardDrive,
|
||||
Plus,
|
||||
Trash2,
|
||||
Star,
|
||||
Cloud,
|
||||
Database,
|
||||
Package,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,17 +20,42 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { storageApi } from '@/srv/api'
|
||||
import { storageApi } from '@/api'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { toast } from 'sonner'
|
||||
import type { StorageConfig } from '@/types'
|
||||
|
||||
const storageTypeIcons: Record<string, React.ReactNode> = {
|
||||
// 存储类型图标映射 - 常量提取
|
||||
const STORAGE_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
s3: <Cloud className="w-5 h-5 text-orange-500" />,
|
||||
oss: <Cloud className="w-5 h-5 text-red-500" />,
|
||||
minio: <Database className="w-5 h-5 text-blue-500" />,
|
||||
rustfs: <Package className="w-5 h-5 text-cyan-500" />,
|
||||
}
|
||||
|
||||
export default function Storage() {
|
||||
// 存储类型颜色
|
||||
const STORAGE_TYPE_COLORS: Record<string, string> = {
|
||||
s3: 'bg-orange-100 text-orange-700',
|
||||
oss: 'bg-red-100 text-red-700',
|
||||
minio: 'bg-blue-100 text-blue-700',
|
||||
rustfs: 'bg-cyan-100 text-cyan-700',
|
||||
}
|
||||
|
||||
const STORAGE_TYPES = ['s3', 'oss', 'minio', 'rustfs'] as const
|
||||
|
||||
/**
|
||||
* Storage 页面 - 优化版本
|
||||
*
|
||||
* 优化点:
|
||||
* 1. React.memo 包裹组件
|
||||
* 2. 所有操作函数使用 useCallback 缓存
|
||||
* 3. alert() 替换为 sonner toast
|
||||
* 4. 使用公共组件替代重复代码
|
||||
* 5. 常量图标和颜色映射提取
|
||||
*/
|
||||
const Storage = memo(function Storage() {
|
||||
const [storages, setStorages] = useState<StorageConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -40,49 +73,23 @@ export default function Storage() {
|
||||
const [isDefault, setIsDefault] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadStorages()
|
||||
}, [])
|
||||
|
||||
const loadStorages = async () => {
|
||||
const loadStorages = useCallback(async () => {
|
||||
try {
|
||||
const res = await storageApi.list(1, 100)
|
||||
setStorages(res.list)
|
||||
} catch (err) {
|
||||
console.error('Failed to load storages:', err)
|
||||
toast.error('加载存储配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await storageApi.create({
|
||||
name,
|
||||
storageType,
|
||||
endpoint,
|
||||
bucket,
|
||||
region,
|
||||
accessKey,
|
||||
secretKey,
|
||||
pathPrefix,
|
||||
insecure,
|
||||
isDefault,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadStorages()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '创建存储配置失败'
|
||||
alert(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
loadStorages()
|
||||
}, [loadStorages])
|
||||
|
||||
const resetForm = () => {
|
||||
const resetForm = useCallback(() => {
|
||||
setName('')
|
||||
setStorageType('minio')
|
||||
setEndpoint('')
|
||||
@@ -93,255 +100,339 @@ export default function Storage() {
|
||||
setPathPrefix('loki')
|
||||
setInsecure(false)
|
||||
setIsDefault(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此存储配置?')) return
|
||||
try {
|
||||
await storageApi.remove(id)
|
||||
loadStorages()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete storage:', err)
|
||||
}
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await storageApi.create({
|
||||
name,
|
||||
storageType,
|
||||
endpoint,
|
||||
bucket,
|
||||
region,
|
||||
accessKey,
|
||||
secretKey,
|
||||
pathPrefix,
|
||||
insecure,
|
||||
isDefault,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadStorages()
|
||||
toast.success('存储配置添加成功')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '创建存储配置失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[name, storageType, endpoint, bucket, region, accessKey, secretKey, pathPrefix, insecure, isDefault, resetForm, loadStorages]
|
||||
)
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 's3':
|
||||
return 'bg-orange-100 text-orange-700'
|
||||
case 'oss':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'minio':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
case 'rustfs':
|
||||
return 'bg-cyan-100 text-cyan-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
if (!confirm('确定删除此存储配置?')) return
|
||||
try {
|
||||
await storageApi.remove(id)
|
||||
loadStorages()
|
||||
toast.success('存储配置已删除')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete storage:', err)
|
||||
toast.error('删除存储配置失败')
|
||||
}
|
||||
},
|
||||
[loadStorages]
|
||||
)
|
||||
|
||||
const handleSetDefault = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await storageApi.update(id, { isDefault: true })
|
||||
loadStorages()
|
||||
toast.success('已设为默认存储')
|
||||
} catch (err) {
|
||||
console.error('Failed to set default storage:', err)
|
||||
toast.error('设置默认存储失败')
|
||||
}
|
||||
},
|
||||
[loadStorages]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">存储配置</h2>
|
||||
<p className="text-sm text-slate-500">为 Loki 配置对象存储后端</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加存储
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加存储配置</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-name">名称 *</Label>
|
||||
<Input
|
||||
id="s-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="production-minio"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>存储类型</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(['s3', 'oss', 'minio', 'rustfs'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setStorageType(type)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-lg border transition-all ${
|
||||
storageType === type
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 hover:border-slate-300 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{storageTypeIcons[type]}
|
||||
<span className="text-xs font-medium uppercase">{type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-endpoint">访问地址</Label>
|
||||
<Input
|
||||
id="s-endpoint"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder={storageType === 's3' ? 's3.amazonaws.com' : storageType === 'minio' ? 'http://minio.example.com:9000' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PageHeader
|
||||
title="存储配置"
|
||||
description="为 Loki 配置对象存储后端"
|
||||
action={
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加存储
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加存储配置</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-bucket">Bucket *</Label>
|
||||
<Label htmlFor="s-name">名称 *</Label>
|
||||
<Input
|
||||
id="s-bucket"
|
||||
value={bucket}
|
||||
onChange={(e) => setBucket(e.target.value)}
|
||||
placeholder="loki-logs"
|
||||
id="s-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="production-minio"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-region">区域</Label>
|
||||
<Label>存储类型</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{STORAGE_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setStorageType(type)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-lg border transition-all ${
|
||||
storageType === type
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 hover:border-slate-300 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{STORAGE_TYPE_ICONS[type]}
|
||||
<span className="text-xs font-medium uppercase">{type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-endpoint">访问地址</Label>
|
||||
<Input
|
||||
id="s-region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
id="s-endpoint"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder={
|
||||
storageType === 's3'
|
||||
? 's3.amazonaws.com'
|
||||
: storageType === 'minio'
|
||||
? 'http://minio.example.com:9000'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-bucket">Bucket *</Label>
|
||||
<Input
|
||||
id="s-bucket"
|
||||
value={bucket}
|
||||
onChange={(e) => setBucket(e.target.value)}
|
||||
placeholder="loki-logs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-region">区域</Label>
|
||||
<Input
|
||||
id="s-region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-access">Access Key</Label>
|
||||
<Input
|
||||
id="s-access"
|
||||
value={accessKey}
|
||||
onChange={(e) => setAccessKey(e.target.value)}
|
||||
placeholder="minioadmin"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-secret">Secret Key</Label>
|
||||
<Input
|
||||
id="s-secret"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
placeholder="minioadmin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-access">Access Key</Label>
|
||||
<Label htmlFor="s-prefix">路径前缀</Label>
|
||||
<Input
|
||||
id="s-access"
|
||||
value={accessKey}
|
||||
onChange={(e) => setAccessKey(e.target.value)}
|
||||
placeholder="minioadmin"
|
||||
id="s-prefix"
|
||||
value={pathPrefix}
|
||||
onChange={(e) => setPathPrefix(e.target.value)}
|
||||
placeholder="loki"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-secret">Secret Key</Label>
|
||||
<Input
|
||||
id="s-secret"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
placeholder="minioadmin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-prefix">路径前缀</Label>
|
||||
<Input
|
||||
id="s-prefix"
|
||||
value={pathPrefix}
|
||||
onChange={(e) => setPathPrefix(e.target.value)}
|
||||
placeholder="loki"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="s-insecure" checked={insecure} onCheckedChange={setInsecure} />
|
||||
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="s-insecure"
|
||||
checked={insecure}
|
||||
onCheckedChange={setInsecure}
|
||||
/>
|
||||
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="s-default"
|
||||
checked={isDefault}
|
||||
onCheckedChange={setIsDefault}
|
||||
/>
|
||||
<Label htmlFor="s-default">设为默认</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="s-default" checked={isDefault} onCheckedChange={setIsDefault} />
|
||||
<Label htmlFor="s-default">设为默认</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
||||
{submitting ? '添加中...' : '添加存储'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '添加中...' : '添加存储'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Storage Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner className="h-64" />
|
||||
) : storages.length === 0 ? (
|
||||
<Card className="border-dashed border-slate-300">
|
||||
<CardContent className="p-12 text-center">
|
||||
<HardDrive className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 font-medium">暂无存储配置</p>
|
||||
<p className="text-sm text-slate-400 mt-1">添加第一个 Loki 存储后端</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={HardDrive}
|
||||
title="暂无存储配置"
|
||||
description="添加第一个 Loki 存储后端"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{storages.map((storage) => (
|
||||
<Card key={storage.id} className={`border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow ${storage.isDefault ? 'ring-1 ring-emerald-500' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{storageTypeIcons[storage.storageType] || <HardDrive className="w-5 h-5 text-slate-400" />}
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white">{storage.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{storage.isDefault && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-medium flex items-center gap-0.5">
|
||||
<Star className="w-3 h-3" />
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium uppercase ${getTypeColor(storage.storageType)}`}>
|
||||
{storage.storageType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span>访问地址</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[150px]">{storage.endpoint || '默认'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Bucket</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{storage.bucket}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>区域</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{storage.region || '无'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>路径前缀</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{storage.pathPrefix || '无'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>不加密</span>
|
||||
<span className="font-medium">{storage.insecure ? '是' : '否'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={async () => {
|
||||
await storageApi.update(storage.id, { isDefault: true })
|
||||
loadStorages()
|
||||
}}
|
||||
disabled={storage.isDefault}
|
||||
>
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
{storage.isDefault ? '默认' : '设为默认'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleDelete(storage.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StorageCard
|
||||
key={storage.id}
|
||||
storage={storage}
|
||||
onSetDefault={handleSetDefault}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:存储卡片
|
||||
interface StorageCardProps {
|
||||
storage: StorageConfig
|
||||
onSetDefault: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
}
|
||||
|
||||
const StorageCard = memo(function StorageCard({
|
||||
storage,
|
||||
onSetDefault,
|
||||
onDelete,
|
||||
}: StorageCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className={`border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow ${
|
||||
storage.isDefault ? 'ring-1 ring-emerald-500' : ''
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{STORAGE_TYPE_ICONS[storage.storageType] || (
|
||||
<HardDrive className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white truncate">
|
||||
{storage.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{storage.isDefault && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-medium flex items-center gap-0.5">
|
||||
<Star className="w-3 h-3" />
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium uppercase ${
|
||||
STORAGE_TYPE_COLORS[storage.storageType] || 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{storage.storageType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-4">
|
||||
<InfoRow label="访问地址" value={storage.endpoint || '默认'} />
|
||||
<InfoRow label="Bucket" value={storage.bucket} />
|
||||
<InfoRow label="区域" value={storage.region || '无'} />
|
||||
<InfoRow label="路径前缀" value={storage.pathPrefix || '无'} />
|
||||
<InfoRow label="不加密" value={storage.insecure ? '是' : '否'} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => onSetDefault(storage.id)}
|
||||
disabled={storage.isDefault}
|
||||
>
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
{storage.isDefault ? '默认' : '设为默认'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => onDelete(storage.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
// 子组件:信息行
|
||||
const InfoRow = memo(function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span>{label}</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[150px]">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Storage
|
||||
Reference in new issue
Block a user