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:
Logan.LiandClaude committed 2026-06-30 10:46:26 +08:00
1 parent fb8d10231a
commit 327e282356
93 files changed
+8433 -3089

No files matched your search

+435 -309
View File
@@ -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
View File
@@ -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
View File
@@ -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} &middot; {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} &middot; {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
File diff suppressed because it is too large. Load diff
-20
View File
@@ -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
View File
@@ -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
+72 -36
View File
@@ -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
+80 -50
View File
@@ -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
View File
@@ -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