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 = { lxc: '#2563eb', kvm: '#7c3aed', images: '#d97706', snapshots: '#059669', backups: '#0891b2', } export default function Storage() { const { t } = useLanguage() const [info, setInfo] = useState(null) const [pools, setPools] = useState([]) 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 (
) } return (

{t('存储管理')}

{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}

{saveMessage && (
{saveMessage.type === 'success' ? : } {t(saveMessage.text)}
)}
{mountedDisks.length === 0 ? ( ) : 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 ( ) })}
{t('磁盘')} {t('空间分布')} {t('用于存储')}
{t('未检测到已挂载磁盘')}
{disk.path || disk.name}
{disk.model || disk.fstype || disk.type || '-'}
{disk.mount_point}
{contentOptions.map(([value, label]) => { const checked = (pool?.content_types || []).includes(value) const isDefault = (pool?.default_contents || []).includes(value) return (
{checked && (
{t('默认盘')}
)}
) })}
) } function DiskUsageBar({ disk, contentUsage, clicdUsed, }: { disk: StorageDisk contentUsage: Record 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 (
{t('已用')} {formatBytes(used)} / {formatBytes(total)} {usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}
{segments.map((segment) => { const pct = usagePct(segment.size, total) return (
0 && pct < 0.6 ? '3px' : undefined, backgroundColor: segment.color }} > {pct >= 9 && {t(segment.label)}}
) })}
{segments.map((segment) => (
{t(segment.label)} {formatBytes(segment.size)} {usagePct(segment.size, total).toFixed(1)}%
))}
) } 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>((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]}` }