diff --git a/backend/src/TeleWave.Api/Endpoints/StorageEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StorageEndpoints.cs new file mode 100644 index 0000000..bffd923 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/StorageEndpoints.cs @@ -0,0 +1,33 @@ +using LiteCqrs; +using TeleWave.Application.Storage; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +/// Чем занят диск: объём по областям хранилища и заполненность тома. +public static class StorageEndpoints +{ + public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/admin/storage", Stats) + .WithTags("Admin.Storage") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)) + .Produces(); + + return app; + } + + /// Пересчитать, не дожидаясь истечения кэша: обход дерева долгий. + private static async Task Stats( + bool? refresh, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new GetStorageStatsQuery(refresh ?? false), + cancellationToken + ); + return Results.Ok(result); + } +} diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index b379094..bfc62aa 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -135,6 +135,7 @@ app.MapJunctionEndpoints(); app.MapChannelEndpoints(); app.MapStreamingEndpoints(); app.MapMaintenanceEndpoints(); +app.MapStorageEndpoints(); app.MapSettingsEndpoints(); app.MapMetadataEndpoints(); app.MapImageEndpoints(); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IStorageInspector.cs b/backend/src/TeleWave.Application/Common/Interfaces/IStorageInspector.cs new file mode 100644 index 0000000..870c68e --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IStorageInspector.cs @@ -0,0 +1,72 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Части хранилища, которые считаются отдельно. Это не «папки», а ответ на вопрос «чем занят диск»: +/// сегменты программ и сегменты заставок лежат в одном каталоге, но растут по разным причинам, +/// и администратору важно видеть их порознь. +/// +public enum StorageArea +{ + /// HLS-сегменты программ — обычно это почти весь объём. + Assets = 0, + + /// HLS-сегменты отрендеренных заставок. + BumperAssets = 1, + + /// Исходники, оставленные после нарезки (Storage:KeepOriginals). + Originals = 2, + + /// Автоматический inbox — то, что ещё не разобрано сканером. + Inbox = 3, + + /// Ручной inbox — то, что админ забирает в шоу сам. + ManualInbox = 4, + + /// Перевалочный каталог заливок: всё, что здесь залежалось, — след оборванной загрузки. + Uploads = 5, + + /// Звук и фоны блоков заставок (сырые файлы, не нарезка). + BumperSources = 6, + + /// Общий реестр изображений: постеры, кадры, логотипы. + Images = 7, + + /// Всё прочее под корнем хранилища — обычно ноль, а если нет, то это и надо увидеть. + Other = 8, +} + +/// Объём области, байт. +/// Число файлов — по нему видно, что «пусто» и «много мелочи» это разные беды. +public sealed record StorageAreaUsage(StorageArea Area, long Bytes, int Files); + +/// Корень хранилища — на сервере их может быть несколько, и надо видеть, о каком речь. +/// Размер тома целиком; 0 — файловая система не отдала метрику. +/// Свободно на томе. Свободное место делится со всем, что есть на диске. +/// Порог, ниже которого загрузка отклоняется; едет вместе с цифрами тома, чтобы их было с чем сравнить. +/// Когда посчитано: обход дерева кэшируется, и возраст цифры надо показывать. +public sealed record StorageUsage( + string RootPath, + long VolumeTotalBytes, + long VolumeFreeBytes, + long StorageBytes, + int StorageFiles, + long MinFreeSpaceBytes, + IReadOnlyList Areas, + DateTimeOffset ComputedAt +); + +/// +/// Порт подсчёта занятого места. Размеров в БД нет и не будет: правда о диске лежит на диске, +/// а любое дублирование в таблицах разошлось бы с ней при первой же ручной правке файлов. +/// +/// Обход дерева стоит дорого (сегментов — сотни тысяч), поэтому результат кэшируется реализацией, +/// а — это явная кнопка «пересчитать» в админке. +/// +public interface IStorageInspector +{ + Task InspectAsync( + IReadOnlySet generatedAssetIds, + bool refresh, + CancellationToken cancellationToken + ); +} diff --git a/backend/src/TeleWave.Application/Storage/GetStorageStatsQuery.cs b/backend/src/TeleWave.Application/Storage/GetStorageStatsQuery.cs new file mode 100644 index 0000000..523704e --- /dev/null +++ b/backend/src/TeleWave.Application/Storage/GetStorageStatsQuery.cs @@ -0,0 +1,25 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Storage; + +/// +/// Чем занят диск. — явный пересчёт: обход дерева долгий, поэтому +/// обычный запрос отдаёт кэш и его возраст. +/// +public sealed record GetStorageStatsQuery(bool Refresh = false) : IQuery; + +public sealed record StorageAreaDto(StorageArea Area, long Bytes, int Files); + +/// Корень хранилища — на сервере их может быть несколько, и надо видеть, о каком речь. +/// Порог, ниже которого загрузка отклоняется (Storage:MinFreeSpaceBytes). +public sealed record StorageStatsDto( + string RootPath, + long VolumeTotalBytes, + long VolumeFreeBytes, + long StorageBytes, + int StorageFiles, + long MinFreeSpaceBytes, + IReadOnlyList Areas, + DateTimeOffset ComputedAt +); diff --git a/backend/src/TeleWave.Application/Storage/GetStorageStatsQueryHandler.cs b/backend/src/TeleWave.Application/Storage/GetStorageStatsQueryHandler.cs new file mode 100644 index 0000000..650fad1 --- /dev/null +++ b/backend/src/TeleWave.Application/Storage/GetStorageStatsQueryHandler.cs @@ -0,0 +1,42 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Storage; + +public sealed class GetStorageStatsQueryHandler( + IAppDbContext dbContext, + IStorageInspector inspector +) : IQueryHandler +{ + public async Task Handle( + GetStorageStatsQuery query, + CancellationToken cancellationToken + ) + { + // Сегменты программ и заставок лежат в одном каталоге; отличить их можно только по базе. + var generated = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => a.Source == MediaSource.Generated) + .Select(a => a.Id) + .ToListAsync(cancellationToken); + + var usage = await inspector.InspectAsync( + generated.ToHashSet(), + query.Refresh, + cancellationToken + ); + + return new StorageStatsDto( + usage.RootPath, + usage.VolumeTotalBytes, + usage.VolumeFreeBytes, + usage.StorageBytes, + usage.StorageFiles, + usage.MinFreeSpaceBytes, + usage.Areas.Select(a => new StorageAreaDto(a.Area, a.Bytes, a.Files)).ToList(), + usage.ComputedAt + ); + } +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index d4155c9..16be7ab 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -154,6 +154,8 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Синглтон: обход дерева дорогой, и кэш результата живёт в самой реализации. + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs new file mode 100644 index 0000000..c25da67 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs @@ -0,0 +1,277 @@ +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Считает занятое место обходом дерева хранилища. Другого источника правды нет: размеры в БД +/// разошлись бы с диском при первой же ручной правке файлов. +/// +/// Обход дорогой — сегментов у нарезанной библиотеки сотни тысяч, — поэтому результат живёт +/// в кэше и пересчитывается по явной просьбе. Параллельные запросы +/// не запускают второй обход: они ждут первый. +/// +public sealed class FileSystemStorageInspector( + MediaPathResolver paths, + IOptions options +) : IStorageInspector +{ + /// Сколько цифра считается свежей. Диск меняется медленно, а обход стоит дорого. + private static readonly TimeSpan CacheLifetime = TimeSpan.FromMinutes(5); + + private readonly StorageOptions _storage = options.Value; + private readonly SemaphoreSlim _lock = new(1, 1); + private StorageUsage? _cached; + + public async Task InspectAsync( + IReadOnlySet generatedAssetIds, + bool refresh, + CancellationToken cancellationToken + ) + { + if (!refresh && IsFresh(_cached)) + return _cached!; + + await _lock.WaitAsync(cancellationToken); + try + { + // Пока ждали очереди, обход мог уже пройти — второй раз ходить незачем. + if (!refresh && IsFresh(_cached)) + return _cached!; + + var usage = await Task.Run( + () => Inspect(generatedAssetIds, cancellationToken), + cancellationToken + ); + _cached = usage; + return usage; + } + finally + { + _lock.Release(); + } + } + + private static bool IsFresh(StorageUsage? usage) => + usage is not null && DateTimeOffset.UtcNow - usage.ComputedAt < CacheLifetime; + + private StorageUsage Inspect( + IReadOnlySet generatedAssetIds, + CancellationToken cancellationToken + ) + { + var known = new[] + { + paths.AssetsDir, + paths.OriginalsDir, + paths.InboxDir, + paths.ManualDir, + paths.UploadsDir, + paths.BumpersDir, + paths.ImagesDir, + }; + + var (programs, bumpers) = MeasureAssets(generatedAssetIds, cancellationToken); + var areas = new List + { + new(StorageArea.Assets, programs.Bytes, programs.Files), + new(StorageArea.BumperAssets, bumpers.Bytes, bumpers.Files), + Area(StorageArea.Originals, paths.OriginalsDir, cancellationToken), + Area(StorageArea.Inbox, paths.InboxDir, cancellationToken), + Area(StorageArea.ManualInbox, paths.ManualDir, cancellationToken), + Area(StorageArea.Uploads, paths.UploadsDir, cancellationToken), + Area(StorageArea.BumperSources, paths.BumpersDir, cancellationToken), + Area(StorageArea.Images, paths.ImagesDir, cancellationToken), + MeasureOther(known, cancellationToken), + }; + + var (total, free) = Volume(); + return new StorageUsage( + _storage.RootPath, + total, + free, + areas.Sum(a => a.Bytes), + areas.Sum(a => a.Files), + _storage.MinFreeSpaceBytes, + areas, + DateTimeOffset.UtcNow + ); + } + + /// + /// Сегменты, разложенные на программы и заставки. Каталог у них общий (assets/{id}), + /// и разделить их можно только по идентификатору: имя каталога — это идентификатор ассета. + /// + private (Measurement Programs, Measurement Bumpers) MeasureAssets( + IReadOnlySet generatedAssetIds, + CancellationToken cancellationToken + ) + { + var programs = new Measurement(); + var bumpers = new Measurement(); + if (!Directory.Exists(paths.AssetsDir)) + return (programs, bumpers); + + foreach (var directory in SafeEnumerateDirectories(paths.AssetsDir)) + { + cancellationToken.ThrowIfCancellationRequested(); + var measured = Measure(directory, cancellationToken); + var generated = + Guid.TryParse(Path.GetFileName(directory), out var assetId) + && generatedAssetIds.Contains(assetId); + if (generated) + bumpers.Add(measured); + else + programs.Add(measured); + } + + // Файлы, лежащие в самом assets/ мимо каталогов ассетов, — тоже занятое место. + programs.Add(MeasureFiles(paths.AssetsDir)); + return (programs, bumpers); + } + + /// Всё, что лежит под корнем мимо известных каталогов, — обычно ноль, но увидеть это надо. + private StorageAreaUsage MeasureOther( + IReadOnlyCollection known, + CancellationToken cancellationToken + ) + { + var root = Path.GetFullPath(_storage.RootPath); + var measurement = new Measurement(); + if (!Directory.Exists(root)) + return new StorageAreaUsage(StorageArea.Other, 0, 0); + + measurement.Add(MeasureFiles(root)); + foreach (var directory in SafeEnumerateDirectories(root)) + { + if (known.Any(k => PathsEqual(k, directory))) + continue; + measurement.Add(Measure(directory, cancellationToken)); + } + + return new StorageAreaUsage(StorageArea.Other, measurement.Bytes, measurement.Files); + } + + private StorageAreaUsage Area( + StorageArea area, + string directory, + CancellationToken cancellationToken + ) + { + var measurement = Measure(directory, cancellationToken); + return new StorageAreaUsage(area, measurement.Bytes, measurement.Files); + } + + /// Размер тома хранилища; нули — файловая система не отдала метрику. + private (long Total, long Free) Volume() + { + try + { + var drive = new DriveInfo(Path.GetFullPath(_storage.RootPath)); + return (drive.TotalSize, drive.AvailableFreeSpace); + } + catch (Exception ex) + when (ex is ArgumentException or IOException or UnauthorizedAccessException) + { + // Экзотическая точка монтирования — показываем только занятое хранилищем. + return (0, 0); + } + } + + private static Measurement Measure(string directory, CancellationToken cancellationToken) + { + var measurement = new Measurement(); + if (!Directory.Exists(directory)) + return measurement; + + // Перечисление с игнорированием недоступного: одна запертая папка не должна ронять отчёт. + var enumeration = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + }; + + foreach (var file in Directory.EnumerateFiles(directory, "*", enumeration)) + { + cancellationToken.ThrowIfCancellationRequested(); + measurement.Add(SizeOf(file)); + } + + return measurement; + } + + private static Measurement MeasureFiles(string directory) + { + var measurement = new Measurement(); + if (!Directory.Exists(directory)) + return measurement; + + foreach ( + var file in Directory.EnumerateFiles( + directory, + "*", + new EnumerationOptions { IgnoreInaccessible = true } + ) + ) + measurement.Add(SizeOf(file)); + + return measurement; + } + + private static IEnumerable SafeEnumerateDirectories(string directory) + { + try + { + return Directory.EnumerateDirectories( + directory, + "*", + new EnumerationOptions { IgnoreInaccessible = true } + ); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return []; + } + } + + private static long SizeOf(string file) + { + try + { + return new FileInfo(file).Length; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Файл исчез между перечислением и замером — обычное дело при живой нарезке. + return 0; + } + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal + ); + + /// Накопитель обхода: байты и число файлов идут вместе всюду, где считается место. + private sealed class Measurement + { + public long Bytes { get; private set; } + public int Files { get; private set; } + + public void Add(long bytes) + { + Bytes += bytes; + Files++; + } + + public void Add(Measurement other) + { + Bytes += other.Bytes; + Files += other.Files; + } + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Storage/StorageStatsTests.cs b/backend/tests/TeleWave.Application.Tests/Storage/StorageStatsTests.cs new file mode 100644 index 0000000..d36cc43 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Storage/StorageStatsTests.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Storage; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Media; +using TeleWave.Infrastructure.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Storage; + +/// +/// Отчёт по диску. Сегменты программ и заставок лежат в одном каталоге, и разделить их можно +/// только по базе — это и проверяется в первую очередь. +/// +public class StorageStatsTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "telewave-storage-" + Guid.NewGuid().ToString("N") + ); + + private FileSystemStorageInspector NewInspector() + { + var options = Options.Create(new StorageOptions { RootPath = _root }); + return new FileSystemStorageInspector(new MediaPathResolver(options), options); + } + + private void Write(string relativePath, int bytes) + { + var path = Path.Combine(_root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, new byte[bytes]); + } + + private static readonly IReadOnlySet Empty = new HashSet(); + + private static long BytesOf(StorageUsage usage, StorageArea area) => + usage.Areas.Single(a => a.Area == area).Bytes; + + [Fact] + public async Task Inspect_SplitsProgramAndBumperSegments() + { + var program = Guid.NewGuid(); + var bumper = Guid.NewGuid(); + Write($"assets/{program:N}/seg00000.ts", 300); + Write($"assets/{bumper:N}/seg00000.ts", 100); + Write("images/poster.png", 50); + Write("uploads/half-uploaded.mkv", 20); + Write("originals/movie.mkv", 10); + + var usage = await NewInspector() + .InspectAsync(new HashSet { bumper }, refresh: false, CancellationToken.None); + + Assert.Equal(300, BytesOf(usage, StorageArea.Assets)); + Assert.Equal(100, BytesOf(usage, StorageArea.BumperAssets)); + Assert.Equal(50, BytesOf(usage, StorageArea.Images)); + Assert.Equal(20, BytesOf(usage, StorageArea.Uploads)); + Assert.Equal(10, BytesOf(usage, StorageArea.Originals)); + Assert.Equal(480, usage.StorageBytes); + Assert.Equal(5, usage.StorageFiles); + Assert.Equal(_root, usage.RootPath); + } + + [Fact] + public async Task Inspect_CountsStrayFilesAsOther() + { + Write("забытая-папка/dump.sql", 70); + Write("readme.txt", 30); + + var usage = await NewInspector() + .InspectAsync(Empty, refresh: false, CancellationToken.None); + + // «Прочее» обычно ноль — и именно поэтому его надо показывать, когда оно не ноль. + Assert.Equal(100, BytesOf(usage, StorageArea.Other)); + } + + [Fact] + public async Task Inspect_ReusesCache_UntilRefreshAsked() + { + Write("images/a.png", 10); + var inspector = NewInspector(); + + var first = await inspector.InspectAsync(Empty, refresh: false, CancellationToken.None); + Write("images/b.png", 40); + var cached = await inspector.InspectAsync(Empty, refresh: false, CancellationToken.None); + var fresh = await inspector.InspectAsync(Empty, refresh: true, CancellationToken.None); + + // Обход дорогой: без явной просьбы отдаём прежнюю цифру вместе с её временем. + Assert.Equal(first.ComputedAt, cached.ComputedAt); + Assert.Equal(10, BytesOf(cached, StorageArea.Images)); + Assert.Equal(50, BytesOf(fresh, StorageArea.Images)); + } + + [Fact] + public async Task Handler_PassesGeneratedAssetsToInspector() + { + var fixture = new TestDb(); + var generated = MediaAsset.RegisterGenerated("Заставка"); + var uploaded = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload); + await using (var seed = fixture.New()) + { + seed.MediaAssets.AddRange(generated, uploaded); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var inspector = new CapturingInspector(); + await using var db = fixture.New(); + var result = await new GetStorageStatsQueryHandler(db, inspector).Handle( + new GetStorageStatsQuery(Refresh: true), + CancellationToken.None + ); + + Assert.True(inspector.Refresh); + Assert.Equal([generated.Id], inspector.GeneratedAssetIds); + Assert.Equal("/media", result.RootPath); + } + + private sealed class CapturingInspector : IStorageInspector + { + public IReadOnlySet GeneratedAssetIds { get; private set; } = new HashSet(); + public bool Refresh { get; private set; } + + public Task InspectAsync( + IReadOnlySet generatedAssetIds, + bool refresh, + CancellationToken cancellationToken + ) + { + GeneratedAssetIds = generatedAssetIds; + Refresh = refresh; + return Task.FromResult( + new StorageUsage("/media", 0, 0, 0, 0, 0, [], DateTimeOffset.UnixEpoch) + ); + } + } + + public void Dispose() + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/docs/server-storage-setup.md b/docs/server-storage-setup.md index d890268..bd87e38 100644 --- a/docs/server-storage-setup.md +++ b/docs/server-storage-setup.md @@ -246,6 +246,19 @@ mkdir -p /srv/telewave/media/manual/.wtest && docker compose exec app sh -c 'rmd --- +## 8. Что дальше смотреть в админке + +Заполненность диска видна в разделе **Админка → Хранилище**: сколько занято на томе целиком, +сколько из этого приходится на TeleWave, и на что именно оно ушло — сегменты программ и заставок, +исходники, inbox, изображения. Отдельной строкой считается «прочее под корнем»: если там не ноль, +значит на томе лежит что-то, чего приложение не создавало. + +Цифра считается обходом дерева и потому кэшируется на пять минут; кнопка «Пересчитать» заставляет +пересчитать сразу. Когда свободного места остаётся меньше `Storage:MinFreeSpaceBytes`, страница +показывает предупреждение — с этого порога сервер начинает отклонять загрузку новых файлов. + +--- + ## Приложение: если диск нужно расширить в будущем Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне diff --git a/frontend/src/features/admin/storage/StoragePanel.tsx b/frontend/src/features/admin/storage/StoragePanel.tsx new file mode 100644 index 0000000..f115d26 --- /dev/null +++ b/frontend/src/features/admin/storage/StoragePanel.tsx @@ -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 = { + 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

