mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
ebba97f1d6
· 增加了局域网DHCP IP分配适配 · 完善了多盘兼容支持 #17
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
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]}`
|
|
}
|