From 8ae8eebc121bfa7daf4fd3d377818eae67bd7280 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 22:23:48 +0300 Subject: [PATCH] Add storage management features and UI components 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. --- .../Endpoints/StorageEndpoints.cs | 33 +++ backend/src/TeleWave.Api/Program.cs | 1 + .../Common/Interfaces/IStorageInspector.cs | 72 +++++ .../Storage/GetStorageStatsQuery.cs | 25 ++ .../Storage/GetStorageStatsQueryHandler.cs | 42 +++ .../DependencyInjection.cs | 2 + .../Media/FileSystemStorageInspector.cs | 277 ++++++++++++++++++ .../Storage/StorageStatsTests.cs | 143 +++++++++ docs/server-storage-setup.md | 13 + .../features/admin/storage/StoragePanel.tsx | 228 ++++++++++++++ frontend/src/features/admin/storage/api.ts | 7 + frontend/src/features/admin/storage/format.ts | 20 ++ frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/admin.tsx | 7 + frontend/src/routes/admin/storage.tsx | 4 + frontend/src/shared/api/query-keys.ts | 4 + frontend/src/shared/api/types.ts | 34 +++ frontend/src/shared/lib/locales/en.ts | 40 +++ frontend/src/shared/lib/locales/ru.ts | 40 +++ 19 files changed, 1013 insertions(+) create mode 100644 backend/src/TeleWave.Api/Endpoints/StorageEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IStorageInspector.cs create mode 100644 backend/src/TeleWave.Application/Storage/GetStorageStatsQuery.cs create mode 100644 backend/src/TeleWave.Application/Storage/GetStorageStatsQueryHandler.cs create mode 100644 backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Storage/StorageStatsTests.cs create mode 100644 frontend/src/features/admin/storage/StoragePanel.tsx create mode 100644 frontend/src/features/admin/storage/api.ts create mode 100644 frontend/src/features/admin/storage/format.ts create mode 100644 frontend/src/routes/admin/storage.tsx 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')} +