From 6a25a3d137da02c8ff1499fd106c9e6dd77e0956 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 12 Aug 2026 13:41:58 +0700 Subject: [PATCH] fix(web): parse string readiness checks on monitoring System tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправляет ложные «Ошибки проверок»: GET /v1/ready отдаёт строки ok/memory, а не boolean. Уплотнён ReUI Frame/donut layout на вкладке Система. Co-authored-by: Cursor --- .../analytics/chart-donut-metric.tsx | 4 +- .../monitoring/monitoring-ready-grid.tsx | 40 +++++++-- .../patterns/donut-breakdown-card.tsx | 28 ++++--- .../lib/metrics/readiness-breakdown.test.ts | 78 +++++++++++++++++ .../src/lib/metrics/readiness-breakdown.ts | 63 +++++++++++++- apps/web/src/lib/ui-labels.ts | 2 + apps/web/src/queries/monitoring.ts | 3 +- apps/web/src/routes/_auth/monitoring.tsx | 84 ++++++++++--------- 8 files changed, 235 insertions(+), 67 deletions(-) create mode 100644 apps/web/src/lib/metrics/readiness-breakdown.test.ts diff --git a/apps/web/src/components/analytics/chart-donut-metric.tsx b/apps/web/src/components/analytics/chart-donut-metric.tsx index 784c8e9..f6e283a 100644 --- a/apps/web/src/components/analytics/chart-donut-metric.tsx +++ b/apps/web/src/components/analytics/chart-donut-metric.tsx @@ -40,7 +40,7 @@ export function ChartDonutMetric({ } return ( -
+
-
    +
      {slices.map((slice) => { const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' return ( diff --git a/apps/web/src/components/monitoring/monitoring-ready-grid.tsx b/apps/web/src/components/monitoring/monitoring-ready-grid.tsx index 76091d1..f4c140d 100644 --- a/apps/web/src/components/monitoring/monitoring-ready-grid.tsx +++ b/apps/web/src/components/monitoring/monitoring-ready-grid.tsx @@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell' import { StatusBadge } from '@/components/status-badge' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { useClientDataGrid } from '@/hooks/use-client-data-grid' -import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels' +import { + isReadyCheckOk, + isSystemReady, + readyCheckStatusLabel, +} from '@/lib/metrics' +import { readyCheckRu } from '@/lib/ui-labels' import type { ReadyStatus } from '@/queries/monitoring' +import { Item, ItemMedia } from '@evobgp/ui/components/item' +import { cn } from '@evobgp/ui/lib/utils' const READY_CHECK_ICONS: Record = { postgres: Database, store: HardDrive, + store_backend: HardDrive, jobs: ListTodo, } @@ -21,6 +29,7 @@ interface ReadyCheckRow { label: string subtitle?: string icon: typeof Database + iconClassName?: string status: string statusLabel: string } @@ -34,12 +43,14 @@ export function MonitoringReadyGrid({ }) { const data = useMemo(() => { const checks = ready.checks ?? {} + const systemReady = isSystemReady(ready.status) const rows: ReadyCheckRow[] = [ { id: 'liveness', label: 'Живучесть', subtitle: '/v1/health', icon: HeartPulse, + iconClassName: health?.ok ? 'text-success' : 'text-destructive', status: health?.ok ? 'ok' : 'error', statusLabel: health?.ok ? 'В норме' : 'Ошибка', }, @@ -48,19 +59,21 @@ export function MonitoringReadyGrid({ label: 'Готовность', subtitle: '/v1/ready', icon: ShieldCheck, - status: ready.status === 'ok' ? 'ok' : 'warning', - statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'), + iconClassName: systemReady ? 'text-success' : 'text-warning', + status: systemReady ? 'ok' : 'warning', + statusLabel: systemReady ? 'Готов' : 'Не готов', }, ] for (const key of Object.keys(checks)) { const value = checks[key] - const ok = typeof value === 'boolean' ? value : value?.ok !== false + const ok = isReadyCheckOk(value) rows.push({ id: key, label: readyCheckRu(key), icon: READY_CHECK_ICONS[key] ?? ListTodo, + iconClassName: ok ? 'text-success' : 'text-destructive', status: ok ? 'ok' : 'error', - statusLabel: ok ? 'В норме' : 'Ошибка', + statusLabel: readyCheckStatusLabel(value, ok), }) } return rows @@ -74,8 +87,17 @@ export function MonitoringReadyGrid({ cell: ({ row }) => { const Icon = row.original.icon return ( -
      - +
      + + + + + ( - +
      + +
      ), meta: { headerTitle: 'Статус' }, }, diff --git a/apps/web/src/components/patterns/donut-breakdown-card.tsx b/apps/web/src/components/patterns/donut-breakdown-card.tsx index c239004..f5a1736 100644 --- a/apps/web/src/components/patterns/donut-breakdown-card.tsx +++ b/apps/web/src/components/patterns/donut-breakdown-card.tsx @@ -8,7 +8,7 @@ import { } from '@evobgp/ui/components/chart' import type { BreakdownSlice } from '@/lib/metrics' -/** chart-27 / chart-13 inspired donut in PanelCard. */ +/** chart-27 / chart-13 inspired donut in PanelCard (Frame surface). */ export function DonutBreakdownCard({ title, description, @@ -30,13 +30,24 @@ export function DonutBreakdownCard({ const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count })) return ( - -
      + + {badge} + + ) : undefined + } + > +
      {total === 0 ? (

      Нет данных

      ) : ( <> -
      +
      {total}
      -
        +
          {slices.map((slice) => { const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' return ( -
        • +
        • )} - {badge ? ( - - {badge} - - ) : null}
      ) diff --git a/apps/web/src/lib/metrics/readiness-breakdown.test.ts b/apps/web/src/lib/metrics/readiness-breakdown.test.ts new file mode 100644 index 0000000..a585f56 --- /dev/null +++ b/apps/web/src/lib/metrics/readiness-breakdown.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { + isReadyCheckOk, + isSystemReady, + readinessBreakdown, + readyCheckStatusLabel, +} from '@/lib/metrics/readiness-breakdown' + +describe('isSystemReady', () => { + it('accepts ready and ok', () => { + expect(isSystemReady('ready')).toBe(true) + expect(isSystemReady('ok')).toBe(true) + expect(isSystemReady('READY')).toBe(true) + }) + + it('rejects not_ready and empty', () => { + expect(isSystemReady('not_ready')).toBe(false) + expect(isSystemReady(undefined)).toBe(false) + expect(isSystemReady('')).toBe(false) + }) +}) + +describe('isReadyCheckOk', () => { + it('parses backend string checks as ok', () => { + expect(isReadyCheckOk('ok')).toBe(true) + expect(isReadyCheckOk('memory')).toBe(true) + expect(isReadyCheckOk('unavailable')).toBe(false) + }) + + it('parses boolean and object forms', () => { + expect(isReadyCheckOk(true)).toBe(true) + expect(isReadyCheckOk(false)).toBe(false) + expect(isReadyCheckOk({ ok: true })).toBe(true) + expect(isReadyCheckOk({ ok: false, error: 'down' })).toBe(false) + }) +}) + +describe('readyCheckStatusLabel', () => { + it('labels memory and failures', () => { + expect(readyCheckStatusLabel('memory', true)).toBe('Memory') + expect(readyCheckStatusLabel('ok', true)).toBe('В норме') + expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно') + }) +}) + +describe('readinessBreakdown', () => { + it('counts healthy handleReady payload without false failures', () => { + const slices = readinessBreakdown( + { + status: 'ready', + checks: { store: 'ok', jobs: 'memory', postgres: 'ok' }, + }, + true, + ) + expect(slices.find((s) => s.key === 'checks-fail')).toBeUndefined() + expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(3) + expect(slices.find((s) => s.key === 'health')?.count).toBe(1) + }) + + it('counts unavailable checks as failures', () => { + const slices = readinessBreakdown( + { + status: 'not_ready', + checks: { store: 'unavailable', jobs: 'memory' }, + }, + true, + ) + expect(slices.find((s) => s.key === 'checks-fail')?.count).toBe(1) + expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(1) + }) + + it('returns API down slice when health fails', () => { + const slices = readinessBreakdown({ status: 'ready', checks: { store: 'ok' } }, false) + expect(slices).toHaveLength(1) + expect(slices[0]?.key).toBe('health-fail') + }) +}) diff --git a/apps/web/src/lib/metrics/readiness-breakdown.ts b/apps/web/src/lib/metrics/readiness-breakdown.ts index ddf0fad..583da54 100644 --- a/apps/web/src/lib/metrics/readiness-breakdown.ts +++ b/apps/web/src/lib/metrics/readiness-breakdown.ts @@ -2,12 +2,65 @@ import type { ReadyStatus } from '@/queries/monitoring' import type { BreakdownSlice } from './types' -function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean { +/** Значение check из GET /v1/ready (строка, boolean или объект). */ +export type ReadyCheckValue = boolean | string | { ok?: boolean; error?: string } | null | undefined + +const OK_STRINGS = new Set(['ok', 'ready', 'memory', 'true', 'healthy', 'up']) +const FAIL_STRINGS = new Set([ + 'unavailable', + 'error', + 'not_ready', + 'failed', + 'down', + 'false', + 'unhealthy', +]) + +/** Top-level status GET /v1/ready: API отдаёт `ready`, не `ok`. */ +export function isSystemReady(status: string | null | undefined): boolean { + if (!status) return false + const normalized = status.trim().toLowerCase() + return normalized === 'ready' || normalized === 'ok' +} + +/** + * Интерпретация check value по контракту handleReady: + * store/postgres → "ok" | "unavailable"; jobs → "memory"; store_backend → "memory". + */ +export function isReadyCheckOk(value: ReadyCheckValue): boolean { if (typeof value === 'boolean') return value + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (OK_STRINGS.has(normalized)) return true + if (FAIL_STRINGS.has(normalized)) return false + // неизвестная непустая строка — считать OK (информативный статус бэкенда) + return normalized.length > 0 + } if (value && typeof value === 'object') return value.ok === true return false } +/** Человекочитаемый статус check для UI. */ +export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): string { + if (!ok) { + if (typeof value === 'string' && value.trim()) { + const n = value.trim().toLowerCase() + if (n === 'unavailable') return 'Недоступно' + if (n === 'not_ready') return 'Не готов' + return value + } + if (value && typeof value === 'object' && value.error) return value.error + return 'Ошибка' + } + if (typeof value === 'string') { + const n = value.trim().toLowerCase() + if (n === 'memory') return 'Memory' + if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме' + if (n) return value + } + return 'В норме' +} + export function readinessBreakdown( ready: ReadyStatus | null | undefined, healthOk: boolean, @@ -28,7 +81,7 @@ export function readinessBreakdown( let failCount = 0 for (const value of Object.values(checks)) { - if (checkOk(value)) okCount += 1 + if (isReadyCheckOk(value)) okCount += 1 else failCount += 1 } @@ -61,9 +114,11 @@ export function readinessBreakdown( if (slices.length === 1 && okCount === 0 && failCount === 0) { slices.push({ key: 'ready', - label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности', + label: isSystemReady(ready?.status) ? 'Готов' : 'Не готов', count: 1, - color: 'var(--color-chart-4)', + color: isSystemReady(ready?.status) + ? 'var(--color-chart-1)' + : 'var(--color-warning)', }) } diff --git a/apps/web/src/lib/ui-labels.ts b/apps/web/src/lib/ui-labels.ts index b03759e..014e83d 100644 --- a/apps/web/src/lib/ui-labels.ts +++ b/apps/web/src/lib/ui-labels.ts @@ -117,6 +117,8 @@ export function readyCheckRu(key: string): string { return 'Хранилище' case 'jobs': return 'Очередь задач' + case 'store_backend': + return 'Бэкенд хранилища' default: return key } diff --git a/apps/web/src/queries/monitoring.ts b/apps/web/src/queries/monitoring.ts index 733cccf..f2928bc 100644 --- a/apps/web/src/queries/monitoring.ts +++ b/apps/web/src/queries/monitoring.ts @@ -9,7 +9,8 @@ export interface HealthStatus { export interface ReadyStatus { status?: string - checks?: Record + /** Backend: string ("ok"|"memory"|"unavailable"), boolean, or { ok, error }. */ + checks?: Record } export interface VersionInfo { diff --git a/apps/web/src/routes/_auth/monitoring.tsx b/apps/web/src/routes/_auth/monitoring.tsx index 591adb9..0d2d28c 100644 --- a/apps/web/src/routes/_auth/monitoring.tsx +++ b/apps/web/src/routes/_auth/monitoring.tsx @@ -81,7 +81,7 @@ function MonitoringComponent() { .slice(0, 5) return ( -
      +
      - + {analyticsLoading ? ( ) : ( -
      +
      )} -
      +
      } description="GET /v1/bird/status" - contentClassName="py-4" + className="h-full" + contentClassName="px-5 py-4" > - } - onRetry={() => birdQ.refetch()} - > - {(bird) => } - + } + onRetry={() => birdQ.refetch()} + > + {(bird) => } +
      -
      -
      +
      +
      {failedJobs.length > 0 ? ( - -
        + +
          {failedJobs.map((job) => (
        • @@ -218,28 +219,29 @@ function MonitoringComponent() { } description="Краткая шпаргалка для первичной диагностики" - contentClassName="py-4" + className="h-full" + contentClassName="px-5 py-4" > -
            -
          • - API недоступен. Если{' '} - /v1/health возвращает ошибку — проверьте процесс API и - его логи. -
          • -
          • - Готовность не «Готов». Сначала{' '} - postgres, затем store{' '} - и jobs в проверках. -
          • -
          • - Низкий ratio BGP. Проверьте{' '} - /v1/bird/status, затем состояние пиров в Сети. -
          • -
          • - Ошибки задач. Откройте Операции и - проверьте последние неуспешные задачи. -
          • -
          +
            +
          • + API недоступен. Если{' '} + /v1/health возвращает ошибку — проверьте процесс API и + его логи. +
          • +
          • + Готовность не «Готов». Сначала{' '} + postgres, затем store{' '} + и jobs в проверках. +
          • +
          • + Низкий ratio BGP. Проверьте{' '} + /v1/bird/status, затем состояние пиров в Сети. +
          • +
          • + Ошибки задач. Откройте Операции и + проверьте последние неуспешные задачи. +
          • +
          @@ -282,7 +284,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { ? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100) : null return ( -
          +
          Установлено / всего