feat(web): add job run timeline and code-block revision diff
Раскрытие строки задачи показывает вертикальный ReUI Timeline: создана (с триггером) -> взята в работу -> результат с текстом ошибки. Diff ревизий переведён с самодельных <pre> на официальный @reui/code-block: единый блок с разметкой добавленных и удалённых строк (знаки +/- и тонировка), нумерацией, копированием и разворотом длинных списков. ResourcePage (filtered-вариант) получил поддержку expandedContent/getRowCanExpand — колонка раскрытия строится так же, как во FrameDataGrid. Точечные упрощения: прогресс BGP-сессий в мониторинге переведён на Progress из кита вместо самодельного бара; удалены @deprecated-константы ui-surface.ts, мёртвый data-grid-defaults.ts и неиспользуемый sparkline-ряд дашборда.
This commit is contained in:
@@ -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 (
|
|
||||||
<section aria-label="KPI обзора" className={kpiGridClassName}>
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown>).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 (
|
||||||
|
<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 ? <TimelineContent>{subtitle}</TimelineContent> : null}
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from '@evobgp/ui/components/button'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
@@ -175,6 +176,8 @@ export function OperationsJobsGrid({
|
|||||||
pinLastColumn
|
pinLastColumn
|
||||||
virtualization={items.length > 80}
|
virtualization={items.length > 80}
|
||||||
emptyState={{ title: 'Нет задач' }}
|
emptyState={{ title: 'Нет задач' }}
|
||||||
|
getRowCanExpand={() => true}
|
||||||
|
expandedContent={(row) => <JobTimeline job={row} />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
useTable,
|
useTable,
|
||||||
|
type ExpandedState,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
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 { Filters } from '@/components/reui/filters/filters'
|
||||||
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
@@ -41,7 +43,7 @@ import {
|
|||||||
} from '@evobgp/ui/components/input-group'
|
} from '@evobgp/ui/components/input-group'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
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 { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||||
import {
|
import {
|
||||||
applyFiltersToData,
|
applyFiltersToData,
|
||||||
@@ -94,6 +96,8 @@ type SimpleGridPassthrough<T extends object> = Pick<
|
|||||||
| 'horizontalScroll'
|
| 'horizontalScroll'
|
||||||
| 'pinLeftColumnIds'
|
| 'pinLeftColumnIds'
|
||||||
| 'columnPinControls'
|
| 'columnPinControls'
|
||||||
|
| 'expandedContent'
|
||||||
|
| 'getRowCanExpand'
|
||||||
>
|
>
|
||||||
|
|
||||||
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
||||||
@@ -218,6 +222,8 @@ function ResourcePageSimple<T extends object>({
|
|||||||
horizontalScroll,
|
horizontalScroll,
|
||||||
pinLeftColumnIds,
|
pinLeftColumnIds,
|
||||||
columnPinControls,
|
columnPinControls,
|
||||||
|
expandedContent,
|
||||||
|
getRowCanExpand,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
if (isLoading) return <ResourcePageSkeleton />
|
if (isLoading) return <ResourcePageSkeleton />
|
||||||
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||||
@@ -256,6 +262,8 @@ function ResourcePageSimple<T extends object>({
|
|||||||
height={height}
|
height={height}
|
||||||
onRowSelectionChange={onRowSelectionChange}
|
onRowSelectionChange={onRowSelectionChange}
|
||||||
enableColumnVisibility={enableColumnVisibility}
|
enableColumnVisibility={enableColumnVisibility}
|
||||||
|
expandedContent={expandedContent}
|
||||||
|
getRowCanExpand={getRowCanExpand}
|
||||||
columnVisibility={columnVisibility}
|
columnVisibility={columnVisibility}
|
||||||
onColumnVisibilityChange={onColumnVisibilityChange}
|
onColumnVisibilityChange={onColumnVisibilityChange}
|
||||||
columnVisibilityTrigger={columnVisibilityTrigger}
|
columnVisibilityTrigger={columnVisibilityTrigger}
|
||||||
@@ -304,6 +312,8 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
virtualization = false,
|
virtualization = false,
|
||||||
height = 480,
|
height = 480,
|
||||||
horizontalScroll = false,
|
horizontalScroll = false,
|
||||||
|
expandedContent,
|
||||||
|
getRowCanExpand,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
const headerActions = primaryAction ?? actions
|
const headerActions = primaryAction ?? actions
|
||||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
@@ -339,6 +349,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -376,9 +387,28 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
|
|
||||||
const selectedCount = selectedIds.length
|
const selectedCount = selectedIds.length
|
||||||
|
|
||||||
|
// Колонка раскрытия — тот же приём, что во FrameDataGrid: рендерер
|
||||||
|
// подстроки живёт в meta колонки, DataGridTable читает его оттуда.
|
||||||
const tableColumns = useMemo(
|
const tableColumns = useMemo(
|
||||||
() => applyKitActionColumn(columns, { pinLastColumn }),
|
() =>
|
||||||
[columns, pinLastColumn],
|
applyKitActionColumn(
|
||||||
|
expandedContent
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'expand',
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
size: 40,
|
||||||
|
meta: { cellClassName: 'w-10', expandedContent },
|
||||||
|
},
|
||||||
|
...columns,
|
||||||
|
]
|
||||||
|
: columns,
|
||||||
|
{ pinLastColumn },
|
||||||
|
),
|
||||||
|
[columns, pinLastColumn, expandedContent],
|
||||||
)
|
)
|
||||||
const { enablePinning, columnPinning } = kitColumnPinning({
|
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||||
pinLastColumn,
|
pinLastColumn,
|
||||||
@@ -399,6 +429,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
sorting,
|
sorting,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
pagination,
|
pagination,
|
||||||
|
expanded,
|
||||||
...(enablePinning ? { columnPinning } : {}),
|
...(enablePinning ? { columnPinning } : {}),
|
||||||
},
|
},
|
||||||
initialState: enablePinning ? { columnPinning } : undefined,
|
initialState: enablePinning ? { columnPinning } : undefined,
|
||||||
@@ -406,6 +437,10 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
getRowCanExpand: expandedContent
|
||||||
|
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
@@ -495,6 +530,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
recordCount={filteredData.length}
|
recordCount={filteredData.length}
|
||||||
emptyMessage="Нет записей по выбранным фильтрам."
|
emptyMessage="Нет записей по выбранным фильтрам."
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
|
i18n={DATA_GRID_I18N_RU}
|
||||||
tableLayout={kitDataGridTableLayout({
|
tableLayout={kitDataGridTableLayout({
|
||||||
dense: true,
|
dense: true,
|
||||||
width: 'fixed',
|
width: 'fixed',
|
||||||
@@ -610,7 +646,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
|
<DataGridPagination sizes={[5, 10, 20, 50]} />
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -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: 'Все записи загружены',
|
|
||||||
}
|
|
||||||
@@ -19,16 +19,6 @@ export const SKIP_TO_CONTENT_CLASS =
|
|||||||
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||||
export const kpiStatGridClassName = '@container w-full'
|
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). */
|
/** Two-column chart panel row (monitoring / network overview). */
|
||||||
export const chartPanelGridClassName =
|
export const chartPanelGridClassName =
|
||||||
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { Bird, RefreshCw } from 'lucide-react'
|
import { Bird, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Progress } from '@evobgp/ui/components/progress'
|
||||||
import { MonitoringHealthCard } from '@/components/analytics'
|
import { MonitoringHealthCard } from '@/components/analytics'
|
||||||
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
@@ -164,14 +165,17 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ratio !== null ? (
|
{ratio !== null ? (
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<Progress
|
||||||
<div
|
value={ratio}
|
||||||
className={`h-full rounded-full transition-all ${
|
aria-label="Установлено BGP-сессий"
|
||||||
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
|
className={
|
||||||
}`}
|
ratio >= 100
|
||||||
style={{ width: `${ratio}%` }}
|
? '[&_[data-slot=progress-indicator]]:bg-success'
|
||||||
/>
|
: ratio >= 50
|
||||||
</div>
|
? '[&_[data-slot=progress-indicator]]:bg-warning'
|
||||||
|
: '[&_[data-slot=progress-indicator]]:bg-destructive'
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import {
|
|||||||
type QuickActionItem,
|
type QuickActionItem,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { Badge } from '@/components/reui/badge'
|
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 { SelectMenu } from '@/components/select-field'
|
||||||
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
|
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
|
||||||
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
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 }) {
|
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
|
||||||
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
|
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
|
||||||
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
|
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
|
||||||
|
|
||||||
|
if (added.length === 0 && removed.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">Различий между ревизиями нет.</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="flex flex-col gap-3">
|
||||||
<div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
|
<Badge variant="success-light" size="sm">
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
Добавлено: {added.length}
|
||||||
{added.join('\n')}
|
</Badge>
|
||||||
</pre>
|
<Badge variant="destructive-light" size="sm">
|
||||||
</div>
|
Удалено: {removed.length}
|
||||||
<div>
|
</Badge>
|
||||||
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
|
||||||
{removed.join('\n')}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
code={code}
|
||||||
|
language="json"
|
||||||
|
showLineNumbers
|
||||||
|
diff={diffSpec}
|
||||||
|
maxLines={24}
|
||||||
|
label="Различия ревизий"
|
||||||
|
>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockCopyButton />
|
||||||
|
<CodeBlockExpandButton>Показать всё</CodeBlockExpandButton>
|
||||||
|
</CodeBlockHeader>
|
||||||
|
</CodeBlock>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user