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]}";
}
}
-