mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 22:04:44 +08:00
first commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} -> 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} -> {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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user