From 09580bbcabfe69b90ac95902a61964293463a411 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 25 Sep 2026 21:46:57 +0700 Subject: [PATCH] feat(web): add job run timeline and code-block revision diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раскрытие строки задачи показывает вертикальный ReUI Timeline: создана (с триггером) -> взята в работу -> результат с текстом ошибки. Diff ревизий переведён с самодельных
 на официальный
@reui/code-block: единый блок с разметкой добавленных и удалённых
строк (знаки +/- и тонировка), нумерацией, копированием и разворотом
длинных списков. ResourcePage (filtered-вариант) получил поддержку
expandedContent/getRowCanExpand — колонка раскрытия строится так же,
как во FrameDataGrid. Точечные упрощения: прогресс BGP-сессий в
мониторинге переведён на Progress из кита вместо самодельного бара;
удалены @deprecated-константы ui-surface.ts, мёртвый
data-grid-defaults.ts и неиспользуемый sparkline-ряд дашборда.
---
 .../dashboard/dashboard-kpi-sparkline-row.tsx | 118 ------------------
 .../components/operations/job-timeline.tsx    |  73 +++++++++++
 .../operations/operations-jobs-grid.tsx       |   3 +
 .../src/components/reui-kit/resource-page.tsx |  44 ++++++-
 apps/web/src/lib/data-grid-defaults.ts        |  20 ---
 apps/web/src/lib/ui-surface.ts                |  10 --
 apps/web/src/routes/_auth/monitoring.tsx      |  20 +--
 apps/web/src/routes/_auth/operations.tsx      |  57 +++++++--
 8 files changed, 173 insertions(+), 172 deletions(-)
 delete mode 100644 apps/web/src/components/dashboard/dashboard-kpi-sparkline-row.tsx
 create mode 100644 apps/web/src/components/operations/job-timeline.tsx
 delete mode 100644 apps/web/src/lib/data-grid-defaults.ts

