Add debug export functionality for channel analysis
Implemented a new endpoint for exporting debug data related to channel scheduling, allowing users to download an archive containing channel settings, slot states, and trace information. Updated the frontend to include a button for triggering the export, along with necessary API adjustments for handling the download. Enhanced localization strings to support the new debug export feature in both English and Russian. Updated .gitignore to include debug export files while ensuring the directory structure is maintained for development.
This commit is contained in:
@@ -84,3 +84,8 @@ dist-ssr/
|
||||
# Отчёты покрытия (артефакт dotnet test / CI)
|
||||
coverage/
|
||||
TestResults/
|
||||
|
||||
# ---> TeleWave
|
||||
# Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory).
|
||||
debug-exports/*
|
||||
!debug-exports/.gitkeep
|
||||
|
||||
@@ -8,6 +8,7 @@ using TeleWave.Application.Broadcast.ListChannels;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelTime;
|
||||
using TeleWave.Application.Broadcast.UpdateViewerSettings;
|
||||
using TeleWave.Application.Programming.Planning.DebugExport;
|
||||
using TeleWave.Application.Programming.Planning.Trace;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
@@ -45,9 +46,29 @@ public static class ChannelEndpoints
|
||||
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
|
||||
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
|
||||
|
||||
// Отладочный дамп: вход планировщика и результат одним архивом.
|
||||
admin.MapPost("/{id:guid}/debug-export", ExportDebug).Produces(StatusCodes.Status200OK);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отдаёт архив со снимком канала. POST, а не GET: команда оставляет копию дампа на сервере,
|
||||
/// и кэшировать такой ответ никому не следует.
|
||||
/// </summary>
|
||||
private static async Task<IResult> ExportDebug(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ExportChannelDebugCommand(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
return Results.File(result.Value.Content, "application/zip", result.Value.FileName);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateViewerSettings(
|
||||
Guid id,
|
||||
UpdateViewerSettingsBody body,
|
||||
|
||||
@@ -20,5 +20,10 @@
|
||||
},
|
||||
"Storage": {
|
||||
"RootPath": ".dev-media"
|
||||
},
|
||||
// Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась
|
||||
// так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api).
|
||||
"Debug": {
|
||||
"ExportDirectory": "../../../debug-exports"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TeleWave.Application.Common;
|
||||
|
||||
/// <summary>Настройки отладочного дампа. Секция «Debug».</summary>
|
||||
public sealed class DebugExportOptions
|
||||
{
|
||||
public const string SectionName = "Debug";
|
||||
|
||||
/// <summary>
|
||||
/// Куда класть копии архивов. Пусто — не класть никуда, отдавать только в браузер: на сервере
|
||||
/// дампы на диске не нужны, а в разработке путь указывает на папку рядом с репозиторием.
|
||||
/// Относительный путь считается от рабочего каталога приложения.
|
||||
/// </summary>
|
||||
public string? ExportDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько последних архивов держать в каталоге. Дамп канала — это мегабайты, а собирают их
|
||||
/// пачками, отлаживая одну и ту же сетку; без потолка папка растёт молча.
|
||||
/// </summary>
|
||||
public int KeepLast { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Куда ложится копия отладочного дампа на сервере. Сам архив всегда уезжает в браузер — это порт
|
||||
/// для второй копии, которая остаётся рядом с репозиторием: разбирать «почему сетка построилась
|
||||
/// вот так» приходится вместе с кодом, и перекладывать архивы руками из загрузок каждый раз —
|
||||
/// лишний шаг.
|
||||
///
|
||||
/// Каталог задаётся настройкой и по умолчанию пуст: на сервере писать дампы на диск незачем,
|
||||
/// это инструмент разработки.
|
||||
/// </summary>
|
||||
public interface IDebugExportStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Сохраняет архив. Возвращает путь, куда он лёг, либо null — сохранение выключено настройкой.
|
||||
/// Ошибка записи наружу не идёт: дамп уже собран, и терять его из-за недоступной папки нельзя.
|
||||
/// </summary>
|
||||
Task<string?> SaveAsync(string fileName, byte[] content, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Groups.Suggest;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Planning.DebugExport;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
@@ -63,6 +64,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<BumperResolver>();
|
||||
services.AddScoped<PostCheckRunner>();
|
||||
services.AddScoped<GridScheduleGenerator>();
|
||||
services.AddScoped<ChannelDebugCollector>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.DebugExport;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает отладочный снимок канала: вход планировщика, его состояние и то, что получилось.
|
||||
///
|
||||
/// Смысл дампа — отвечать на вопрос «почему сетка построилась так», не подключаясь к рабочей базе.
|
||||
/// Поэтому здесь не выжимка, а полный вход: развёрнутые группы вплоть до отдельных единиц,
|
||||
/// эффективная сетка по суткам, курсоры слотов, стыки с условиями, записанная лента с трейсом
|
||||
/// и сухой прогон от текущего состояния. Всё, что хранится JSON-строкой (стратегия, правила,
|
||||
/// условия врезки, трейс), разбирается в объекты: читать дамп глазами придётся чаще, чем кодом.
|
||||
/// </summary>
|
||||
public sealed class ChannelDebugCollector(
|
||||
IAppDbContext dbContext,
|
||||
GroupExpander expander,
|
||||
GridScheduleGenerator generator,
|
||||
IOptions<SchedulerOptions> options
|
||||
)
|
||||
{
|
||||
private readonly SchedulerOptions _options = options.Value;
|
||||
|
||||
/// <summary>Сколько прошлого класть в дамп: сдвиги и повторы объясняются вчерашним эфиром.</summary>
|
||||
private const int PastDays = 2;
|
||||
|
||||
public async Task<IReadOnlyList<DebugFile>> CollectAsync(
|
||||
Channel channel,
|
||||
ScheduleTemplate template,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var horizonDays = Math.Max(1, _options.HorizonDays);
|
||||
var from = now.AddDays(-PastDays);
|
||||
var to = now.AddDays(horizonDays);
|
||||
|
||||
var grid = EffectiveGridBuilder.Build(
|
||||
template,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
now,
|
||||
to
|
||||
);
|
||||
|
||||
var junctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var groups = await dbContext.Groups.AsNoTracking().ToListAsync(cancellationToken);
|
||||
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
|
||||
var junctionNames = junctions.ToDictionary(j => j.Id, j => j.Name);
|
||||
var bumperNames = await dbContext
|
||||
.BumperTemplates.AsNoTracking()
|
||||
.ToDictionaryAsync(b => b.Id, b => b.Name, cancellationToken);
|
||||
|
||||
// Разворачиваем ровно те группы, что участвуют в эфире канала: чужие только раздули бы дамп.
|
||||
var usedGroupIds = grid
|
||||
.Slots.Concat(grid.Background)
|
||||
.Select(s => s.Slot.GroupId)
|
||||
.Concat(junctions.SelectMany(j => j.Elements).Select(e => e.GroupId))
|
||||
.Append(template.FallbackGroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var expanded = await expander.ExpandAsync(usedGroupIds, channel.Id, cancellationToken);
|
||||
var showNames = await ShowNamesAsync(expanded, cancellationToken);
|
||||
|
||||
var slotIds = grid.Slots.Concat(grid.Background).Select(s => s.Slot.Id).Distinct().ToList();
|
||||
var slotTitles = template
|
||||
.Layers.SelectMany(l => l.Slots)
|
||||
.ToDictionary(s => s.Id, s => s.Title);
|
||||
var states = await dbContext
|
||||
.SlotStates.AsNoTracking()
|
||||
.Where(s => slotIds.Contains(s.SlotId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var entries = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == channel.Id && e.StartsAtUtc < to && e.EndsAtUtc > from)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var entryShowNames = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => entries.Select(e => e.ShowId).Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
|
||||
// Сухой прогон от текущего состояния: то, что планировщик построил бы прямо сейчас. Рядом
|
||||
// с записанной лентой по нему видно, расходится ли расчёт с тем, что уже в эфире.
|
||||
var preview = await generator.PreviewAsync(channel.Id, now, horizonDays, cancellationToken);
|
||||
|
||||
return
|
||||
[
|
||||
new DebugFile("README.md", Readme(channel, now, horizonDays)),
|
||||
new DebugFile(
|
||||
"summary.json",
|
||||
Summary(channel, template, now, horizonDays, grid, entries, preview)
|
||||
),
|
||||
new DebugFile("channel.json", ChannelFacts(channel)),
|
||||
new DebugFile("template.json", TemplateFacts(template, groupNames, junctionNames)),
|
||||
new DebugFile("slot-states.json", StateFacts(states, slotTitles)),
|
||||
new DebugFile("effective-grid.json", GridFacts(grid, groupNames)),
|
||||
new DebugFile("groups.json", GroupFacts(expanded, groupNames, showNames)),
|
||||
new DebugFile("junctions.json", JunctionFacts(junctions, groupNames, bumperNames)),
|
||||
new DebugFile("schedule.json", ScheduleFacts(entries, slotTitles, entryShowNames)),
|
||||
new DebugFile("plan-preview.json", PreviewFacts(preview, slotTitles, showNames)),
|
||||
];
|
||||
}
|
||||
|
||||
private static string Readme(Channel channel, DateTimeOffset now, int horizonDays) =>
|
||||
$"""
|
||||
# Отладочный дамп канала «{channel.Name}»
|
||||
|
||||
Собран: {now:yyyy-MM-dd HH:mm:ss} UTC. Горизонт планирования: {horizonDays} сут.,
|
||||
прошлое в дампе: {PastDays} сут.
|
||||
|
||||
- `summary.json` — что за канал, сколько чего собралось, свод предупреждений.
|
||||
- `channel.json` — настройки канала: время, начало вещательных суток, эпоха, филлер.
|
||||
- `template.json` — сетка: слои, слоты, стратегии, правила канала. JSON-поля разобраны.
|
||||
- `slot-states.json` — курсоры слотов: на чём каждый слот остановился.
|
||||
- `effective-grid.json` — эффективная сетка на горизонт: экземпляры слотов по суткам
|
||||
с временами в UTC. `background` — перекрытые слоты фонового слоя.
|
||||
- `groups.json` — развёрнутые группы вплоть до единиц воспроизведения: что именно
|
||||
планировщик мог поставить и в каком порядке.
|
||||
- `junctions.json` — стыки: врезки, условия показа, развилки.
|
||||
- `schedule.json` — записанная лента с трейсом «почему это здесь».
|
||||
- `plan-preview.json` — сухой прогон от текущего состояния: что построилось бы сейчас.
|
||||
|
||||
Времена везде UTC. Время канала = UTC{(
|
||||
channel.UtcOffsetMinutes < 0 ? "-" : "+"
|
||||
)}{Math.Abs(channel.UtcOffsetMinutes) / 60:00}:{Math.Abs(channel.UtcOffsetMinutes)
|
||||
% 60:00}.
|
||||
""";
|
||||
|
||||
private object Summary(
|
||||
Channel channel,
|
||||
ScheduleTemplate template,
|
||||
DateTimeOffset now,
|
||||
int horizonDays,
|
||||
EffectiveGrid grid,
|
||||
IReadOnlyList<ScheduleEntry> entries,
|
||||
PlanningResult? preview
|
||||
) =>
|
||||
new
|
||||
{
|
||||
CollectedAtUtc = now,
|
||||
Channel = new
|
||||
{
|
||||
channel.Id,
|
||||
channel.Name,
|
||||
channel.Slug,
|
||||
channel.IsEnabled,
|
||||
},
|
||||
Scheduler = new
|
||||
{
|
||||
HorizonDays = horizonDays,
|
||||
_options.RetentionDays,
|
||||
_options.TickMinutes,
|
||||
},
|
||||
Template = new
|
||||
{
|
||||
template.Id,
|
||||
template.Revision,
|
||||
template.HasPendingChanges,
|
||||
Layers = template.Layers.Count,
|
||||
Slots = template.Layers.Sum(l => l.Slots.Count),
|
||||
},
|
||||
Grid = new { Instances = grid.Slots.Count, Background = grid.Background.Count },
|
||||
Tape = new
|
||||
{
|
||||
Entries = entries.Count,
|
||||
ByKind = entries
|
||||
.GroupBy(e => e.Kind)
|
||||
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
|
||||
FirstUtc = entries.Count > 0 ? entries[0].StartsAtUtc : (DateTimeOffset?)null,
|
||||
LastUtc = entries.Count > 0 ? entries[^1].EndsAtUtc : (DateTimeOffset?)null,
|
||||
},
|
||||
Preview = new
|
||||
{
|
||||
Items = preview?.Items.Count ?? 0,
|
||||
Warnings = preview
|
||||
?.Warnings.GroupBy(w => w.Kind)
|
||||
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
|
||||
},
|
||||
};
|
||||
|
||||
private static object ChannelFacts(Channel channel) =>
|
||||
new
|
||||
{
|
||||
channel.Id,
|
||||
channel.Name,
|
||||
channel.Slug,
|
||||
channel.Number,
|
||||
channel.IsEnabled,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
channel.EpochUtc,
|
||||
channel.TemplateId,
|
||||
channel.FillerAssetId,
|
||||
channel.LogoImageId,
|
||||
channel.ShowClock,
|
||||
channel.AnalogFilterStrength,
|
||||
};
|
||||
|
||||
private static object TemplateFacts(
|
||||
ScheduleTemplate template,
|
||||
IReadOnlyDictionary<Guid, string> groupNames,
|
||||
IReadOnlyDictionary<Guid, string> junctionNames
|
||||
) =>
|
||||
new
|
||||
{
|
||||
template.Id,
|
||||
template.Name,
|
||||
template.Revision,
|
||||
template.HasPendingChanges,
|
||||
FallbackGroup = Named(template.FallbackGroupId, groupNames),
|
||||
DefaultJunction = Named(template.DefaultJunctionId, junctionNames),
|
||||
Rules = PlanningRules.FromJson(template.RulesJson),
|
||||
Layers = template
|
||||
.Layers.OrderByDescending(l => l.Priority)
|
||||
.Select(layer => new
|
||||
{
|
||||
layer.Id,
|
||||
layer.Name,
|
||||
layer.Priority,
|
||||
layer.IsEnabled,
|
||||
layer.IsBackground,
|
||||
Applicability = LayerApplicability.FromJson(layer.ApplicabilityJson),
|
||||
Slots = layer
|
||||
.Slots.OrderBy(s => s.Weekday)
|
||||
.ThenBy(s => s.TargetStart)
|
||||
.Select(slot => new
|
||||
{
|
||||
slot.Id,
|
||||
slot.Title,
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
Group = Named(slot.GroupId, groupNames),
|
||||
Strategy = SlotStrategy.FromJson(slot.StrategyJson),
|
||||
Repeat = RepeatSource.FromJson(slot.RepeatSourceJson),
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes,
|
||||
JunctionBetween = Named(slot.JunctionBetweenId, junctionNames),
|
||||
JunctionAfter = Named(slot.JunctionAfterId, junctionNames),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
private static object StateFacts(
|
||||
IReadOnlyList<SlotState> states,
|
||||
IReadOnlyDictionary<Guid, string> slotTitles
|
||||
) =>
|
||||
states.Select(state => new
|
||||
{
|
||||
state.SlotId,
|
||||
Slot = slotTitles.GetValueOrDefault(state.SlotId),
|
||||
state.CurrentElementKind,
|
||||
state.CurrentElementId,
|
||||
state.NextUnitIndex,
|
||||
});
|
||||
|
||||
private static object GridFacts(
|
||||
EffectiveGrid grid,
|
||||
IReadOnlyDictionary<Guid, string> groupNames
|
||||
) =>
|
||||
new
|
||||
{
|
||||
Slots = grid.Slots.Select(s => Instance(s, groupNames)),
|
||||
Background = grid.Background.Select(s => Instance(s, groupNames)),
|
||||
};
|
||||
|
||||
private static object Instance(
|
||||
ScheduledSlot scheduled,
|
||||
IReadOnlyDictionary<Guid, string> groupNames
|
||||
) =>
|
||||
new
|
||||
{
|
||||
scheduled.Slot.Id,
|
||||
scheduled.Slot.Title,
|
||||
scheduled.BroadcastDate,
|
||||
StartUtc = scheduled.StartUtc,
|
||||
EndUtc = scheduled.StartUtc.AddMinutes(scheduled.Slot.TargetDurationMinutes),
|
||||
scheduled.Slot.TargetDurationMinutes,
|
||||
scheduled.Slot.SlotKind,
|
||||
scheduled.Slot.IsAnchor,
|
||||
Group = Named(scheduled.Slot.GroupId, groupNames),
|
||||
};
|
||||
|
||||
private static object GroupFacts(
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> expanded,
|
||||
IReadOnlyDictionary<Guid, string> groupNames,
|
||||
IReadOnlyDictionary<Guid, string> showNames
|
||||
) =>
|
||||
expanded.Select(pair => new
|
||||
{
|
||||
GroupId = pair.Key,
|
||||
Name = groupNames.GetValueOrDefault(pair.Key),
|
||||
Elements = pair.Value.Select(element => new
|
||||
{
|
||||
element.Kind,
|
||||
element.ElementId,
|
||||
element.Weight,
|
||||
element.Position,
|
||||
element.Audience,
|
||||
element.LastPlayedUtc,
|
||||
UnitCount = element.Units.Count,
|
||||
TotalMinutes = Math.Round(element.Units.Sum(u => u.Duration.TotalMinutes), 1),
|
||||
Units = element.Units.Select(unit => new
|
||||
{
|
||||
unit.MediaAssetId,
|
||||
unit.ShowId,
|
||||
Show = showNames.GetValueOrDefault(unit.ShowId),
|
||||
unit.UnitIndex,
|
||||
Minutes = Math.Round(unit.Duration.TotalMinutes, 2),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
private static object JunctionFacts(
|
||||
IReadOnlyList<JunctionTemplate> junctions,
|
||||
IReadOnlyDictionary<Guid, string> groupNames,
|
||||
IReadOnlyDictionary<Guid, string> bumperNames
|
||||
) =>
|
||||
junctions.Select(junction => new
|
||||
{
|
||||
junction.Id,
|
||||
junction.Name,
|
||||
junction.MaxTotalSeconds,
|
||||
Elements = junction
|
||||
.Elements.OrderBy(e => e.Position)
|
||||
.Select(element => new
|
||||
{
|
||||
element.Id,
|
||||
element.Position,
|
||||
element.Kind,
|
||||
Group = Named(element.GroupId, groupNames),
|
||||
BumperTemplate = Named(element.BumperTemplateId, bumperNames),
|
||||
element.BumperVariantId,
|
||||
element.AmountMode,
|
||||
element.AmountValue,
|
||||
element.IsRequired,
|
||||
element.ChoiceKey,
|
||||
element.ChoiceWeight,
|
||||
Conditions = JunctionConditions.FromJson(element.ConditionsJson),
|
||||
}),
|
||||
});
|
||||
|
||||
private static object ScheduleFacts(
|
||||
IReadOnlyList<ScheduleEntry> entries,
|
||||
IReadOnlyDictionary<Guid, string> slotTitles,
|
||||
IReadOnlyDictionary<Guid, string> showNames
|
||||
) =>
|
||||
entries.Select(entry => new
|
||||
{
|
||||
entry.Id,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
Minutes = Math.Round((entry.EndsAtUtc - entry.StartsAtUtc).TotalMinutes, 2),
|
||||
entry.Kind,
|
||||
entry.ShowId,
|
||||
Show = entry.ShowId is { } showId ? showNames.GetValueOrDefault(showId) : null,
|
||||
entry.EpisodeIndex,
|
||||
entry.CollectionId,
|
||||
entry.SlotId,
|
||||
Slot = entry.SlotId is { } slotId ? slotTitles.GetValueOrDefault(slotId) : null,
|
||||
entry.MediaAssetId,
|
||||
Trace = Trace(entry.TraceJson),
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Трейс записи как объект, а не строкой в строке. Разбирается в узел, а не в <c>PlanTrace</c>:
|
||||
/// в старых записях полей могло быть меньше, и терять их при типизации — ровно тот случай,
|
||||
/// когда дамп перестаёт объяснять прошлое.
|
||||
/// </summary>
|
||||
private static JsonNode? Trace(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static object? PreviewFacts(
|
||||
PlanningResult? preview,
|
||||
IReadOnlyDictionary<Guid, string> slotTitles,
|
||||
IReadOnlyDictionary<Guid, string> showNames
|
||||
) =>
|
||||
preview is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
Warnings = preview.Warnings.Select(w => new
|
||||
{
|
||||
w.Kind,
|
||||
w.SlotId,
|
||||
Slot = w.SlotId is { } id ? slotTitles.GetValueOrDefault(id) : null,
|
||||
w.Details,
|
||||
}),
|
||||
Cursors = preview.Cursors,
|
||||
Items = preview.Items.Select(item => new
|
||||
{
|
||||
item.StartsAtUtc,
|
||||
item.EndsAtUtc,
|
||||
Minutes = Math.Round((item.EndsAtUtc - item.StartsAtUtc).TotalMinutes, 2),
|
||||
item.Kind,
|
||||
item.ShowId,
|
||||
Show = item.ShowId is { } showId ? showNames.GetValueOrDefault(showId) : null,
|
||||
item.UnitIndex,
|
||||
item.CollectionId,
|
||||
item.SlotId,
|
||||
Slot = item.SlotId is { } slotId ? slotTitles.GetValueOrDefault(slotId) : null,
|
||||
item.MediaAssetId,
|
||||
item.Trace,
|
||||
}),
|
||||
};
|
||||
|
||||
/// <summary>Ссылка «идентификатор + имя»: по одному GUID в дампе ничего не понять.</summary>
|
||||
private static object? Named(Guid? id, IReadOnlyDictionary<Guid, string> names) =>
|
||||
id is not { } value ? null : new { Id = value, Name = names.GetValueOrDefault(value) };
|
||||
|
||||
private async Task<Dictionary<Guid, string>> ShowNamesAsync(
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> expanded,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = expanded
|
||||
.Values.SelectMany(elements => elements)
|
||||
.SelectMany(element => element.Units)
|
||||
.Select(unit => unit.ShowId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TeleWave.Application.Programming.Planning.DebugExport;
|
||||
|
||||
/// <summary>
|
||||
/// Готовый дамп: имя архива, его содержимое и путь сохранённой на сервере копии (null — каталог
|
||||
/// не настроен, копии нет).
|
||||
/// </summary>
|
||||
public sealed record DebugExportDto(string FileName, byte[] Content, string? SavedTo);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TeleWave.Application.Programming.Planning.DebugExport;
|
||||
|
||||
/// <summary>
|
||||
/// Один файл будущего архива. <see cref="Payload"/> — либо объект под сериализацию в JSON, либо
|
||||
/// готовая строка (README): решает по типу упаковщик, чтобы сборщик снимка не знал про формат.
|
||||
/// </summary>
|
||||
public sealed record DebugFile(string Name, object? Payload);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.DebugExport;
|
||||
|
||||
/// <summary>
|
||||
/// Собрать отладочный дамп канала: всё, из чего строится сетка, плюс то, что из неё получилось.
|
||||
///
|
||||
/// Команда, а не запрос, хотя в БД ничего не меняет: копия архива ложится на диск, а побочный
|
||||
/// эффект в запросе — это ровно то место, где потом ищут «почему после обновления страницы
|
||||
/// появился файл».
|
||||
/// </summary>
|
||||
public sealed record ExportChannelDebugCommand(Guid ChannelId) : ICommand<Result<DebugExportDto>>;
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.DebugExport;
|
||||
|
||||
/// <summary>
|
||||
/// Пакует снимок канала в архив и, если настроен каталог, оставляет копию на сервере.
|
||||
///
|
||||
/// Zip, а не один большой JSON: файлов десяток, каждый читают по отдельности, а лента и группы
|
||||
/// сжимаются в разы — дамп канала на неделю это мегабайты текста.
|
||||
/// </summary>
|
||||
public sealed class ExportChannelDebugCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ChannelDebugCollector collector,
|
||||
IDebugExportStore store
|
||||
) : ICommandHandler<ExportChannelDebugCommand, Result<DebugExportDto>>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
// Циклы в дампе возможны (навигации EF), и падать на них нельзя: дамп собирают тогда,
|
||||
// когда уже что-то не так.
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
public async Task<Result<DebugExportDto>> Handle(
|
||||
ExportChannelDebugCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<DebugExportDto>(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.TemplateId is not { } templateId
|
||||
? null
|
||||
: await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<DebugExportDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var files = await collector.CollectAsync(channel, template, now, cancellationToken);
|
||||
|
||||
var fileName = FileName(channel.Slug, now);
|
||||
var content = Pack(files);
|
||||
var savedTo = await store.SaveAsync(fileName, content, cancellationToken);
|
||||
|
||||
return Result.Success(new DebugExportDto(fileName, content, savedTo));
|
||||
}
|
||||
|
||||
/// <summary>Имя архива: канал и время сборки — дампы одного канала копятся десятками.</summary>
|
||||
private static string FileName(string slug, DateTimeOffset now) =>
|
||||
$"telewave-debug-{slug}-{now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.zip";
|
||||
|
||||
private static byte[] Pack(IReadOnlyList<DebugFile> files)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
var entry = archive.CreateEntry(file.Name, CompressionLevel.Optimal);
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
writer.Write(
|
||||
file.Payload is string text
|
||||
? text
|
||||
: JsonSerializer.Serialize(file.Payload, JsonOptions)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
@@ -18,6 +19,7 @@ using TeleWave.Infrastructure.Metadata;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using TeleWave.Infrastructure.Settings;
|
||||
using TeleWave.Infrastructure.Streaming;
|
||||
using TeleWave.Infrastructure.Support;
|
||||
|
||||
namespace TeleWave.Infrastructure;
|
||||
|
||||
@@ -149,6 +151,10 @@ public static class DependencyInjection
|
||||
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
|
||||
services.Configure<DebugExportOptions>(
|
||||
configuration.GetSection(DebugExportOptions.SectionName)
|
||||
);
|
||||
services.AddSingleton<IDebugExportStore, DebugExportStore>();
|
||||
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
|
||||
|
||||
services.AddSingleton<MediaPathResolver>();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Складывает копии отладочных дампов в настроенный каталог и подрезает его до последних N архивов.
|
||||
///
|
||||
/// Ошибки записи только логируются: дамп к этому моменту уже собран и уходит в браузер, и валить
|
||||
/// запрос из-за того, что папка недоступна, значит терять данные ровно тогда, когда они нужны.
|
||||
/// </summary>
|
||||
public sealed class DebugExportStore(
|
||||
IOptions<DebugExportOptions> options,
|
||||
ILogger<DebugExportStore> logger
|
||||
) : IDebugExportStore
|
||||
{
|
||||
private const string ArchivePattern = "*.zip";
|
||||
|
||||
private readonly DebugExportOptions _options = options.Value;
|
||||
|
||||
public async Task<string?> SaveAsync(
|
||||
string fileName,
|
||||
byte[] content,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ExportDirectory))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetFullPath(_options.ExportDirectory);
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var path = Path.Combine(directory, Path.GetFileName(fileName));
|
||||
await File.WriteAllBytesAsync(path, content, cancellationToken);
|
||||
|
||||
Prune(directory);
|
||||
return path;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Не удалось сохранить отладочный дамп в {Directory}",
|
||||
_options.ExportDirectory
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Оставляет последние <see cref="DebugExportOptions.KeepLast"/> архивов по времени записи.</summary>
|
||||
private void Prune(string directory)
|
||||
{
|
||||
var keep = Math.Max(1, _options.KeepLast);
|
||||
var stale = new DirectoryInfo(directory)
|
||||
.GetFiles(ArchivePattern)
|
||||
.OrderByDescending(file => file.LastWriteTimeUtc)
|
||||
.Skip(keep)
|
||||
.ToList();
|
||||
|
||||
foreach (var file in stale)
|
||||
{
|
||||
try
|
||||
{
|
||||
file.Delete();
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Файл держит открытым архиватор или антивирус — не повод срывать экспорт.
|
||||
logger.LogDebug(exception, "Не удалось удалить старый дамп {File}", file.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Planning;
|
||||
using TeleWave.Application.Programming.Planning.DebugExport;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Отладочный дамп канала. Проверяется не текст файлов, а то, ради чего он существует: архив
|
||||
/// собирается целиком, в нём есть все части входа планировщика, и по ним можно узнать конкретное
|
||||
/// шоу — дамп из одних идентификаторов бесполезен ровно тогда, когда его открывают.
|
||||
/// </summary>
|
||||
public class ChannelDebugExportTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class FirstAlways : IRandomSource
|
||||
{
|
||||
public int Next(int maxExclusive) => 0;
|
||||
}
|
||||
|
||||
private static MediaAsset ReadyAsset(string name, TimeSpan duration)
|
||||
{
|
||||
var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||
asset.MarkProcessing();
|
||||
asset.MarkReady(
|
||||
new MediaReadyInfo(
|
||||
duration,
|
||||
2,
|
||||
(int)(duration.TotalSeconds / 2),
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
"assets/x"
|
||||
)
|
||||
);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private static async Task<(TestDb Fixture, Guid ChannelId)> SeedAsync()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
|
||||
var show = Show.Create("Симпсоны", ShowKind.Series);
|
||||
var episodes = new[]
|
||||
{
|
||||
ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)),
|
||||
ReadyAsset("s01e02.mkv", TimeSpan.FromMinutes(20)),
|
||||
};
|
||||
foreach (var episode in episodes)
|
||||
show.AddEpisode(episode.Id);
|
||||
|
||||
var group = Group.Create("Мультсериалы");
|
||||
group.AddElement(GroupElementKind.Show, show.Id);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||
null,
|
||||
SlotBlockMode.Count,
|
||||
2,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
await using var seed = fixture.New();
|
||||
seed.MediaAssets.AddRange(episodes);
|
||||
seed.Shows.Add(show);
|
||||
seed.Groups.Add(group);
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleTemplates.Add(template);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return (fixture, channel.Id);
|
||||
}
|
||||
|
||||
private static ExportChannelDebugCommandHandler Handler(
|
||||
AppDbContext db,
|
||||
IDebugExportStore store
|
||||
)
|
||||
{
|
||||
var random = new FirstAlways();
|
||||
var expander = new GroupExpander(db, GroupServices.Dynamic(db));
|
||||
var generator = new GridScheduleGenerator(
|
||||
db,
|
||||
expander,
|
||||
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
|
||||
new PostCheckRunner(db),
|
||||
random,
|
||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
|
||||
Options.Create(new StreamingOptions())
|
||||
);
|
||||
|
||||
return new ExportChannelDebugCommandHandler(
|
||||
db,
|
||||
new ChannelDebugCollector(
|
||||
db,
|
||||
expander,
|
||||
generator,
|
||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 })
|
||||
),
|
||||
store
|
||||
);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Unpack(byte[] archive)
|
||||
{
|
||||
using var buffer = new MemoryStream(archive);
|
||||
using var zip = new ZipArchive(buffer, ZipArchiveMode.Read);
|
||||
|
||||
var files = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var entry in zip.Entries)
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
files[entry.FullName] = reader.ReadToEnd();
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_PacksEveryPartOfThePlannerInput()
|
||||
{
|
||||
var (fixture, channelId) = await SeedAsync();
|
||||
var store = Substitute.For<IDebugExportStore>();
|
||||
store
|
||||
.SaveAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.Returns("D:/debug-exports/dump.zip");
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, store)
|
||||
.Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.EndsWith(".zip", result.Value.FileName, StringComparison.Ordinal);
|
||||
Assert.Equal("D:/debug-exports/dump.zip", result.Value.SavedTo);
|
||||
|
||||
var files = Unpack(result.Value.Content);
|
||||
Assert.Equal(
|
||||
[
|
||||
"README.md",
|
||||
"channel.json",
|
||||
"effective-grid.json",
|
||||
"groups.json",
|
||||
"junctions.json",
|
||||
"plan-preview.json",
|
||||
"schedule.json",
|
||||
"slot-states.json",
|
||||
"summary.json",
|
||||
"template.json",
|
||||
],
|
||||
files.Keys.Order(StringComparer.Ordinal)
|
||||
);
|
||||
|
||||
// Дамп обязан читаться глазами: рядом с идентификаторами стоят имена, а единицы группы
|
||||
// развёрнуты поштучно — иначе «почему вышла эта серия» по нему не разобрать.
|
||||
Assert.Contains("Симпсоны", files["groups.json"], StringComparison.Ordinal);
|
||||
Assert.Contains("Дневной блок", files["template.json"], StringComparison.Ordinal);
|
||||
Assert.Contains("Дневной блок", files["effective-grid.json"], StringComparison.Ordinal);
|
||||
|
||||
using var groups = JsonDocument.Parse(files["groups.json"]);
|
||||
var units = groups
|
||||
.RootElement[0]
|
||||
.GetProperty("elements")[0]
|
||||
.GetProperty("units")
|
||||
.EnumerateArray()
|
||||
.ToList();
|
||||
Assert.Equal(2, units.Count);
|
||||
Assert.Equal("Симпсоны", units[0].GetProperty("show").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_DryRunGoesIntoTheDump()
|
||||
{
|
||||
// Сухой прогон — половина ценности дампа: он показывает, что планировщик построил бы
|
||||
// сейчас, и без него по архиву не отличить «данные плохие» от «расчёт плохой».
|
||||
var (fixture, channelId) = await SeedAsync();
|
||||
// Каталог не настроен — хранилище так и отвечает: копии нет.
|
||||
var store = Substitute.For<IDebugExportStore>();
|
||||
store
|
||||
.SaveAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.Returns((string?)null);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await Handler(db, store)
|
||||
.Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var files = Unpack(result.Value.Content);
|
||||
|
||||
using var preview = JsonDocument.Parse(files["plan-preview.json"]);
|
||||
Assert.NotEqual(JsonValueKind.Null, preview.RootElement.ValueKind);
|
||||
Assert.NotEmpty(preview.RootElement.GetProperty("items").EnumerateArray());
|
||||
|
||||
// Копии нет, но архив всё равно собран и уезжает в браузер.
|
||||
Assert.Null(result.Value.SavedTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_FailsWithoutChannel()
|
||||
{
|
||||
var (fixture, _) = await SeedAsync();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await Handler(db, Substitute.For<IDebugExportStore>())
|
||||
.Handle(new ExportChannelDebugCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Сюда складываются архивы отладочного дампа канала (кнопка «Дебаг-экспорт» в админке).
|
||||
# Содержимое в гит не едет — см. .gitignore.
|
||||
@@ -767,6 +767,19 @@ ScheduleEntry — существующая сущность,
|
||||
}
|
||||
```
|
||||
|
||||
**Отладочный дамп канала.** Трейс объясняет одну запись, но «сетка собралась не так» — вопрос
|
||||
не про запись, а про весь вход планировщика сразу. Кнопка в карточке канала собирает его архивом:
|
||||
настройки канала, шаблон со слотами и разобранными стратегиями, курсоры слотов, эффективная сетка
|
||||
на горизонт (включая перекрытые слоты фона), развёрнутые группы **вплоть до отдельных единиц**,
|
||||
стыки с условиями, записанная лента с трейсами и сухой прогон от текущего состояния. Всё, что
|
||||
хранится JSON-строкой, разбирается в объекты, а рядом с каждым идентификатором стоит имя: дамп
|
||||
читают глазами чаще, чем кодом.
|
||||
|
||||
Сухой прогон в дампе принципиален: рядом с записанной лентой он отвечает на первый вопрос
|
||||
разбора — данные плохие или расчёт. Архив всегда уезжает в браузер, а копия ложится в каталог
|
||||
`Debug:ExportDirectory` (в разработке — папка `debug-exports/` репозитория, в гите игнорируется);
|
||||
пустая настройка означает «копию не хранить», и на сервере это состояние по умолчанию.
|
||||
|
||||
---
|
||||
|
||||
## 4. Алгоритм генерации
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, Send, Undo2 } from 'lucide-react'
|
||||
import { Bug, ChevronLeft, Send, Undo2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
@@ -12,7 +12,13 @@ import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { applyChannelTemplate, getChannel, getChannelTemplate, restoreChannelTemplate } from './api'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
exportChannelDebug,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
restoreChannelTemplate,
|
||||
} from './api'
|
||||
import { AirSchedule } from './components/AirSchedule'
|
||||
import { ApplyDialog } from './components/ApplyDialog'
|
||||
import { ApplyReportDialog } from './components/ApplyReportDialog'
|
||||
@@ -55,6 +61,12 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
||||
}
|
||||
const onError = useApiError()
|
||||
|
||||
const debugMutation = useMutation({
|
||||
mutationFn: () => exportChannelDebug(channelId),
|
||||
onSuccess: (fileName) => toast.success(fileName),
|
||||
onError,
|
||||
})
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: () => applyChannelTemplate(channelId),
|
||||
onSuccess: (result) => {
|
||||
@@ -96,6 +108,17 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
|
||||
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
||||
{channel.number !== null && <Badge variant="muted">№ {channel.number}</Badge>}
|
||||
{!channel.isEnabled && <Badge variant="muted">{t('admin.channels.disabled')}</Badge>}
|
||||
{/* Дамп для разбора «почему сетка построилась так»: архив уезжает в браузер, а копия
|
||||
остаётся на сервере, если каталог настроен. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title={t('admin.channels.debugExportHint')}
|
||||
disabled={debugMutation.isPending}
|
||||
onClick={() => debugMutation.mutate()}
|
||||
>
|
||||
<Bug className="h-4 w-4" /> {t('admin.channels.debugExport')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import { apiDownload, apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
ApplyResultDto,
|
||||
ChannelDto,
|
||||
@@ -245,3 +245,25 @@ export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отладочный дамп канала: вход планировщика, состояние слотов, лента и сухой прогон одним архивом.
|
||||
* Тянем через fetch, а не ссылкой: эндпоинт закрыт Bearer'ом, и `<a href>` заголовок не отправит.
|
||||
*/
|
||||
export async function exportChannelDebug(channelId: string) {
|
||||
const { blob, fileName } = await apiDownload(
|
||||
`/admin/channels/${channelId}/debug-export`,
|
||||
'telewave-debug.zip',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
@@ -74,15 +74,19 @@ async function parseError(response: Response): Promise<HttpError> {
|
||||
export async function apiDownload(
|
||||
path: string,
|
||||
fallbackName: string,
|
||||
skipRefresh = false,
|
||||
options: { method?: 'GET' | 'POST'; skipRefresh?: boolean } = {},
|
||||
): Promise<{ blob: Blob; fileName: string }> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
|
||||
const response = await fetch(`/api${path}`, { headers, credentials: 'include' })
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (response.status === 401 && !skipRefresh && (await refreshAccessToken()))
|
||||
return apiDownload(path, fallbackName, true)
|
||||
if (response.status === 401 && !options.skipRefresh && (await refreshAccessToken()))
|
||||
return apiDownload(path, fallbackName, { ...options, skipRefresh: true })
|
||||
|
||||
if (!response.ok) throw await parseError(response)
|
||||
|
||||
|
||||
@@ -783,6 +783,9 @@ export const en = {
|
||||
noSchedule: 'Schedule not built yet',
|
||||
airToday: 'Today',
|
||||
airCount: 'Entries for the day: {{count}}',
|
||||
debugExport: 'Debug export',
|
||||
debugExportHint:
|
||||
'An archive with a snapshot of the channel: grid, groups, slot cursors, tape and a dry run — to work out why the schedule came out the way it did.',
|
||||
airHidePast: 'Hide past entries',
|
||||
airHideBreaks: 'Hide breaks',
|
||||
},
|
||||
|
||||
@@ -778,6 +778,9 @@ export const ru = {
|
||||
noSchedule: 'Расписание ещё не построено',
|
||||
airToday: 'Сегодня',
|
||||
airCount: 'Записей за сутки: {{count}}',
|
||||
debugExport: 'Дебаг-экспорт',
|
||||
debugExportHint:
|
||||
'Архив со снимком канала: сетка, группы, курсоры слотов, лента и сухой прогон — для разбора, почему эфир собрался именно так.',
|
||||
airHidePast: 'Скрывать прошедшее',
|
||||
airHideBreaks: 'Скрывать врезки',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user