quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 1m3s
quality / web (push) Successful in 1m58s
quality / go (push) Successful in 3m21s
quality / bird2 (push) Successful in 19s
CD / quality (push) Successful in 7m6s
CD / publish (push) Successful in 9m18s
Колонка «Вид» в журнале задач (Операции → Задачи и Расписание) стала информативнее: подзаголовок с именами обновляемых модулей для tenant_refresh (из meta.module_ids), сообщение apply_summary для deploy_apply и бейдж триггера («по расписанию», «вручную», «после правки данных»). Поиск по журналу учитывает новые поля.
Бэкенд: meta.trigger теперь записывается для module_refresh и deploy_apply при ручных запусках (POST /v1/modules/{id}/refresh, /v1/apply, /v1/speakers/{id}/apply), а deploy_apply из цепочки обновления наследует триггер родительской задачи.
169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { ListTodo, SearchIcon } from 'lucide-react'
|
||
|
||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||
import { StatusBadge } from '@/components/status-badge'
|
||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||
import {
|
||
ResourcePage,
|
||
createTextFilterQuery,
|
||
type DataGridColumnDef,
|
||
} from '@/components/reui-kit'
|
||
import { Badge } from '@/components/reui/badge'
|
||
import { isRefreshJobKind, jobKindRu, jobSubtitleRu, jobTriggerRu } from '@/lib/ui-labels'
|
||
import type { JobRow } from '@/types/api'
|
||
|
||
const FAILED_STATUSES = ['failed', 'error', 'cancelled']
|
||
|
||
const filterFields: FilterField[] = [
|
||
{
|
||
id: 'search',
|
||
label: 'Поиск',
|
||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||
type: 'text',
|
||
placeholder: 'Поиск задач…',
|
||
},
|
||
]
|
||
|
||
function tabFilter(item: JobRow, tabId: string): boolean {
|
||
if (tabId === 'refresh') return isRefreshJobKind(item.kind)
|
||
if (tabId === 'failed') return FAILED_STATUSES.includes(item.status.toLowerCase())
|
||
return true
|
||
}
|
||
|
||
export function ScheduleJobsGrid({
|
||
items,
|
||
nameById = new Map<string, string>(),
|
||
isLoading = false,
|
||
}: {
|
||
items: JobRow[]
|
||
nameById?: Map<string, string>
|
||
isLoading?: boolean
|
||
}) {
|
||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||
createTextFilterQuery('search'),
|
||
)
|
||
|
||
const tabs = useMemo(
|
||
() => [
|
||
{ id: 'all', label: 'Все', count: items.length },
|
||
{
|
||
id: 'refresh',
|
||
label: 'Обновление',
|
||
count: items.filter((job) => isRefreshJobKind(job.kind)).length,
|
||
},
|
||
{
|
||
id: 'failed',
|
||
label: 'С ошибкой',
|
||
count: items.filter((job) => FAILED_STATUSES.includes(job.status.toLowerCase())).length,
|
||
},
|
||
],
|
||
[items],
|
||
)
|
||
|
||
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
|
||
() => [
|
||
{
|
||
accessorKey: 'kind',
|
||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||
cell: ({ row }) => {
|
||
const trigger = jobTriggerRu(row.original.meta?.trigger)
|
||
return (
|
||
<DataGridNameCell
|
||
icon={ListTodo}
|
||
title={
|
||
<span className="flex min-w-0 items-center gap-1.5">
|
||
<span className="truncate">{jobKindRu(row.original.kind)}</span>
|
||
{trigger ? (
|
||
<Badge variant="outline" size="sm" className="shrink-0 font-normal">
|
||
{trigger}
|
||
</Badge>
|
||
) : null}
|
||
</span>
|
||
}
|
||
subtitle={jobSubtitleRu(row.original.kind, row.original.meta, nameById)}
|
||
/>
|
||
)
|
||
},
|
||
meta: { headerTitle: 'Вид' },
|
||
},
|
||
{
|
||
accessorKey: 'status',
|
||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||
cell: ({ row }) => {
|
||
const finished = row.original.finished_at
|
||
const hint = finished
|
||
? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||
: undefined
|
||
return <StatusBadge status={row.original.status} hint={hint} />
|
||
},
|
||
meta: { headerTitle: 'Статус' },
|
||
},
|
||
{
|
||
id: 'created_at',
|
||
accessorFn: (row) => row.created_at ?? '',
|
||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||
cell: ({ row }) => (
|
||
<DataGridMutedCell>
|
||
{row.original.created_at
|
||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||
: '—'}
|
||
</DataGridMutedCell>
|
||
),
|
||
meta: { headerTitle: 'Создана' },
|
||
},
|
||
{
|
||
id: 'finished_at',
|
||
accessorFn: (row) => row.finished_at ?? '',
|
||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||
cell: ({ row }) => (
|
||
<DataGridMutedCell>
|
||
{row.original.finished_at
|
||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||
: '—'}
|
||
</DataGridMutedCell>
|
||
),
|
||
meta: { headerTitle: 'Завершена' },
|
||
},
|
||
{
|
||
accessorKey: 'error',
|
||
enableSorting: false,
|
||
header: 'Ошибка',
|
||
cell: ({ row }) => (
|
||
<span className="max-w-xs truncate text-xs text-destructive">
|
||
{row.original.error ?? ''}
|
||
</span>
|
||
),
|
||
meta: { headerTitle: 'Ошибка' },
|
||
},
|
||
],
|
||
[nameById],
|
||
)
|
||
|
||
return (
|
||
<ResourcePage
|
||
title="Задачи"
|
||
description="Последние задачи из API"
|
||
tabs={tabs}
|
||
tabFilter={tabFilter}
|
||
filterFields={filterFields}
|
||
filterQuery={filterQuery}
|
||
onFilterQueryChange={setFilterQuery}
|
||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||
getFilterFieldValue={(item, field) => {
|
||
if (field !== 'search') return undefined
|
||
const subtitle = jobSubtitleRu(item.kind, item.meta, nameById)
|
||
const trigger = jobTriggerRu(item.meta?.trigger)
|
||
return `${jobKindRu(item.kind)} ${item.status} ${item.error ?? ''} ${item.job_id} ${subtitle ?? ''} ${trigger ?? ''}`
|
||
}}
|
||
columns={columns}
|
||
data={items}
|
||
getRowId={(row) => row.job_id}
|
||
isLoading={isLoading}
|
||
virtualization={items.length > 80}
|
||
emptyState={{ title: 'Нет задач в выборке' }}
|
||
/>
|
||
)
|
||
}
|