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,
})