Add debug export functionality for channel analysis
ci / build-backend (push) Successful in 1m16s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 1m24s
ci / sonar (push) Successful in 5m9s

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:
Leonid Pershin
2026-07-31 02:58:17 +03:00
parent 01ba48e163
commit 9cd364b174
21 changed files with 1055 additions and 7 deletions
@@ -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>
+23 -1
View File
@@ -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
}
+8 -4
View File
@@ -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)
+3
View File
@@ -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',
},
+3
View File
@@ -778,6 +778,9 @@ export const ru = {
noSchedule: 'Расписание ещё не построено',
airToday: 'Сегодня',
airCount: 'Записей за сутки: {{count}}',
debugExport: 'Дебаг-экспорт',
debugExportHint:
'Архив со снимком канала: сетка, группы, курсоры слотов, лента и сухой прогон — для разбора, почему эфир собрался именно так.',
airHidePast: 'Скрывать прошедшее',
airHideBreaks: 'Скрывать врезки',
},