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
+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>
)
}