mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
@@ -44,6 +44,7 @@ import {
|
||||
getContainerSnapshots,
|
||||
getContainerUsage,
|
||||
getHostInfo,
|
||||
getStorageInfo,
|
||||
getTrafficInfo,
|
||||
HostInfo,
|
||||
TrafficInfo,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
stopContainer,
|
||||
Snapshot,
|
||||
SnapshotSchedule,
|
||||
StorageInfo,
|
||||
Template,
|
||||
updateContainerExpiry,
|
||||
updateFirewall,
|
||||
@@ -75,6 +77,7 @@ import {
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import WebSSHViewer from '../components/WebSSHViewer'
|
||||
import WebVNCViewer from '../components/WebVNCViewer'
|
||||
import { RingStat } from '../components/RingStats'
|
||||
@@ -127,6 +130,7 @@ export default function ContainerDetail() {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const { isSubUser } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [container, setContainer] = useState<Container | null>(null)
|
||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||
const [usage, setUsage] = useState<ContainerUsage | null>(null)
|
||||
@@ -182,6 +186,9 @@ export default function ContainerDetail() {
|
||||
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(!isSubUser)
|
||||
const [snapshotStoragePoolID, setSnapshotStoragePoolID] = useState('')
|
||||
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||
const [showFirewall, setShowFirewall] = useState(false)
|
||||
@@ -224,6 +231,23 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}, [containerIdentifier, container?.snapshot_limit])
|
||||
|
||||
const fetchStorage = useCallback(async () => {
|
||||
if (isSubUser) {
|
||||
setStorageLoading(false)
|
||||
return
|
||||
}
|
||||
setStorageLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
setStorageInfo(res.data.data || null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch storage:', err)
|
||||
setStorageInfo(null)
|
||||
} finally {
|
||||
setStorageLoading(false)
|
||||
}
|
||||
}, [isSubUser])
|
||||
|
||||
const fetchMetricHistory = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
try {
|
||||
@@ -297,8 +321,11 @@ export default function ContainerDetail() {
|
||||
}, [fetchMetricHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSnapshots) fetchSnapshots()
|
||||
}, [showSnapshots, fetchSnapshots])
|
||||
if (showSnapshots) {
|
||||
fetchSnapshots()
|
||||
fetchStorage()
|
||||
}
|
||||
}, [showSnapshots, fetchSnapshots, fetchStorage])
|
||||
|
||||
// Poll task status for this container
|
||||
useEffect(() => {
|
||||
@@ -774,6 +801,10 @@ export default function ContainerDetail() {
|
||||
const handleCreateSnapshot = async () => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (!snapshotStorageReady) {
|
||||
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||
return
|
||||
}
|
||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||
return
|
||||
@@ -787,7 +818,7 @@ export default function ContainerDetail() {
|
||||
}
|
||||
setSnapshotBusy('create')
|
||||
try {
|
||||
await createContainerSnapshot(containerIdentifier)
|
||||
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
@@ -799,6 +830,10 @@ export default function ContainerDetail() {
|
||||
|
||||
const openSnapshotSchedule = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
if (!snapshotStorageReady) {
|
||||
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||
return
|
||||
}
|
||||
setSnapshotScheduleDraft({
|
||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||
time: snapshotSchedule?.time || '03:00',
|
||||
@@ -924,6 +959,10 @@ export default function ContainerDetail() {
|
||||
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
||||
const hasIndependentIPv6 = ipv6List.length > 0
|
||||
const defaultConnPort = isWindows ? 3389 : 22
|
||||
const snapshotStoragePools = (storageInfo?.pools || []).filter((pool) =>
|
||||
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('snapshots')
|
||||
)
|
||||
const snapshotStorageReady = isSubUser || snapshotStoragePools.length > 0
|
||||
|
||||
let publicEndpoint = '-'
|
||||
let sshCommand = ''
|
||||
@@ -1231,8 +1270,8 @@ export default function ContainerDetail() {
|
||||
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
||||
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
||||
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
||||
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
|
||||
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
|
||||
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
|
||||
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="实时状态">
|
||||
@@ -1492,7 +1531,7 @@ export default function ContainerDetail() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSnapshotSchedule}
|
||||
disabled={!!snapshotBusy}
|
||||
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady}
|
||||
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'
|
||||
@@ -1504,7 +1543,7 @@ export default function ContainerDetail() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSnapshot}
|
||||
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady || (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" />
|
||||
@@ -1514,6 +1553,20 @@ export default function ContainerDetail() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{storageLoading && !isSubUser && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
)}
|
||||
{!storageLoading && !snapshotStorageReady && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<span>尚未开启快照存储,无法新建或启用定时快照。</span>
|
||||
<button onClick={() => { setShowSnapshots(false); navigate('/storage') }} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
快照数量:
|
||||
@@ -1549,6 +1602,26 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSubUser && snapshotStoragePools.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<Field label="新建快照存储磁盘">
|
||||
<select
|
||||
value={snapshotStoragePoolID}
|
||||
onChange={(event) => setSnapshotStoragePoolID(event.target.value)}
|
||||
className="w-72 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"
|
||||
>
|
||||
<option value="">自动选择(默认盘优先,空间不足自动切换)</option>
|
||||
{snapshotStoragePools.map((pool) => (
|
||||
<option key={pool.id} value={pool.id}>
|
||||
{pool.name} · {pool.mount_point || pool.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="pb-2 text-xs text-gray-400">仅影响手动新建快照;定时快照使用默认磁盘。</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="子用户每台容器快照上限">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import CreateContainerModal from '../components/CreateContainerModal'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import {
|
||||
Container,
|
||||
CreateContainerRequest,
|
||||
@@ -391,7 +392,7 @@ export default function Containers() {
|
||||
{pageContainers.map((container) => {
|
||||
const isRunning = container.status === 'running'
|
||||
const isInitializing = container.status === 'initializing'
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask
|
||||
const isPlaceholder = !!container.isPlaceholder
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const usage = usageByName[container.name]
|
||||
@@ -581,12 +582,13 @@ type DisplayContainer = Container & {
|
||||
}
|
||||
|
||||
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||
const { t } = useLanguage()
|
||||
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
||||
if (policyBlocked) {
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
策略封禁
|
||||
{t('策略封禁')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -595,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
初始化失败
|
||||
{t('初始化失败')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -604,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
初始化完成
|
||||
{t('初始化完成')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (task?.type === 'create' && task.status === 'running') {
|
||||
const detail = t(task.stage_detail || '正在初始化')
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
<span className="truncate">{detail}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -622,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
排队等待
|
||||
{t('排队等待')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -634,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
{taskLabels[task.type] || '处理中'}
|
||||
{t(taskLabels[task.type] || '处理中')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -643,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
{t('正在初始化')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -651,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{running ? '在线' : '离线'}
|
||||
{t(running ? '在线' : '离线')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -788,7 +791,7 @@ type ContainerFilters = {
|
||||
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
||||
const keyword = filters.search.trim().toLowerCase()
|
||||
return containers.filter((container) => {
|
||||
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
|
||||
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask
|
||||
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
||||
return false
|
||||
}
|
||||
@@ -865,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
|
||||
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
|
||||
return actionLabels[task.type] || '处理中...'
|
||||
}
|
||||
|
||||
@@ -873,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
onRefresh: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-black">任务队列</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">共 {tasks.length} 个任务</p>
|
||||
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{t(`共 ${tasks.length} 个任务`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -887,26 +892,27 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
{t('刷新')}
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
|
||||
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title={t('关闭')}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500">暂无任务</div>
|
||||
<div className="p-8 text-center text-sm text-gray-500">{t('暂无任务')}</div>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
||||
<th className="whitespace-nowrap px-4 py-2.5">状态</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">操作</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">容器</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">创建时间</th>
|
||||
<th className="px-4 py-2.5">错误</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('状态')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('操作')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('创建时间')}</th>
|
||||
<th className="px-4 py-2.5">{t('错误')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -915,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
<tr key={task.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-2.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
|
||||
{taskStatusLabel(task.status)}
|
||||
{t(taskStatusLabel(task.status))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{t(actionLabel(task.type))}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
|
||||
<td className="min-w-[210px] px-4 py-2.5 text-xs text-gray-700">
|
||||
{task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
|
||||
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
|
||||
<td className="whitespace-nowrap px-2 py-2.5">
|
||||
@@ -932,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="取消任务"
|
||||
title={t('取消任务')}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
@@ -11,15 +12,18 @@ import {
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||
import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
|
||||
export default function ImageManagement() {
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const [images, setImages] = useState<ImageInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(true)
|
||||
|
||||
const fetchImages = useCallback(async () => {
|
||||
try {
|
||||
@@ -33,9 +37,22 @@ export default function ImageManagement() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchStorage = useCallback(async () => {
|
||||
setStorageLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
setStorageInfo(res.data.data || null)
|
||||
} catch {
|
||||
setStorageInfo(null)
|
||||
} finally {
|
||||
setStorageLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages()
|
||||
}, [fetchImages])
|
||||
fetchStorage()
|
||||
}, [fetchImages, fetchStorage])
|
||||
|
||||
useEffect(() => {
|
||||
const hasDownloads = images.some((img) => img.downloading)
|
||||
@@ -101,6 +118,9 @@ export default function ImageManagement() {
|
||||
const downloadedCount = images.filter((img) => img.downloaded).length
|
||||
const lxcImages = images.filter((img) => img.type === 'lxc')
|
||||
const kvmImages = images.filter((img) => img.type === 'kvm')
|
||||
const imageStorageReady = (storageInfo?.pools || []).some((pool) =>
|
||||
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('images')
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -121,7 +141,7 @@ export default function ImageManagement() {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchImages}
|
||||
onClick={() => { fetchImages(); fetchStorage() }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
@@ -136,6 +156,25 @@ export default function ImageManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageLoading && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!storageLoading && !imageStorageReady && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
尚未开启镜像缓存存储,无法下载新镜像。
|
||||
</div>
|
||||
<button onClick={() => navigate('/storage')} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImageTable
|
||||
title="LXC 容器镜像"
|
||||
images={lxcImages}
|
||||
@@ -146,6 +185,8 @@ export default function ImageManagement() {
|
||||
onCancelDownload={handleCancelDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
storageReady={imageStorageReady}
|
||||
storageLoading={storageLoading}
|
||||
/>
|
||||
|
||||
{kvmImages.length > 0 && (
|
||||
@@ -159,6 +200,8 @@ export default function ImageManagement() {
|
||||
onCancelDownload={handleCancelDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
storageReady={imageStorageReady}
|
||||
storageLoading={storageLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -175,6 +218,8 @@ function ImageTable({
|
||||
onCancelDownload,
|
||||
onDelete,
|
||||
onToggle,
|
||||
storageReady,
|
||||
storageLoading,
|
||||
}: {
|
||||
title: string
|
||||
images: ImageInfo[]
|
||||
@@ -185,6 +230,8 @@ function ImageTable({
|
||||
onCancelDownload: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
storageReady: boolean
|
||||
storageLoading: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -253,7 +300,8 @@ function ImageTable({
|
||||
{!img.downloaded && !img.downloading && (
|
||||
<button
|
||||
onClick={() => onDownload(img.id)}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || storageLoading || !storageReady}
|
||||
title={storageLoading ? '正在检查存储配置...' : storageReady ? '下载镜像' : '请先在存储管理中开启镜像缓存存储'}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{isBusy ? (
|
||||
@@ -281,7 +329,8 @@ function ImageTable({
|
||||
<>
|
||||
<button
|
||||
onClick={() => onToggle(img.id, img.enabled)}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || storageLoading || !storageReady}
|
||||
title={storageLoading ? '正在检查存储配置...' : storageReady ? (img.enabled ? '禁用镜像' : '启用镜像') : '请先在存储管理中开启镜像缓存存储'}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
img.enabled
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
|
||||
@@ -317,7 +366,7 @@ function ImageTable({
|
||||
function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
if (img.downloading) {
|
||||
const progress = Math.max(0, Math.min(100, img.progress || 0))
|
||||
const showProgress = img.stage === 'downloading' && progress > 0
|
||||
const showProgress = img.stage === 'downloading' && (progress > 0 || img.downloaded_bytes > 0)
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span
|
||||
@@ -329,7 +378,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
</span>
|
||||
{showProgress && (
|
||||
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
|
||||
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
|
||||
<span
|
||||
className={`block h-full rounded-full bg-amber-500 transition-all ${progress <= 0 ? 'animate-pulse' : ''}`}
|
||||
style={{ width: progress > 0 ? `${progress}%` : '35%' }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -375,6 +427,7 @@ function downloadStatusLabel(img: ImageInfo) {
|
||||
if (img.stage === 'converting') return '转换中'
|
||||
if (img.stage === 'lxc-create') return '下载中'
|
||||
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
|
||||
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
|
||||
return '下载中'
|
||||
}
|
||||
|
||||
|
||||
+233
-73
@@ -1,23 +1,38 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
||||
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
|
||||
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
|
||||
import {
|
||||
changePassword,
|
||||
changeUsername,
|
||||
getLoginLogs,
|
||||
getSSLSettings,
|
||||
getTaskQueueSettings,
|
||||
getWebSSHOriginSettings,
|
||||
LoginLog,
|
||||
SSLSettings,
|
||||
TaskQueueSettings,
|
||||
updateTaskQueueSettings,
|
||||
updateSSLSettings,
|
||||
updateWebSSHOriginSettings,
|
||||
WebSSHOriginSettings,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type SettingsSection = 'tasks' | 'account' | 'webssh' | 'ssl' | 'logs'
|
||||
|
||||
const settingsSections = [
|
||||
{ id: 'tasks', label: '任务队列', icon: ListTodo },
|
||||
{ id: 'account', label: '账号设置', icon: UserCog },
|
||||
{ id: 'webssh', label: 'WebSSH 访问', icon: Terminal },
|
||||
{ id: 'ssl', label: 'SSL 证书', icon: ShieldCheck },
|
||||
{ id: 'logs', label: '登录日志', icon: LogIn },
|
||||
] as const
|
||||
|
||||
export default function Settings() {
|
||||
const dialog = useDialog()
|
||||
const { username } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [logs, setLogs] = useState<LoginLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
@@ -39,6 +54,10 @@ export default function Settings() {
|
||||
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
|
||||
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
|
||||
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
|
||||
const [taskQueue, setTaskQueue] = useState<TaskQueueSettings | null>(null)
|
||||
const [taskConcurrency, setTaskConcurrency] = useState(2)
|
||||
const [savingTaskQueue, setSavingTaskQueue] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('tasks')
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
@@ -78,13 +97,49 @@ export default function Settings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchTaskQueue = useCallback(async () => {
|
||||
try {
|
||||
const res = await getTaskQueueSettings()
|
||||
const data = res.data.data
|
||||
if (!data) return
|
||||
setTaskQueue(data)
|
||||
setTaskConcurrency(data.concurrency)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
fetchSSL()
|
||||
fetchWebSSHOrigins()
|
||||
const timer = setInterval(fetchLogs, 15000)
|
||||
return () => clearInterval(timer)
|
||||
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
|
||||
fetchTaskQueue()
|
||||
const logTimer = setInterval(fetchLogs, 15000)
|
||||
const taskTimer = setInterval(fetchTaskQueue, 5000)
|
||||
return () => {
|
||||
clearInterval(logTimer)
|
||||
clearInterval(taskTimer)
|
||||
}
|
||||
}, [fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
|
||||
|
||||
const handleSaveTaskQueue = async () => {
|
||||
const concurrency = Math.max(1, Math.min(16, Math.round(taskConcurrency || 1)))
|
||||
setSavingTaskQueue(true)
|
||||
try {
|
||||
const res = await updateTaskQueueSettings(concurrency)
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setTaskQueue(data)
|
||||
setTaskConcurrency(data.concurrency)
|
||||
}
|
||||
dialog.alert('完成', '任务队列并发设置已保存并立即生效')
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('失败', e.response?.data?.message || '任务队列设置保存失败')
|
||||
} finally {
|
||||
setSavingTaskQueue(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||
setSSLMode(mode)
|
||||
@@ -190,72 +245,173 @@ export default function Settings() {
|
||||
const totalPages = Math.ceil(logs.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">账号、安全证书与登录日志</p>
|
||||
<h1 className="text-2xl font-bold text-black dark:text-white">面板设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">任务队列、账号、安全证书与访问记录</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<div className="space-y-6">
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
|
||||
<aside className="overflow-x-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-900 lg:sticky lg:top-4">
|
||||
<nav className="flex min-w-max gap-1 lg:min-w-0 lg:flex-col" aria-label="设置分类">
|
||||
{settingsSections.map((section) => {
|
||||
const Icon = section.icon
|
||||
const active = activeSection === section.id
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors ${active ? 'bg-black text-white dark:bg-white dark:text-black' : 'text-gray-600 hover:bg-gray-100 hover:text-black dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white'}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t(section.label)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<WebSSHOriginCard
|
||||
settings={webSSHOrigins}
|
||||
originsText={webSSHOriginsText}
|
||||
saving={savingWebSSHOrigins}
|
||||
onOriginsTextChange={setWebSSHOriginsText}
|
||||
onRefresh={fetchWebSSHOrigins}
|
||||
onSave={handleSaveWebSSHOrigins}
|
||||
/>
|
||||
<section className="min-w-0">
|
||||
{activeSection === 'tasks' && (
|
||||
<TaskQueueCard
|
||||
settings={taskQueue}
|
||||
concurrency={taskConcurrency}
|
||||
saving={savingTaskQueue}
|
||||
onConcurrencyChange={setTaskConcurrency}
|
||||
onRefresh={fetchTaskQueue}
|
||||
onSave={handleSaveTaskQueue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'account' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={handleSaveAccount} className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200">保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'webssh' && (
|
||||
<WebSSHOriginCard
|
||||
settings={webSSHOrigins}
|
||||
originsText={webSSHOriginsText}
|
||||
saving={savingWebSSHOrigins}
|
||||
onOriginsTextChange={setWebSSHOriginsText}
|
||||
onRefresh={fetchWebSSHOrigins}
|
||||
onSave={handleSaveWebSSHOrigins}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'ssl' && (
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'logs' && (
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskQueueCardProps {
|
||||
settings: TaskQueueSettings | null
|
||||
concurrency: number
|
||||
saving: boolean
|
||||
onConcurrencyChange: (value: number) => void
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function TaskQueueCard(props: TaskQueueCardProps) {
|
||||
const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value)))
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<ListTodo className="h-4 w-4" />任务队列
|
||||
</h2>
|
||||
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 divide-x divide-gray-200 border-y border-gray-100 bg-gray-50">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[11px] text-gray-500">运行中</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.active ?? 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800">保存修改</button>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[11px] text-gray-500">等待中</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.pending ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-xs text-gray-500">总并发上限</label>
|
||||
<div className="flex h-9 items-stretch">
|
||||
<button type="button" onClick={() => setBounded(props.concurrency - 1)} disabled={props.concurrency <= 1} className="flex w-10 items-center justify-center rounded-l-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="减少并发">
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
value={props.concurrency}
|
||||
onChange={(event) => setBounded(Number(event.target.value) || 1)}
|
||||
className="min-w-0 flex-1 border-y border-gray-300 px-2 text-center text-sm font-medium text-black outline-none focus:ring-2 focus:ring-inset focus:ring-black"
|
||||
/>
|
||||
<button type="button" onClick={() => setBounded(props.concurrency + 1)} disabled={props.concurrency >= 16} className="flex w-10 items-center justify-center rounded-r-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="增加并发">
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存队列设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -292,7 +448,7 @@ interface WebSSHOriginCardProps {
|
||||
|
||||
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<Terminal className="h-4 w-4" />WebSSH Origin 白名单
|
||||
@@ -307,7 +463,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
<textarea
|
||||
value={props.originsText}
|
||||
onChange={(e) => props.onOriginsTextChange(e.target.value)}
|
||||
rows={5}
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
|
||||
/>
|
||||
</div>
|
||||
@@ -315,10 +471,12 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>当前面板来源:{props.settings?.current_origin || '-'}</div>
|
||||
<div className="mt-1">默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。</div>
|
||||
</div>
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -425,10 +583,12 @@ function SSLCard(props: SSLCardProps) {
|
||||
保存后自动重启服务并立即生效
|
||||
</label>
|
||||
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, HardDrive, RefreshCw, Save } from 'lucide-react'
|
||||
import { getStorageInfo, updateStoragePools, StorageDisk, StorageInfo, StoragePool } from '../services/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
const contentOptions = [
|
||||
['lxc', 'LXC 容器'],
|
||||
['kvm', 'KVM 磁盘'],
|
||||
['images', '镜像缓存'],
|
||||
['snapshots', '快照'],
|
||||
['backups', '备份'],
|
||||
] as const
|
||||
|
||||
const contentLabels = Object.fromEntries(contentOptions)
|
||||
|
||||
const contentColors: Record<string, string> = {
|
||||
lxc: '#2563eb',
|
||||
kvm: '#7c3aed',
|
||||
images: '#d97706',
|
||||
snapshots: '#059669',
|
||||
backups: '#0891b2',
|
||||
}
|
||||
|
||||
export default function Storage() {
|
||||
const { t } = useLanguage()
|
||||
const [info, setInfo] = useState<StorageInfo | null>(null)
|
||||
const [pools, setPools] = useState<StoragePool[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
const data = res.data.data || { pools: [], disks: [], content_types: [] }
|
||||
setInfo(data)
|
||||
setPools(data.pools || [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveMessage) return
|
||||
const timer = window.setTimeout(() => setSaveMessage(null), 3500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [saveMessage])
|
||||
|
||||
const mountedDisks = useMemo(() => (info?.disks || []).filter((disk) => !!disk.mount_point), [info?.disks])
|
||||
|
||||
const save = async () => {
|
||||
setSaveMessage(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const normalized = pools
|
||||
.map((pool) => ({
|
||||
...pool,
|
||||
id: (pool.id || pool.name || '').trim(),
|
||||
name: (pool.name || '').trim(),
|
||||
path: (pool.path || '').trim(),
|
||||
content_types: pool.content_types || [],
|
||||
default_contents: (pool.default_contents || []).filter((item) => (pool.content_types || []).includes(item)),
|
||||
enabled: pool.enabled !== false,
|
||||
}))
|
||||
const res = await updateStoragePools(normalized)
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setInfo(data)
|
||||
setPools(data.pools || [])
|
||||
}
|
||||
setSaveMessage({ type: 'success', text: '存储配置已保存' })
|
||||
} catch (err: any) {
|
||||
setSaveMessage({ type: 'error', text: err?.response?.data?.message || '保存存储配置失败' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateDiskPool = (disk: StorageDisk, updater: (pool: StoragePool) => StoragePool) => {
|
||||
setPools((current) => {
|
||||
const index = current.findIndex((pool) => poolForDisk(pool, disk))
|
||||
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
|
||||
const nextPool = updater(base)
|
||||
if (index >= 0) {
|
||||
return current.map((item, i) => i === index ? nextPool : item)
|
||||
}
|
||||
return [...current, nextPool]
|
||||
})
|
||||
}
|
||||
|
||||
const toggleContent = (disk: StorageDisk, content: string) => {
|
||||
updateDiskPool(disk, (pool) => {
|
||||
const current = pool.content_types || []
|
||||
const enabled = current.includes(content)
|
||||
const contentTypes = enabled ? current.filter((item) => item !== content) : [...current, content]
|
||||
return {
|
||||
...pool,
|
||||
enabled: true,
|
||||
content_types: contentTypes,
|
||||
default_contents: (pool.default_contents || []).filter((item) => contentTypes.includes(item)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDefault = (disk: StorageDisk, content: string) => {
|
||||
setPools((current) => {
|
||||
const index = current.findIndex((pool) => poolForDisk(pool, disk))
|
||||
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
|
||||
if (!(base.content_types || []).includes(content)) return current
|
||||
const hasDefault = (base.default_contents || []).includes(content)
|
||||
const baseDefaults = (base.default_contents || []).filter((value) => value !== content)
|
||||
const cleared = current.map((item) => ({
|
||||
...item,
|
||||
default_contents: (item.default_contents || []).filter((value) => value !== content),
|
||||
}))
|
||||
const nextPool = {
|
||||
...base,
|
||||
default_contents: hasDefault ? baseDefaults : [...baseDefaults, content],
|
||||
}
|
||||
if (index >= 0) {
|
||||
return cleared.map((item, i) => i === index ? nextPool : item)
|
||||
}
|
||||
return [...cleared, nextPool]
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black dark:text-white">{t('存储管理')}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetchData} className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<RefreshCw className="h-4 w-4" />{t('刷新')}
|
||||
</button>
|
||||
<button onClick={save} disabled={saving} className="inline-flex items-center gap-2 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />{t(saving ? '保存中...' : '保存')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMessage && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
|
||||
saveMessage.type === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.type === 'success'
|
||||
? <CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
: <AlertCircle className="h-4 w-4 shrink-0" />}
|
||||
<span>{t(saveMessage.text)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||
<table className="w-full min-w-[1240px] 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">{t('磁盘')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('空间分布')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('用于存储')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{mountedDisks.length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</td></tr>
|
||||
) : mountedDisks.map((disk) => {
|
||||
const pool = pools.find((item) => poolForDisk(item, disk))
|
||||
const contentUsage = contentUsageMap(pool?.content_usage || disk.content_usage || [])
|
||||
const clicdUsed = pool?.clicd_used_bytes || disk.clicd_used_bytes || 0
|
||||
return (
|
||||
<tr key={`${disk.path}-${disk.mount_point}`} className="align-top hover:bg-gray-50/70">
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-md bg-gray-100 text-gray-600">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-xs font-medium text-gray-900">{disk.path || disk.name}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{disk.model || disk.fstype || disk.type || '-'}</div>
|
||||
<div className="mt-1 font-mono text-xs text-gray-400">{disk.mount_point}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<DiskUsageBar disk={disk} contentUsage={contentUsage} clicdUsed={clicdUsed} />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex min-w-[620px] flex-nowrap items-start gap-2">
|
||||
{contentOptions.map(([value, label]) => {
|
||||
const checked = (pool?.content_types || []).includes(value)
|
||||
const isDefault = (pool?.default_contents || []).includes(value)
|
||||
return (
|
||||
<div key={value} className={`w-[116px] shrink-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-700">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
|
||||
{t(label)}
|
||||
</label>
|
||||
{checked && (
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2 border-t border-gray-100 pt-1.5">
|
||||
<span className="text-[11px] text-gray-500">{t('默认盘')}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isDefault}
|
||||
title={isDefault ? `${t('关闭')} ${t(label)} ${t('默认盘')}` : `${t('设为')} ${t(label)} ${t('默认盘')}`}
|
||||
onClick={() => toggleDefault(disk, value)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 appearance-none items-center rounded-full border p-0 transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 ${isDefault ? 'border-black bg-black' : 'border-gray-300 bg-gray-200'}`}
|
||||
>
|
||||
<span className={`pointer-events-none absolute left-0.5 top-0.5 block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${isDefault ? 'translate-x-4' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskUsageBar({
|
||||
disk,
|
||||
contentUsage,
|
||||
clicdUsed,
|
||||
}: {
|
||||
disk: StorageDisk
|
||||
contentUsage: Record<string, number>
|
||||
clicdUsed: number
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const total = Math.max(0, disk.size_bytes || 0)
|
||||
const free = Math.max(0, Math.min(total, disk.free_bytes || 0))
|
||||
const used = Math.max(0, total - free)
|
||||
const rawContentSegments = contentOptions.map(([value, label]) => ({
|
||||
key: value,
|
||||
label,
|
||||
size: Math.max(0, contentUsage[value] || 0),
|
||||
color: contentColors[value],
|
||||
}))
|
||||
const rawContentTotal = rawContentSegments.reduce((sum, segment) => sum + segment.size, 0)
|
||||
const normalizedClicdUsed = Math.max(0, Math.min(used, Math.max(clicdUsed || 0, rawContentTotal)))
|
||||
const contentScale = rawContentTotal > normalizedClicdUsed && rawContentTotal > 0
|
||||
? normalizedClicdUsed / rawContentTotal
|
||||
: 1
|
||||
const contentSegments = rawContentSegments.map((segment) => ({ ...segment, size: segment.size * contentScale }))
|
||||
const categorizedClicdUsed = contentSegments.reduce((sum, segment) => sum + segment.size, 0)
|
||||
const unclassifiedClicdUsed = Math.max(0, normalizedClicdUsed - categorizedClicdUsed)
|
||||
const nonClicdUsed = Math.max(0, used - normalizedClicdUsed)
|
||||
const segments = [
|
||||
...contentSegments,
|
||||
{ key: 'clicd-other', label: 'CLICD 其他', size: unclassifiedClicdUsed, color: '#111827' },
|
||||
{ key: 'other', label: '非 CLICD', size: nonClicdUsed, color: '#4b5563' },
|
||||
{ key: 'free', label: '可用空间', size: free, color: '#e5e7eb' },
|
||||
].filter((segment) => segment.size > 0)
|
||||
|
||||
return (
|
||||
<div className="min-w-[420px] max-w-[620px]">
|
||||
<div className="flex items-center justify-between gap-4 text-xs text-gray-600">
|
||||
<span>{t('已用')} {formatBytes(used)} / {formatBytes(total)}</span>
|
||||
<span>{usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex h-8 w-full overflow-hidden rounded-md border border-gray-300 bg-gray-100">
|
||||
{segments.map((segment) => {
|
||||
const pct = usagePct(segment.size, total)
|
||||
return (
|
||||
<div
|
||||
key={segment.key}
|
||||
title={`${t(segment.label)}: ${formatBytes(segment.size)} (${pct.toFixed(2)}%)`}
|
||||
className="flex h-full items-center justify-center overflow-hidden border-r border-white/70 text-[10px] font-medium text-white last:border-r-0"
|
||||
style={{ width: `${pct}%`, minWidth: pct > 0 && pct < 0.6 ? '3px' : undefined, backgroundColor: segment.color }}
|
||||
>
|
||||
{pct >= 9 && <span className={segment.key === 'free' ? 'text-gray-600' : ''}>{t(segment.label)}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.key} className="flex items-center gap-1.5 text-[11px] text-gray-600">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm border border-black/5" style={{ backgroundColor: segment.color }} />
|
||||
<span>{t(segment.label)}</span>
|
||||
<span className="font-medium text-gray-800">{formatBytes(segment.size)}</span>
|
||||
<span className="text-gray-400">{usagePct(segment.size, total).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function poolForDisk(pool: StoragePool, disk: StorageDisk) {
|
||||
if (!disk.mount_point) return false
|
||||
const mount = cleanPath(disk.mount_point)
|
||||
const poolMount = cleanPath(pool.mount_point || '')
|
||||
const poolPath = cleanPath(pool.path || '')
|
||||
return poolMount === mount || poolPath === mount || poolPath.startsWith(`${mount}/`)
|
||||
}
|
||||
|
||||
function defaultPoolForDisk(disk: StorageDisk): StoragePool {
|
||||
const mount = cleanPath(disk.mount_point || '/')
|
||||
const baseName = mount === '/' ? 'system' : mount.split('/').filter(Boolean).pop() || disk.name || 'disk'
|
||||
const primaryContents = mount === '/' ? contentOptions.map(([value]) => value) : []
|
||||
return {
|
||||
id: `disk-${slugID(mount === '/' ? 'root' : baseName)}`,
|
||||
name: `${baseName} (${disk.path || disk.name})`,
|
||||
path: mount === '/' ? '/var/lib/clicd' : `${mount}/clicd`,
|
||||
content_types: primaryContents,
|
||||
default_contents: [...primaryContents],
|
||||
enabled: true,
|
||||
mount_point: disk.mount_point,
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPath(value: string) {
|
||||
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || '/'
|
||||
}
|
||||
|
||||
function slugID(value: string) {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'storage'
|
||||
}
|
||||
|
||||
function contentUsageMap(items: Array<{ content_type: string; size_bytes: number }>) {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.content_type] = (acc[item.content_type] || 0) + (item.size_bytes || 0)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function usagePct(used: number, total: number) {
|
||||
if (!total || total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, (used / total) * 100))
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!bytes) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index++
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
@@ -31,6 +32,7 @@ interface AuditLogExt extends AuditLog {
|
||||
|
||||
export default function SubUserManagement() {
|
||||
const dialog = useDialog()
|
||||
const { t } = useLanguage()
|
||||
const [users, setUsers] = useState<SubUserItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
|
||||
@@ -173,8 +175,10 @@ export default function SubUserManagement() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-black dark:text-white">子用户管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">容器分配的子用户列表,共 {users.length} 个</p>
|
||||
<h1 className="text-xl font-semibold text-black dark:text-white">{t('子用户管理')}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('容器分配的子用户列表,共')} {users.length} {t('个')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
|
||||
Reference in New Issue
Block a user