Add storage management features and UI components
ci / build-backend (push) Successful in 1m32s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 3m50s
ci / sonar (push) Successful in 4m7s

Implemented new storage endpoints in the API and updated the dependency injection to include storage-related services. Enhanced the frontend by adding storage routes and links in the admin layout, along with new types and localization for storage management. Updated documentation to reflect the new storage features and their usage in the admin interface.
This commit is contained in:
Leonid Pershin
2026-07-27 22:23:48 +03:00
parent 779f322f62
commit 8ae8eebc12
19 changed files with 1013 additions and 0 deletions
@@ -0,0 +1,228 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { StorageArea, StorageAreaDto } from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { cn } from '@/shared/lib/cn'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { getStorageStats } from './api'
import { formatBytes, percentOf } from './format'
/** Цвета областей — те же и в полосе, и в списке: полоса без легенды не читается. */
const AREA_COLORS: Record<StorageArea, string> = {
Assets: 'bg-emerald-500',
BumperAssets: 'bg-violet-500',
Originals: 'bg-sky-500',
Inbox: 'bg-amber-500',
ManualInbox: 'bg-orange-500',
Uploads: 'bg-rose-500',
BumperSources: 'bg-fuchsia-500',
Images: 'bg-teal-500',
Other: 'bg-muted-foreground',
}
/** Порядок вывода: сверху то, что реально занимает место, снизу — служебное. */
const AREA_ORDER: StorageArea[] = [
'Assets',
'BumperAssets',
'Originals',
'Inbox',
'ManualInbox',
'Uploads',
'BumperSources',
'Images',
'Other',
]
/**
* Чем занят диск. Два разных вопроса на одном экране: сколько осталось на томе (его делят все,
* включая базу и систему) и на что ушло место у самого TeleWave.
*/
export function StoragePanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const { data, isLoading } = useQuery({
queryKey: qk.storage.all,
queryFn: () => getStorageStats(),
})
const refresh = async () => {
setRefreshing(true)
try {
const fresh = await getStorageStats(true)
queryClient.setQueryData(qk.storage.all, fresh)
} finally {
setRefreshing(false)
}
}
if (isLoading || !data) return <p className="text-muted-foreground">{t('common.loading')}</p>
const { volumeTotalBytes: total, volumeFreeBytes: free, storageBytes: storage } = data
const volumeKnown = total > 0
const used = Math.max(0, total - free)
// На томе есть и чужое: база, система, чей-то бэкап. Показываем это отдельной долей, иначе
// «занято 80%» выглядит как вина медиатеки.
const foreign = Math.max(0, used - storage)
const lowSpace = volumeKnown && free < data.minFreeSpaceBytes
const areas = [...data.areas]
.filter((a) => a.bytes > 0)
.sort((a, b) => AREA_ORDER.indexOf(a.area) - AREA_ORDER.indexOf(b.area))
return (
<div className="flex flex-col gap-4">
<Card>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col">
<span className="text-sm font-medium">{t('admin.storage.volume')}</span>
<span className="font-mono text-xs text-muted-foreground">{data.rootPath}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{t('admin.storage.computedAt', {
time: new Date(data.computedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
})}
</span>
<Button size="sm" variant="outline" disabled={refreshing} onClick={refresh}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
{t('admin.storage.recount')}
</Button>
</div>
</div>
{volumeKnown ? (
<>
{/* Полоса тома: своё, чужое, свободное. */}
<div className="flex h-4 overflow-hidden rounded-full bg-muted/40">
<div
className="bg-primary"
style={{ width: `${percentOf(storage, total)}%` }}
title={`${t('admin.storage.ours')} · ${formatBytes(storage)}`}
/>
<div
className="bg-muted-foreground/50"
style={{ width: `${percentOf(foreign, total)}%` }}
title={`${t('admin.storage.foreign')} · ${formatBytes(foreign)}`}
/>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-4">
<Metric label={t('admin.storage.totalVolume')} value={formatBytes(total)} />
<Metric
label={t('admin.storage.ours')}
value={formatBytes(storage)}
hint={`${percentOf(storage, total).toFixed(1)}%`}
accent="text-primary"
/>
<Metric
label={t('admin.storage.foreign')}
value={formatBytes(foreign)}
hint={t('admin.storage.foreignHint')}
/>
<Metric
label={t('admin.storage.free')}
value={formatBytes(free)}
hint={`${percentOf(free, total).toFixed(1)}%`}
accent={lowSpace ? 'text-destructive' : 'text-emerald-500'}
/>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">{t('admin.storage.volumeUnknown')}</p>
)}
{lowSpace && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
<span>
{t('admin.storage.lowSpace', { threshold: formatBytes(data.minFreeSpaceBytes) })}
</span>
</div>
)}
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-medium">{t('admin.storage.breakdown')}</span>
<span className="text-xs text-muted-foreground">
{t('admin.storage.filesTotal', { count: data.storageFiles })} · {formatBytes(storage)}
</span>
</div>
{/* Полоса состава хранилища — доли областей друг относительно друга. */}
{storage > 0 && (
<div className="flex h-3 overflow-hidden rounded-full bg-muted/40">
{areas.map((area) => (
<div
key={area.area}
className={AREA_COLORS[area.area]}
style={{ width: `${percentOf(area.bytes, storage)}%` }}
title={`${t(`admin.storage.areas.${area.area}`)} · ${formatBytes(area.bytes)}`}
/>
))}
</div>
)}
<div className="flex flex-col divide-y divide-border">
{areas.map((area) => (
<AreaRow key={area.area} area={area} total={storage} />
))}
{areas.length === 0 && (
<p className="text-sm text-muted-foreground">{t('admin.storage.empty')}</p>
)}
</div>
</CardContent>
</Card>
</div>
)
}
function Metric({
label,
value,
hint,
accent,
}: Readonly<{ label: string; value: string; hint?: string; accent?: string }>) {
return (
<div className="flex flex-col">
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
<span className={cn('font-mono text-lg tabular-nums', accent)}>{value}</span>
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</div>
)
}
function AreaRow({ area, total }: Readonly<{ area: StorageAreaDto; total: number }>) {
const { t } = useTranslation()
const share = percentOf(area.bytes, total)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
<span className={cn('h-3 w-3 shrink-0 rounded-sm', AREA_COLORS[area.area])} />
<div className="flex min-w-48 flex-col">
<span>{t(`admin.storage.areas.${area.area}`)}</span>
<span className="text-xs text-muted-foreground">
{t(`admin.storage.areaHints.${area.area}`)}
</span>
</div>
<span className="ml-auto font-mono tabular-nums">{formatBytes(area.bytes)}</span>
<span className="w-14 text-right font-mono text-xs tabular-nums text-muted-foreground">
{share.toFixed(1)}%
</span>
<span className="w-24 text-right text-xs text-muted-foreground">
{t('admin.storage.files', { count: area.files })}
</span>
</div>
)
}
@@ -0,0 +1,7 @@
import { apiRequest } from '@/shared/api/client'
import type { StorageStatsDto } from '@/shared/api/types'
/** Отчёт по диску. `refresh` — явный пересчёт: обход дерева хранилища долгий и кэшируется. */
export function getStorageStats(refresh = false) {
return apiRequest<StorageStatsDto>(`/admin/storage${refresh ? '?refresh=true' : ''}`)
}
@@ -0,0 +1,20 @@
/** Единицы двоичные (КиБ/МиБ/…), но подписи привычные — так их и пишут в панелях хостингов. */
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
/**
* Байты в человеческий размер. Точность плавающая: у гигабайт десятая доля значима, у килобайт —
* уже шум, и «1.0 KB» читается хуже, чем «1 KB».
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const power = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1)
const value = bytes / 1024 ** power
const digits = power >= 3 && value < 100 ? 1 : 0
return `${value.toFixed(digits)} ${UNITS[power]}`
}
/** Доля в процентах для полос и подписей; 0 — когда делить не на что. */
export function percentOf(part: number, total: number): number {
return total > 0 ? (part / total) * 100 : 0
}