mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
添加了快照功能支持,支持定时快照和回滚快照
This commit is contained in:
@@ -10,6 +10,7 @@ import AuditLogs from './pages/AuditLogs'
|
||||
import ApiIntegration from './pages/ApiIntegration'
|
||||
import Settings from './pages/Settings'
|
||||
import ImageManagement from './pages/ImageManagement'
|
||||
import Snapshots from './pages/Snapshots'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -57,6 +58,7 @@ function App() {
|
||||
<Route path="container/:id" element={<ContainerDetail />} />
|
||||
<Route path="oversell" element={<Oversell />} />
|
||||
<Route path="security" element={<Security />} />
|
||||
<Route path="snapshots" element={<Snapshots />} />
|
||||
<Route path="audit-logs" element={<AuditLogs />} />
|
||||
<Route path="api-integration" element={<ApiIntegration />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
|
||||
@@ -24,6 +24,7 @@ const defaultForm: CreateContainerRequest = {
|
||||
io_speed_mbps: 0,
|
||||
extra_ports: [],
|
||||
port_mapping_count: 2,
|
||||
snapshot_limit: 3,
|
||||
assign_ipv6: false,
|
||||
expires_at: '',
|
||||
}
|
||||
@@ -94,7 +95,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
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: [] })
|
||||
containers.push({
|
||||
...boundedForm,
|
||||
name,
|
||||
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
|
||||
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||
extra_ports: [],
|
||||
})
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
@@ -248,6 +255,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.snapshot_limit}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</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" />
|
||||
@@ -325,6 +341,7 @@ function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB
|
||||
vcpu: clampVCPU(form.vcpu, maxVCPU),
|
||||
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
|
||||
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
|
||||
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Camera,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Package,
|
||||
@@ -31,6 +32,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
const isImagesPage = location.pathname.startsWith('/images')
|
||||
const isOversellPage = location.pathname.startsWith('/oversell')
|
||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||
const isSecurityPage = location.pathname.startsWith('/security')
|
||||
@@ -136,6 +138,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
{!collapsed && <span>安全告警</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/snapshots')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isSnapshotsPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Camera 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 ${
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
Clock,
|
||||
Copy,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
@@ -29,9 +31,12 @@ import {
|
||||
Container,
|
||||
ContainerUsage,
|
||||
createSubUser,
|
||||
createContainerSnapshot,
|
||||
deleteContainer,
|
||||
deleteContainerSnapshot,
|
||||
deletePortMapping,
|
||||
getContainer,
|
||||
getContainerSnapshots,
|
||||
getContainerUsage,
|
||||
getHostInfo,
|
||||
getTrafficInfo,
|
||||
@@ -44,8 +49,13 @@ import {
|
||||
restartContainer,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
Snapshot,
|
||||
SnapshotSchedule,
|
||||
Template,
|
||||
updateContainerExpiry,
|
||||
updateSnapshotQuota,
|
||||
updateSnapshotSchedule,
|
||||
restoreContainerSnapshot,
|
||||
resetTraffic,
|
||||
updateTrafficLimit,
|
||||
updateResourceLimit,
|
||||
@@ -125,6 +135,15 @@ export default function ContainerDetail() {
|
||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
|
||||
const [savingResource, setSavingResource] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showSnapshots, setShowSnapshots] = useState(false)
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [snapshotQuota, setSnapshotQuota] = useState(3)
|
||||
const [snapshotQuotaDraft, setSnapshotQuotaDraft] = useState(3)
|
||||
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||
|
||||
const fetchContainer = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
@@ -142,6 +161,21 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}, [containerIdentifier, isSubUser])
|
||||
|
||||
const fetchSnapshots = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
try {
|
||||
const res = await getContainerSnapshots(containerIdentifier)
|
||||
const data = res.data.data
|
||||
const quota = data?.quota || container?.snapshot_limit || 3
|
||||
setSnapshots(data?.snapshots || [])
|
||||
setSnapshotQuota(quota)
|
||||
setSnapshotQuotaDraft(quota)
|
||||
setSnapshotSchedule(data?.schedule || null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch snapshots:', err)
|
||||
}
|
||||
}, [containerIdentifier, container?.snapshot_limit])
|
||||
|
||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
||||
if (!containerIdentifier || !currentContainer) return
|
||||
|
||||
@@ -198,6 +232,10 @@ export default function ContainerDetail() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [fetchUsage])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSnapshots) fetchSnapshots()
|
||||
}, [showSnapshots, fetchSnapshots])
|
||||
|
||||
// Poll task status for this container
|
||||
useEffect(() => {
|
||||
if (!containerIdentifier) return
|
||||
@@ -478,6 +516,108 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateSnapshot = async () => {
|
||||
if (!containerIdentifier) return
|
||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||
return
|
||||
}
|
||||
if (container?.status === 'running') {
|
||||
const confirmed = await dialog.confirm(
|
||||
'拍摄快照',
|
||||
`拍摄快照需要先关机,完成后会自动重启容器 ${container.name}。是否继续?`
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
setSnapshotBusy('create')
|
||||
try {
|
||||
await createContainerSnapshot(containerIdentifier)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('创建快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const openSnapshotSchedule = () => {
|
||||
setSnapshotScheduleDraft({
|
||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||
time: snapshotSchedule?.time || '03:00',
|
||||
})
|
||||
setShowSnapshotSchedule(true)
|
||||
}
|
||||
|
||||
const saveSnapshotSchedule = async (enabled: boolean) => {
|
||||
if (!containerIdentifier) return
|
||||
const intervalHours = snapshotScheduleDraft.intervalHours
|
||||
const scheduleTime = snapshotScheduleDraft.time || '03:00'
|
||||
if (enabled && intervalHours < 24) {
|
||||
await dialog.alert('参数错误', '自动快照周期最低是 1 天一次。')
|
||||
return
|
||||
}
|
||||
setSnapshotBusy('schedule')
|
||||
try {
|
||||
await updateSnapshotSchedule(containerIdentifier, enabled, intervalHours, scheduleTime)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
setShowSnapshotSchedule(false)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('定时快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const saveSnapshotQuota = async () => {
|
||||
if (!containerIdentifier || isSubUser) return
|
||||
const nextQuota = Math.max(1, Math.round(snapshotQuotaDraft || 1))
|
||||
setSnapshotBusy('quota')
|
||||
try {
|
||||
await updateSnapshotQuota(containerIdentifier, nextQuota)
|
||||
setSnapshotQuota(nextQuota)
|
||||
setSnapshotQuotaDraft(nextQuota)
|
||||
setEditingSnapshotQuota(false)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('保存快照配额失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
|
||||
setSnapshotBusy(snapshot.id)
|
||||
try {
|
||||
await deleteContainerSnapshot(containerIdentifier, snapshot.id)
|
||||
await fetchSnapshots()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('删除快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
|
||||
setSnapshotBusy(snapshot.id)
|
||||
try {
|
||||
await restoreContainerSnapshot(containerIdentifier, snapshot.id)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('恢复快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await copyText(text)
|
||||
@@ -641,6 +781,10 @@ export default function ContainerDetail() {
|
||||
NAT 管理
|
||||
</ActionButton>
|
||||
</>
|
||||
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
快照
|
||||
</ActionButton>
|
||||
{!isSubUser && (
|
||||
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
@@ -846,6 +990,171 @@ export default function ContainerDetail() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSnapshots && (
|
||||
<Modal
|
||||
title="快照"
|
||||
onClose={() => {
|
||||
setShowSnapshots(false)
|
||||
setEditingSnapshotQuota(false)
|
||||
}}
|
||||
wide
|
||||
extra={
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSnapshotSchedule}
|
||||
disabled={!!snapshotBusy}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
||||
snapshotSchedule?.enabled
|
||||
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{snapshotBusy === 'schedule' ? '处理中...' : snapshotSchedule?.enabled ? '定时设置' : '定时快照'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSnapshot}
|
||||
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
{snapshotBusy === 'create' ? '创建中...' : '新建快照'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
|
||||
<div>
|
||||
快照数量:
|
||||
<span className="font-mono text-gray-900">
|
||||
{snapshots.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>子用户配额:</span>
|
||||
<span className="font-mono text-gray-900">{snapshotQuota}</span>
|
||||
{!isSubUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSnapshotQuotaDraft(snapshotQuota)
|
||||
setEditingSnapshotQuota((value) => !value)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded border border-gray-300 bg-white px-2 py-1 text-[11px] text-gray-700 hover:bg-gray-50"
|
||||
disabled={snapshotBusy === 'quota'}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
修改
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
定时状态:
|
||||
<span className="text-gray-900">
|
||||
{snapshotSchedule?.enabled ? `已开启,每 ${formatScheduleInterval(snapshotSchedule.interval_hours || 24)},${snapshotSchedule.time || '03:00'} 执行` : '未开启'}
|
||||
</span>
|
||||
</div>
|
||||
{snapshotSchedule?.next_run && (
|
||||
<div>下次执行:<span className="font-mono text-gray-900">{formatDateTime(snapshotSchedule.next_run)}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingSnapshotQuota && !isSubUser && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<Field label="子用户每台容器快照上限">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={snapshotQuotaDraft}
|
||||
onChange={(event) => setSnapshotQuotaDraft(Math.max(1, Math.round(Number(event.target.value) || 1)))}
|
||||
className="w-44 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"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2 pb-0.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSnapshotQuota(false)
|
||||
setSnapshotQuotaDraft(snapshotQuota)
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||
disabled={snapshotBusy === 'quota'}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSnapshotQuota}
|
||||
disabled={snapshotBusy === 'quota'}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{snapshotBusy === 'quota' ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SnapshotTable
|
||||
snapshots={snapshots}
|
||||
busy={snapshotBusy}
|
||||
onRestore={handleRestoreSnapshot}
|
||||
onDelete={handleDeleteSnapshot}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSnapshotSchedule && (
|
||||
<Modal title="定时快照" onClose={() => setShowSnapshotSchedule(false)}>
|
||||
<div className="space-y-4">
|
||||
<Field label="自动快照周期">
|
||||
<select
|
||||
value={snapshotScheduleDraft.intervalHours}
|
||||
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, intervalHours: Number(e.target.value) })}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value={24}>1 天</option>
|
||||
<option value={72}>3 天</option>
|
||||
<option value={168}>7 天</option>
|
||||
<option value={336}>14 天</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="执行时间">
|
||||
<input
|
||||
type="time"
|
||||
value={snapshotScheduleDraft.time}
|
||||
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, time: e.target.value || '03:00' })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||
{`每 ${formatScheduleInterval(snapshotScheduleDraft.intervalHours)} 在 ${snapshotScheduleDraft.time || '03:00'} 执行。`}
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 pt-2">
|
||||
{snapshotSchedule?.enabled ? (
|
||||
<button
|
||||
onClick={() => saveSnapshotSchedule(false)}
|
||||
disabled={snapshotBusy === 'schedule'}
|
||||
className="px-4 py-2 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
关闭定时
|
||||
</button>
|
||||
) : <div />}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowSnapshotSchedule(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md">取消</button>
|
||||
<button
|
||||
onClick={() => saveSnapshotSchedule(true)}
|
||||
disabled={snapshotBusy === 'schedule'}
|
||||
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{snapshotBusy === 'schedule' ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showNat && (
|
||||
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && !showNatAdd && (
|
||||
@@ -1229,6 +1538,65 @@ function PlainRow({ label, value, mono = false, copyValue, onCopy, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotTable({ snapshots, busy, onRestore, onDelete }: {
|
||||
snapshots: Snapshot[]
|
||||
busy: string
|
||||
onRestore: (snapshot: Snapshot) => void
|
||||
onDelete: (snapshot: Snapshot) => void
|
||||
}) {
|
||||
if (snapshots.length === 0) {
|
||||
return <p className="rounded-lg border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-400">暂无快照</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<TableHead>快照时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>创建者</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id}>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{snapshot.created_at}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{snapshot.scheduled ? '定时' : '手动'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{snapshot.created_by || '-'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => onRestore(snapshot)}
|
||||
disabled={!!busy}
|
||||
className="rounded border border-gray-300 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{busy === snapshot.id ? '处理中...' : '恢复'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(snapshot)}
|
||||
disabled={!!busy}
|
||||
className="rounded border border-red-200 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, isSubUser = false }: { mappings: PortMapping[]; publicHost: string; onEdit: (pm: PortMapping, index: number) => void; onDelete: (index: number) => void; compact?: boolean; isSubUser?: boolean }) {
|
||||
if (mappings.length === 0) {
|
||||
return <p className="text-sm text-gray-400">暂无端口映射</p>
|
||||
@@ -1389,6 +1757,19 @@ function formatExpiration(value?: string): string {
|
||||
return value.length >= 10 ? value.slice(0, 10) : value
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string): string {
|
||||
if (!value) return '-'
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
|
||||
function formatScheduleInterval(hours: number): string {
|
||||
if (hours === 24) return '1 天'
|
||||
if (hours % 24 === 0) return `${hours / 24} 天`
|
||||
return `${hours} 小时`
|
||||
}
|
||||
|
||||
function formatRate(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
|
||||
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||
|
||||
@@ -538,8 +538,15 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
ssh_password: '',
|
||||
port_mappings: [],
|
||||
port_mapping_limit: 2,
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
expires_at: cfg.expires_at,
|
||||
snapshot_schedule_enabled: false,
|
||||
snapshot_schedule_interval_hours: 24,
|
||||
snapshot_schedule_time: '03:00',
|
||||
snapshot_schedule_last_run: '',
|
||||
snapshot_schedule_next_run: '',
|
||||
snapshot_schedule_created_by: '',
|
||||
isPlaceholder: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Camera, RefreshCw, Server } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getSnapshots, Snapshot } from '../services/api'
|
||||
|
||||
export default function Snapshots() {
|
||||
const navigate = useNavigate()
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await getSnapshots()
|
||||
setSnapshots(res.data.data || [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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="mt-1 text-sm text-gray-500">全局快照列表,共 {snapshots.length} 个</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setRefreshing(true); fetchData() }}
|
||||
disabled={refreshing}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Camera className="h-7 w-7 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700">暂无快照</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full min-w-[820px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium">快照时间</th>
|
||||
<th className="px-4 py-3 text-left font-medium">类型</th>
|
||||
<th className="px-4 py-3 text-left font-medium">创建者</th>
|
||||
<th className="px-4 py-3 text-right font-medium">大小</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/container/${snapshot.container_id}`)}
|
||||
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||
>
|
||||
<Server className="h-4 w-4 text-gray-400" />
|
||||
{snapshot.container_name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{snapshot.lxc_name}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{snapshot.created_at}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{snapshot.scheduled ? '定时' : '手动'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) 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(1)} GB`
|
||||
}
|
||||
@@ -71,8 +71,15 @@ export interface Container {
|
||||
ssh_password: string
|
||||
port_mappings: PortMapping[]
|
||||
port_mapping_limit: number
|
||||
snapshot_limit: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
snapshot_schedule_enabled: boolean
|
||||
snapshot_schedule_interval_hours: number
|
||||
snapshot_schedule_time: string
|
||||
snapshot_schedule_last_run: string
|
||||
snapshot_schedule_next_run: string
|
||||
snapshot_schedule_created_by: string
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
@@ -100,6 +107,7 @@ export interface CreateContainerRequest {
|
||||
io_speed_mbps: number
|
||||
extra_ports: number[]
|
||||
port_mapping_count: number
|
||||
snapshot_limit: number
|
||||
assign_ipv6: boolean
|
||||
expires_at: string
|
||||
}
|
||||
@@ -354,6 +362,62 @@ export const getOversellStatus = () =>
|
||||
export const reclaimMemory = () =>
|
||||
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
|
||||
|
||||
// Snapshots
|
||||
export interface Snapshot {
|
||||
id: string
|
||||
container_id: number
|
||||
container_name: string
|
||||
lxc_name: string
|
||||
created_at: string
|
||||
created_by: string
|
||||
scheduled: boolean
|
||||
path: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export interface SnapshotSchedule {
|
||||
enabled: boolean
|
||||
interval_hours: number
|
||||
time: string
|
||||
last_run: string
|
||||
next_run: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface ContainerSnapshotsResponse {
|
||||
snapshots: Snapshot[]
|
||||
quota: number
|
||||
schedule: SnapshotSchedule
|
||||
}
|
||||
|
||||
export const getSnapshots = () =>
|
||||
api.get<APIResponse<Snapshot[]>>('/snapshots')
|
||||
|
||||
export const getContainerSnapshots = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
|
||||
|
||||
export const createContainerSnapshot = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
|
||||
|
||||
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
|
||||
|
||||
export const restoreContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||
api.post<APIResponse>(`/containers/${id}/snapshots/${snapshotId}/restore`, {}, { timeout: 600000 })
|
||||
|
||||
export const updateSnapshotSchedule = (id: ContainerIdentifier, enabled: boolean, intervalHours: number, time: string) =>
|
||||
api.post<APIResponse<{ container: Container; snapshot?: Snapshot }>>(
|
||||
`/containers/${id}/snapshots/schedule`,
|
||||
{ enabled, interval_hours: intervalHours, time },
|
||||
{ timeout: 600000 }
|
||||
)
|
||||
|
||||
export const updateSnapshotQuota = (id: ContainerIdentifier, snapshotLimit: number) =>
|
||||
api.put<APIResponse<{ container: Container; quota: number }>>(
|
||||
`/containers/${id}/snapshots/quota`,
|
||||
{ snapshot_limit: snapshotLimit }
|
||||
)
|
||||
|
||||
// WebSSH URL generator
|
||||
export const getWebSSHUrl = (containerName: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
|
||||
Reference in New Issue
Block a user