Add diagnostics endpoints and types for improved system monitoring
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 38s
ci / tests (push) Successful in 1m21s
ci / sonar (push) Successful in 3m46s

Implemented new diagnostics endpoints in the API and added corresponding route configurations in the frontend. Introduced types for diagnostics data, including host, forwarding, and system diagnostics, to enhance monitoring capabilities. Updated localization files to support diagnostics UI elements in both English and Russian, ensuring a comprehensive user experience.
This commit is contained in:
Leonid Pershin
2026-08-02 13:49:48 +03:00
parent 88cfe3716b
commit bed6d4fcfc
16 changed files with 971 additions and 0 deletions
@@ -0,0 +1,162 @@
using System.Reflection;
using System.Runtime.InteropServices;
using LiteCqrs;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Options;
using TeleWave.Application.Diagnostics;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет
/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут
/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен.
/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос.
/// </summary>
public static class DiagnosticsEndpoints
{
public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/diagnostics", Get)
.WithTags("Admin.Diagnostics")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
.Produces<DiagnosticsResponse>();
return app;
}
private static async Task<IResult> Get(
HttpContext http,
IOptions<ForwardedHeadersOptions> forwardedOptions,
IHostEnvironment env,
ISender sender,
CancellationToken cancellationToken
)
{
var system = await sender.Send(new GetSystemDiagnosticsQuery(), cancellationToken);
var host = BuildHost(env);
var forwarding = BuildForwarding(http, forwardedOptions.Value);
return Results.Ok(new DiagnosticsResponse(host, forwarding, system));
}
private static HostDiagnosticsDto BuildHost(IHostEnvironment env)
{
var assembly = Assembly.GetEntryAssembly();
var version =
assembly
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? assembly?.GetName().Version?.ToString();
return new HostDiagnosticsDto(
version,
env.EnvironmentName,
RuntimeInformation.FrameworkDescription,
RuntimeInformation.OSDescription,
DateTimeOffset.UtcNow
);
}
private static ForwardingDiagnosticsDto BuildForwarding(
HttpContext http,
ForwardedHeadersOptions options
)
{
var request = http.Request;
var scheme = request.Scheme;
var host = request.Host.Value ?? string.Empty;
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
var remoteIp = http.Connection.RemoteIpAddress?.ToString();
var forwardedProto = Header(request, "X-Forwarded-Proto");
// X-Original-* заголовки ставит сам ForwardedHeaders-middleware, когда доверяет соседу и
// применяет форвардинг — по их наличию отличаем «применено» от «отброшено как недоверенное».
var originalProto = Header(request, "X-Original-Proto");
var originalRemoteIp = Header(request, "X-Original-For");
var applied = originalProto is not null || originalRemoteIp is not null;
// Что прокси реально прислал (до возможного применения), и вывод с готовой подсказкой.
var sentProto = originalProto ?? forwardedProto;
string verdict;
string? suggestedTrustedIp = null;
if (isHttps)
verdict = "ok";
else if (string.Equals(sentProto, "https", StringComparison.OrdinalIgnoreCase))
{
// Заголовок пришёл, но схема осталась http → непосредственный сосед не в доверенных.
verdict = "proxyNotTrusted";
suggestedTrustedIp = remoteIp;
}
else
verdict = "protoHeaderMissing";
return new ForwardingDiagnosticsDto(
scheme,
host,
$"{scheme}://{host}",
isHttps,
remoteIp,
forwardedProto,
Header(request, "X-Forwarded-For"),
Header(request, "X-Forwarded-Host"),
Header(request, "X-Real-IP"),
originalProto,
originalRemoteIp,
applied,
options.ForwardLimit,
options.ForwardedHeaders.ToString(),
[.. options.KnownProxies.Select(ip => ip.ToString())],
[.. options.KnownIPNetworks.Select(net => net.ToString())],
verdict,
suggestedTrustedIp
);
}
private static string? Header(HttpRequest request, string name) =>
request.Headers.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value)
? value.ToString()
: null;
}
/// <summary>Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры.</summary>
public sealed record DiagnosticsResponse(
HostDiagnosticsDto Host,
ForwardingDiagnosticsDto Forwarding,
SystemDiagnosticsDto System
);
public sealed record HostDiagnosticsDto(
string? AppVersion,
string Environment,
string Framework,
string Os,
DateTimeOffset ServerTimeUtc
);
/// <summary>
/// Картина X-Forwarded-*. <c>Verdict</c>: <c>ok</c> — схема https; <c>proxyNotTrusted</c> — прокси
/// прислал https, но <c>Applied=false</c>, и <c>SuggestedTrustedIp</c> нужно внести в KnownProxies;
/// <c>protoHeaderMissing</c> — прокси вообще не шлёт X-Forwarded-Proto.
/// </summary>
public sealed record ForwardingDiagnosticsDto(
string Scheme,
string Host,
string Origin,
bool IsHttps,
string? RemoteIp,
string? XForwardedProto,
string? XForwardedFor,
string? XForwardedHost,
string? XRealIp,
string? OriginalProto,
string? OriginalRemoteIp,
bool Applied,
int? ForwardLimit,
string ForwardedHeaders,
IReadOnlyList<string> KnownProxies,
IReadOnlyList<string> KnownNetworks,
string Verdict,
string? SuggestedTrustedIp
);
+1
View File
@@ -150,6 +150,7 @@ app.MapStorageEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
app.MapDiagnosticsEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -0,0 +1,26 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Живые проверки инфраструктуры для админской диагностики: свободное место на томе хранилища и
/// доступность внешних инструментов (ffmpeg/ffprobe). Отдельный порт — чтобы Application не ходил
/// в файловую систему и процессы напрямую: это забота Infrastructure.
/// </summary>
public interface IDiagnosticsProbe
{
Task<StorageProbe> ProbeStorageAsync(CancellationToken cancellationToken);
Task<ToolProbe> ProbeFfmpegAsync(CancellationToken cancellationToken);
Task<ToolProbe> ProbeFfprobeAsync(CancellationToken cancellationToken);
}
/// <summary>Состояние тома хранилища. Нули в размерах — ФС не отдала метрику (экзотический маунт).</summary>
public sealed record StorageProbe(
string RootPath,
bool Exists,
bool Writable,
long TotalBytes,
long FreeBytes,
long MinFreeBytes
);
/// <summary>Доступность внешнего инструмента и его версия (первая строка вывода <c>-version</c>).</summary>
public sealed record ToolProbe(bool Available, string? Version);
@@ -0,0 +1,29 @@
using LiteCqrs;
namespace TeleWave.Application.Diagnostics;
/// <summary>Живое состояние инфраструктуры для админской панели диагностики: БД, хранилище, ffmpeg.</summary>
public sealed record GetSystemDiagnosticsQuery : IQuery<SystemDiagnosticsDto>;
public sealed record SystemDiagnosticsDto(
DatabaseDiagnosticsDto Database,
StorageDiagnosticsDto Storage,
ToolDiagnosticsDto Ffmpeg,
ToolDiagnosticsDto Ffprobe
);
/// <summary>Доступность БД и время лёгкого запроса; при ошибке — её текст без latency.</summary>
public sealed record DatabaseDiagnosticsDto(bool Reachable, long? LatencyMs, string? Error);
/// <summary><c>LowSpace</c> — свободного меньше порога <c>MinFreeBytes</c> (место известно и оно мало).</summary>
public sealed record StorageDiagnosticsDto(
string RootPath,
bool Exists,
bool Writable,
long TotalBytes,
long FreeBytes,
long MinFreeBytes,
bool LowSpace
);
public sealed record ToolDiagnosticsDto(bool Available, string? Version);
@@ -0,0 +1,64 @@
using System.Data.Common;
using System.Diagnostics;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Diagnostics;
public sealed class GetSystemDiagnosticsQueryHandler(
IAppDbContext dbContext,
IDiagnosticsProbe probe
) : IQueryHandler<GetSystemDiagnosticsQuery, SystemDiagnosticsDto>
{
public async Task<SystemDiagnosticsDto> Handle(
GetSystemDiagnosticsQuery query,
CancellationToken cancellationToken
)
{
var database = await CheckDatabaseAsync(cancellationToken);
var storageProbe = await probe.ProbeStorageAsync(cancellationToken);
var ffmpeg = await probe.ProbeFfmpegAsync(cancellationToken);
var ffprobe = await probe.ProbeFfprobeAsync(cancellationToken);
var storage = new StorageDiagnosticsDto(
storageProbe.RootPath,
storageProbe.Exists,
storageProbe.Writable,
storageProbe.TotalBytes,
storageProbe.FreeBytes,
storageProbe.MinFreeBytes,
LowSpace: storageProbe.FreeBytes > 0
&& storageProbe.FreeBytes < storageProbe.MinFreeBytes
);
return new SystemDiagnosticsDto(
database,
storage,
new ToolDiagnosticsDto(ffmpeg.Available, ffmpeg.Version),
new ToolDiagnosticsDto(ffprobe.Available, ffprobe.Version)
);
}
/// <summary>
/// Лёгкая живая проверка БД: тривиальный запрос под секундомер. Не полагаемся на стартовый
/// health-check — админ хочет видеть состояние прямо сейчас. Ловим <see cref="DbException"/>
/// (база провайдера Npgsql-исключений) — так Application не тянет ссылку на Npgsql.
/// </summary>
private async Task<DatabaseDiagnosticsDto> CheckDatabaseAsync(
CancellationToken cancellationToken
)
{
var stopwatch = Stopwatch.StartNew();
try
{
await dbContext.AppSettings.AnyAsync(cancellationToken);
stopwatch.Stop();
return new DatabaseDiagnosticsDto(true, stopwatch.ElapsedMilliseconds, null);
}
catch (DbException ex)
{
return new DatabaseDiagnosticsDto(false, null, ex.Message);
}
}
}
@@ -169,6 +169,7 @@ public static class DependencyInjection
services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IImageDownloader, ImageDownloader>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IDiagnosticsProbe, DiagnosticsProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IMediaProcessingLimits, MediaProcessingLimits>();
@@ -0,0 +1,111 @@
using System.ComponentModel;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Инфраструктурные пробы для админской диагностики: том хранилища (место + право записи) и внешние
/// инструменты (ffmpeg/ffprobe). Проба записи ловит частую ошибку развёртывания — bind-mount с чужими
/// правами: без прав на запись нарезка молча не идёт, а тут это видно сразу.
/// </summary>
public sealed class DiagnosticsProbe(
IOptions<StorageOptions> storageOptions,
IOptions<MediaOptions> mediaOptions
) : IDiagnosticsProbe
{
/// <summary>-version отрабатывает мгновенно; таймаут страхует от подвисшего/подменённого бинаря.</summary>
private static readonly TimeSpan ToolTimeout = TimeSpan.FromSeconds(10);
private readonly StorageOptions _storage = storageOptions.Value;
private readonly MediaOptions _media = mediaOptions.Value;
public Task<StorageProbe> ProbeStorageAsync(CancellationToken cancellationToken)
{
var root = Path.GetFullPath(_storage.RootPath);
var exists = Directory.Exists(root);
var (total, free) = Volume(root);
var writable = exists && IsWritable(root);
return Task.FromResult(
new StorageProbe(
_storage.RootPath,
exists,
writable,
total,
free,
_storage.MinFreeSpaceBytes
)
);
}
public Task<ToolProbe> ProbeFfmpegAsync(CancellationToken cancellationToken) =>
ProbeToolAsync(_media.FfmpegPath, cancellationToken);
public Task<ToolProbe> ProbeFfprobeAsync(CancellationToken cancellationToken) =>
ProbeToolAsync(_media.FfprobePath, cancellationToken);
/// <summary>Запускает инструмент с <c>-version</c>: успех + первая строка вывода как версия.</summary>
private static async Task<ToolProbe> ProbeToolAsync(
string path,
CancellationToken cancellationToken
)
{
try
{
var result = await ProcessRunner.RunAsync(
path,
["-version"],
lowPriority: false,
ToolTimeout,
cancellationToken
);
if (result.ExitCode != 0)
return new ToolProbe(false, null);
var firstLine = result
.StdOut.Split(
'\n',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
.FirstOrDefault();
return new ToolProbe(true, firstLine);
}
catch (Exception ex) when (ex is Win32Exception or TimeoutException)
{
// Бинаря нет по указанному пути/в PATH (Win32Exception) или он завис (TimeoutException)
// — считаем недоступным. OperationCanceledException не глушим: отмена запроса должна всплыть.
return new ToolProbe(false, null);
}
}
/// <summary>Размер тома хранилища; нули — файловая система не отдала метрику (как в инспекторе).</summary>
private static (long Total, long Free) Volume(string root)
{
try
{
var drive = new DriveInfo(root);
return (drive.TotalSize, drive.AvailableFreeSpace);
}
catch (Exception ex)
when (ex is ArgumentException or IOException or UnauthorizedAccessException)
{
return (0, 0);
}
}
/// <summary>Проба записи: создаём и тут же удаляем скрытый файл в корне хранилища.</summary>
private static bool IsWritable(string root)
{
var probe = Path.Combine(root, $".tw-diag-{Guid.NewGuid():N}");
try
{
using (File.Create(probe)) { }
File.Delete(probe);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false;
}
}
}