first commit

This commit is contained in:
MengMengCode
2026-06-05 19:23:28 +08:00
commit e306d2d06b
66 changed files with 18825 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './contexts/AuthContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Containers from './pages/Containers'
import ContainerDetail from './pages/ContainerDetail'
import Oversell from './pages/Oversell'
import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration'
import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Layout from './components/Layout'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function HomeRoute() {
const { isSubUser, containerIdentifiers } = useAuth()
if (isSubUser) {
const firstContainer = containerIdentifiers[0]
return <Navigate to={firstContainer ? `/container/${encodeURIComponent(firstContainer)}` : '/containers'} replace />
}
return <Dashboard />
}
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<HomeRoute />} />
<Route path="containers" element={<Containers />} />
<Route path="images" element={<ImageManagement />} />
<Route path="container/:id" element={<ContainerDetail />} />
<Route path="oversell" element={<Oversell />} />
<Route path="security" element={<Security />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default App
+14
View File
@@ -0,0 +1,14 @@
type AppIconProps = {
className?: string
}
export default function AppIcon({ className = 'w-6 h-6' }: AppIconProps) {
return (
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M852.9 147.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V156.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V156.9c0-32.5-26.6-59.1-59.1-59.1z" fill="#707070" />
<path d="M290.5 214h-60v60h60v-60zM393.5 214h-60v60h60v-60zM806 214H591v60h215v-60zM852.9 417.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V426.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V426.9c0-32.5-26.6-59.1-59.1-59.1z" fill="#707070" />
<path d="M290.5 484h-60v60h60v-60zM393.5 484h-60v60h60v-60zM806 484H591v60h215v-60zM852.9 687.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V696.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V696.9c0-32.5-26.6-59.1-59.1-59.1z" fill="#707070" />
<path d="M290.5 754h-60v60h60v-60zM393.5 754h-60v60h60v-60zM806 754H591v60h215v-60z" fill="#707070" />
</svg>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { useNavigate } from 'react-router-dom'
import {
Server,
Cpu,
HardDrive,
MemoryStick,
Globe,
Play,
Square,
RotateCcw,
Trash2,
} from 'lucide-react'
import { Container, startContainer, stopContainer, restartContainer, deleteContainer } from '../services/api'
interface ContainerCardProps {
container: Container
onRefresh: () => void
}
export default function ContainerCard({ container, onRefresh }: ContainerCardProps) {
const navigate = useNavigate()
const containerIdentifier = container.uuid || container.id
const handleAction = async (action: string) => {
try {
switch (action) {
case 'start':
await startContainer(containerIdentifier)
break
case 'stop':
await stopContainer(containerIdentifier)
break
case 'restart':
await restartContainer(containerIdentifier)
break
case 'delete':
if (window.confirm(`确定要删除容器 ${container.name} 吗?此操作不可撤销。`)) {
await deleteContainer(containerIdentifier)
} else {
return
}
break
}
onRefresh()
} catch (err) {
console.error('Action failed:', err)
alert('操作失败')
}
}
const statusColor = container.status === 'running' ? 'bg-green-500' : 'bg-red-500'
const statusText = container.status === 'running' ? '运行中' : '已停止'
return (
<div className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Server className="w-5 h-5 text-gray-700" />
</div>
<div>
<button
onClick={() => navigate(`/container/${encodeURIComponent(String(containerIdentifier))}`)}
className="font-semibold text-black hover:underline text-left"
>
{container.name}
</button>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${statusColor}`}></span>
<span className="text-xs text-gray-500">{statusText}</span>
</div>
</div>
</div>
</div>
{/* Specs */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Cpu className="w-3.5 h-3.5" />
<span>{container.vcpu} vCPU</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<MemoryStick className="w-3.5 h-3.5" />
<span>{container.ram_mb} MB</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<HardDrive className="w-3.5 h-3.5" />
<span>{container.disk_gb} GB</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Globe className="w-3.5 h-3.5" />
<span>{container.network_bw_mbps} Mbps</span>
</div>
</div>
{container.ip && (
<div className="text-xs text-gray-400 mb-3">
IP: {container.ip}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-1.5 pt-3 border-t border-gray-100">
{container.status !== 'running' ? (
<button
onClick={() => handleAction('start')}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-700 transition-colors"
>
<Play className="w-3 h-3" />
</button>
) : (
<>
<button
onClick={() => handleAction('stop')}
className="flex items-center gap-1 px-3 py-1.5 bg-yellow-500 text-white rounded text-xs hover:bg-yellow-600 transition-colors"
>
<Square className="w-3 h-3" />
</button>
<button
onClick={() => handleAction('restart')}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 transition-colors"
>
<RotateCcw className="w-3 h-3" />
</button>
</>
)}
<div className="flex-1" />
<button
onClick={() => handleAction('delete')}
className="flex items-center gap-1 px-3 py-1.5 text-red-600 hover:bg-red-50 rounded text-xs transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,342 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
import { useDialog } from './Dialog'
interface CreateContainerModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
}
const defaultForm: CreateContainerRequest = {
name: '',
template_id: '',
vcpu: 1,
cpu_percent: 100,
ram_mb: 512,
disk_gb: 10,
network_bw_mbps: 0,
monthly_traffic_gb: 0,
traffic_mode: 'total',
traffic_in_gb: 0,
traffic_out_gb: 0,
io_speed_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
assign_ipv6: false,
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) {
const dialog = useDialog()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
const [batchCount, setBatchCount] = useState(1)
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
useEffect(() => {
if (!isOpen) return
getEnabledImages()
.then((res) => {
const data = res.data.data || []
setTemplates(data)
if (data.length > 0) {
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
}
})
.catch(console.error)
getIPv6Status()
.then((res) => {
const status = res.data.data || null
setIPv6Status(status)
if (!status?.available) {
setForm((prev) => ({ ...prev, assign_ipv6: false }))
}
})
.catch(() => {
setIPv6Status({ available: false, reachable: false, reason: 'IPv6 status check failed', prefixes: [] })
setForm((prev) => ({ ...prev, assign_ipv6: false }))
})
getHostInfo()
.then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null))
}, [isOpen])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count)
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
}, [form.port_mapping_count])
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
const handleSubmit = async () => {
if (!form.name || !form.template_id) {
dialog.alert('提示', '请填写容器名称并选择系统模板')
return
}
const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB)
// Build batch of containers
const containers: CreateContainerRequest[] = []
for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] })
}
setLoading(true)
try {
await batchCreate(containers)
await onSuccess(containers)
onClose()
setBatchCount(1)
setForm({ ...defaultForm, template_id: templates[0]?.id || '' })
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
} finally {
setLoading(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-black"></h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field label="容器名称">
<input
type="text"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
className={inputClass}
placeholder="my-container"
required
/>
</Field>
<Field label="批量创建数量">
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
</Field>
</div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-1 {form.name}-{batchCount}</p>}
<Field label="系统模板">
{templates.length === 0 ? (
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
</div>
) : (
<select
value={form.template_id}
onChange={(event) => setForm({ ...form, template_id: event.target.value })}
className={inputClass}
>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
)}
</Field>
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<input
type="checkbox"
checked={!!form.assign_ipv6}
disabled={!ipv6Available}
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">Public IPv6</span>
<span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
</span>
</span>
</label>
<div className="grid grid-cols-2 gap-4">
<Field label="vCPU">
<NumberInput value={form.vcpu} min={0.25} max={maxVCPU} step={0.25} onChange={(value) => setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} />
</Field>
<Field label="内存 (MB)">
<NumberInput value={form.ram_mb} min={128} max={maxRAMMB} step={128} onChange={(value) => setForm({ ...form, ram_mb: clampInt(value, 128, maxRAMMB, 512) })} />
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="磁盘 (GB)">
<NumberInput value={form.disk_gb} min={1} max={maxDiskGB} onChange={(value) => setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} />
</Field>
<Field label="带宽 (Mbps)">
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
</Field>
<Field label="IO 速度 (MB/s)">
<NumberInput value={form.io_speed_mbps} min={0} onChange={(value) => setForm({ ...form, io_speed_mbps: value })} />
</Field>
</div>
{/* Traffic control */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-gray-700"></label>
<select
value={form.traffic_mode}
onChange={(e) => setForm({ ...form, traffic_mode: e.target.value })}
className="h-8 px-2 border border-gray-300 rounded text-xs text-gray-600 bg-white"
>
<option value="total"></option>
<option value="in_out">/</option>
</select>
</div>
{form.traffic_mode === 'total' ? (
<div className="flex items-center gap-2">
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
<span className="text-xs text-gray-400">GB (0=)</span>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label="入站 (GB)">
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
</Field>
<Field label="出站 (GB)">
<NumberInput value={form.traffic_out_gb} min={0} onChange={(value) => setForm({ ...form, traffic_out_gb: value || 0 })} />
</Field>
</div>
)}
</div>
<Field label="NAT 端口映射数量">
<NumberInput
value={form.port_mapping_count}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
/>
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
SSH: {sshPortPreview} -&gt; 22
</span>
{autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
</div>
</Field>
<Field label="到期时间">
<div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="date"
value={form.expires_at}
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
min={new Date().toISOString().slice(0, 10)}
className={`${inputClass} pl-10`}
/>
</div>
<p className="text-xs text-gray-400 mt-1.5"></p>
</Field>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors">
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '创建中...' : '创建容器'}
</button>
</div>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{label}</label>
{children}
</div>
)
}
function NumberInput({
value,
min,
max,
step,
onChange,
}: {
value: number
min?: number
max?: number
step?: number
onChange: (value: number) => void
}) {
return (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(event) => {
const raw = event.target.value
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
onChange(value)
}}
className={inputClass}
/>
)
}
function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest {
return {
...form,
vcpu: clampVCPU(form.vcpu, maxVCPU),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
}
}
function clampVCPU(value: number, max: number) {
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2))
}
function clampInt(value: number, min: number, max?: number, fallback = min) {
const next = Math.round(Number.isFinite(value) ? value : fallback)
return Math.min(Math.max(next, min), max ?? next)
}
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
+94
View File
@@ -0,0 +1,94 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
type DialogType = 'confirm' | 'alert'
interface DialogState {
open: boolean
type: DialogType
title: string
message: string
resolve?: (value: boolean) => void
}
interface DialogContextType {
confirm: (title: string, message: string) => Promise<boolean>
alert: (title: string, message: string) => Promise<void>
}
const DialogContext = createContext<DialogContextType | undefined>(undefined)
export function DialogProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
const confirm = useCallback((title: string, message: string) => {
return new Promise<boolean>((resolve) => {
setDialog({ open: true, type: 'confirm', title, message, resolve })
})
}, [])
const alert = useCallback((title: string, message: string) => {
return new Promise<void>((resolve) => {
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
})
}, [])
const close = (result: boolean) => {
dialog.resolve?.(result)
setDialog({ open: false, type: 'alert', title: '', message: '' })
}
return (
<DialogContext.Provider value={{ confirm, alert }}>
{children}
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
}`}>
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</div>
<h3 className="text-sm font-semibold text-black flex-1">{dialog.title}</h3>
{dialog.type === 'alert' && (
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="px-5 py-4">
<p className="text-sm text-gray-600">{dialog.message}</p>
</div>
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
{dialog.type === 'confirm' && (
<button
onClick={() => close(false)}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
>
</button>
)}
<button
onClick={() => close(true)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
dialog.type === 'confirm'
? 'bg-black text-white hover:bg-gray-800'
: 'bg-black text-white hover:bg-gray-800'
}`}
>
{dialog.type === 'confirm' ? '确认' : '确定'}
</button>
</div>
</div>
</div>
)}
</DialogContext.Provider>
)
}
export function useDialog() {
const ctx = useContext(DialogContext)
if (!ctx) throw new Error('useDialog must be used within DialogProvider')
return ctx
}
+18
View File
@@ -0,0 +1,18 @@
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import { useState } from 'react'
export default function Layout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6">
<Outlet />
</div>
</main>
</div>
)
}
@@ -0,0 +1,224 @@
import { ReactNode } from 'react'
import { RefreshCw } from 'lucide-react'
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
export type ChartPoint = {
ts: number
value: number
}
export type ResourceChartConfig = {
title: string
icon: ReactNode
points: ChartPoint[]
current: number
detail?: string
max?: number
unitLabel?: string
formatValue: (value: number) => string
}
const rangeLabels: Record<StatsRangeKey, string> = {
'30m': '30分钟',
'1h': '1小时',
'1d': '1天',
'1w': '1周',
}
export const statsRanges: Record<StatsRangeKey, number> = {
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'1d': 24 * 60 * 60 * 1000,
'1w': 7 * 24 * 60 * 60 * 1000,
}
export default function ResourceStatsPanel({
range,
onRangeChange,
onRefresh,
charts,
}: {
range: StatsRangeKey
onRangeChange: (range: StatsRangeKey) => void
onRefresh: () => void
charts: ResourceChartConfig[]
}) {
return (
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white">
<h2 className="text-sm font-semibold text-gray-950"></h2>
<div className="flex items-center gap-1.5">
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5">
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
<button
key={item}
onClick={() => onRangeChange(item)}
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900'
}`}
>
{rangeLabels[item]}
</button>
))}
</div>
<button
onClick={onRefresh}
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900"
title="刷新"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2">
{charts.map((chart, index) => (
<DetailedChart key={chart.title} chart={chart} className={chartBorderClass(index)} />
))}
</div>
</section>
)
}
function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) {
const values = chart.points.map((point) => point.value)
const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
const peak = values.length > 0 ? Math.max(...values) : 0
return (
<div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950">
<span className="text-gray-500">{chart.icon}</span>
<span>{chart.title}</span>
</div>
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>}
</div>
<div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} />
<Stat label="平均" value={chart.formatValue(avg)} />
<Stat label="峰值" value={chart.formatValue(peak)} />
</div>
</div>
<LineAreaChart
points={chart.points}
max={chart.max}
formatValue={chart.formatValue}
unitLabel={chart.unitLabel}
/>
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] text-gray-400">{label}</div>
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div>
</div>
)
}
function LineAreaChart({
points,
max,
formatValue,
unitLabel,
}: {
points: ChartPoint[]
max?: number
formatValue: (value: number) => string
unitLabel?: string
}) {
const width = 520
const height = 150
const left = 50
const right = 10
const top = 8
const bottom = 28
const innerWidth = width - left - right
const innerHeight = height - top - bottom
const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }]
const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1)
const minTs = values[0]?.ts || Date.now()
const maxTs = values[values.length - 1]?.ts || minTs + 1
const span = Math.max(maxTs - minTs, 1)
const coords = values.map((point, index) => {
const x = left + ((point.ts - minTs) / span) * innerWidth
const y = top + innerHeight - (point.value / maxValue) * innerHeight
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
})
const fallbackX = left
const fallbackY = top + innerHeight
const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}`
const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}`
const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1]
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#555" stopOpacity="0.25" />
<stop offset="100%" stopColor="#555" stopOpacity="0.02" />
</linearGradient>
</defs>
{yTicks.map((tick) => {
const y = top + (1 - tick) * innerHeight
return (
<g key={tick}>
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" />
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888">
{formatValue(maxValue * tick)}
</text>
</g>
)
})}
{xTicks.map((tick) => {
const x = left + tick * innerWidth
const ts = minTs + tick * span
return (
<g key={tick}>
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" />
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888">
{formatTime(ts)}
</text>
</g>
)
})}
{unitLabel && (
<text x={left - 45} y={top + 10} fontSize="10" fill="#888">
{unitLabel}
</text>
)}
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" />
<polygon points={area} fill="url(#resource-chart-fill)" />
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function chartBorderClass(index: number) {
const right = index % 2 === 0 ? 'xl:border-r' : ''
const top = index > 1 ? 'border-t' : ''
return `${right} ${top} border-gray-200`
}
function formatTime(ts: number) {
return new Date(ts).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
+132
View File
@@ -0,0 +1,132 @@
import type { ReactNode } from 'react'
interface RingStatProps {
value: number
max?: number
label: string
subLabel?: ReactNode
size?: number
strokeWidth?: number
}
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background ring */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#f3f4f6"
strokeWidth={strokeWidth}
/>
{/* Progress ring */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#000000"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
</svg>
{/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
</div>
</div>
<div className="mt-2 text-center">
<div className="text-sm font-medium text-gray-800">{label}</div>
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>}
</div>
</div>
)
}
interface RingStatsProps {
cpuPercent: number
cpuCores: number
cpuUsed: number
ramPercent: number
ramUsed: number
ramTotal: number
swapPercent?: number
swapUsed?: number
swapTotal?: number
loadPercent: number
loadStatus: string
diskPercent: number
diskUsed: number
diskTotal: number
}
export default function RingStats({
cpuPercent,
cpuCores,
cpuUsed,
ramPercent,
ramUsed,
ramTotal,
swapPercent = 0,
swapUsed = 0,
swapTotal = 0,
loadPercent,
loadStatus,
diskPercent,
diskUsed,
diskTotal,
}: RingStatsProps) {
const formatGB = (mb: number) => {
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
return `${mb} MB`
}
const hasSwap = swapTotal > 0
return (
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
<RingStat
value={cpuPercent}
label="CPU"
subLabel={`(${cpuUsed.toFixed(1)} / ${cpuCores} 核)`}
/>
<RingStat
value={ramPercent}
label="内存"
subLabel={`${formatGB(ramUsed)} / ${formatGB(ramTotal)}`}
/>
{hasSwap && (
<RingStat
value={swapPercent}
label="SWAP"
subLabel={`${formatGB(swapUsed)} / ${formatGB(swapTotal)}`}
/>
)}
<RingStat
value={loadPercent}
label="负载"
subLabel={loadStatus}
/>
<RingStat
value={diskPercent}
label="/"
subLabel={`${formatGB(diskUsed)} / ${formatGB(diskTotal)}`}
/>
</div>
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useLocation, useNavigate } from 'react-router-dom'
import {
ChevronLeft,
ChevronRight,
Code2,
LayoutDashboard,
LogOut,
Package,
ScrollText,
Server,
Settings2,
ShieldAlert,
UserCog,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import AppIcon from './AppIcon'
interface SidebarProps {
collapsed: boolean
onToggle: () => void
}
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const { logout, isSubUser } = useAuth()
const isContainerPage =
location.pathname.startsWith('/containers') ||
location.pathname.startsWith('/container')
const isImagesPage = location.pathname.startsWith('/images')
const isOversellPage = location.pathname.startsWith('/oversell')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isSecurityPage = location.pathname.startsWith('/security')
const isSettingsPage = location.pathname.startsWith('/settings')
return (
<aside
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${
collapsed ? 'w-16' : 'w-60'
}`}
>
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center">
<AppIcon className="w-5 h-5" />
</div>
<span className="font-bold text-black text-sm">CLICD</span>
</div>
)}
{collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto">
<AppIcon className="w-5 h-5" />
</div>
)}
<button
onClick={onToggle}
className="p-1 rounded hover:bg-gray-100 text-gray-500"
title="切换侧边栏"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button>
</div>
<nav className="flex-1 py-4 px-2 space-y-1">
{!isSubUser && (
<button
onClick={() => navigate('/')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname === '/'
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<LayoutDashboard className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
)}
<button
onClick={() => navigate('/containers')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isContainerPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Server className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
{!isSubUser && (
<button
onClick={() => navigate('/images')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isImagesPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Package className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
)}
{!isSubUser && (
<>
<button
onClick={() => navigate('/oversell')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isOversellPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Settings2 className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button
onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSecurityPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<ShieldAlert className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isAuditLogsPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<ScrollText className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isApiIntegrationPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Code2 className="w-4 h-4" />
{!collapsed && <span>API </span>}
</button>
<button
onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSettingsPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<UserCog className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
</>
)}
</nav>
<div className="border-t border-gray-200 p-2">
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
>
<LogOut className="w-4 h-4" />
{!collapsed && <span>退</span>}
</button>
</div>
</aside>
)
}
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { RefreshCw, TerminalSquare, X } from 'lucide-react'
import { createWebSSHTicket } from '../services/api'
interface WebSSHViewerProps {
containerName: string
onClose: () => void
}
export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerProps) {
const terminalRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const termRef = useRef<Terminal | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
const buildWebSSHUrl = (ticket: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({
container: containerName,
ticket,
})
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
}
const sendResize = () => {
const ws = wsRef.current
const term = termRef.current
if (!ws || !term || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
const cleanup = () => {
resizeObserverRef.current?.disconnect()
resizeObserverRef.current = null
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (termRef.current) {
termRef.current.dispose()
termRef.current = null
fitRef.current = null
}
}
const connect = async () => {
if (!terminalRef.current) return
cleanup()
setStatus('connecting')
setErrorMsg('')
const term = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily: 'Consolas, Menlo, Monaco, monospace',
fontSize: 13,
theme: {
background: '#050505',
foreground: '#f3f4f6',
cursor: '#ffffff',
selectionBackground: '#374151',
},
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(terminalRef.current)
termRef.current = term
fitRef.current = fitAddon
const fitTerminal = () => {
try {
fitAddon.fit()
sendResize()
} catch {
// The modal may report zero size during the first paint. Retry below.
}
}
requestAnimationFrame(() => {
fitTerminal()
window.setTimeout(fitTerminal, 80)
window.setTimeout(fitTerminal, 250)
})
let ticket = ''
try {
const response = await createWebSSHTicket(containerName)
ticket = response.data.data?.ticket || ''
} catch {
setStatus('error')
setErrorMsg('WebSSH ticket 创建失败,请重新登录后再试')
return
}
if (!ticket) {
setStatus('error')
setErrorMsg('WebSSH ticket 为空,请重新登录后再试')
return
}
const ws = new WebSocket(buildWebSSHUrl(ticket))
ws.binaryType = 'arraybuffer'
wsRef.current = ws
term.writeln(`Connecting to ${containerName} as root...`)
ws.onopen = () => {
setStatus('preparing')
term.writeln('\r\nWebSocket connected. Preparing SSH shell...')
sendResize()
term.focus()
}
ws.onmessage = async (event) => {
setStatus('connected')
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data))
return
}
if (event.data instanceof Blob) {
const buffer = await event.data.arrayBuffer()
term.write(new Uint8Array(buffer))
return
}
term.write(String(event.data))
}
ws.onerror = () => {
setStatus('error')
setErrorMsg('WebSSH 连接失败,请确认容器已运行且 SSH 服务可用')
}
ws.onclose = () => {
if (status !== 'error') {
setStatus((current) => current === 'connected' ? 'disconnected' : current)
}
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data))
}
})
const observer = new ResizeObserver(() => {
fitTerminal()
})
observer.observe(terminalRef.current)
resizeObserverRef.current = observer
}
useEffect(() => {
const timer = window.setTimeout(connect, 100)
return () => {
window.clearTimeout(timer)
cleanup()
}
}, [containerName])
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
<div className="flex items-center gap-2">
<TerminalSquare className="w-4 h-4 text-gray-600" />
<span className="text-sm font-medium text-black">WebSSH - {containerName}</span>
{status === 'connected' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700"></span>
)}
{status === 'connecting' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">...</span>
)}
{status === 'preparing' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">SSH preparing...</span>
)}
{status === 'disconnected' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"></span>
)}
{status === 'error' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700"></span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={connect}
className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
title="重新连接"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-200 rounded text-gray-500"
title="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="relative flex-1 bg-black min-h-[500px]">
<div ref={terminalRef} className="absolute inset-0 p-2" />
{status === 'error' && (
<div className="absolute inset-x-0 bottom-0 border-t border-red-900 bg-red-950 px-4 py-2 text-sm text-red-100">
{errorMsg}
</div>
)}
</div>
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import api, { login as apiLogin, checkAuth, LoginResponse } from '../services/api'
interface AuthContextType {
isAuthenticated: boolean
isLoading: boolean
username: string | null
isSubUser: boolean
containerIdentifiers: string[]
login: (username: string, password: string) => Promise<void>
accessCodeLogin: (code: string, password: string) => Promise<void>
logout: () => void
token: string | null
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [username, setUsername] = useState<string | null>(null)
const [isSubUser, setIsSubUser] = useState(false)
const [containerIdentifiers, setContainerIdentifiers] = useState<string[]>([])
const [token, setToken] = useState<string | null>(null)
const navigate = useNavigate()
const saveAuth = (t: string, u: string, sub: boolean, ids: string[]) => {
localStorage.setItem('clicd_token', t)
localStorage.setItem('clicd_username', u)
setToken(t)
setUsername(u)
setIsSubUser(sub)
setContainerIdentifiers(ids)
setIsAuthenticated(true)
}
useEffect(() => {
const savedToken = localStorage.getItem('clicd_token')
const savedUsername = localStorage.getItem('clicd_username')
if (savedToken) {
const payload = decodeTokenPayload(savedToken)
const nextUsername = payload?.username || payload?.sub_user || savedUsername || null
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0
? payload.container_uuids
: Array.isArray(payload?.container_names) ? payload.container_names : []
setToken(savedToken)
setUsername(nextUsername)
setIsSubUser(!!payload?.sub_user)
setContainerIdentifiers(nextContainerIdentifiers)
checkAuth()
.then(() => {
setIsAuthenticated(true)
})
.catch(() => {
localStorage.removeItem('clicd_token')
localStorage.removeItem('clicd_username')
setToken(null)
setUsername(null)
setIsSubUser(false)
setContainerIdentifiers([])
})
.finally(() => setIsLoading(false))
} else {
setIsLoading(false)
}
}, [navigate])
const login = async (user: string, password: string) => {
try {
const response = await apiLogin(user, password)
const data = response.data.data as LoginResponse
saveAuth(data.token, data.username, false, [])
navigate('/')
} catch (adminError) {
try {
const res = await api.post('/sub-user/login', { username: user, password })
const data = res.data.data as { token: string; username: string; container_uuids: string[] }
saveAuth(data.token, data.username, true, data.container_uuids || [])
const first = data.container_uuids?.[0]
navigate(first ? `/container/${encodeURIComponent(first)}` : '/containers')
} catch {
throw adminError
}
}
}
const accessCodeLogin = async (code: string, password: string) => {
const res = await api.post('/sub-user/access', { code, password })
const data = res.data.data as { token: string; username: string; container_uuids: string[] }
saveAuth(data.token, data.username, true, data.container_uuids || [])
const first = data.container_uuids?.[0]
navigate(first ? `/container/${encodeURIComponent(first)}` : '/containers')
}
const logout = () => {
localStorage.removeItem('clicd_token')
localStorage.removeItem('clicd_username')
setToken(null)
setUsername(null)
setIsSubUser(false)
setContainerIdentifiers([])
setIsAuthenticated(false)
navigate('/login')
}
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, username, isSubUser, containerIdentifiers, login, accessCodeLogin, logout, token }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
type TokenPayload = {
username?: string
sub_user?: string
container_names?: string[]
container_uuids?: string[]
}
function decodeTokenPayload(token: string): TokenPayload | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
const json = decodeURIComponent(
atob(padded)
.split('')
.map((char) => `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`)
.join('')
)
return JSON.parse(json) as TokenPayload
} catch {
return null
}
}
function subUserTargetPath(containerIdentifiers: string[]) {
const firstContainer = containerIdentifiers[0]
return firstContainer ? `/container/${encodeURIComponent(firstContainer)}` : '/containers'
}
+32
View File
@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #ffffff;
color: #000000;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { DialogProvider } from './components/Dialog'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+306
View File
@@ -0,0 +1,306 @@
import { useState, useEffect, useCallback } from 'react'
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
import api, { APIResponse } from '../services/api'
interface ApiKeyItem {
id: string
name: string
key?: string
prefix: string
ip_whitelist: string
created_at: string
last_used: string
}
const BASE_URL = window.location.origin
export default function ApiIntegration() {
const [keys, setKeys] = useState<ApiKeyItem[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [newIPs, setNewIPs] = useState('')
const [creating, setCreating] = useState(false)
const [newKey, setNewKey] = useState('')
const [showDocs, setShowDocs] = useState(true)
const [copiedKey, setCopiedKey] = useState(false)
const fetchKeys = useCallback(async () => {
try {
const res = await api.get<APIResponse<ApiKeyItem[]>>('/api-keys')
setKeys(res.data.data || [])
} catch { /* ignore */ }
finally { setLoading(false) }
}, [])
useEffect(() => { fetchKeys() }, [fetchKeys])
const createKey = async () => {
if (!newName.trim()) return
setCreating(true)
try {
const res = await api.post<APIResponse<ApiKeyItem>>('/api-keys', {
name: newName.trim(),
ip_whitelist: newIPs.trim(),
})
if (res.data.data?.key) {
setNewKey(res.data.data.key)
setKeys(prev => [res.data.data!, ...prev])
}
setNewName('')
setNewIPs('')
setShowCreate(false)
} catch { /* ignore */ }
finally { setCreating(false) }
}
const deleteKey = async (id: string) => {
if (!window.confirm('确定要删除此 API Key 吗?')) return
try {
await api.delete(`/api-keys/${id}`)
setKeys(prev => prev.filter(k => k.id !== id))
} catch { /* ignore */ }
}
const copyKey = () => {
try {
navigator.clipboard.writeText(newKey)
} catch {
const ta = document.createElement('textarea')
ta.value = newKey
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black">API </h1>
<p className="text-sm text-gray-500 mt-1"> API Key </p>
</div>
{/* API Keys */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
<Key className="w-4 h-4" />API Keys
</h2>
<div className="flex items-center gap-2">
<button onClick={fetchKeys} className="p-1.5 text-gray-400 hover:text-black rounded" title="刷新"><RefreshCw className="w-3.5 h-3.5" /></button>
<button onClick={() => setShowCreate(true)} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
<Plus className="w-3.5 h-3.5" /> Key
</button>
</div>
</div>
{newKey && (
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-amber-800"> API Key </span>
<button onClick={() => setNewKey('')} className="text-amber-600 hover:text-amber-800 text-xs"></button>
</div>
<p className="text-xs text-amber-700 mb-2"> Key </p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-white border border-amber-300 rounded text-xs font-mono text-gray-800 break-all">{newKey}</code>
<button onClick={copyKey} className="px-3 py-2 bg-amber-600 text-white rounded-md text-xs hover:bg-amber-700 whitespace-nowrap">
{copiedKey ? '已复制' : '复制'}
</button>
</div>
</div>
)}
{loading ? (
<div className="py-8 text-center text-sm text-gray-400">...</div>
) : keys.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-400"> API Key"创建 Key"</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Key </th>
<th className="px-3 py-2">IP </th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">使</th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{keys.map(k => (
<tr key={k.id} className="hover:bg-gray-50">
<td className="px-3 py-2.5 font-medium text-gray-800">{k.name}</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{k.prefix}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.ip_whitelist || '不限制'}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.created_at}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.last_used || '未使用'}</td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => deleteKey(k.id)} className="p-1 text-gray-400 hover:text-red-600 rounded" title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Create Key Modal */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowCreate(false)} />
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-black"> API Key</h3>
<button onClick={() => setShowCreate(false)} className="p-1 text-gray-400 hover:text-black rounded"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="例如:自动化脚本、CI/CD"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
onKeyDown={e => e.key === 'Enter' && createKey()}
autoFocus
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">IP </label>
<textarea
value={newIPs}
onChange={e => setNewIPs(e.target.value)}
placeholder={`1.2.3.4\n10.0.0.0/24`}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono resize-none"
/>
<p className="text-[10px] text-gray-400 mt-1"> IP CIDR IP</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"></button>
<button onClick={createKey} disabled={creating || !newName.trim()} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
{creating ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
</div>
)}
{/* API Documentation */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
<Code className="w-4 h-4" />API
</h2>
<button onClick={() => setShowDocs(!showDocs)} className="text-xs text-gray-500 hover:text-black">
{showDocs ? '收起' : '展开'}
</button>
</div>
{showDocs && (
<div className="space-y-6 text-sm">
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<p className="text-gray-600 mb-3"> API 使 <strong>POST</strong> API Key</p>
<div className="bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-xs space-y-2">
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"X-API-Key: clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
<div className="text-gray-500"># Bearer </div>
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"Authorization: Bearer clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
</div>
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/containers/list" desc="获取容器列表" />
<Endpoint method="POST" path="/api/containers/detail" desc="获取容器详情" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/create" desc="创建容器" body={`{\n "name": "my-container",\n "template_id": "ubuntu-noble",\n "vcpu": 2,\n "ram_mb": 1024,\n "disk_gb": 20,\n "network_bw_mbps": 100,\n "monthly_traffic_gb": 1000,\n "io_speed_mbps": 500\n}`} />
<Endpoint method="POST" path="/api/containers/start" desc="启动容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/stop" desc="停止容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/restart" desc="重启容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/delete" desc="删除容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/reinstall" desc="重装系统" body='{"id": 1, "template_id": "debian-bookworm"}' />
<Endpoint method="POST" path="/api/containers/usage" desc="获取资源用量" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic" desc="获取流量统计" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic-reset" desc="重置流量" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic-limit" desc="修改流量限制" body='{"id": 1, "traffic_mode": "total", "monthly_traffic_gb": 1000}' />
<Endpoint method="POST" path="/api/containers/resource-limit" desc="修改资源限制" body='{"id": 1, "vcpu": 2, "ram_mb": 2048, "io_speed_mbps": 500, "network_bw_mbps": 100}' />
<Endpoint method="POST" path="/api/containers/expiry" desc="修改到期时间" body='{"id": 1, "expires_at": "2026-12-31 23:59:59"}' />
<Endpoint method="POST" path="/api/containers/reset-password" desc="重置 SSH 密码" body='{"id": 1}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/containers/port-mappings/add" desc="添加映射" body='{"id": 1, "container_port": 8080, "host_port": 8080, "protocol": "tcp", "description": "Web"}' />
<Endpoint method="POST" path="/api/containers/port-mappings/update" desc="更新映射" body='{"id": 1, "index": 0, "container_port": 8080, "host_port": 9090, "protocol": "tcp", "description": "API"}' />
<Endpoint method="POST" path="/api/containers/port-mappings/delete" desc="删除映射" body='{"id": 1, "index": 0}' />
<Endpoint method="POST" path="/api/containers/random-port" desc="获取随机空闲端口" body='{"id": 1}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/dashboard" desc="容器统计概览" />
<Endpoint method="POST" path="/api/host-info" desc="宿主机资源信息" />
<Endpoint method="POST" path="/api/templates" desc="可用系统模板列表" />
<Endpoint method="POST" path="/api/tasks" desc="任务队列" />
<Endpoint method="POST" path="/api/tasks/delete" desc="删除任务" body='{"id": "task-1"}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/oversell" desc="获取/更新超售配置" body='{"cpu_overcommit": 4, "ram_overcommit": 2, "disk_overcommit": 1, "ksm_enabled": true, "swappiness": 10}' />
<Endpoint method="POST" path="/api/oversell/reclaim" desc="触发一次内存回收" />
<Endpoint method="POST" path="/api/oversell/status" desc="超售状态" />
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/sub-user/create" desc="创建管理链接" body='{"container_name": "my-container"}' />
<Endpoint method="POST" path="/api/audit-logs" desc="操作日志" />
<Endpoint method="POST" path="/api/login-logs" desc="登录日志" />
<Endpoint method="POST" path="/api/security/alerts" desc="安全告警" />
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 font-mono text-xs text-gray-700">
{`{
"success": true,
"message": "操作成功",
"data": { ... }
}`}
</div>
</section>
</div>
)}
</div>
</div>
)
}
function Endpoint({ method, path, desc, body }: { method: string; path: string; desc: string; body?: string }) {
return (
<div className="flex items-start gap-3 py-2 border-b border-gray-50">
<span className="shrink-0 px-1.5 py-0.5 rounded border text-[10px] font-mono font-bold bg-blue-50 text-blue-700 border-blue-200">{method}</span>
<code className="shrink-0 text-xs text-gray-800 font-mono">{path}</code>
<span className="text-xs text-gray-500 min-w-0">{desc}</span>
{body && (
<details className="text-xs">
<summary className="text-gray-400 cursor-pointer hover:text-gray-600">Body</summary>
<pre className="mt-1 p-2 bg-gray-50 rounded text-xs text-gray-600 overflow-x-auto">{body}</pre>
</details>
)}
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, RefreshCw } from 'lucide-react'
import { AuditLog, getAuditLogs } from '../services/api'
import { actionLabel } from '../utils/labels'
const PAGE_SIZE = 10
export default function AuditLogs() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const fetchData = useCallback(async () => {
try {
const res = await getAuditLogs()
setLogs(res.data.data || [])
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const timer = window.setInterval(fetchData, 10000)
return () => window.clearInterval(timer)
}, [fetchData])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
const totalPages = Math.max(1, Math.ceil(logs.length / PAGE_SIZE))
const pageLogs = logs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"> {logs.length} </p>
</div>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
{logs.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{pageLogs.map((log, index) => (
<tr key={`${log.time}-${index}`} className="hover:bg-gray-50">
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2.5 whitespace-nowrap">
{log.user === 'admin' ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-black text-white"></span>
) : log.user?.startsWith('user:') ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-gray-100 text-gray-700"></span>
) : (
<span className="text-xs text-gray-500">{log.user || '-'}</span>
)}
</td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{actionLabel(log.action)}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{log.target || '-'}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[280px]">{log.detail || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 bg-gray-50">
<span className="text-xs text-gray-400"> {page}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setPage(1)} disabled={page === 1} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="首页"><ChevronsLeft className="w-4 h-4" /></button>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="上一页"><ChevronLeft className="w-4 h-4" /></button>
{getPageNumbers(page, totalPages).map(n => (
<button key={n} onClick={() => setPage(n)} className={`w-7 h-7 text-xs rounded ${n === page ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-100'}`}>{n}</button>
))}
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="下一页"><ChevronRight className="w-4 h-4" /></button>
<button onClick={() => setPage(totalPages)} disabled={page >= totalPages} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="末页"><ChevronsRight className="w-4 h-4" /></button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
function getPageNumbers(current: number, total: number): number[] {
if (total <= 5) return Array.from({ length: total }, (_, i) => i + 1)
let start = Math.max(1, current - 2)
if (start + 4 > total) start = total - 4
return Array.from({ length: 5 }, (_, i) => start + i)
}
File diff suppressed because it is too large Load Diff
+736
View File
@@ -0,0 +1,736 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowDown,
ArrowUp,
Cpu,
Eye,
HardDrive,
MemoryStick,
Network,
Play,
Plus,
RefreshCw,
RotateCcw,
Server,
Square,
Trash2,
ListTodo,
X,
} from 'lucide-react'
import CreateContainerModal from '../components/CreateContainerModal'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
CreateContainerRequest,
ContainerUsage,
getContainerUsage,
getContainers,
batchAction,
Task,
getTasks,
deleteTask,
} from '../services/api'
import { actionLabel, taskStatusClass, taskStatusLabel } from '../utils/labels'
export default function Containers() {
const navigate = useNavigate()
const { isSubUser } = useAuth()
const [containers, setContainers] = useState<Container[]>([])
const [usageByName, setUsageByName] = useState<Record<string, ContainerUsage>>({})
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [batchLoading, setBatchLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [showTasks, setShowTasks] = useState(false)
const [tasks, setTasks] = useState<Task[]>([])
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
const refreshUsage = useCallback(async (items: Container[]) => {
const targets = items.filter((container) => container.status === 'running')
if (targets.length === 0) {
setUsageByName({})
return
}
const results = await Promise.allSettled(
targets.map(async (container) => {
const res = await getContainerUsage(container.uuid || container.id)
return [container.name, res.data.data] as const
})
)
setUsageByName((current) => {
const next: Record<string, ContainerUsage> = {}
const activeNames = new Set(items.map((container) => container.name))
for (const [name, usage] of Object.entries(current)) {
if (activeNames.has(name)) next[name] = usage
}
for (const result of results) {
if (result.status === 'fulfilled' && result.value[1]) {
next[result.value[0]] = result.value[1]
}
}
return next
})
}, [])
const fetchData = useCallback(async () => {
try {
const res = await getContainers()
const nextContainers = res.data.data || []
setContainers(nextContainers)
await refreshUsage(nextContainers)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [refreshUsage])
useEffect(() => {
fetchData()
const interval = window.setInterval(fetchData, 5000)
return () => window.clearInterval(interval)
}, [fetchData])
const toggleSelect = (id: number) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleAll = () => {
const selectableIDs = displayContainers
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
.map((container) => container.id)
if (selected.size === selectableIDs.length) {
setSelected(new Set())
} else {
setSelected(new Set(selectableIDs))
}
}
// Map of container_id -> current task status.
// For create tasks, container_id may be 0 initially but gets set after creation,
// so we also index by container_name as fallback for placeholder items.
const taskStatusMap: Record<number, Task> = {}
const taskNameMap: Record<string, Task> = {}
for (const t of tasks) {
if (t.status === 'pending' || t.status === 'running') {
if (t.container_id != null && t.container_id > 0) {
taskStatusMap[t.container_id] = t
}
if (t.container_name) {
taskNameMap[t.container_name] = t
}
}
}
const handleBatchAction = async (action: string) => {
if (selected.size === 0) return
setBatchLoading(true)
try {
await batchAction(action, [...selected])
setSelected(new Set())
await fetchTasks()
} catch (err) {
console.error(err)
} finally {
setBatchLoading(false)
}
}
const fetchTasks = useCallback(async () => {
try {
const res = await getTasks()
const nextTasks = res.data.data || []
setTasks(nextTasks)
setQueuedCreates((current) => syncQueuedCreates(current, nextTasks, containers))
} catch { /* ignore */ }
}, [containers])
useEffect(() => { fetchTasks(); const t = setInterval(fetchTasks, 2000); return () => clearInterval(t) }, [fetchTasks])
const handleRefreshList = useCallback(async () => {
setRefreshing(true)
try {
await Promise.all([fetchData(), fetchTasks()])
} finally {
setRefreshing(false)
}
}, [fetchData, fetchTasks])
const actionLabels: Record<string, string> = {
create: '正在初始化', start: '开机中', stop: '关机中', restart: '重启中', delete: '删除中', reinstall: '重装中',
}
const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
const handleCreateQueued = async (items: CreateContainerRequest[]) => {
setQueuedCreates((current) => {
const next = { ...current }
for (const item of items) {
next[item.name] = item
}
return next
})
fetchTasks()
fetchData()
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"> {displayContainers.length} {selected.size > 0 && `,已选 ${selected.size}`}</p>
</div>
<div className="flex items-center gap-2">
{selected.size > 0 && (
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
<span className="text-xs text-gray-500 mr-1">{selected.size} </span>
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
</button>
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
</button>
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
</button>
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
</button>
</div>
)}
<button
onClick={handleRefreshList}
disabled={refreshing}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
title="刷新列表"
>
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setShowTasks(true)}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium whitespace-nowrap"
>
<ListTodo className="w-3.5 h-3.5" />
{activeTaskCount > 0 && (
<span className="ml-0.5 rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-700">
{activeTaskCount}
</span>
)}
</button>
{!isSubUser && (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{displayContainers.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<Server className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-700 mb-2"></h3>
<p className="text-sm text-gray-500 mb-4">"创建容器"</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px]">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-10 px-3 py-3">
{!isSubUser && (
<input
type="checkbox"
checked={displayContainers.length > 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length}
onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
/>
)}
</th>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead>
<TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
<TableHead icon><Network className="w-3.5 h-3.5" />NET</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead right></TableHead>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displayContainers.map((container) => {
const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder
const usage = usageByName[container.name]
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0
const ramPct = isRunning && container.ram_mb > 0
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100)
: 0
const diskPct = container.disk_gb > 0
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
: 0
const rx = isRunning ? usage?.network_rx_bps || 0 : 0
const tx = isRunning ? usage?.network_tx_bps || 0 : 0
return (
<tr key={container.isPlaceholder ? `placeholder-${container.name}` : container.id} className="hover:bg-gray-50 transition-colors">
<td className="px-2 py-2 align-top">
{!isSubUser && (
<input
type="checkbox"
checked={selected.has(container.id)}
onChange={() => toggleSelect(container.id)}
disabled={isPlaceholder || !!taskStatusMap[container.id] || !!taskNameMap[container.name]}
className="w-3.5 h-3.5 rounded border-gray-300 text-black focus:ring-black accent-black disabled:opacity-30"
/>
)}
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-400 font-mono whitespace-nowrap">
#{container.id}
</td>
<td className="px-2.5 py-2 align-top">
<button
onClick={() => navigate(`/container/${encodeURIComponent(container.uuid || String(container.id))}`)}
disabled={isPlaceholder}
className="font-medium text-black hover:underline text-xs disabled:no-underline disabled:text-gray-500 disabled:cursor-not-allowed whitespace-nowrap"
>
{container.name}
</button>
</td>
<td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} />
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
{getTemplateIcon(container.template)}
{getTemplateName(container.template)}
</span>
</td>
<td className="px-2.5 py-2 align-top">
<ProgressCell pct={cpuPct} />
</td>
<td className="px-2.5 py-2 align-top">
<ProgressCell pct={ramPct} />
</td>
<td className="px-2.5 py-2 align-top">
<ProgressCell pct={diskPct} />
</td>
<td className="px-2.5 py-2 align-top">
<div className="space-y-0.5 text-[11px] font-medium tabular-nums min-w-[70px] whitespace-nowrap">
<div className="flex items-center gap-0.5">
<ArrowUp className="w-3 h-3 text-gray-400" />
<span className="text-gray-700">{formatRate(tx)}</span>
</div>
<div className="flex items-center gap-0.5">
<ArrowDown className="w-3 h-3 text-gray-400" />
<span className="text-gray-700">{formatRate(rx)}</span>
</div>
</div>
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
{container.vcpu}/{formatRAM(container.ram_mb)}/{container.disk_gb}GB
</td>
<td className="px-2.5 py-2 align-top text-xs whitespace-nowrap">
{container.expires_at ? getRemaining(container.expires_at) : <span className="text-gray-400"></span>}
</td>
<td className="px-2.5 py-2 align-top">
<div className="flex justify-end">
{task?.status === 'failed' ? (
<button
onClick={async () => {
try {
const { default: api } = await import('../services/api')
await api.delete(`/tasks/${task.id}`)
fetchData()
} catch { /* ignore */ }
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-red-200 text-[11px] text-red-600 hover:bg-red-50 transition-colors whitespace-nowrap"
>
<Trash2 className="w-3 h-3" />
</button>
) : (
<button
onClick={() => navigate(`/container/${encodeURIComponent(container.uuid || String(container.id))}`)}
disabled={isPlaceholder}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-gray-300 text-[11px] text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
<Eye className="w-3 h-3" />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} />
{showTasks && (
<TaskQueueModal
tasks={tasks}
onRefresh={fetchTasks}
onClose={() => setShowTasks(false)}
/>
)}
</div>
)
}
function TableHead({ children, right, icon }: { children: ReactNode; right?: boolean; icon?: boolean }) {
return (
<th className={`${right ? 'text-right' : 'text-left'} px-2.5 py-2 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap`}>
<span className={icon ? 'inline-flex items-center gap-1' : ''}>{children}</span>
</th>
)
}
type DisplayContainer = Container & {
isPlaceholder?: boolean
createTask?: Task
}
function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) {
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (task?.status === 'failed') {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
)
}
if (task?.type === 'create' && task.status === 'done') {
return (
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
</span>
)
}
if (task?.type === 'create' && task.status === 'running') {
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
</span>
)
}
if (placeholder || task?.type === 'create') {
return (
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
</span>
)
}
if (task && task.status !== 'done' && task.status !== 'failed') {
const taskLabels: Record<string, string> = {
start: '开机中', stop: '关机中', restart: '重启中', delete: '删除中', reinstall: '重装中',
}
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{taskLabels[task.type] || '处理中'}
</span>
)
}
return (
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
{running ? '在线' : '离线'}
</span>
)
}
function buildDisplayContainers(
containers: Container[],
queuedCreates: Record<string, CreateContainerRequest>,
tasks: Task[]
): DisplayContainer[] {
const realNames = new Set(containers.map((container) => container.name))
const placeholders = new Map<string, DisplayContainer>()
for (const [name, cfg] of Object.entries(queuedCreates)) {
if (!realNames.has(name)) {
placeholders.set(name, toPlaceholder(cfg))
}
}
for (const task of tasks) {
if (task.type !== 'create' || !task.config?.name || realNames.has(task.config.name)) {
continue
}
if (task.status === 'pending' || task.status === 'running' || task.status === 'failed' || placeholders.has(task.config.name)) {
placeholders.set(task.config.name, { ...toPlaceholder(task.config), createTask: task })
}
}
return [...containers, ...placeholders.values()]
}
function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
return {
id: 0,
uuid: '',
name: cfg.name,
template: cfg.template_id,
vcpu: cfg.vcpu,
ram_mb: cfg.ram_mb,
disk_gb: cfg.disk_gb,
network_bw_mbps: cfg.network_bw_mbps,
monthly_traffic_gb: cfg.monthly_traffic_gb,
traffic_mode: cfg.traffic_mode || 'total',
traffic_in_gb: cfg.traffic_in_gb || 0,
traffic_out_gb: cfg.traffic_out_gb || 0,
traffic_used_rx: 0,
traffic_used_tx: 0,
traffic_reset_date: '',
io_speed_mbps: cfg.io_speed_mbps,
status: 'creating',
ip: '',
ipv6: '',
ipv6_prefix_len: 0,
ipv6_interface: '',
vnc_port: 0,
ssh_port: 0,
ssh_password: '',
port_mappings: [],
port_mapping_limit: 2,
created_at: '',
expires_at: cfg.expires_at,
isPlaceholder: true,
}
}
function syncQueuedCreates(
current: Record<string, CreateContainerRequest>,
tasks: Task[],
containers: Container[]
): Record<string, CreateContainerRequest> {
const realNames = new Set(containers.map((container) => container.name))
const activeOrFailedCreateNames = new Set(
tasks
.filter((task) => task.type === 'create' && (task.status === 'pending' || task.status === 'running' || task.status === 'failed' || task.status === 'done'))
.map((task) => task.config?.name || task.container_name)
)
const next: Record<string, CreateContainerRequest> = {}
for (const [name, cfg] of Object.entries(current)) {
if (!realNames.has(name)) {
next[name] = cfg
}
}
for (const task of tasks) {
if (task.type === 'create' && task.config?.name && !realNames.has(task.config.name)) {
if (task.status === 'pending' || task.status === 'running' || task.status === 'failed') {
next[task.config.name] = task.config
}
}
}
return next
}
function hasActiveTasks(tasks: Task[]) {
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
}
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成'
return actionLabels[task.type] || '处理中...'
}
function TaskQueueModal({ tasks, onRefresh, onClose }: {
tasks: Task[]
onRefresh: () => void | Promise<void>
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="text-base font-semibold text-black"></h2>
<p className="mt-0.5 text-xs text-gray-500"> {tasks.length} </p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<RefreshCw className="h-4 w-4" />
</button>
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
{tasks.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tasks.map((task) => (
<tr key={task.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-2.5">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
{taskStatusLabel(task.status)}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
<td className="whitespace-nowrap px-2 py-2.5">
{task.status === 'pending' && (
<button
onClick={async () => {
try {
await deleteTask(task.id)
onRefresh()
} catch { /* ignore */ }
}}
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
title="取消任务"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
function ProgressCell({ pct }: { pct: number }) {
return (
<div className="flex items-center gap-2 min-w-[100px]">
<span className="w-10 text-xs font-medium tabular-nums text-gray-700">{pct.toFixed(1)}%</span>
<div className="h-1.5 flex-1 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full bg-gray-500 transition-all duration-500"
style={{ width: `${Math.max(pct, pct > 0 ? 2 : 0)}%` }}
/>
</div>
</div>
)
}
function getTemplateName(id: string) {
const map: Record<string, string> = {
'ubuntu-noble': 'Ubuntu 24.04',
'ubuntu-jammy': 'Ubuntu 22.04',
'debian-bookworm': 'Debian 12',
'debian-bullseye': 'Debian 11',
'alpine-3.21': 'Alpine 3.21',
'centos-9-stream': 'CentOS 9',
'archlinux-current': 'Arch Linux',
'fedora-44': 'Fedora 44',
'rockylinux-10': 'Rocky 10',
}
return map[id] || id
}
function getTemplateIcon(id: string): ReactNode {
const size = 'w-4 h-4'
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
if (id.startsWith('centos')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M153.650377 358.349623v112.005247h-3.694326v-108.310921l3.694326-3.694326z" fill="#932279"/><path d="M453.058708 512l-29.554608 29.554608H137.86553v108.310922L0 512l137.86553-137.86553v108.310922h285.63857l29.554608 29.554608zM738.529354 226.529354L553.64513 411.413578V149.956051h108.310921l3.694326 3.694326 72.878977 72.878977z" fill="#932279"/><path d="M649.86553 137.86553h-108.310922v285.63857l-29.554608 29.554608-29.554608-29.554608V137.86553h-108.310922L512 0l137.86553 137.86553zM874.043949 553.64513v108.310921l-3.694326 3.694326-72.878977 72.878977-184.884224-184.884224h261.457527z" fill="#EFA724"/><path d="M886.13447 361.036405v13.098065l-6.04526-6.04526-6.045261-6.045261v108.310921h-3.694326v-125.103312l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526z" fill="#262577"/><path d="M886.13447 649.86553v-108.310922H600.4959L570.941292 512l29.554608-29.554608h285.63857v-108.310922l137.86553 137.86553-137.86553 137.86553z" fill="#262577"/><path d="M411.413578 470.35487H149.956051v-108.310921l3.694326-3.694326L226.529354 285.470646 411.413578 470.35487zM470.35487 149.956051V411.413578L285.470646 226.529354l72.878977-72.878977 3.694326-3.694326h108.310921z" fill="#9CCD2A"/><path d="M738.529354 797.470646L553.64513 612.586422v261.457527h108.310921l3.694326-3.694326 72.878977-72.878977z" fill="#EFA724"/><path d="M649.86553 886.13447h-108.310922V600.4959L512 570.941292l-29.554608 29.554608v285.63857h-108.310922l137.86553 137.86553 137.86553-137.86553z" fill="#9CCD2A"/><path d="M470.35487 874.043949V612.586422L285.470646 797.470646l72.878977 72.878977 3.694326 3.694326h108.310921z" fill="#262577"/><path d="M470.35487 428.541817v41.813053h-41.813053L226.529354 268.342407l-76.573303 76.573303V149.956051h194.959659l-76.573303 76.573303 202.012463 202.012463z" fill="#9CCD2A"/><path d="M880.08921 143.91079v224.17842l-6.045261-6.045261v108.310921H612.586422L797.470646 285.470646l72.878977 72.878977v-13.098065l3.694326 3.694326v-4.030174l-76.573303-76.573303-202.012463 202.012463h-41.813053v-41.813053L755.657593 226.529354l-82.618564-82.618564h207.050181z" fill="#932279"/><path d="M666.993768 137.86553l12.090522 12.090521h194.959659v212.087898l6.045261 6.045261 6.04526 6.04526V137.86553z" fill="#FFF"/><path d="M874.043949 679.08429v194.959659H679.08429L755.657593 797.470646 553.64513 595.458183v-41.813053h41.813053L797.470646 755.657593l76.573303-76.573303z" fill="#EFA724"/><path d="M411.413578 553.64513L226.529354 738.529354l-72.878977-72.878977-3.694326-3.694326v-108.310921H411.413578z" fill="#262577"/><path d="M470.35487 595.458183L268.342407 797.470646l76.573303 76.573303H149.956051V679.08429L226.529354 755.657593l202.012463-202.012463h41.813053v41.813053z" fill="#262577"/><path d="M874.043949 344.91571v4.030174l-3.694326-3.694326v13.098065l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526v-16.792391z" fill="#FFF"/></svg>
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
return null
}
function clamp(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(value, 100))
}
function formatRAM(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(0)} GB`
return `${mb} MB`
}
function getRemaining(expires: string): ReactNode {
const end = new Date(expires).getTime()
const now = Date.now()
const diff = end - now
if (diff <= 0) return <span className="text-red-600 font-medium"></span>
const days = Math.floor(diff / 86400000)
if (days > 30) return `${Math.floor(days / 30)}个月`
if (days > 0) return `${days}`
const hours = Math.floor(diff / 3600000)
if (hours > 0) return `${hours}小时`
return `${Math.floor(diff / 60000)}分钟`
}
function formatRate(value: number) {
if (value < 1024) return `${value.toFixed(0)} B/s`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB/s`
return `${(value / 1024 / 1024).toFixed(2)} MB/s`
}
+220
View File
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useState } from 'react'
import { Cpu, HardDrive, MemoryStick, Network, Server } from 'lucide-react'
import RingStats from '../components/RingStats'
import ResourceStatsPanel, {
ChartPoint,
ResourceChartConfig,
StatsRangeKey,
statsRanges,
} from '../components/ResourceStatsPanel'
import { DashboardStats, getDashboard, getHostInfo, HostInfo } from '../services/api'
type HostMetricPoint = {
ts: number
cpu: number
memory: number
network: number
diskIO: number
}
const hostHistoryKey = 'clicd_host_metric_history_v2'
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [host, setHost] = useState<HostInfo | null>(null)
const [history, setHistory] = useState<HostMetricPoint[]>(readHostHistory)
const [range, setRange] = useState<StatsRangeKey>('30m')
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
try {
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
if (dashRes.data.data) setStats(dashRes.data.data)
if (hostRes.data.data) {
const nextHost = hostRes.data.data
setHost(nextHost)
appendHostPoint(nextHost, setHistory)
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = window.setInterval(fetchData, 5000)
return () => window.clearInterval(interval)
}, [fetchData])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
const filtered = filterHistory(history, range)
const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0
const networkBps = (host?.network.rx_bps || 0) + (host?.network.tx_bps || 0)
const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0)
const charts: ResourceChartConfig[] = [
{
title: 'CPU 使用率',
icon: <Cpu className="w-5 h-5" />,
current: host?.cpu.usage_pct || 0,
points: toChartPoints(filtered, 'cpu'),
max: 100,
formatValue: formatPercent,
detail: `${host?.cpu.cores || 0}`,
},
{
title: '内存使用',
icon: <MemoryStick className="w-5 h-5" />,
current: memoryPct,
points: toChartPoints(filtered, 'memory'),
max: 100,
formatValue: formatPercent,
detail: `${formatMB(host?.ram.used_mb || 0)} / ${formatMB(host?.ram.total_mb || 0)}`,
},
{
title: '网络流量',
icon: <Network className="w-5 h-5" />,
current: networkBps,
points: toChartPoints(filtered, 'network'),
formatValue: formatRate,
detail: `${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`,
},
{
title: '磁盘IO',
icon: <HardDrive className="w-5 h-5" />,
current: diskIOBps,
points: toChartPoints(filtered, 'diskIO'),
formatValue: formatRate,
detail: `${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1">宿</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryCard icon={<Server className="w-5 h-5" />} title="容器总数" value={stats?.total_containers || 0} />
<SummaryCard dot="bg-green-500" title="运行中" value={stats?.running || 0} />
<SummaryCard dot="bg-red-500" title="已停止" value={stats?.stopped || 0} muted />
</div>
{host && (
<RingStats
cpuPercent={host.cpu.usage_pct}
cpuCores={host.cpu.cores}
cpuUsed={host.cpu.usage_pct * host.cpu.cores / 100}
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
ramUsed={host.ram.used_mb}
ramTotal={host.ram.total_mb}
loadPercent={Math.min((host.load.load1 / host.cpu.cores) * 100, 100)}
loadStatus={host.load.load1 < host.cpu.cores * 0.7 ? '正常' : host.load.load1 < host.cpu.cores * 1.0 ? '中等' : '高'}
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
diskUsed={host.disk.used_gb * 1024}
diskTotal={host.disk.total_gb * 1024}
/>
)}
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={fetchData} charts={charts} />
</div>
)
}
function SummaryCard({
icon,
dot,
title,
value,
muted = false,
}: {
icon?: JSX.Element
dot?: string
title: string
value: number
muted?: boolean
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
{icon}
{dot && <span className={`w-2 h-2 rounded-full ${dot}`}></span>}
{title}
</div>
<div className={`text-3xl font-bold ${muted ? 'text-gray-600' : 'text-black'}`}>{value}</div>
</div>
)
}
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
const point: HostMetricPoint = {
ts: Date.now(),
cpu: clamp(host.cpu.usage_pct),
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0),
diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0),
}
setHistory((prev) => {
const cutoff = Date.now() - statsRanges['1w']
const next = [...prev.filter((item) => item.ts >= cutoff), point]
localStorage.setItem(hostHistoryKey, JSON.stringify(next))
return next
})
}
function readHostHistory(): HostMetricPoint[] {
try {
const raw = localStorage.getItem(hostHistoryKey)
if (!raw) return []
const parsed = JSON.parse(raw) as HostMetricPoint[]
const cutoff = Date.now() - statsRanges['1w']
return parsed.filter((item) => item.ts >= cutoff)
} catch {
return []
}
}
function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) {
const cutoff = Date.now() - statsRanges[range]
return history.filter((point) => point.ts >= cutoff)
}
function toChartPoints<T extends keyof Omit<HostMetricPoint, 'ts'>>(history: HostMetricPoint[], key: T): ChartPoint[] {
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
}
function clamp(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(value, 100))
}
function formatPercent(value: number) {
return `${value.toFixed(1)}%`
}
function formatMB(mb: number) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${Math.round(mb)} MB`
}
function formatBytes(value: number) {
if (value < 1024) return `${value.toFixed(0)} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`
return `${(value / 1024 / 1024).toFixed(2)} MB`
}
function formatRate(value: number) {
return `${formatBytes(value)}/s`
}
+283
View File
@@ -0,0 +1,283 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import {
Download,
Trash2,
RefreshCw,
CheckCircle2,
XCircle,
ToggleLeft,
ToggleRight,
Loader2,
AlertCircle,
} from 'lucide-react'
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
export default function ImageManagement() {
const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState('')
const fetchImages = useCallback(async () => {
try {
const res = await getImages()
setImages(res.data.data || [])
setError('')
} catch {
setError('获取镜像列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [fetchImages])
const handleDownload = async (templateId: string) => {
setActionLoading(templateId)
setError('')
try {
await downloadImage(templateId)
await fetchImages()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '下载失败'
setError(msg)
} finally {
setActionLoading(null)
}
}
const handleDelete = async (templateId: string) => {
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return
setActionLoading(templateId)
setError('')
try {
await deleteImage(templateId)
await fetchImages()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
setError(msg)
} finally {
setActionLoading(null)
}
}
const handleToggle = async (templateId: string, enabled: boolean) => {
setActionLoading(templateId)
setError('')
try {
await toggleImage(templateId, !enabled)
await fetchImages()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
setError(msg)
} finally {
setActionLoading(null)
}
}
const downloadedCount = images.filter((img) => img.downloaded).length
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1">
LXC
{downloadedCount}/{images.length}
</p>
</div>
<button
onClick={fetchImages}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{error && (
<div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-right px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{images.map((img) => {
const isBusy = actionLoading === img.id
return (
<tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
{getTemplateIcon(img.id)}
</span>
<div>
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
<p className="text-[11px] text-gray-400">{img.description}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-xs text-gray-600 font-mono">
{img.distro} {img.release}
</td>
<td className="px-4 py-3 text-xs text-gray-500 font-mono">
{img.arch}
</td>
<td className="px-4 py-3 text-xs text-gray-600 tabular-nums">
{formatSize(img.size_bytes)}
</td>
<td className="px-4 py-3">
<StatusBadge img={img} />
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{!img.downloaded && !img.downloading && (
<button
onClick={() => handleDownload(img.id)}
disabled={isBusy}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
>
{isBusy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
{isBusy ? '下载中...' : '下载'}
</button>
)}
{img.downloading && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-md text-amber-700 text-xs font-medium">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</span>
)}
{img.downloaded && (
<>
<button
onClick={() => handleToggle(img.id, img.enabled)}
disabled={isBusy}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
img.enabled
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
: 'bg-gray-50 text-gray-500 border border-gray-200 hover:bg-gray-100'
}`}
>
{img.enabled ? <ToggleRight className="w-3.5 h-3.5" /> : <ToggleLeft className="w-3.5 h-3.5" />}
{img.enabled ? '启用' : '禁用'}
</button>
<button
onClick={() => handleDelete(img.id)}
disabled={isBusy}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
title="删除镜像缓存"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
</span>
)
}
if (img.downloaded && img.enabled) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">
<CheckCircle2 className="w-3 h-3" />
</span>
)
}
if (img.downloaded && !img.enabled) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">
<XCircle className="w-3 h-3" />
</span>
)
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-50 text-red-600">
<XCircle className="w-3 h-3" />
</span>
)
}
function getTemplateIcon(id: string): ReactNode {
const size = 'w-5 h-5'
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
if (id.startsWith('centos')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M153.650377 358.349623v112.005247h-3.694326v-108.310921l3.694326-3.694326z" fill="#932279"/><path d="M453.058708 512l-29.554608 29.554608H137.86553v108.310922L0 512l137.86553-137.86553v108.310922h285.63857l29.554608 29.554608zM738.529354 226.529354L553.64513 411.413578V149.956051h108.310921l3.694326 3.694326 72.878977 72.878977z" fill="#932279"/><path d="M649.86553 137.86553h-108.310922v285.63857l-29.554608 29.554608-29.554608-29.554608V137.86553h-108.310922L512 0l137.86553 137.86553zM874.043949 553.64513v108.310921l-3.694326 3.694326-72.878977 72.878977-184.884224-184.884224h261.457527z" fill="#EFA724"/><path d="M886.13447 361.036405v13.098065l-6.04526-6.04526-6.045261-6.045261v108.310921h-3.694326v-125.103312l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526z" fill="#262577"/><path d="M886.13447 649.86553v-108.310922H600.4959L570.941292 512l29.554608-29.554608h285.63857v-108.310922l137.86553 137.86553-137.86553 137.86553z" fill="#262577"/><path d="M411.413578 470.35487H149.956051v-108.310921l3.694326-3.694326L226.529354 285.470646 411.413578 470.35487zM470.35487 149.956051V411.413578L285.470646 226.529354l72.878977-72.878977 3.694326-3.694326h108.310921z" fill="#9CCD2A"/><path d="M738.529354 797.470646L553.64513 612.586422v261.457527h108.310921l3.694326-3.694326 72.878977-72.878977z" fill="#EFA724"/><path d="M649.86553 886.13447h-108.310922V600.4959L512 570.941292l-29.554608 29.554608v285.63857h-108.310922l137.86553 137.86553 137.86553-137.86553z" fill="#9CCD2A"/><path d="M470.35487 874.043949V612.586422L285.470646 797.470646l72.878977 72.878977 3.694326 3.694326h108.310921z" fill="#262577"/><path d="M470.35487 428.541817v41.813053h-41.813053L226.529354 268.342407l-76.573303 76.573303V149.956051h194.959659l-76.573303 76.573303 202.012463 202.012463z" fill="#9CCD2A"/><path d="M880.08921 143.91079v224.17842l-6.045261-6.045261v108.310921H612.586422L797.470646 285.470646l72.878977 72.878977v-13.098065l3.694326 3.694326v-4.030174l-76.573303-76.573303-202.012463 202.012463h-41.813053v-41.813053L755.657593 226.529354l-82.618564-82.618564h207.050181z" fill="#932279"/><path d="M666.993768 137.86553l12.090522 12.090521h194.959659v212.087898l6.045261 6.045261 6.04526 6.04526V137.86553z" fill="#FFF"/><path d="M874.043949 679.08429v194.959659H679.08429L755.657593 797.470646 553.64513 595.458183v-41.813053h41.813053L797.470646 755.657593l76.573303-76.573303z" fill="#EFA724"/><path d="M411.413578 553.64513L226.529354 738.529354l-72.878977-72.878977-3.694326-3.694326v-108.310921H411.413578z" fill="#262577"/><path d="M470.35487 595.458183L268.342407 797.470646l76.573303 76.573303H149.956051V679.08429L226.529354 755.657593l202.012463-202.012463h41.813053v41.813053z" fill="#262577"/><path d="M874.043949 344.91571v4.030174l-3.694326-3.694326v13.098065l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526v-16.792391z" fill="#FFF"/></svg>
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
return null
}
function formatSize(bytes: number): string {
if (bytes <= 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
+120
View File
@@ -0,0 +1,120 @@
import { FormEvent, useState } from 'react'
import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext'
async function sha256Hash(input: string): Promise<string> {
const msgBuffer = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export default function Login() {
const { login, accessCodeLogin } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
// Check for access code in URL
const urlParams = new URLSearchParams(window.location.search)
const accessCode = urlParams.get('code') || ''
const isAccessCodeLogin = !!accessCode
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
setError('')
setLoading(true)
try {
if (isAccessCodeLogin) {
await accessCodeLogin(accessCode, password)
} else {
await login(username, password)
}
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || '登录失败,请检查用户名和密码')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4">
<AppIcon className="w-10 h-10" />
</div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'LXC Container Manager'}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
{!isAccessCodeLogin && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-4 w-4 text-gray-400" />
</div>
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
placeholder="输入用户名"
required
autoComplete="username"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-4 w-4 text-gray-400" />
</div>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
placeholder="输入密码"
required
autoComplete="current-password"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-2.5 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.0.0</p>
</div>
</div>
)
}
+490
View File
@@ -0,0 +1,490 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { Cpu, MemoryStick, HardDrive, RefreshCw, Save, RotateCcw } from 'lucide-react'
import {
getOversell,
updateOversell,
getOversellStatus,
getHostInfo,
reclaimMemory,
HostInfo,
OversellConfig,
OversellStatus,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { formatMB } from '../utils/labels'
export default function Oversell() {
const dialog = useDialog()
const [config, setConfig] = useState<OversellConfig | null>(null)
const [status, setStatus] = useState<OversellStatus | null>(null)
const [host, setHost] = useState<HostInfo | null>(null)
const [estimateSpec, setEstimateSpec] = useState({ vcpu: 1, ramMb: 1024, diskGb: 10 })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [reclaiming, setReclaiming] = useState(false)
const fetchData = useCallback(async () => {
try {
const [cfgRes, stRes, hostRes] = await Promise.all([
getOversell(),
getOversellStatus(),
getHostInfo(),
])
if (cfgRes.data.data) setConfig(cfgRes.data.data)
if (stRes.data.data) setStatus(stRes.data.data)
if (hostRes.data.data) setHost(hostRes.data.data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const handleSave = async () => {
if (!config) return
if (config.cpu_overcommit < 1 || config.ram_overcommit < 1 || config.disk_overcommit < 1) {
await dialog.alert('参数错误', '超售倍数不能小于 1。')
return
}
if (config.swappiness < 0 || config.swappiness > 100) {
await dialog.alert('参数错误', 'Swap 倾向必须在 0 到 100 之间。')
return
}
setSaving(true)
try {
await updateOversell(config)
await fetchData()
await dialog.alert('已应用', '宿主机控制参数已保存。')
} catch (err) {
console.error(err)
await dialog.alert('保存失败', getErrorMessage(err, '请检查宿主机权限或稍后重试。'))
} finally {
setSaving(false)
}
}
const handleReclaimMemory = async () => {
setReclaiming(true)
try {
const res = await reclaimMemory()
await fetchData()
const result = res.data.data
const errors = result?.errors?.length ? `\n失败: ${result.errors.join('; ')}` : ''
await dialog.alert(
'回收已触发',
`已处理 ${result?.attempted || 0} 个运行中容器,成功 ${result?.reclaimed || 0} 个,不支持 ${result?.unsupported || 0} 个。${errors}`
)
} catch (err) {
console.error(err)
await dialog.alert('回收失败', getErrorMessage(err, '请检查宿主机是否支持 cgroup v2 memory.reclaim。'))
} finally {
setReclaiming(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
if (!config) return null
const estimate = host ? buildCapacityEstimate(host, status, config, estimateSpec) : null
const ksmSupported = status?.ksm_supported !== false
const reclaimSupported = status?.reclaim_supported !== false
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-black">宿</h1>
<p className="text-sm text-gray-500 mt-1">KSM 宿</p>
</div>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<ResourceCard
icon={<Cpu className="w-3.5 h-3.5" />}
label="已分配 vCPU"
value={String(status?.allocated_cpu || 0)}
hint={`超售倍数: ${config.cpu_overcommit}x`}
/>
<ResourceCard
icon={<MemoryStick className="w-3.5 h-3.5" />}
label="已分配内存"
value={formatMB(status?.allocated_ram_mb || 0)}
hint={`超售倍数: ${config.ram_overcommit}x`}
/>
<ResourceCard
icon={<HardDrive className="w-3.5 h-3.5" />}
label="已分配磁盘"
value={`${status?.allocated_disk_gb || 0} GB`}
hint={`超售倍数: ${config.disk_overcommit}x`}
/>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<SliderField
label="CPU 超售"
value={config.cpu_overcommit}
min={1}
max={32}
suffix="x"
onChange={(v) => setConfig({ ...config, cpu_overcommit: v })}
hint="只用于容量估算,不改变单台容器限制"
/>
<SliderField
label="内存超售"
value={config.ram_overcommit}
min={1}
max={16}
suffix="x"
onChange={(v) => setConfig({ ...config, ram_overcommit: v })}
hint="只用于容量估算,不改变单台容器限制"
/>
<SliderField
label="磁盘超售"
value={config.disk_overcommit}
min={1}
max={16}
suffix="x"
onChange={(v) => setConfig({ ...config, disk_overcommit: v })}
hint="用于容量预估,实际写入仍受文件系统限制"
/>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between gap-4 mb-4">
<h2 className="text-sm font-semibold text-black"></h2>
<span className="text-xs text-gray-500"></span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-5">
<div className="grid grid-cols-3 gap-3">
<NumberField
label="vCPU"
value={estimateSpec.vcpu}
min={0.25}
step={0.25}
onChange={(value) => setEstimateSpec({ ...estimateSpec, vcpu: value })}
/>
<NumberField
label="内存 MB"
value={estimateSpec.ramMb}
min={128}
step={128}
onChange={(value) => setEstimateSpec({ ...estimateSpec, ramMb: value })}
/>
<NumberField
label="磁盘 GB"
value={estimateSpec.diskGb}
min={1}
onChange={(value) => setEstimateSpec({ ...estimateSpec, diskGb: value })}
/>
</div>
{estimate && (
<div className="grid grid-cols-1 xl:grid-cols-[220px_1fr] gap-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="text-xs text-gray-500"></div>
<div className="mt-1 text-3xl font-bold text-black">{estimate.remainingCount}</div>
<div className="mt-1 text-xs text-gray-400">
{estimate.totalCount} {estimate.bottleneckLabel}
</div>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{estimate.rows.map((row) => (
<tr key={row.label}>
<td className="px-3 py-2 text-gray-700">{row.label}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.actual}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.capacity}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.allocated}</td>
<td className="px-3 py-2 text-right font-semibold text-black">{row.remainingCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className="space-y-4">
<ToggleRow
label="KSM 合并"
desc="合并容器间相同内存页,减少实际内存占用"
value={config.ksm_enabled && ksmSupported}
disabled={!ksmSupported}
onChange={(v) => setConfig({ ...config, ksm_enabled: v })}
extra={ksmSupported ? `已合并 ${status?.ksm_pages || 0}` : '当前内核不支持 KSM'}
/>
<SliderField
label="Swap 倾向"
value={config.swappiness}
min={0}
max={100}
suffix=""
onChange={(v) => setConfig({ ...config, swappiness: v })}
hint="写入 /proc/sys/vm/swappiness,值越低越少使用 swap"
/>
<ActionRow
title="立即回收缓存"
desc={reclaimSupported ? '对运行中容器触发一次 cgroup v2 memory.reclaim' : '当前环境未检测到 memory.reclaim'}
disabled={!reclaimSupported || reclaiming}
busy={reclaiming}
onClick={handleReclaimMemory}
/>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-sm font-medium disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? '保存中...' : '应用设置'}
</button>
</div>
</div>
)
}
function ResourceCard({ icon, label, value, hint }: {
icon: ReactNode
label: string
value: string
hint: string
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-xs text-gray-500 mb-1">
{icon}{label}
</div>
<div className="text-2xl font-bold text-black">{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{hint}</div>
</div>
)
}
function SliderField({ label, value, min, max, suffix, onChange, hint }: {
label: string
value: number
min: number
max: number
suffix: string
onChange: (v: number) => void
hint?: string
}) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="text-sm text-gray-500 font-mono">{value}{suffix}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || min)}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black"
/>
<div className="flex justify-between text-[10px] text-gray-300 mt-0.5">
<span>{min}{suffix}</span><span>{max}{suffix}</span>
</div>
{hint && <div className="text-[10px] text-gray-400 mt-1">{hint}</div>}
</div>
)
}
function NumberField({ label, value, min, step = 1, onChange }: {
label: string
value: number
min: number
step?: number
onChange: (value: number) => void
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-gray-600">{label}</span>
<input
type="number"
min={min}
step={step}
value={value}
onChange={(e) => {
const parsed = step % 1 === 0 ? parseInt(e.target.value, 10) : parseFloat(e.target.value)
onChange(Math.max(min, Number.isFinite(parsed) ? parsed : min))
}}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black focus:border-black focus:outline-none focus:ring-2 focus:ring-black"
/>
</label>
)
}
function ToggleRow({ label, desc, value, disabled = false, onChange, extra }: {
label: string
desc: string
value: boolean
disabled?: boolean
onChange: (v: boolean) => void
extra?: string
}) {
return (
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium text-gray-700">{label}</div>
<div className="text-xs text-gray-400">{desc}</div>
{extra && <div className="text-xs text-gray-500 mt-0.5">{extra}</div>}
</div>
<label className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={value}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-300 peer-checked:bg-black rounded-full after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4"></div>
</label>
</div>
)
}
function ActionRow({ title, desc, disabled, busy, onClick }: {
title: string
desc: string
disabled: boolean
busy: boolean
onClick: () => void
}) {
return (
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="text-xs text-gray-400">{desc}</div>
</div>
<button
onClick={onClick}
disabled={disabled}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<RotateCcw className={`w-4 h-4 ${busy ? 'animate-spin' : ''}`} />
{busy ? '回收中...' : '执行'}
</button>
</div>
)
}
type EstimateSpec = {
vcpu: number
ramMb: number
diskGb: number
}
type EstimateRow = {
label: string
actual: string
capacity: string
allocated: string
totalCount: number
remainingCount: number
}
function buildCapacityEstimate(
host: HostInfo,
status: OversellStatus | null,
config: OversellConfig,
spec: EstimateSpec
) {
const cpuCapacity = host.cpu.cores * config.cpu_overcommit
const ramCapacity = host.ram.total_mb * config.ram_overcommit
const diskCapacity = host.disk.total_gb * config.disk_overcommit
const allocatedCPU = status?.allocated_cpu || 0
const allocatedRAM = status?.allocated_ram_mb || 0
const allocatedDisk = status?.allocated_disk_gb || 0
const rows: EstimateRow[] = [
{
label: 'CPU',
actual: `${host.cpu.cores}`,
capacity: `${cpuCapacity} vCPU`,
allocated: `${allocatedCPU} vCPU`,
totalCount: safeFloor(cpuCapacity / spec.vcpu),
remainingCount: safeFloor((cpuCapacity - allocatedCPU) / spec.vcpu),
},
{
label: '内存',
actual: formatMB(Number(host.ram.total_mb)),
capacity: formatMB(ramCapacity),
allocated: formatMB(allocatedRAM),
totalCount: safeFloor(ramCapacity / spec.ramMb),
remainingCount: safeFloor((ramCapacity - allocatedRAM) / spec.ramMb),
},
{
label: '磁盘',
actual: `${host.disk.total_gb} GB`,
capacity: `${diskCapacity} GB`,
allocated: `${allocatedDisk} GB`,
totalCount: safeFloor(diskCapacity / spec.diskGb),
remainingCount: safeFloor((diskCapacity - allocatedDisk) / spec.diskGb),
},
]
const totalCount = Math.min(...rows.map((row) => row.totalCount))
const remainingCount = Math.min(...rows.map((row) => row.remainingCount))
const bottleneck = rows.reduce((current, row) => row.remainingCount < current.remainingCount ? row : current, rows[0])
return {
rows,
totalCount,
remainingCount,
bottleneckLabel: bottleneck.label,
}
}
function safeFloor(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.floor(value)
}
function getErrorMessage(err: unknown, fallback: string): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const response = (err as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
+131
View File
@@ -0,0 +1,131 @@
import { useState, useEffect, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import { getSecurityAlerts, SecurityAlert } from '../services/api'
const typeLabels: Record<string, string> = {
port_scan: '端口扫描',
horizontal_scan: '横向扫描',
brute_force: '暴力破解',
ddos: 'DDoS/大规模扫描',
spam: '垃圾邮件',
malware: '恶意软件',
mining: '挖矿连接',
proxy: '代理/VPN/Tor',
reflection: 'UDP反射放大',
}
const severityLabels: Record<string, string> = {
critical: '严重',
high: '高危',
medium: '中危',
low: '低危',
}
export default function Security() {
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
try {
const alertRes = await getSecurityAlerts()
if (alertRes.data.data) setAlerts(alertRes.data.data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 10000)
return () => clearInterval(interval)
}, [fetchData])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-black"></h1>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h2 className="text-sm font-semibold text-black"> ({alerts.length})</h2>
</div>
{alerts.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap">IP</th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{alerts.map((alert) => (
<tr key={alert.id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 whitespace-nowrap">{alert.timestamp}</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<SeverityBadge severity={alert.severity} />
</td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
{formatTarget(alert)}
</td>
<td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[260px]">{alert.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
function SeverityBadge({ severity }: { severity: string }) {
const colors: Record<string, string> = {
critical: 'bg-red-100 text-red-700',
high: 'bg-amber-100 text-amber-700',
medium: 'bg-gray-100 text-gray-700',
low: 'bg-gray-50 text-gray-500',
}
return (
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${colors[severity] || 'bg-gray-100 text-gray-700'}`}>
{severityLabels[severity] || severity}
</span>
)
}
function formatTarget(alert: SecurityAlert): string {
if (alert.target_ip === '*') return '*'
if (!alert.target_ip) return '-'
return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip
}
+188
View File
@@ -0,0 +1,188 @@
import { useState, useEffect, useCallback } from 'react'
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
LoginLog,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
export default function Settings() {
const dialog = useDialog()
const { username } = useAuth()
const [logs, setLogs] = useState<LoginLog[]>([])
const [loading, setLoading] = useState(true)
const [logPage, setLogPage] = useState(1)
const pageSize = 10
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('')
const [pwdForUser, setPwdForUser] = useState('')
const fetchLogs = useCallback(async () => {
try {
const res = await getLoginLogs()
if (res.data.data) setLogs(res.data.data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
const handleSaveAccount = async () => {
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
let results: string[] = []
try {
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
if (newUsername) {
const res = await changeUsername(newUsername, oldPwd)
if (res.data.success) results.push('用户名已修改')
else results.push('用户名修改失败')
}
if (newPwd) {
const res = await changePassword(oldPwd, newPwd)
if (res.data.success) results.push('密码已修改')
else results.push('密码修改失败')
}
if (results.length > 0) {
dialog.alert('完成', results.join('') + '。下次登录生效')
setOldPwd(''); setNewPwd(''); setNewUsername('')
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || '修改失败')
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
{/* Account Settings */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<UserCog className="w-4 h-4" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800"></button>
</div>
</div>
{/* Login Logs */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<LogIn className="w-4 h-4" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
<tr key={i}>
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{Math.ceil(logs.length / pageSize)} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
const totalPages = Math.ceil(logs.length / pageSize)
let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i
if (page > totalPages) return null
return (
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
)
})}
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
function formatUA(ua: string): string {
// Extract browser/OS info from UA string
const parts: string[] = []
if (ua.includes('Windows NT')) parts.push('Windows')
else if (ua.includes('Mac OS X')) parts.push('macOS')
else if (ua.includes('Linux')) parts.push('Linux')
else if (ua.includes('Android')) parts.push('Android')
else if (ua.includes('iPhone') || ua.includes('iPad')) parts.push('iOS')
if (ua.includes('Chrome') && !ua.includes('Edg')) parts.push('Chrome')
else if (ua.includes('Firefox')) parts.push('Firefox')
else if (ua.includes('Edg')) parts.push('Edge')
else if (ua.includes('Safari') && !ua.includes('Chrome')) parts.push('Safari')
return parts.join(' / ') || ua.substring(0, 40)
}
+454
View File
@@ -0,0 +1,454 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('clicd_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor to handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('clicd_token')
localStorage.removeItem('clicd_username')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export interface LoginResponse {
token: string
username: string
}
export type ContainerIdentifier = number | string
export interface PortMapping {
container_port: number
host_port: number
protocol: string
description: string
}
export interface Container {
id: number
uuid: string
name: string
template: string
vcpu: number
ram_mb: number
disk_gb: number
network_bw_mbps: number
monthly_traffic_gb: number
traffic_mode: string
traffic_in_gb: number
traffic_out_gb: number
traffic_used_rx: number
traffic_used_tx: number
traffic_reset_date: string
io_speed_mbps: number
status: string
ip: string
ipv6: string
ipv6_prefix_len: number
ipv6_interface: string
vnc_port: number
ssh_port: number
ssh_password: string
port_mappings: PortMapping[]
port_mapping_limit: number
created_at: string
expires_at: string
}
export interface Template {
id: string
name: string
distro: string
release: string
arch: string
variant?: string
description: string
}
export interface CreateContainerRequest {
name: string
template_id: string
vcpu: number
cpu_percent: number
ram_mb: number
disk_gb: number
network_bw_mbps: number
monthly_traffic_gb: number
traffic_mode: string
traffic_in_gb: number
traffic_out_gb: number
io_speed_mbps: number
extra_ports: number[]
port_mapping_count: number
assign_ipv6: boolean
expires_at: string
}
export interface IPv6PrefixInfo {
interface: string
address: string
prefix: string
prefix_len: number
gateway: string
is_tunnel?: boolean
source?: string
}
export interface IPv6Status {
available: boolean
reachable: boolean
reason: string
prefixes: IPv6PrefixInfo[]
}
export interface DashboardStats {
total_containers: number
running: number
stopped: number
}
export interface HostInfo {
cpu: { cores: number; usage_pct: number }
ram: { total_mb: number; used_mb: number; free_mb: number }
disk: { total_gb: number; used_gb: number; free_gb: number }
network: {
rx_bytes: number
tx_bytes: number
rx_bps: number
tx_bps: number
public_ipv4?: string
public_ipv4_interface?: string
public_ipv6?: string
public_ipv6_interface?: string
ipv6_prefixes?: IPv6PrefixInfo[]
}
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
load: { load1: number; load5: number; load15: number }
}
export interface ContainerUsage {
memory_usage_bytes: number
cpu_usage_usec: number
cpu_usage_pct: number
disk_usage_bytes: number
network_rx_bytes: number
network_tx_bytes: number
network_rx_bps: number
network_tx_bps: number
disk_read_bytes: number
disk_write_bytes: number
disk_read_bps: number
disk_write_bps: number
}
export interface APIResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
// Auth
export const login = (username: string, password: string) =>
api.post<APIResponse<LoginResponse>>('/login', { username, password })
export const checkAuth = () =>
api.get<APIResponse>('/check-auth')
export const changePassword = (oldPassword: string, newPassword: string) =>
api.post<APIResponse>('/change-password', { old_password: oldPassword, new_password: newPassword })
export const changeUsername = (newUsername: string, password: string) =>
api.post<APIResponse>('/change-username', { new_username: newUsername, password })
// Login Logs
export interface LoginLog {
time: string
username: string
ip: string
user_agent: string
success: boolean
}
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')
export const getContainer = (id: ContainerIdentifier) =>
api.get<APIResponse<Container>>(`/containers/${id}`)
export const createContainer = (data: CreateContainerRequest) =>
api.post<APIResponse>('/containers', data)
export const deleteContainer = (id: ContainerIdentifier) =>
api.delete<APIResponse>(`/containers/${id}/delete`)
export const startContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/start`)
export const stopContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/stop`)
export const restartContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/restart`)
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`)
export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
export interface TrafficInfo {
total_used_bytes: number
rx_used_bytes: number
tx_used_bytes: number
mode: string
limit_gb: number
in_limit_gb: number
out_limit_gb: number
used_pct: number
reset_date: string
}
export const getTrafficInfo = (id: ContainerIdentifier) =>
api.get<APIResponse<TrafficInfo>>(`/containers/${id}/traffic`)
export const resetTraffic = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/traffic-reset`)
export const updateTrafficLimit = (id: ContainerIdentifier, data: {
traffic_mode: string
monthly_traffic_gb: number
traffic_in_gb: number
traffic_out_gb: number
}) =>
api.put<APIResponse>(`/containers/${id}/traffic-limit`, data)
export const updateResourceLimit = (id: ContainerIdentifier, data: {
vcpu: number
ram_mb: number
io_speed_mbps: number
network_bw_mbps: number
}) =>
api.put<APIResponse>(`/containers/${id}/resource-limit`, data)
export const addPortMapping = (id: ContainerIdentifier, data: PortMapping) =>
api.post<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings`, data)
export const updatePortMapping = (id: ContainerIdentifier, index: number, data: PortMapping) =>
api.put<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`, data)
export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
export const getIPv6Status = () =>
api.get<APIResponse<IPv6Status>>('/ipv6/status')
export const assignIPv6 = (id: ContainerIdentifier) =>
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
// Templates
export const getTemplates = () =>
api.get<APIResponse<Template[]>>('/templates')
// Images (template download/enable management)
export interface ImageInfo {
id: string
name: string
distro: string
release: string
arch: string
description: string
downloaded: boolean
enabled: boolean
downloading: boolean
size_bytes: number
}
export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout
export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = () =>
api.get<APIResponse<Template[]>>('/images/enabled')
// Dashboard
export const getDashboard = () =>
api.get<APIResponse<DashboardStats>>('/dashboard')
export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info')
// Oversell
export interface OversellConfig {
cpu_overcommit: number
ram_overcommit: number
disk_overcommit: number
ksm_enabled: boolean
swappiness: number
}
export interface OversellStatus {
ksm_active: boolean
ksm_pages: number
ksm_supported: boolean
swappiness: number
reclaim_supported: boolean
allocated_cpu: number
allocated_ram_mb: number
allocated_disk_gb: number
}
export interface ReclaimResult {
attempted: number
reclaimed: number
unsupported: number
errors: string[]
}
export const getOversell = () =>
api.get<APIResponse<OversellConfig>>('/oversell')
export const updateOversell = (data: OversellConfig) =>
api.post<APIResponse<OversellConfig>>('/oversell', data)
export const getOversellStatus = () =>
api.get<APIResponse<OversellStatus>>('/oversell/status')
export const reclaimMemory = () =>
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
// WebSSH URL generator
export const getWebSSHUrl = (containerName: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ container: containerName })
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
}
// Task Queue
export interface Task {
id: string
type: string
container_id?: number
container_name: string
status: string
error?: string
created_at: string
template_id?: string
config?: CreateContainerRequest
}
export const getTasks = () =>
api.get<APIResponse<Task[]>>('/tasks')
export const deleteTask = (taskId: string) =>
api.delete<APIResponse>(`/tasks/${taskId}`)
export const batchCreate = (containers: CreateContainerRequest[]) =>
api.post<APIResponse<string[]>>('/batch-create', { containers })
export const batchAction = (action: string, containers: number[], templateId?: string) =>
api.post<APIResponse>('/batch-action', { action, containers, template_id: templateId })
// Sub Users
export interface SubUser {
id: string
username: string
password: string
container_names: string[]
container_uuids?: string[]
token: string
access_code: string
created_at: string
}
export const createSubUser = (containerId: ContainerIdentifier) =>
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
// Audit Logs
export interface AuditLog {
time: string
action: string
target: string
detail: string
user: string
}
export const getAuditLogs = () =>
api.get<APIResponse<AuditLog[]>>('/audit-logs')
// Security
export interface SecurityAlert {
id: string
container_name: string
type: string
severity: string
source_ip: string
target_ip: string
target_port: number
detail: string
log_line: string
timestamp: string
count: number
}
export interface SecuritySummary {
total_alerts: number
critical: number
high: number
medium: number
low: number
}
export const getSecurityAlerts = () =>
api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
export const checkContainerSecurity = (containerName: string) =>
api.post<APIResponse>('/security/check', { container_name: containerName })
export const getSecurityLogs = (containerName: string) =>
api.get<APIResponse>('/security/logs', { params: { container: containerName } })
export const getSecuritySummary = () =>
api.get<APIResponse<SecuritySummary>>('/security/summary')
export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
export default api
+36
View File
@@ -0,0 +1,36 @@
export function actionLabel(action: string): string {
const map: Record<string, string> = {
create: '创建',
start: '开机',
stop: '关机',
restart: '重启',
delete: '删除',
reinstall: '重装',
}
return map[action] || action
}
export function taskStatusLabel(status: string): string {
const map: Record<string, string> = {
pending: '等待中',
running: '执行中',
done: '已完成',
failed: '失败',
}
return map[status] || status
}
export function taskStatusClass(status: string): string {
const map: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700',
running: 'bg-amber-100 text-amber-700',
done: 'bg-emerald-50 text-emerald-700',
failed: 'bg-red-50 text-red-700',
}
return map[status] || 'bg-gray-100 text-gray-700'
}
export function formatMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb} MB`
}