From 69119a08a4e17347292975b73881a8136f541d20 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 20 Aug 2026 00:09:55 +0700 Subject: [PATCH] feat(health): enhance HealthTimeline and HealthSourceTiles components - Added optional props `emptyTitle` and `emptyDescription` to HealthTimeline for customizable empty state messages. - Refactored HealthSourceTiles to export `HEALTH_PROVIDER_ITEMS` and introduced a new `HealthProviderStatusTiles` component for improved health monitoring. - Updated UptimeChart to support period selection and filtering, enhancing data visualization capabilities. - Improved ServiceDetailPage by integrating HealthProviderStatusTiles and ServiceHealthMonitor for better service health insights. --- .../src/components/health/health-timeline.tsx | 12 +- .../web/src/components/layout/site-header.tsx | 7 + .../reui-kit/health-source-tiles.tsx | 85 ++++++++++-- apps/web/src/components/reui-kit/index.ts | 4 +- .../reui-kit/service-health-monitor.tsx | 79 +++++++++++ .../src/components/reui-kit/uptime-chart.tsx | 59 ++++---- apps/web/src/lib/health-log.test.ts | 97 +++++++++++++ apps/web/src/lib/health-log.ts | 127 ++++++++++++++++++ .../_auth/services/$serviceId/index.tsx | 105 +++++++++------ .../_auth/services/$serviceId/route.tsx | 5 +- 10 files changed, 504 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/components/reui-kit/service-health-monitor.tsx create mode 100644 apps/web/src/lib/health-log.test.ts create mode 100644 apps/web/src/lib/health-log.ts diff --git a/apps/web/src/components/health/health-timeline.tsx b/apps/web/src/components/health/health-timeline.tsx index 2a552ed..6bfd866 100644 --- a/apps/web/src/components/health/health-timeline.tsx +++ b/apps/web/src/components/health/health-timeline.tsx @@ -29,15 +29,21 @@ export interface HealthTimelineEvent { interface HealthTimelineProps { events: HealthTimelineEvent[] + emptyTitle?: string + emptyDescription?: string } -export function HealthTimeline({ events }: HealthTimelineProps) { +export function HealthTimeline({ + events, + emptyTitle = 'Нет событий', + emptyDescription = 'Результаты проверок появятся после первого прогона', +}: HealthTimelineProps) { if (events.length === 0) { return ( ) diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 3b719e8..e6f4572 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -36,6 +36,13 @@ function getBreadcrumbs( return [{ label: 'Панель управления', href: '/' }] } + if (pathname.match(/^\/services\/\d+$/)) { + return [ + { label: 'Сервисы', href: '/services' }, + { label: dynamicLabels[pathname] ?? 'Сервис', href: pathname }, + ] + } + if (pathname.match(/^\/groups\/\d+$/)) { return [ { label: 'Группы доменов', href: '/groups' }, diff --git a/apps/web/src/components/reui-kit/health-source-tiles.tsx b/apps/web/src/components/reui-kit/health-source-tiles.tsx index 90a3e1a..2da1ad0 100644 --- a/apps/web/src/components/reui-kit/health-source-tiles.tsx +++ b/apps/web/src/components/reui-kit/health-source-tiles.tsx @@ -12,8 +12,10 @@ import { ItemMedia, ItemTitle, } from '@cfdm/ui/components/item' +import { HealthCheckBadge } from '@/components/health-check-badge' import { cn } from '@cfdm/ui/lib/utils' import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared' +import type { HealthLogStatus } from '@/lib/health-log' export type HealthProvider = HealthCheckProvider export type HealthAggregate = HealthCheckAggregate @@ -48,7 +50,7 @@ function GlobalpingMark() { ) } -const PROVIDER_ITEMS: Array<{ +export const HEALTH_PROVIDER_ITEMS: Array<{ id: HealthProvider title: string description: string @@ -118,6 +120,7 @@ function ChoicePanel({ icon, iconClassName, role, + trailing, onActivate, }: { selected: boolean @@ -126,6 +129,7 @@ function ChoicePanel({ icon: ReactNode iconClassName?: string role: 'checkbox' | 'radio' + trailing?: ReactNode onActivate: () => void }) { return ( @@ -157,12 +161,8 @@ function ChoicePanel({ {title} {description} - {selected ? ( - - - Выбрано - - + {trailing ? ( + {trailing} ) : null} @@ -202,7 +202,7 @@ export function HealthSourceTiles({ return ( - {PROVIDER_ITEMS.map((item) => ( + {HEALTH_PROVIDER_ITEMS.map((item) => ( + Выбрано + + ) : null + } onActivate={() => toggle(item.id)} /> ))} @@ -240,9 +247,71 @@ export function HealthAggregateTiles({ description={item.description} icon={item.icon} role="radio" + trailing={ + selected === item.id ? ( + + Выбрано + + ) : null + } onActivate={() => onChange(item.id)} /> ))} ) } + +/** + * Read-only status tiles for enabled probe sources; click filters the monitor. + * Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile + */ +export function HealthProviderStatusTiles({ + enabled, + selected, + statuses, + onChange, +}: { + enabled: HealthProvider[] + selected: HealthProvider[] + statuses: Partial> + onChange: (next: HealthProvider[]) => void +}) { + const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id)) + if (visible.length === 0) return null + + const active = selected.length > 0 ? selected : enabled + + function toggle(id: HealthProvider) { + if (active.includes(id)) { + if (active.length === 1) return + onChange(active.filter((item) => item !== id)) + return + } + onChange([...active, id]) + } + + return ( + + {visible.map((item) => ( + + } + onActivate={() => toggle(item.id)} + /> + ))} + + ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 0b48b27..d2a46b1 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -1,4 +1,5 @@ -export { UptimeChart, type UptimeProbe } from './uptime-chart' +export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart' +export { ServiceHealthMonitor } from './service-health-monitor' export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils' export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics' export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page' @@ -22,6 +23,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell' export { HealthSourceTiles, HealthAggregateTiles, + HealthProviderStatusTiles, type HealthProvider, type HealthAggregate, } from './health-source-tiles' diff --git a/apps/web/src/components/reui-kit/service-health-monitor.tsx b/apps/web/src/components/reui-kit/service-health-monitor.tsx new file mode 100644 index 0000000..1e32972 --- /dev/null +++ b/apps/web/src/components/reui-kit/service-health-monitor.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from 'react' + +import { HealthTimeline } from '@/components/health/health-timeline' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart' +import { + collapseStatusChanges, + filterByPeriod, + filterByProviders, + type HealthLogProbe, +} from '@/lib/health-log' +import type { HealthCheckProvider } from '@cfdm/shared' + +/** + * Combined uptime chart + status-change timeline (solution-analytics-8 DNA). + * Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline + */ +export function ServiceHealthMonitor({ + items, + selectedProviders, + isLoading = false, +}: { + items: HealthLogProbe[] + selectedProviders: readonly HealthCheckProvider[] + isLoading?: boolean +}) { + const [period, setPeriod] = useState('5D') + const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5 + + const filtered = useMemo( + () => filterByProviders(filterByPeriod(items, days), selectedProviders), + [items, days, selectedProviders], + ) + + const changes = useMemo(() => collapseStatusChanges(filtered), [filtered]) + + return ( + + + + + Смены статуса + + Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API + + + ({ + id: row.id, + hostname: row.ip, + type: row.provider, + status: row.status, + latency_ms: row.latency_ms, + error: row.error, + checked_at: row.checked_at, + colo: row.colo, + provider: row.provider, + }))} + emptyTitle="Нет смен статуса" + emptyDescription="События появятся при переходе up / degraded / down" + /> + + + ) +} diff --git a/apps/web/src/components/reui-kit/uptime-chart.tsx b/apps/web/src/components/reui-kit/uptime-chart.tsx index 24cfb21..82fcc49 100644 --- a/apps/web/src/components/reui-kit/uptime-chart.tsx +++ b/apps/web/src/components/reui-kit/uptime-chart.tsx @@ -6,7 +6,8 @@ import { EmptyState } from '@/components/empty-state' import { Badge } from '@/components/reui/badge' import { Frame, FramePanel } from '@/components/reui/frame' import { IconTile } from '@/components/reui/icon-tile' -import { formatDate, sqliteUtcToIso } from '@/lib/format' +import { filterByPeriod, probeTime } from '@/lib/health-log' +import { formatDate } from '@/lib/format' import { Button } from '@cfdm/ui/components/button' import { ChartContainer, @@ -38,7 +39,7 @@ export interface UptimeProbe { export type UptimePeriodKey = '5D' | '2W' | '1M' -const PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [ +export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [ { key: '5D', label: '5D', days: 5 }, { key: '2W', label: '2W', days: 14 }, { key: '1M', label: '1M', days: 30 }, @@ -59,17 +60,6 @@ interface ChartPoint { status: UptimeProbe['status'] } -function probeTime(checkedAt: string): number { - const iso = sqliteUtcToIso(checkedAt) ?? checkedAt - const time = new Date(iso).getTime() - return Number.isNaN(time) ? 0 : time -} - -function filterByPeriod(items: UptimeProbe[], days: number): UptimeProbe[] { - const cutoff = Date.now() - days * 86_400_000 - return items.filter((item) => probeTime(item.checked_at) >= cutoff) -} - function toSeries(items: UptimeProbe[]): ChartPoint[] { return [...items] .sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at)) @@ -118,25 +108,41 @@ function UptimeTooltip({ active, payload }: UptimeTooltipProps) { interface UptimeChartProps { items: UptimeProbe[] isLoading?: boolean + period?: UptimePeriodKey + onPeriodChange?: (period: UptimePeriodKey) => void + skipPeriodFilter?: boolean + embedded?: boolean } -export function UptimeChart({ items, isLoading = false }: UptimeChartProps) { +export function UptimeChart({ + items, + isLoading = false, + period: periodProp, + onPeriodChange, + skipPeriodFilter = false, + embedded = false, +}: UptimeChartProps) { const gradientId = useId().replace(/:/g, '') - const [period, setPeriod] = useState('5D') - const days = PERIODS.find((entry) => entry.key === period)?.days ?? 5 + const [internalPeriod, setInternalPeriod] = useState('5D') + const period = periodProp ?? internalPeriod + const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5 + + function handlePeriodChange(next: UptimePeriodKey) { + onPeriodChange?.(next) + if (periodProp == null) setInternalPeriod(next) + } const points = useMemo( - () => toSeries(filterByPeriod(items, days)), - [items, days], + () => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)), + [items, days, skipPeriodFilter], ) const uptime = uptimePercent(points) const delta = deltaPercent(points) const lastOk = points.at(-1)?.ok ?? true const tileClass = lastOk ? 'text-success' : 'text-destructive' - return ( - - + const panel = ( +
setPeriod(value as UptimePeriodKey)} + onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)} > - {PERIODS.map((entry) => ( + {UPTIME_PERIODS.map((entry) => ( {entry.label} @@ -296,6 +302,13 @@ export function UptimeChart({ items, isLoading = false }: UptimeChartProps) { + ) + + if (embedded) return panel + + return ( + + {panel} ) } diff --git a/apps/web/src/lib/health-log.test.ts b/apps/web/src/lib/health-log.test.ts new file mode 100644 index 0000000..dc7c0c0 --- /dev/null +++ b/apps/web/src/lib/health-log.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { + collapseStatusChanges, + enabledHealthProviders, + providerHealthStatuses, + worstHealthStatus, + type HealthLogProbe, +} from '@/lib/health-log' + +function probe( + overrides: Partial & Pick, +): HealthLogProbe { + return { + ip: '1.1.1.1', + provider: 'local', + ok: overrides.status === 'up', + latency_ms: 12, + colo: null, + error: null, + ...overrides, + } +} + +describe('collapseStatusChanges', () => { + it('keeps only status transitions per ip+provider', () => { + const items = [ + probe({ id: 1, status: 'up', checked_at: '2026-01-01T00:00:00Z' }), + probe({ id: 2, status: 'up', checked_at: '2026-01-01T00:01:00Z' }), + probe({ id: 3, status: 'down', checked_at: '2026-01-01T00:02:00Z' }), + probe({ id: 4, status: 'down', checked_at: '2026-01-01T00:03:00Z' }), + probe({ id: 5, status: 'up', checked_at: '2026-01-01T00:04:00Z' }), + ] + const changes = collapseStatusChanges(items) + expect(changes.map((item) => item.id)).toEqual([5, 3, 1]) + }) + + it('tracks series independently by provider', () => { + const items = [ + probe({ id: 1, provider: 'local', status: 'up', checked_at: '2026-01-01T00:00:00Z' }), + probe({ + id: 2, + provider: 'cloudflare', + status: 'up', + checked_at: '2026-01-01T00:00:00Z', + }), + probe({ id: 3, provider: 'local', status: 'up', checked_at: '2026-01-01T00:01:00Z' }), + probe({ + id: 4, + provider: 'cloudflare', + status: 'down', + checked_at: '2026-01-01T00:01:00Z', + }), + ] + const changes = collapseStatusChanges(items) + expect(changes.map((item) => item.id).sort()).toEqual([1, 2, 4]) + }) +}) + +describe('enabledHealthProviders', () => { + it('unions bindings in registry order', () => { + expect( + enabledHealthProviders([ + { health_check_providers: ['globalping'] }, + { health_check_providers: ['local', 'cloudflare'] }, + ]), + ).toEqual(['local', 'cloudflare', 'globalping']) + }) + + it('falls back to local', () => { + expect(enabledHealthProviders([])).toEqual(['local']) + }) +}) + +describe('providerHealthStatuses', () => { + it('uses worst latest-per-ip status', () => { + const items = [ + probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }), + probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }), + probe({ + id: 3, + ip: '2.2.2.2', + status: 'up', + checked_at: '2026-01-01T00:00:00Z', + }), + ] + expect(providerHealthStatuses(items, ['local']).local).toBe('down') + }) +}) + +describe('worstHealthStatus', () => { + it('ranks down over degraded over up', () => { + expect(worstHealthStatus(['up', 'degraded'])).toBe('degraded') + expect(worstHealthStatus(['degraded', 'down'])).toBe('down') + expect(worstHealthStatus([])).toBe('unknown') + }) +}) diff --git a/apps/web/src/lib/health-log.ts b/apps/web/src/lib/health-log.ts new file mode 100644 index 0000000..fe45020 --- /dev/null +++ b/apps/web/src/lib/health-log.ts @@ -0,0 +1,127 @@ +import type { HealthCheckProvider } from '@cfdm/shared' +import { HEALTH_CHECK_PROVIDERS, uniqueHealthProviders } from '@cfdm/shared' + +import { sqliteUtcToIso } from '@/lib/format' +import type { IpHealthStatus } from '@/lib/schemas' + +export type HealthLogStatus = IpHealthStatus['status'] + +export interface HealthLogProbe { + id: number + ip: string + provider: HealthCheckProvider + status: HealthLogStatus + ok: boolean + latency_ms: number | null + colo: string | null + error: string | null + checked_at: string +} + +const STATUS_RANK: Record = { + unknown: 0, + up: 1, + degraded: 2, + down: 3, +} + +export function probeTime(checkedAt: string): number { + const iso = sqliteUtcToIso(checkedAt) ?? checkedAt + const time = new Date(iso).getTime() + return Number.isNaN(time) ? 0 : time +} + +export function filterByPeriod( + items: T[], + days: number, +): T[] { + const cutoff = Date.now() - days * 86_400_000 + return items.filter((item) => probeTime(item.checked_at) >= cutoff) +} + +export function filterByProviders( + items: T[], + providers: readonly HealthCheckProvider[], +): T[] { + if (providers.length === 0) return items + const allowed = new Set(providers) + return items.filter((item) => allowed.has(item.provider as HealthCheckProvider)) +} + +/** + * Keep the first probe of each ip+provider series and every later probe + * whose status differs from the previous one. Newest first. + */ +export function collapseStatusChanges(items: T[]): T[] { + const byKey = new Map() + for (const item of items) { + const key = `${item.ip}\0${item.provider}` + const list = byKey.get(key) + if (list) list.push(item) + else byKey.set(key, [item]) + } + + const changes: T[] = [] + for (const list of byKey.values()) { + list.sort( + (a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id, + ) + let previous: HealthLogStatus | undefined + for (const item of list) { + if (item.status !== previous) { + changes.push(item) + previous = item.status + } + } + } + + changes.sort( + (a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id, + ) + return changes +} + +export function enabledHealthProviders( + domains: Array<{ health_check_providers?: readonly HealthCheckProvider[] | null }>, +): HealthCheckProvider[] { + const collected = uniqueHealthProviders( + domains.flatMap((domain) => domain.health_check_providers ?? []), + ) + if (collected.length === 0) return ['local'] + return HEALTH_CHECK_PROVIDERS.filter((provider) => collected.includes(provider)) +} + +export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthLogStatus { + if (statuses.length === 0) return 'unknown' + return statuses.reduce((worst, status) => + STATUS_RANK[status] > STATUS_RANK[worst] ? status : worst, + ) +} + +/** Latest probe per IP for a provider, then worst among those IPs. */ +export function providerHealthStatuses( + items: readonly HealthLogProbe[], + providers: readonly HealthCheckProvider[], +): Record { + const latestByIp = new Map() + const sorted = [...items].sort( + (a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id, + ) + for (const item of sorted) { + const key = `${item.provider}\0${item.ip}` + if (!latestByIp.has(key)) latestByIp.set(key, item) + } + + const result = Object.fromEntries( + HEALTH_CHECK_PROVIDERS.map((provider) => [provider, 'unknown' as HealthLogStatus]), + ) as Record + + for (const provider of providers) { + const statuses = [...latestByIp.values()] + .filter((item) => item.provider === provider) + .map((item) => item.status) + result[provider] = worstHealthStatus(statuses) + } + + return result +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx index 625f9dd..f160b1e 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/index.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx @@ -1,11 +1,10 @@ -import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' -import { useState } from 'react' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { ActivityIcon, - ArrowLeftIcon, GlobeIcon, NetworkIcon, PencilIcon, @@ -19,7 +18,6 @@ import { FailoverTimeline } from '@/components/failover-timeline' import { FormFieldSimple } from '@/components/form-field' import { FormSheet } from '@/components/form-sheet' import { HealthCheckBadge } from '@/components/health-check-badge' -import { HealthTimeline } from '@/components/health/health-timeline' import { LoadingButton } from '@/components/loading-button' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' @@ -29,7 +27,11 @@ import { type ServiceFqdnRow, } from '@/components/services/service-detail-grid' import { LbModeTile } from '@/components/services/service-unit-card' -import { KpiStatGrid, UptimeChart } from '@/components/reui-kit' +import { + HealthProviderStatusTiles, + KpiStatGrid, + ServiceHealthMonitor, +} from '@/components/reui-kit' import { Frame, FrameDescription, @@ -38,7 +40,12 @@ import { FrameTitle, } from '@/components/reui/frame' import { api } from '@/lib/api-client' +import { + enabledHealthProviders, + providerHealthStatuses, +} from '@/lib/health-log' import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas' +import type { HealthCheckProvider } from '@cfdm/shared' import { createServiceNode, deleteServiceNode, @@ -56,6 +63,11 @@ import { } from '@/queries' import { Button } from '@cfdm/ui/components/button' import { Input } from '@cfdm/ui/components/input' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' export const Route = createFileRoute('/_auth/services/$serviceId/')({ component: ServiceDetailPage, @@ -92,11 +104,17 @@ function ServiceDetailPage() { const service = viewQuery.data const overview = overviewQuery.data as OverviewPayload | undefined - const logItems = logQuery.data?.items ?? [] + const logItems = useMemo( + () => logQuery.data?.items ?? [], + [logQuery.data?.items], + ) const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? [] const [editOpen, setEditOpen] = useState(false) const [saving, setSaving] = useState(false) + const [selectedProviders, setSelectedProviders] = useState< + HealthCheckProvider[] | null + >(null) const [togglingIp, setTogglingIp] = useState(null) const [changeIp, setChangeIp] = useState<{ bindingId: number @@ -212,6 +230,20 @@ function ServiceDetailPage() { : `fail ${node.consecutive_failures}`, })) + const enabledProviders = useMemo( + () => enabledHealthProviders(service?.domains ?? []), + [service], + ) + const activeProviders = + selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ?? + enabledProviders + const effectiveProviders = + activeProviders.length > 0 ? activeProviders : enabledProviders + const providerStatuses = useMemo( + () => providerHealthStatuses(logItems, enabledProviders), + [logItems, enabledProviders], + ) + return ( - - + + setEditOpen(true)} + /> + } + > + + Изменить + } /> @@ -293,11 +333,22 @@ function ServiceDetailPage() { ]} /> + +
- + Failover @@ -311,30 +362,6 @@ function ServiceDetailPage() {
- - - Журнал проб - - Cloudflare = Worker с edge, не Health Checks API - - - - ({ - id: row.id, - hostname: row.ip, - type: row.provider, - status: row.status, - latency_ms: row.latency_ms, - error: row.error, - checked_at: row.checked_at, - colo: row.colo, - provider: row.provider, - }))} - /> - - - {service.ips.length === 0 && service.domains.length === 0 ? ( { + loader: async ({ context: { queryClient }, params }) => { const id = Number(params.serviceId) - return Promise.all([ + const [view] = await Promise.all([ queryClient.ensureQueryData(serviceViewQueryOptions(id)), queryClient.ensureQueryData(serviceOverviewQueryOptions(id)), queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)), queryClient.ensureQueryData(serviceNodesQueryOptions(id)), ]) + return { breadcrumb: view.name } }, component: ServiceLayout, })