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,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;
/// <summary>
/// Отчёт по диску. Сегменты программ и заставок лежат в одном каталоге, и разделить их можно
/// только по базе — это и проверяется в первую очередь.
/// </summary>
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<Guid> Empty = new HashSet<Guid>();
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<Guid> { 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<Guid> GeneratedAssetIds { get; private set; } = new HashSet<Guid>();
public bool Refresh { get; private set; }
public Task<StorageUsage> InspectAsync(
IReadOnlySet<Guid> 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);
}
}