{t('common.loading')}

+ + 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 ( +
+ + +
+
+ {t('admin.storage.volume')} + {data.rootPath} +
+
+ + {t('admin.storage.computedAt', { + time: new Date(data.computedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + })} + + +
+
+ + {volumeKnown ? ( + <> + {/* Полоса тома: своё, чужое, свободное. */} +
+
+
+
+ +
+ + + + +
+ + ) : ( +

{t('admin.storage.volumeUnknown')}

+ )} + + {lowSpace && ( +
+ + + {t('admin.storage.lowSpace', { threshold: formatBytes(data.minFreeSpaceBytes) })} + +
+ )} + + + + + +
+ {t('admin.storage.breakdown')} + + {t('admin.storage.filesTotal', { count: data.storageFiles })} · {formatBytes(storage)} + +
+ + {/* Полоса состава хранилища — доли областей друг относительно друга. */} + {storage > 0 && ( +
+ {areas.map((area) => ( +
+ ))} +
+ )} + +
+ {areas.map((area) => ( + + ))} + {areas.length === 0 && ( +

{t('admin.storage.empty')}

+ )} +
+ + +
+ ) +} + +function Metric({ + label, + value, + hint, + accent, +}: Readonly<{ label: string; value: string; hint?: string; accent?: string }>) { + return ( +
+ {label} + {value} + {hint && {hint}} +
+ ) +} + +function AreaRow({ area, total }: Readonly<{ area: StorageAreaDto; total: number }>) { + const { t } = useTranslation() + const share = percentOf(area.bytes, total) + + return ( +
+ +
+ {t(`admin.storage.areas.${area.area}`)} + + {t(`admin.storage.areaHints.${area.area}`)} + +
+ {formatBytes(area.bytes)} + + {share.toFixed(1)}% + + + {t('admin.storage.files', { count: area.files })} + +
+ ) +} diff --git a/frontend/src/features/admin/storage/api.ts b/frontend/src/features/admin/storage/api.ts new file mode 100644 index 0000000..256de97 --- /dev/null +++ b/frontend/src/features/admin/storage/api.ts @@ -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(`/admin/storage${refresh ? '?refresh=true' : ''}`) +} diff --git a/frontend/src/features/admin/storage/format.ts b/frontend/src/features/admin/storage/format.ts new file mode 100644 index 0000000..c1510e3 --- /dev/null +++ b/frontend/src/features/admin/storage/format.ts @@ -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 +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index d593ca3..a6763a1 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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, } diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 1f197bf..9c45e0b 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -99,6 +99,13 @@ function AdminLayout() { > {t('admin.users.title')} + + {t('admin.storage.title')} +