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
}
+21
View File
@@ -29,6 +29,7 @@ import { Route as AdminMediaRouteImport } from './routes/admin/media'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
import { Route as AdminStorageRouteImport } from './routes/admin/storage'
import { Route as AdminUsersRouteImport } from './routes/admin/users'
import { Route as AdminChannelsIndexRouteImport } from './routes/admin/channels.index'
import { Route as AdminChannelsChannelIdRouteImport } from './routes/admin/channels.$channelId'
@@ -139,6 +140,11 @@ const AdminShowsRoute = AdminShowsRouteImport.update({
path: '/shows',
getParentRoute: () => AdminRoute,
} as any)
const AdminStorageRoute = AdminStorageRouteImport.update({
id: '/storage',
path: '/storage',
getParentRoute: () => AdminRoute,
} as any)
const AdminUsersRoute = AdminUsersRouteImport.update({
id: '/users',
path: '/users',
@@ -206,6 +212,7 @@ export interface FileRoutesByFullPath {
'/admin/roles': typeof AdminRolesRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
'/admin/storage': typeof AdminStorageRoute
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
@@ -232,6 +239,7 @@ export interface FileRoutesByTo {
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/storage': typeof AdminStorageRoute
'/admin/users': typeof AdminUsersRoute
'/admin': typeof AdminIndexRoute
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
@@ -264,6 +272,7 @@ export interface FileRoutesById {
'/admin/roles': typeof AdminRolesRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
'/admin/storage': typeof AdminStorageRoute
'/admin/users': typeof AdminUsersRoute
'/admin/': typeof AdminIndexRoute
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
@@ -297,6 +306,7 @@ export interface FileRouteTypes {
| '/admin/roles'
| '/admin/settings'
| '/admin/shows'
| '/admin/storage'
| '/admin/users'
| '/admin/'
| '/admin/channels/$channelId'
@@ -323,6 +333,7 @@ export interface FileRouteTypes {
| '/admin/media'
| '/admin/roles'
| '/admin/settings'
| '/admin/storage'
| '/admin/users'
| '/admin'
| '/admin/channels/$channelId'
@@ -354,6 +365,7 @@ export interface FileRouteTypes {
| '/admin/roles'
| '/admin/settings'
| '/admin/shows'
| '/admin/storage'
| '/admin/users'
| '/admin/'
| '/admin/channels/$channelId'
@@ -517,6 +529,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminShowsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/storage': {
id: '/admin/storage'
path: '/storage'
fullPath: '/admin/storage'
preLoaderRoute: typeof AdminStorageRouteImport
parentRoute: typeof AdminRoute
}
'/admin/users': {
id: '/admin/users'
path: '/users'
@@ -652,6 +671,7 @@ interface AdminRouteChildren {
AdminRolesRoute: typeof AdminRolesRoute
AdminSettingsRoute: typeof AdminSettingsRoute
AdminShowsRoute: typeof AdminShowsRouteWithChildren
AdminStorageRoute: typeof AdminStorageRoute
AdminUsersRoute: typeof AdminUsersRoute
AdminIndexRoute: typeof AdminIndexRoute
}
@@ -670,6 +690,7 @@ const AdminRouteChildren: AdminRouteChildren = {
AdminRolesRoute: AdminRolesRoute,
AdminSettingsRoute: AdminSettingsRoute,
AdminShowsRoute: AdminShowsRouteWithChildren,
AdminStorageRoute: AdminStorageRoute,
AdminUsersRoute: AdminUsersRoute,
AdminIndexRoute: AdminIndexRoute,
}
+7
View File
@@ -99,6 +99,13 @@ function AdminLayout() {
>
{t('admin.users.title')}
</Link>
<Link
to="/admin/storage"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.storage.title')}
</Link>
<Link
to="/admin/maintenance"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { StoragePanel } from '@/features/admin/storage/StoragePanel'
export const Route = createFileRoute('/admin/storage')({ component: StoragePanel })
+4
View File
@@ -107,6 +107,10 @@ export const qk = {
all: ['admin', 'settings'] as const,
},
storage: {
all: ['admin', 'storage'] as const,
},
metadata: {
providers: ['admin', 'metadata', 'providers'] as const,
},
+34
View File
@@ -427,6 +427,40 @@ export type ShowDto = {
collections: ShowCollectionRefDto[]
}
// ── Хранилище ─────────────────────────────────────────────────────────────
/** Части хранилища, которые считаются отдельно (см. StorageArea на сервере). */
export type StorageArea =
| 'Assets'
| 'BumperAssets'
| 'Originals'
| 'Inbox'
| 'ManualInbox'
| 'Uploads'
| 'BumperSources'
| 'Images'
| 'Other'
export type StorageAreaDto = {
area: StorageArea
bytes: number
files: number
}
export type StorageStatsDto = {
rootPath: string
/** Размер тома целиком; 0 — файловая система не отдала метрику. */
volumeTotalBytes: number
volumeFreeBytes: number
/** Сколько занимает само хранилище TeleWave. */
storageBytes: number
storageFiles: number
/** Порог, ниже которого загрузка отклоняется. */
minFreeSpaceBytes: number
areas: StorageAreaDto[]
/** Когда посчитано: обход дерева кэшируется. */
computedAt: string
}
// ── Каналы ────────────────────────────────────────────────────────────────
type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
export type BumperFont = 'Sans' | 'Serif'
+40
View File
@@ -798,6 +798,46 @@ export const en = {
slot: 'Slot title',
},
},
storage: {
title: 'Storage',
volume: 'Storage volume',
volumeUnknown:
'The file system did not report the volume size — only the storage usage is shown.',
totalVolume: 'Volume total',
ours: 'Used by TeleWave',
foreign: 'Other on the volume',
foreignHint: 'database, system, foreign files',
free: 'Free',
lowSpace: 'Free space is below the threshold ({{threshold}}) — new uploads are rejected.',
recount: 'Recount',
computedAt: 'computed at {{time}}',
breakdown: 'What takes the space',
filesTotal: 'files: {{count}}',
files: 'files: {{count}}',
empty: 'The storage is empty.',
areas: {
Assets: 'Programme segments',
BumperAssets: 'Bumper segments',
Originals: 'Originals',
Inbox: 'Inbox',
ManualInbox: 'Manual inbox',
Uploads: 'Unfinished uploads',
BumperSources: 'Bumper sound and backgrounds',
Images: 'Images',
Other: 'Other under the root',
},
areaHints: {
Assets: 'HLS segments of the library — usually almost everything',
BumperAssets: 'rendered bumpers; cleaned up along with the schedule',
Originals: 'kept only when Storage:KeepOriginals is on',
Inbox: 'not picked up by the scanner yet',
ManualInbox: 'taken into shows by hand',
Uploads: 'staging directory; leftovers mean an aborted upload',
BumperSources: 'raw block files, not the segments',
Images: 'posters, stills, logos',
Other: 'files outside the known directories — usually zero',
},
},
maintenance: {
title: 'Maintenance',
warning: 'These actions are irreversible — data and files are deleted permanently.',
+40
View File
@@ -798,6 +798,46 @@ export const ru = {
slot: 'Название слота',
},
},
storage: {
title: 'Хранилище',
volume: 'Том хранилища',
volumeUnknown: 'Файловая система не отдала размер тома — показано только занятое хранилищем.',
totalVolume: 'Всего на томе',
ours: 'Занято TeleWave',
foreign: 'Прочее на томе',
foreignHint: 'база, система, чужие файлы',
free: 'Свободно',
lowSpace:
'Свободного места меньше порога ({{threshold}}) — загрузка новых файлов отклоняется.',
recount: 'Пересчитать',
computedAt: 'посчитано в {{time}}',
breakdown: 'Из чего складывается',
filesTotal: 'файлов: {{count}}',
files: 'файлов: {{count}}',
empty: 'Хранилище пустое.',
areas: {
Assets: 'Сегменты программ',
BumperAssets: 'Сегменты заставок',
Originals: 'Исходники',
Inbox: 'Inbox',
ManualInbox: 'Ручной inbox',
Uploads: 'Незавершённые загрузки',
BumperSources: 'Звук и фоны заставок',
Images: 'Изображения',
Other: 'Прочее под корнем',
},
areaHints: {
Assets: 'HLS-нарезка библиотеки — обычно почти весь объём',
BumperAssets: 'отрендеренные заставки; чистятся вместе с расписанием',
Originals: 'остаются, только если включён Storage:KeepOriginals',
Inbox: 'то, что ещё не разобрал сканер',
ManualInbox: 'то, что забирается в шоу вручную',
Uploads: 'перевалочный каталог; залежавшееся — след оборванной заливки',
BumperSources: 'сырые файлы блоков, не нарезка',
Images: 'постеры, кадры, логотипы',
Other: 'файлы мимо известных каталогов — обычно ноль',
},
},
maintenance: {
title: 'Обслуживание',
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',