Files
EvoBGP/apps/web/src/components/operations/job-timeline.tsx
T
DenozordecandCursor bda45d633e feat(web): show live BGP observation and deployment progress
Панель и сеть читают сохранённое наблюдение и прогресс deploy. Качество и свежесть сессии видны на спикерах и пирах, без подмены статуса неопрошенным Established.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:24:11 +07:00

129 lines
5.4 KiB
TypeScript

import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { Badge } from '@/components/reui/badge'
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* ReUI Timeline в раскрытии строки задач: создана → взята в работу → результат.
* Активный шаг выводится из фактических отметок времени задачи.
* @see https://reui.io/docs/components/base/timeline
*/
const ruDateTime = (value: string | null | undefined) =>
value ? new Date(value).toLocaleString('ru-RU') : undefined
function resultSubtitle(job: JobRow): string | undefined {
if (job.error) return job.error
const summary = job.meta?.apply_summary
if (summary && typeof summary === 'object') {
const message = (summary as Record<string, unknown>).message
if (typeof message === 'string' && message !== '') return message
}
return undefined
}
function applyCounts(job: JobRow) {
const value = job.meta?.apply_summary
if (!value || typeof value !== 'object') return undefined
const summary = value as Record<string, unknown>
const count = (key: string) => typeof summary[key] === 'number' ? summary[key] as number : undefined
return {
published: count('published_count'),
applied: count('applied_count'),
unconfirmed: count('unconfirmed_count'),
failed: count('failed_count'),
}
}
function dispatchConfirmations(job: JobRow): string[] {
const dispatch = job.meta?.node_dispatch
if (!dispatch || typeof dispatch !== 'object') return []
const results = (dispatch as Record<string, unknown>).results
if (!Array.isArray(results)) return []
return results.flatMap((item) => {
if (!item || typeof item !== 'object') return []
const result = item as Record<string, unknown>
const speaker = typeof result.speaker_id === 'string' ? result.speaker_id.slice(0, 8) : 'Спикер'
const label = result.confirmation === 'agent_reported'
? 'агент подтвердил'
: result.confirmation === 'local_applied'
? 'применено локально'
: result.confirmation === 'unconfirmed'
? 'без подтверждения агента'
: 'подтверждение не указано'
return [`${speaker}: ${label}`]
})
}
export function JobTimeline({ job }: { job: JobRow }) {
const trigger = jobTriggerRu(job.meta?.trigger)
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
const subtitle = resultSubtitle(job)
const counts = applyCounts(job)
const confirmations = dispatchConfirmations(job)
return (
<div className="py-4 ps-3 pe-2">
<Timeline defaultValue={completed}>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.created_at)}</TimelineDate>
<TimelineTitle>Создана{trigger ? ` (${trigger})` : ''}</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Идентификатор: {job.job_id}</TimelineContent>
</TimelineItem>
<TimelineItem step={2}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.started_at)}</TimelineDate>
<TimelineTitle>Взята в работу</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
</TimelineItem>
<TimelineItem step={3}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.finished_at)}</TimelineDate>
<TimelineTitle className={failed ? 'text-destructive' : undefined}>
{job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'}
</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
{subtitle || counts || confirmations.length > 0 ? (
<TimelineContent>
<div className="flex flex-col gap-2">
{subtitle ? <span>{subtitle}</span> : null}
{counts ? (
<div className="flex flex-wrap gap-2">
{counts.published != null ? <Badge variant="info-light" size="sm">Опубликовано: {counts.published}</Badge> : null}
{counts.applied != null ? <Badge variant="success-light" size="sm">Подтверждено: {counts.applied}</Badge> : null}
{counts.unconfirmed != null && counts.unconfirmed > 0 ? <Badge variant="warning-light" size="sm">Без подтверждения агента: {counts.unconfirmed}</Badge> : null}
{counts.failed != null && counts.failed > 0 ? <Badge variant="destructive-light" size="sm">Ошибок: {counts.failed}</Badge> : null}
</div>
) : null}
{confirmations.length > 0 ? (
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{confirmations.map((confirmation, index) => <li key={`${index}-${confirmation}`}>{confirmation}</li>)}
</ul>
) : null}
</div>
</TimelineContent>
) : null}
</TimelineItem>
</Timeline>
</div>
)
}