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:
@@ -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