mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-08 06:24:44 +08:00
@@ -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]}`
|
||||
}
|
||||
Reference in New Issue
Block a user