diff --git a/apps/web/src/components/dashboard/dashboard-kpi-sparkline-row.tsx b/apps/web/src/components/dashboard/dashboard-kpi-sparkline-row.tsx
deleted file mode 100644
index 9c47892..0000000
--- a/apps/web/src/components/dashboard/dashboard-kpi-sparkline-row.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { useMemo } from 'react'
-
-import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
-import { kpiGridClassName } from '@/lib/ui-surface'
-import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
-import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
-
-function syntheticSparkline(seed: number, points = 9): number[] {
-  const base = Math.max(4, seed)
-  return Array.from({ length: points }, (_, i) =>
-    Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
-  )
-}
-
-function buildMetrics({
-  modules,
-  peers,
-  speakers,
-  jobs,
-  loading,
-}: {
-  modules: ModuleRow[]
-  peers: PeerRow[]
-  speakers: SpeakerRow[]
-  jobs: JobRow[]
-  loading?: boolean
-}): KpiSparklineMetric[] {
-  const enabledModules = modules.filter((m) => m.enabled !== false).length
-  const network = aggregateNetworkMetrics(peers, speakers)
-  const bgpPct =
-    network.peersEnabled > 0
-      ? Math.round((network.peersEstablished / network.peersEnabled) * 100)
-      : 0
-  const running = runningJobCount(jobs)
-  const failedJobs = jobs.filter((j) =>
-    ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
-  ).length
-
-  return [
-    {
-      id: 'bgp',
-      title: 'BGP готовность',
-      label: 'Установлено / включено',
-      value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
-      delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
-      deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
-      detail: loading ? '' : `${network.peersEstablished} сессий`,
-      tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
-      sparkline: syntheticSparkline(bgpPct || 40),
-    },
-    {
-      id: 'modules',
-      title: 'Модули',
-      label: 'Активные списки',
-      value: loading ? '—' : `${enabledModules}`,
-      delta: loading ? '…' : `${modules.length} всего`,
-      deltaVariant: 'primary-light',
-      detail: loading ? '' : 'маршрутизация',
-      tone: 'info',
-      sparkline: syntheticSparkline(enabledModules || 3),
-    },
-    {
-      id: 'speakers',
-      title: 'Спикеры',
-      label: 'В сети / всего',
-      value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
-      delta:
-        loading || network.speakersTotal === 0
-          ? '…'
-          : network.speakersOnline === network.speakersTotal
-            ? 'все в сети'
-            : 'частично',
-      deltaVariant:
-        network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
-      detail: loading ? '' : 'live-снимок',
-      tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
-      sparkline: syntheticSparkline(network.speakersOnline || 2),
-    },
-    {
-      id: 'jobs',
-      title: 'Задачи',
-      label: 'Активные / ошибки',
-      value: loading ? '—' : String(running),
-      delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
-      deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
-      detail: loading ? '' : `${jobs.length} в выборке`,
-      tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
-      sparkline: syntheticSparkline(running + failedJobs || 1),
-    },
-  ]
-}
-
-export function DashboardKpiSparklineRow({
-  modules,
-  peers,
-  speakers,
-  jobs,
-  loading,
-}: {
-  modules: ModuleRow[]
-  peers: PeerRow[]
-  speakers: SpeakerRow[]
-  jobs: JobRow[]
-  loading?: boolean
-}) {
-  const metrics = useMemo(
-    () => buildMetrics({ modules, peers, speakers, jobs, loading }),
-    [modules, peers, speakers, jobs, loading],
-  )
-
-  return (
-    
- {metrics.map((metric) => ( - - ))} -
- ) -} diff --git a/apps/web/src/components/operations/job-timeline.tsx b/apps/web/src/components/operations/job-timeline.tsx new file mode 100644 index 0000000..b4e6e60 --- /dev/null +++ b/apps/web/src/components/operations/job-timeline.tsx @@ -0,0 +1,73 @@ +import { + Timeline, + TimelineContent, + TimelineDate, + TimelineHeader, + TimelineIndicator, + TimelineItem, + TimelineSeparator, + TimelineTitle, +} from '@/components/reui/timeline' +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).message + if (typeof message === 'string' && message !== '') return message + } + return undefined +} + +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) + + return ( +
+ + + + {ruDateTime(job.created_at)} + Создана{trigger ? ` (${trigger})` : ''} + + + + Идентификатор: {job.job_id} + + + + {ruDateTime(job.started_at)} + Взята в работу + + + + + + + {ruDateTime(job.finished_at)} + + {job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'} + + + + + {subtitle ? {subtitle} : null} + + +
+ ) +} diff --git a/apps/web/src/components/operations/operations-jobs-grid.tsx b/apps/web/src/components/operations/operations-jobs-grid.tsx index b84abee..65f1cac 100644 --- a/apps/web/src/components/operations/operations-jobs-grid.tsx +++ b/apps/web/src/components/operations/operations-jobs-grid.tsx @@ -7,6 +7,7 @@ import { Button } from '@evobgp/ui/components/button' import { Badge } from '@/components/reui/badge' import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell' import { StatusBadge } from '@/components/status-badge' +import { JobTimeline } from '@/components/operations/job-timeline' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types' import { @@ -175,6 +176,8 @@ export function OperationsJobsGrid({ pinLastColumn virtualization={items.length > 80} emptyState={{ title: 'Нет задач' }} + getRowCanExpand={() => true} + expandedContent={(row) => } /> ) } diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index eb80d50..dedfa14 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useTable, + type ExpandedState, type PaginationState, type RowSelectionState, type SortingState, @@ -21,6 +22,7 @@ import { import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table' import { Filters } from '@/components/reui/filters/filters' import { flattenFilterConditions } from '@/components/reui/filters/filters-query' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types' @@ -41,7 +43,7 @@ import { } from '@evobgp/ui/components/input-group' import { Separator } from '@evobgp/ui/components/separator' import { Skeleton } from '@evobgp/ui/components/skeleton' -import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults' +import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru' import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n' import { applyFiltersToData, @@ -94,6 +96,8 @@ type SimpleGridPassthrough = Pick< | 'horizontalScroll' | 'pinLeftColumnIds' | 'columnPinControls' + | 'expandedContent' + | 'getRowCanExpand' > export interface ResourcePageProps extends SimpleGridPassthrough { @@ -218,6 +222,8 @@ function ResourcePageSimple({ horizontalScroll, pinLeftColumnIds, columnPinControls, + expandedContent, + getRowCanExpand, }: ResourcePageProps) { if (isLoading) return if (isError) return @@ -256,6 +262,8 @@ function ResourcePageSimple({ height={height} onRowSelectionChange={onRowSelectionChange} enableColumnVisibility={enableColumnVisibility} + expandedContent={expandedContent} + getRowCanExpand={getRowCanExpand} columnVisibility={columnVisibility} onColumnVisibilityChange={onColumnVisibilityChange} columnVisibilityTrigger={columnVisibilityTrigger} @@ -304,6 +312,8 @@ function ResourcePageFiltered({ virtualization = false, height = 480, horizontalScroll = false, + expandedContent, + getRowCanExpand, }: ResourcePageProps) { const headerActions = primaryAction ?? actions const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') @@ -339,6 +349,7 @@ function ResourcePageFiltered({ const [sorting, setSorting] = useState([]) const [rowSelection, setRowSelection] = useState({}) + const [expanded, setExpanded] = useState({}) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize, @@ -376,9 +387,28 @@ function ResourcePageFiltered({ const selectedCount = selectedIds.length + // Колонка раскрытия — тот же приём, что во FrameDataGrid: рендерер + // подстроки живёт в meta колонки, DataGridTable читает его оттуда. const tableColumns = useMemo( - () => applyKitActionColumn(columns, { pinLastColumn }), - [columns, pinLastColumn], + () => + applyKitActionColumn( + expandedContent + ? [ + { + id: 'expand', + header: () => null, + cell: ({ row }) => , + enableSorting: false, + enableHiding: false, + size: 40, + meta: { cellClassName: 'w-10', expandedContent }, + }, + ...columns, + ] + : columns, + { pinLastColumn }, + ), + [columns, pinLastColumn, expandedContent], ) const { enablePinning, columnPinning } = kitColumnPinning({ pinLastColumn, @@ -399,6 +429,7 @@ function ResourcePageFiltered({ sorting, rowSelection, pagination, + expanded, ...(enablePinning ? { columnPinning } : {}), }, initialState: enablePinning ? { columnPinning } : undefined, @@ -406,6 +437,10 @@ function ResourcePageFiltered({ onSortingChange: setSorting, onRowSelectionChange: setRowSelection, onPaginationChange: setPagination, + onExpandedChange: setExpanded, + getRowCanExpand: expandedContent + ? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true) + : undefined, }) const handleTabChange = useCallback( @@ -495,6 +530,7 @@ function ResourcePageFiltered({ recordCount={filteredData.length} emptyMessage="Нет записей по выбранным фильтрам." onRowClick={onRowClick} + i18n={DATA_GRID_I18N_RU} tableLayout={kitDataGridTableLayout({ dense: true, width: 'fixed', @@ -610,7 +646,7 @@ function ResourcePageFiltered({ - + diff --git a/apps/web/src/lib/data-grid-defaults.ts b/apps/web/src/lib/data-grid-defaults.ts deleted file mode 100644 index 9fc4dc4..0000000 --- a/apps/web/src/lib/data-grid-defaults.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const DATA_GRID_PAGINATION_RU = { - sizes: [10, 25, 50] as number[], - sizesLabel: 'Показать', - sizesDescription: 'на странице', - info: '{from}–{to} из {count}', - rowsPerPageLabel: 'Строк на странице', - previousPageLabel: 'Предыдущая страница', - nextPageLabel: 'Следующая страница', -} - -export const DATA_GRID_MESSAGES_RU = { - emptyMessage: 'Нет данных', - loadingMessage: 'Загрузка…', - fetchingMoreMessage: 'Загрузка…', - selectAllLabel: 'Выбрать все', - selectRowLabel: 'Выбрать строку', - pinRowLabel: 'Закрепить строку', - unpinRowLabel: 'Открепить строку', - allRecordsLoadedMessage: 'Все записи загружены', -} diff --git a/apps/web/src/lib/ui-surface.ts b/apps/web/src/lib/ui-surface.ts index 54f8586..6376b4f 100644 --- a/apps/web/src/lib/ui-surface.ts +++ b/apps/web/src/lib/ui-surface.ts @@ -19,16 +19,6 @@ export const SKIP_TO_CONTENT_CLASS = /** Grid for ReUI stats-12 KPI rows (3–6 tiles). */ export const kpiStatGridClassName = '@container w-full' -/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */ -export const dashboardKpiGridClassName = kpiStatGridClassName - -/** @deprecated Sparkline KPI row replaced by stats-12 grid. */ -export const kpiGridClassName = - 'grid grid-cols-1 gap-5 @3xl:grid-cols-2 @6xl:grid-cols-4' - -/** @deprecated */ -export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5' - /** Two-column chart panel row (monitoring / network overview). */ export const chartPanelGridClassName = 'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2' diff --git a/apps/web/src/routes/_auth/monitoring.tsx b/apps/web/src/routes/_auth/monitoring.tsx index 4da2420..4d7947e 100644 --- a/apps/web/src/routes/_auth/monitoring.tsx +++ b/apps/web/src/routes/_auth/monitoring.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { Bird, RefreshCw } from 'lucide-react' import { Button } from '@evobgp/ui/components/button' +import { Progress } from '@evobgp/ui/components/progress' import { MonitoringHealthCard } from '@/components/analytics' import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid' import { PageHeader } from '@/components/page-header' @@ -164,14 +165,17 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { {ratio !== null ? ( -
-
= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive' - }`} - style={{ width: `${ratio}%` }} - /> -
+ = 100 + ? '[&_[data-slot=progress-indicator]]:bg-success' + : ratio >= 50 + ? '[&_[data-slot=progress-indicator]]:bg-warning' + : '[&_[data-slot=progress-indicator]]:bg-destructive' + } + /> ) : null} {bird.error ?

{bird.error}

: null}
diff --git a/apps/web/src/routes/_auth/operations.tsx b/apps/web/src/routes/_auth/operations.tsx index 48d30e8..593cb8a 100644 --- a/apps/web/src/routes/_auth/operations.tsx +++ b/apps/web/src/routes/_auth/operations.tsx @@ -14,6 +14,13 @@ import { type QuickActionItem, } from '@/components/reui-kit' import { Badge } from '@/components/reui/badge' +import { + CodeBlock, + CodeBlockCopyButton, + CodeBlockExpandButton, + CodeBlockHeader, +} from '@/components/reui/code-block/code-block' +import type { CodeBlockDiffSpec } from '@/components/reui/code-block/code-block-highlight' import { SelectMenu } from '@/components/select-field' import { OperationsJobsCard } from '@/components/operations/operations-jobs-card' import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid' @@ -320,20 +327,46 @@ function DiffTab({ revisions }: { revisions: RevisionRow[] }) { function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) { const added = diff.prefixes?.added ?? (diff.added as string[]) ?? [] const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? [] + + if (added.length === 0 && removed.length === 0) { + return ( +

Различий между ревизиями нет.

+ ) + } + + // ReUI CodeBlock diff: добавленные строки идут первыми, удалённые следом, + // диапазоны размечают их (+/− канал и зелёный/красный фон строк). + const code = [...added, ...removed].join('\n') + const diffSpec: CodeBlockDiffSpec = { + ...(added.length > 0 ? { added: `1-${added.length}` } : {}), + ...(removed.length > 0 + ? { removed: `${added.length + 1}-${added.length + removed.length}` } + : {}), + } + return ( -
-
-

Добавлено: {added.length}

-
-          {added.join('\n')}
-        
-
-
-

Удалено: {removed.length}

-
-          {removed.join('\n')}
-        
+
+
+ + Добавлено: {added.length} + + + Удалено: {removed.length} +
+ + + + Показать всё + +
) }