diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a7f2f58..f081de0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -823,12 +823,17 @@ export interface DashboardData { storageStatus: DashboardStorageStatus; recentSessions: DashboardRecentSession[]; topRooms: DashboardTopRoom[]; + pendingTranscodeCount: number; + pendingUploadCount: number; + queuedDataBytes: number; } export interface DashboardStorageStatus { hasEnoughSpace: boolean; message: string; availableBytes: number; + tier: string; + usagePercent: number; } export interface DashboardRecentSession { diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index d6c79d9..a0c1812 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -39,6 +39,20 @@ function sessionStatusTagType(status: number) { return "warning"; } +function storageTierTagType(): "success" | "warning" | "danger" { + const tier = data.value?.storageStatus.tier; + if (tier === "Green") return "success"; + if (tier === "Yellow") return "warning"; + return "danger"; +} + +function storageTierLabel(): string { + const tier = data.value?.storageStatus.tier; + if (tier === "Green") return "正常"; + if (tier === "Yellow") return "警告"; + return "紧急"; +} + async function loadData() { loading.value = true; loadError.value = ""; @@ -109,11 +123,15 @@ onMounted(loadData);

录制输出路径的磁盘剩余空间和当前录制保护阈值。

- 状态 - - {{ data.storageStatus.hasEnoughSpace ? "充足" : "不足" }} + 水位线 + + {{ storageTierLabel() }}
+
+ 使用率 + {{ data.storageStatus.usagePercent.toFixed(1) }}% +
可用空间 {{ formatDataSize(data.storageStatus.availableBytes) }} @@ -125,6 +143,36 @@ onMounted(loadData);
+ + + + +

处理队列

+

待转码和待上传的文件积压情况。

+
+
+ 待转码 + + {{ data.pendingTranscodeCount }} + +
+
+ 待上传 + + {{ data.pendingUploadCount }} + +
+
+ 积压数据量 + + {{ formatDataSize(data.queuedDataBytes) }} + +
+
+
+
+
+ diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs index 7415132..2ca06e9 100644 --- a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs +++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs @@ -48,6 +48,8 @@ public interface IRecordTaskRepository Task SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default); + Task CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default); + Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default); void Remove(RecordTask recordTask); @@ -84,6 +86,10 @@ public interface IRecordResultRepository Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default); + Task CountPendingUploadAsync(CancellationToken cancellationToken = default); + + Task SumPendingUploadBytesAsync(CancellationToken cancellationToken = default); + Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default); void Update(RecordResult recordResult); diff --git a/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs b/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs index f7beacc..c304f92 100644 --- a/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs +++ b/src/LiveRecorder.Application/Abstractions/Storage/IStorageGuardService.cs @@ -2,6 +2,18 @@ using LiveRecorder.Application.Models.Settings; namespace LiveRecorder.Application.Abstractions.Storage; +public enum StorageTier +{ + /// Disk has sufficient free space for normal operation. + Green = 0, + + /// Disk space is low. Deny new recordings but allow existing to finish. + Yellow = 1, + + /// Disk space is critically low. Deny new recordings and pause active ones. + Red = 2 +} + public interface IStorageGuardService { StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0); @@ -15,5 +27,25 @@ public sealed record StorageGuardResult( string CheckedPath, long AvailableBytes, long RequiredBytes, - string Message); + string Message) +{ + /// + /// Current storage tier (Green/Yellow/Red). + /// + public StorageTier Tier { get; init; } + /// + /// Disk usage percentage (0-100). Only populated when IsEnabled is true. + /// + public double UsagePercent { get; init; } + + /// + /// True if new recordings can be started. False in Yellow and Red tiers. + /// + public bool CanStartNewRecording => HasEnoughSpace || Tier == StorageTier.Green; + + /// + /// True if active recordings should be paused. Only true in Red tier. + /// + public bool ShouldPauseActive => Tier == StorageTier.Red; +} diff --git a/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs b/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs index 4e4efc4..0f1c74b 100644 --- a/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs +++ b/src/LiveRecorder.Application/Models/Recovery/RecoveryModels.cs @@ -24,6 +24,12 @@ public sealed class StorageGuardStatusDto public long RequiredBytes { get; init; } public required string Message { get; init; } + + /// Storage tier: Green, Yellow, or Red. + public string Tier { get; init; } = "Green"; + + /// Disk usage percentage (0-100). + public double UsagePercent { get; init; } } public sealed class RecoverableLiveRoomDto diff --git a/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs b/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs index 7cc746e..dcd72a9 100644 --- a/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs +++ b/src/LiveRecorder.Application/Models/Reports/DashboardModels.cs @@ -64,6 +64,21 @@ public sealed class DashboardDto /// Top live rooms by recording duration today (up to 5). /// public required IReadOnlyList TopRooms { get; init; } + + /// + /// Number of recording tasks currently in Processing (transcoding) status. + /// + public int PendingTranscodeCount { get; init; } + + /// + /// Number of recording results with NotUploaded status where local file still exists. + /// + public int PendingUploadCount { get; init; } + + /// + /// Total file size in bytes of files awaiting upload. + /// + public long QueuedDataBytes { get; init; } } public sealed class StorageStatusDto @@ -71,6 +86,8 @@ public sealed class StorageStatusDto public bool HasEnoughSpace { get; init; } public string Message { get; init; } = string.Empty; public long AvailableBytes { get; init; } + public string Tier { get; init; } = "Green"; + public double UsagePercent { get; init; } } public sealed class RecentSessionItemDto diff --git a/src/LiveRecorder.Application/Services/DashboardService.cs b/src/LiveRecorder.Application/Services/DashboardService.cs index 2d3133d..26dea25 100644 --- a/src/LiveRecorder.Application/Services/DashboardService.cs +++ b/src/LiveRecorder.Application/Services/DashboardService.cs @@ -90,8 +90,13 @@ public sealed class DashboardService { HasEnoughSpace = storageCheck.HasEnoughSpace, Message = storageCheck.Message ?? string.Empty, - AvailableBytes = storageCheck.AvailableBytes + AvailableBytes = storageCheck.AvailableBytes, + Tier = storageCheck.Tier.ToString(), + UsagePercent = storageCheck.UsagePercent }, + PendingTranscodeCount = await _recordTaskRepository.CountByStatusAsync(Domain.Enums.RecordTaskStatus.Processing, cancellationToken), + PendingUploadCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken), + QueuedDataBytes = await _recordResultRepository.SumPendingUploadBytesAsync(cancellationToken), RecentSessions = recentSessionsTask.Result .Select(MapRecentSession) .ToList(), diff --git a/src/LiveRecorder.Application/Services/RecordService.cs b/src/LiveRecorder.Application/Services/RecordService.cs index 74347d1..81e74d5 100644 --- a/src/LiveRecorder.Application/Services/RecordService.cs +++ b/src/LiveRecorder.Application/Services/RecordService.cs @@ -207,8 +207,17 @@ public sealed class RecordService var settings = await _systemSettingsService.GetAsync(cancellationToken); var storageCheck = _storageGuardService.CheckCanStartOrResume(settings); - if (!storageCheck.HasEnoughSpace) + if (!storageCheck.CanStartNewRecording) { + var storageMessage = storageCheck.Tier switch + { + Application.Abstractions.Storage.StorageTier.Yellow => + "Storage is in warning state. New recordings are paused but existing recordings continue. Transcoding and uploading will free up space.", + Application.Abstractions.Storage.StorageTier.Red => + "Storage is critically low. All recordings are paused until enough disk space is freed.", + _ => storageCheck.Message + }; + await _systemLogService.WriteAsync( SystemLogLevel.Warning, "Storage", @@ -222,12 +231,12 @@ public sealed class RecordService await UpdateAutoStartDecisionAsync( liveRoom, AutoStartDecisionCodes.SkippedStorage, - "Auto-start skipped because storage is below threshold.", + $"Auto-start skipped because storage tier is {storageCheck.Tier}.", storageCheck.Message, cancellationToken); } - throw new InvalidOperationException(storageCheck.Message); + throw new InvalidOperationException(storageMessage); } var effectiveSettings = _liveRoomRecordingSettingsResolver.Resolve(liveRoom, settings); @@ -661,14 +670,14 @@ public sealed class RecordService } var storageCheck = _storageGuardService.CheckCanStartOrResume(settings); - if (!storageCheck.HasEnoughSpace) + if (!storageCheck.CanStartNewRecording) { foreach (var liveRoomId in liveRoomIds) { await TryUpdateAutoStartDecisionAsync( liveRoomId, AutoStartDecisionCodes.SkippedStorage, - "Auto-start skipped because storage is below threshold.", + $"Auto-start skipped because storage tier is {storageCheck.Tier}.", storageCheck.Message, cancellationToken); await _systemLogService.WriteAsync( diff --git a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs index 7656b80..cfa2ff3 100644 --- a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs +++ b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs @@ -142,6 +142,9 @@ public sealed class RecordTaskRepository : IRecordTaskRepository .Where(item => item.RecordSession != null && item.RecordSession.StartedAt >= startedFrom && item.RecordSession.StartedAt <= startedTo) .SumAsync(item => item.DurationSeconds ?? 0, cancellationToken); + public Task CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default) => + _dbContext.RecordTasks.CountAsync(item => item.Status == status, cancellationToken); + public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) => _dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask(); @@ -266,6 +269,14 @@ public sealed class RecordResultRepository : IRecordResultRepository return (result?.TotalBytes ?? 0L, result?.TotalDanmaku ?? 0); } + public Task CountPendingUploadAsync(CancellationToken cancellationToken = default) => + _dbContext.RecordResults.CountAsync(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded, cancellationToken); + + public Task SumPendingUploadBytesAsync(CancellationToken cancellationToken = default) => + _dbContext.RecordResults + .Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded) + .SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken); + public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) => _dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask(); diff --git a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs index 08ed5be..a8f3f51 100644 --- a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs +++ b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs @@ -75,7 +75,8 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR var settings = await settingsService.GetAsync(stoppingToken); var ffmpegService = scope.ServiceProvider.GetRequiredService(); var storageGuardService = scope.ServiceProvider.GetRequiredService(); - if (storageGuardService.CheckCanStartOrResume(settings).HasEnoughSpace) + var storageCheck = storageGuardService.CheckCanStartOrResume(settings); + if (storageCheck.Tier != StorageTier.Red) { await ffmpegService.ResumePausedFinalizationsAsync(stoppingToken); } @@ -382,13 +383,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR } var startCheck = storageGuardService.CheckCanStartOrResume(settings); - if (!startCheck.HasEnoughSpace) + if (!startCheck.CanStartNewRecording) { await UpdateAutoStartDecisionAsync( dbContext, liveRoom, AutoStartDecisionCodes.SkippedStorage, - "Auto-start skipped because storage is below threshold.", + $"Auto-start skipped because storage tier is {startCheck.Tier}.", startCheck.Message, cancellationToken); await logService.WriteAsync( diff --git a/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs b/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs index 46d0bbc..6c8b4bf 100644 --- a/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs +++ b/src/LiveRecorder.Infrastructure/Services/RecoveryService.cs @@ -50,7 +50,9 @@ public sealed class RecoveryService CheckedPath = storage.CheckedPath, AvailableBytes = storage.AvailableBytes, RequiredBytes = storage.RequiredBytes, - Message = storage.Message + Message = storage.Message, + Tier = storage.Tier.ToString(), + UsagePercent = storage.UsagePercent }, LiveRooms = liveRooms, Finalizations = finalizations diff --git a/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs b/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs index c69cf6e..5eb2247 100644 --- a/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs +++ b/src/LiveRecorder.Infrastructure/Services/StorageGuardService.cs @@ -7,6 +7,8 @@ namespace LiveRecorder.Infrastructure.Services; public sealed class StorageGuardService : IStorageGuardService { private const long Megabyte = 1024L * 1024L; + private const double GreenThresholdPercent = 30.0; + private const double YellowThresholdPercent = 10.0; private readonly ILogger _logger; public StorageGuardService(ILogger logger) @@ -24,7 +26,11 @@ public sealed class StorageGuardService : IStorageGuardService { if (!settings.EnableStorageGuard) { - return new StorageGuardResult(false, true, ResolveOutputRoot(settings.OutputRoot), long.MaxValue, 0, "Storage guard is disabled."); + return new StorageGuardResult(false, true, ResolveOutputRoot(settings.OutputRoot), long.MaxValue, 0, "Storage guard is disabled.") + { + Tier = StorageTier.Green, + UsagePercent = 0 + }; } var checkedPath = ResolveOutputRoot(settings.OutputRoot); @@ -35,17 +41,51 @@ public sealed class StorageGuardService : IStorageGuardService { var drive = new DriveInfo(Path.GetPathRoot(checkedPath) ?? checkedPath); var availableBytes = drive.AvailableFreeSpace; - var hasEnoughSpace = availableBytes >= requiredBytes; - var message = hasEnoughSpace - ? $"Storage is available. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}" - : $"Storage is below threshold. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}"; + var totalBytes = drive.TotalSize; + var usedBytes = Math.Max(0, totalBytes - availableBytes); + var usagePercent = totalBytes > 0 ? (double)usedBytes / totalBytes * 100.0 : 0; + var freePercent = 100.0 - usagePercent; - return new StorageGuardResult(true, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message); + // Determine tier + StorageTier tier; + if (freePercent >= GreenThresholdPercent) + { + tier = StorageTier.Green; + } + else if (freePercent >= YellowThresholdPercent) + { + tier = StorageTier.Yellow; + } + else + { + tier = StorageTier.Red; + } + + var hasEnoughSpace = availableBytes >= requiredBytes; + var message = tier switch + { + StorageTier.Green => $"Storage is healthy. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}", + StorageTier.Yellow => $"Storage is low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. New recordings paused, existing recordings continue.", + StorageTier.Red => $"Storage is critically low. free={FormatBytes(availableBytes)} ({freePercent:F1}%), required={FormatBytes(requiredBytes)}, path={checkedPath}. All recordings paused, uploads continue.", + _ => hasEnoughSpace + ? $"Storage is available. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}" + : $"Storage is below threshold. free={FormatBytes(availableBytes)}, required={FormatBytes(requiredBytes)}, path={checkedPath}" + }; + + return new StorageGuardResult(true, hasEnoughSpace, checkedPath, availableBytes, requiredBytes, message) + { + Tier = tier, + UsagePercent = Math.Round(usagePercent, 1) + }; } catch (Exception ex) { _logger.LogWarning(ex, "Storage guard failed to inspect output root {OutputRoot}", checkedPath); - return new StorageGuardResult(true, false, checkedPath, 0, requiredBytes, $"Unable to inspect storage path {checkedPath}: {ex.Message}"); + return new StorageGuardResult(true, false, checkedPath, 0, requiredBytes, $"Unable to inspect storage path {checkedPath}: {ex.Message}") + { + Tier = StorageTier.Red, + UsagePercent = 0 + }; } } @@ -77,4 +117,3 @@ public sealed class StorageGuardService : IStorageGuardService return $"{display:0.##} {units[unitIndex]}"; } } -