Панель и сеть читают сохранённое наблюдение и прогресс deploy. Качество и свежесть сессии видны на спикерах и пирах, без подмены статуса неопрошенным Established. Co-authored-by: Cursor <[email protected]>
129 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|