feat: LokiDeploy 初始提交 - 轻量级 K8s Loki 部署平台

Go(Gin) + React(Vite + shadcn/ui + Tailwind) 前后端分离,全中文 UI

- 集群管理:Kubeconfig / Token 双接入,kubectl + helm 部署
- 主机管理:SSH 密钥 / 密码双认证,CPU/内存/磁盘使用率监控,SSH 错误中文友好提示
- 存储配置、部署管理、系统设置
- Docker 镜像内置 kubectl + helm,开箱即用
- run.sh 一键启停(start/stop/restart/status)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Logan.LiandClaude committed 2026-06-29 17:03:57 +08:00
commit 2a93de600d
134 files changed
+19595

No files matched your search

+417
View File
@@ -0,0 +1,417 @@
import { useState, useEffect } 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'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import { clusterApi } from '@/srv/api'
import { statusLabel } from '@/lib/utils'
import type { Cluster, ClusterInfo } from '@/types'
// 从 kubeconfig 文本自动提取 server 地址
const parseApiServer = (kubeconfig: string): string => {
const m = kubeconfig.match(/^\s*server:\s*(\S+)/m)
return m ? m[1] : ''
}
export default function Clusters() {
const [clusters, setClusters] = useState<Cluster[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [selectedCluster, setSelectedCluster] = useState<Cluster | null>(null)
const [clusterInfo, setClusterInfo] = useState<ClusterInfo | null>(null)
const [validating, setValidating] = useState<number | null>(null)
// Form state
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [configType, setConfigType] = useState<'kubeconfig' | 'token'>('kubeconfig')
const [kubeconfig, setKubeconfig] = useState('')
const [apiServer, setApiServer] = useState('')
const [token, setToken] = useState('')
const [context, setContext] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
loadClusters()
}, [])
const loadClusters = async () => {
try {
const res = await clusterApi.list(1, 100)
setClusters(res.list)
} catch (err) {
console.error('Failed to load clusters:', err)
} 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)
}
}
const resetForm = () => {
setName('')
setDescription('')
setConfigType('kubeconfig')
setKubeconfig('')
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 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 viewClusterInfo = async (cluster: Cluster) => {
setSelectedCluster(cluster)
setDetailOpen(true)
try {
const info = await clusterApi.getInfo(cluster.id)
setClusterInfo(info)
} catch (err) {
console.error('Failed to get cluster info:', err)
}
}
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' && (
<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>
</div>
{/* Clusters Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : 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>
) : (
<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>
))}
</div>
)}
{/* Cluster Detail Dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="w-5 h-5 text-blue-500" />
{selectedCluster?.name}
</DialogTitle>
</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>
) : (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
+303
View File
@@ -0,0 +1,303 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import {
Rocket,
Server,
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 { statusLabel } from '@/lib/utils'
import type { DashboardStats, LokiDeployment, Cluster, Host } from '@/types'
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
const [hosts, setHosts] = useState<Host[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
loadDashboard()
const interval = setInterval(loadDashboard, 10000)
return () => clearInterval(interval)
}, [])
const loadDashboard = async () => {
try {
const [statsRes, deployRes, clusterRes, hostRes] = await Promise.all([
deploymentApi.stats(),
deploymentApi.list(1, 5),
clusterApi.list(1, 5),
hostApi.list(1, 5),
])
setStats(statsRes)
setDeployments(deployRes.list)
setClusters(clusterRes.list)
setHosts(hostRes.list)
} catch (err) {
console.error('Failed to load dashboard:', err)
} finally {
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" />
}
}
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'
}
}
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 (
<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>
<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>
<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" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Recent Deployments */}
<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>
{/* 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>
</div>
</div>
</div>
)
}
+557
View File
@@ -0,0 +1,557 @@
import { useState, useEffect } from 'react'
import {
Rocket,
Plus,
Play,
Square,
RotateCw,
Trash2,
Terminal,
CheckCircle,
AlertTriangle,
Clock,
PauseCircle,
Activity,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { deploymentApi, clusterApi, hostApi, storageApi } from '@/srv/api'
import { statusLabel } from '@/lib/utils'
import type { LokiDeployment, Cluster, Host, StorageConfig, DeploymentLog } from '@/types'
export default function Deployments() {
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
const [hosts, setHosts] = useState<Host[]>([])
const [storages, setStorages] = useState<StorageConfig[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [logsOpen, setLogsOpen] = useState(false)
const [selectedDeployment, setSelectedDeployment] = useState<LokiDeployment | null>(null)
const [logs, setLogs] = useState<DeploymentLog[]>([])
const [deploying, setDeploying] = useState<number | null>(null)
// Form state
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [version, setVersion] = useState('3.0.0')
const [mode, setMode] = useState<'k8s-helm' | 'k8s-yaml' | 'host-binary'>('k8s-helm')
const [clusterID, setClusterID] = useState('')
const [hostID, setHostID] = useState('')
const [namespace, setNamespace] = useState('loki')
const [storageConfigID, setStorageConfigID] = useState('')
const [authEnabled, setAuthEnabled] = useState(false)
const [retentionDays, setRetentionDays] = useState(30)
const [replicas, setReplicas] = useState(1)
const [resourcesCPU, setResourcesCPU] = useState('500m')
const [resourcesMem, setResourcesMem] = useState('512Mi')
const [storageSize, setStorageSize] = useState('10Gi')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
loadAll()
}, [])
const loadAll = async () => {
try {
const [deployRes, clusterRes, hostRes, storageRes] = await Promise.all([
deploymentApi.list(1, 100),
clusterApi.list(1, 100),
hostApi.list(1, 100),
storageApi.list(1, 100),
])
setDeployments(deployRes.list)
setClusters(clusterRes.list)
setHosts(hostRes.list)
setStorages(storageRes.list)
} catch (err) {
console.error('Failed to load deployments:', err)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
const clusterIDNum = clusterID ? Number(clusterID) : undefined
const hostIDNum = hostID ? Number(hostID) : undefined
const storageIDNum = storageConfigID ? Number(storageConfigID) : 0
await deploymentApi.create({
name,
description,
version,
mode,
clusterID: clusterIDNum,
hostID: hostIDNum,
namespace,
storageConfigID: storageIDNum,
authEnabled,
retentionDays,
replicas,
resourcesCPU,
resourcesMem,
storageSize,
})
setOpen(false)
resetForm()
loadAll()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建部署失败'
alert(message)
} finally {
setSubmitting(false)
}
}
const resetForm = () => {
setName('')
setDescription('')
setVersion('3.0.0')
setMode('k8s-helm')
setClusterID('')
setHostID('')
setNamespace('loki')
setStorageConfigID('')
setAuthEnabled(false)
setRetentionDays(30)
setReplicas(1)
setResourcesCPU('500m')
setResourcesMem('512Mi')
setStorageSize('10Gi')
}
const handleDeploy = async (id: number) => {
setDeploying(id)
try {
await deploymentApi.deploy(id)
setTimeout(loadAll, 2000)
} catch (err) {
console.error('Failed to deploy:', err)
} finally {
setTimeout(() => setDeploying(null), 5000)
}
}
const handleStop = async (id: number) => {
try {
await deploymentApi.stop(id)
loadAll()
} catch (err) {
console.error('Failed to stop:', err)
}
}
const handleRestart = async (id: number) => {
setDeploying(id)
try {
await deploymentApi.restart(id)
setTimeout(loadAll, 3000)
} catch (err) {
console.error('Failed to restart:', err)
} finally {
setTimeout(() => setDeploying(null), 5000)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除此部署?')) return
try {
await deploymentApi.remove(id)
loadAll()
} catch (err) {
console.error('Failed to delete:', err)
}
}
const viewLogs = async (deployment: LokiDeployment) => {
setSelectedDeployment(deployment)
setLogsOpen(true)
try {
const res = await deploymentApi.logs(deployment.id, 1, 100)
setLogs(res.list)
} catch (err) {
console.error('Failed to load logs:', err)
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <CheckCircle className="w-5 h-5 text-emerald-500" />
case 'failed':
return <AlertTriangle className="w-5 h-5 text-red-500" />
case 'pending':
return <Clock className="w-5 h-5 text-amber-500" />
case 'stopped':
return <PauseCircle className="w-5 h-5 text-slate-400" />
case 'deploying':
return <Activity className="w-5 h-5 text-blue-500 animate-pulse" />
default:
return <Clock className="w-5 h-5 text-slate-400" />
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'bg-emerald-100 text-emerald-700'
case 'failed':
return 'bg-red-100 text-red-700'
case 'pending':
return 'bg-amber-100 text-amber-700'
case 'stopped':
return 'bg-slate-100 text-slate-600'
case 'deploying':
return 'bg-blue-100 text-blue-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
const getTargetLabel = (d: LokiDeployment) => {
if (d.cluster) return `集群:${d.cluster.name}`
if (d.host) return `主机:${d.host.name}`
return '未知'
}
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">部署与管理 Grafana 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>创建 Loki 部署</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="d-name">部署名称 *</Label>
<Input id="d-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="production-loki" required />
</div>
<div className="space-y-2">
<Label htmlFor="d-desc">描述</Label>
<Textarea id="d-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="生产环境 Loki 日志服务" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>版本</Label>
<Select value={version} onValueChange={setVersion}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3.0.0">3.0.0</SelectItem>
<SelectItem value="2.9.8">2.9.8</SelectItem>
<SelectItem value="2.9.7">2.9.7</SelectItem>
<SelectItem value="2.9.6">2.9.6</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>部署方式</Label>
<Select value={mode} onValueChange={(v) => setMode(v as 'k8s-helm' | 'k8s-yaml' | 'host-binary')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k8s-helm">K8s Helm 部署</SelectItem>
<SelectItem value="k8s-yaml">K8s YAML 部署</SelectItem>
<SelectItem value="host-binary">主机二进制部署</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(mode === 'k8s-helm' || mode === 'k8s-yaml') && (
<>
<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="space-y-2">
<Label htmlFor="d-ns">命名空间</Label>
<Input id="d-ns" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder="loki" />
</div>
</>
)}
{mode === 'host-binary' && (
<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>存储配置</Label>
<Select value={storageConfigID} onValueChange={setStorageConfigID}>
<SelectTrigger>
<SelectValue placeholder="请选择存储(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">本地存储(默认)</SelectItem>
{storages.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}({s.storageType})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="d-replicas">副本数</Label>
<Input id="d-replicas" type="number" min={1} max={10} value={replicas} onChange={(e) => setReplicas(Number(e.target.value))} />
</div>
<div className="space-y-2">
<Label htmlFor="d-retention">保留天数</Label>
<Input id="d-retention" type="number" min={1} value={retentionDays} onChange={(e) => setRetentionDays(Number(e.target.value))} />
</div>
<div className="space-y-2">
<Label htmlFor="d-size">存储容量</Label>
<Input id="d-size" value={storageSize} onChange={(e) => setStorageSize(e.target.value)} placeholder="10Gi" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="d-cpu">CPU</Label>
<Input id="d-cpu" value={resourcesCPU} onChange={(e) => setResourcesCPU(e.target.value)} placeholder="500m" />
</div>
<div className="space-y-2">
<Label htmlFor="d-mem">内存</Label>
<Input id="d-mem" value={resourcesMem} onChange={(e) => setResourcesMem(e.target.value)} placeholder="512Mi" />
</div>
</div>
<div className="flex items-center gap-2">
<Switch id="d-auth" checked={authEnabled} onCheckedChange={setAuthEnabled} />
<Label htmlFor="d-auth">启用多租户鉴权</Label>
</div>
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
{submitting ? '创建中...' : '创建部署'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
{/* Deployments Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RotateCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : deployments.length === 0 ? (
<Card className="border-dashed border-slate-300">
<CardContent className="p-12 text-center">
<Rocket 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>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{deployments.map((d) => (
<Card key={d.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">
{getStatusIcon(d.status)}
<div>
<h3 className="font-semibold text-slate-800 dark:text-white">{d.name}</h3>
<p className="text-xs text-slate-500">{d.uuid}</p>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(d.status)}`}>
{statusLabel(d.status)}
</span>
</div>
<p className="text-sm text-slate-500 mb-3">{d.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">{d.mode}</span>
</div>
<div className="flex justify-between">
<span>版本</span>
<span className="font-medium">{d.version}</span>
</div>
<div className="flex justify-between">
<span>目标</span>
<span className="font-medium">{getTargetLabel(d)}</span>
</div>
<div className="flex justify-between">
<span>命名空间</span>
<span className="font-medium">{d.namespace || 'loki'}</span>
</div>
<div className="flex justify-between">
<span>副本数</span>
<span className="font-medium">{d.replicas}</span>
</div>
<div className="flex justify-between">
<span>存储</span>
<span className="font-medium">{d.storageConfig?.name || '本地'}</span>
</div>
{d.endpoint && (
<div className="flex justify-between">
<span>访问地址</span>
<span className="font-medium text-emerald-600 truncate max-w-[200px]">{d.endpoint}</span>
</div>
)}
{d.statusMessage && (
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs">
{d.statusMessage}
</div>
)}
</div>
<div className="flex gap-1 pt-3 border-t border-slate-100 dark:border-slate-800">
{d.status === 'pending' || d.status === 'failed' || d.status === 'stopped' ? (
<Button
variant="outline"
size="sm"
className="flex-1 text-xs bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border-emerald-200"
onClick={() => handleDeploy(d.id)}
disabled={deploying === d.id}
>
<Play className="w-3 h-3 mr-1" />
{deploying === d.id ? '部署中...' : '部署'}
</Button>
) : d.status === 'deploying' ? (
<Button variant="outline" size="sm" className="flex-1 text-xs" disabled>
<Activity className="w-3 h-3 mr-1 animate-pulse" />
部署中...
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleStop(d.id)}
>
<Square className="w-3 h-3 mr-1" />
停止
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleRestart(d.id)}
disabled={deploying === d.id}
>
<RotateCw className={`w-3 h-3 mr-1 ${deploying === d.id ? 'animate-spin' : ''}`} />
重启
</Button>
</>
)}
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => viewLogs(d)}
>
<Terminal 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(d.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Logs Dialog */}
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-emerald-500" />
部署日志 - {selectedDeployment?.name}
</DialogTitle>
</DialogHeader>
<div className="bg-slate-900 text-green-400 font-mono text-xs p-4 rounded-lg h-96 overflow-auto">
{logs.length === 0 ? (
<p className="text-slate-500">暂无日志...</p>
) : (
logs.map((log) => (
<div key={log.id} className={`mb-1 ${
log.level === 'error' ? 'text-red-400' :
log.level === 'warn' ? 'text-amber-400' :
log.level === 'success' ? 'text-emerald-400' :
'text-green-400'
}`}>
<span className="text-slate-500">[{new Date(log.createdAt).toLocaleTimeString()}]</span>
{' '}
<span className="font-bold uppercase">{log.level}</span>
{' '}
{log.message}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
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>
</>
)
}
+505
View File
@@ -0,0 +1,505 @@
import { useState, useEffect } 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'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import { hostApi } from '@/srv/api'
import { statusLabel } from '@/lib/utils'
import type { Host, HostInfo } from '@/types'
// 使用率颜色阈值
const usageColor = (v: number) => (v >= 90 ? 'bg-red-500' : v >= 70 ? 'bg-amber-500' : 'bg-emerald-500')
export default function Hosts() {
const [hosts, setHosts] = useState<Host[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [terminalOpen, setTerminalOpen] = useState(false)
const [selectedHost, setSelectedHost] = useState<Host | null>(null)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [validating, setValidating] = useState<number | null>(null)
// Terminal state
const [command, setCommand] = useState('')
const [terminalOutput, setTerminalOutput] = useState('')
const [executing, setExecuting] = useState(false)
// Form state
const [name, setName] = useState('')
const [hostIP, setHostIP] = useState('')
const [port, setPort] = useState(22)
const [user, setUser] = useState('root')
const [authType, setAuthType] = useState<'password' | 'key'>('password')
const [password, setPassword] = useState('')
const [privateKey, setPrivateKey] = useState('')
const [keyPhrase, setKeyPhrase] = useState('')
const [labels, setLabels] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
loadHosts()
}, [])
const loadHosts = async () => {
try {
const res = await hostApi.list(1, 100)
setHosts(res.list)
} catch (err) {
console.error('Failed to load hosts:', err)
} 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)
}
}
const resetForm = () => {
setName('')
setHostIP('')
setPort(22)
setUser('root')
setAuthType('password')
setPassword('')
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 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 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 openTerminal = (host: Host) => {
setSelectedHost(host)
setTerminalOpen(true)
setTerminalOutput(`已连接到 ${host.name} (${host.hostIP})\n输入命令后按"执行"...\n`)
}
const executeCommand = async () => {
if (!selectedHost || !command.trim()) return
setExecuting(true)
setTerminalOutput((prev) => prev + `\n$ ${command}\n`)
try {
const res = await hostApi.execute(selectedHost.id, command)
setTerminalOutput((prev) => prev + (res.stdout || '') + (res.stderr || '') + '\n')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '命令执行失败'
setTerminalOutput((prev) => prev + `错误:${message}\n`)
} finally {
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'
}
}
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')}
/>
<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-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>
{/* Hosts Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : 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>
) : (
<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>
))}
</div>
)}
{/* Host Detail Dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5 text-purple-500" />
{selectedHost?.name}
</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>
) : (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
)}
</DialogContent>
</Dialog>
{/* Terminal Dialog */}
<Dialog open={terminalOpen} onOpenChange={setTerminalOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-emerald-500" />
终端 - {selectedHost?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="bg-slate-900 text-green-400 font-mono text-xs p-4 rounded-lg h-80 overflow-auto whitespace-pre-wrap">
{terminalOutput}
</div>
<div className="flex gap-2">
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && executeCommand()}
placeholder="输入命令..."
className="flex-1 font-mono text-sm"
disabled={executing}
/>
<Button
onClick={executeCommand}
disabled={executing || !command.trim()}
className="bg-emerald-600 hover:bg-emerald-700"
>
{executing ? '执行中...' : '执行'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useState, useEffect } 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'
export default function Login() {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('admin123')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [backendStatus, setBackendStatus] = useState<'checking' | 'online' | 'offline'>('checking')
const navigate = useNavigate()
useEffect(() => {
// Check if already logged in
const token = localStorage.getItem('token')
if (token) {
navigate('/')
return
}
// Quick backend check
checkBackend()
}, [navigate])
const checkBackend = 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: '{}',
signal: controller.signal,
})
clearTimeout(timeoutId)
// Even a 400 error means backend is up
setBackendStatus('online')
} catch {
setBackendStatus('offline')
}
}
const handleSubmit = 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)
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-500 mb-4">
<Activity className="w-9 h-9 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">LokiDeploy</h1>
<p className="text-sm text-slate-500 mt-1">Kubernetes Loki 日志部署平台</p>
</div>
{/* Backend Status */}
{backendStatus === 'offline' && (
<div className="mb-4 p-4 rounded-xl bg-amber-50 border border-amber-200 flex items-start gap-3">
<ServerOff className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800">后端服务未启动</p>
<p className="text-xs text-amber-700 mt-1">
无法连接 API 服务,请先启动后端:
</p>
<code className="block mt-2 px-3 py-2 bg-amber-100 rounded text-xs font-mono text-amber-900">
./run.sh
</code>
</div>
</div>
)}
{/* Login Card */}
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-800 p-8">
<div className="flex items-center gap-2 mb-6">
<Shield className="w-5 h-5 text-emerald-500" />
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">登录</h2>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">用户名</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
required
className="h-10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<Button
type="submit"
className="w-full h-10 bg-emerald-600 hover:bg-emerald-700 text-white"
disabled={loading}
>
{loading ? '登录中...' : '登录'}
</Button>
</form>
<div className="mt-6 pt-4 border-t border-slate-100 dark:border-slate-800">
<p className="text-xs text-center text-slate-500">
默认账号:admin / admin123
</p>
</div>
</div>
</div>
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { 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'
export default 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)
}
}
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>
<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">
<CardTitle className="text-base flex items-center gap-2">
<KeyRound className="w-4 h-4" />
修改密码
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="old-pwd">当前密码</Label>
<Input
id="old-pwd"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="请输入当前密码"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-pwd">新密码</Label>
<Input
id="new-pwd"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码(至少 6 位)"
required
minLength={6}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-pwd">确认新密码</Label>
<Input
id="confirm-pwd"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
required
/>
</div>
<Button type="submit" className="bg-emerald-600 hover:bg-emerald-700" disabled={changing}>
{changing ? '修改中...' : '修改密码'}
</Button>
</form>
</CardContent>
</Card>
{/* 关于 */}
<Card className="border-slate-200 dark:border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
关于 LokiDeploy
</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>
</CardContent>
</Card>
</div>
</div>
)
}
+347
View File
@@ -0,0 +1,347 @@
import { useState, useEffect } 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'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import { storageApi } from '@/srv/api'
import type { StorageConfig } from '@/types'
const storageTypeIcons: 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 [storages, setStorages] = useState<StorageConfig[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
// Form state
const [name, setName] = useState('')
const [storageType, setStorageType] = useState<'s3' | 'oss' | 'minio' | 'rustfs'>('minio')
const [endpoint, setEndpoint] = useState('')
const [bucket, setBucket] = useState('')
const [region, setRegion] = useState('')
const [accessKey, setAccessKey] = useState('')
const [secretKey, setSecretKey] = useState('')
const [pathPrefix, setPathPrefix] = useState('loki')
const [insecure, setInsecure] = useState(false)
const [isDefault, setIsDefault] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
loadStorages()
}, [])
const loadStorages = async () => {
try {
const res = await storageApi.list(1, 100)
setStorages(res.list)
} catch (err) {
console.error('Failed to load storages:', err)
} 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)
}
}
const resetForm = () => {
setName('')
setStorageType('minio')
setEndpoint('')
setBucket('')
setRegion('')
setAccessKey('')
setSecretKey('')
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 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'
}
}
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">
<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-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>
<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>
{/* 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>
) : 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>
) : (
<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>
))}
</div>
)}
</div>
)
}