fix(health): заменить плитки источников на компактный мультивыбор
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m48s
CD / publish (push) Successful in 1m39s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m48s
CD / publish (push) Successful in 1m39s
Крупные KPI-кнопки и вкладка «Все» заменены на ToggleGroup: фильтр занимает меньше места и не сбрасывает последний источник. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -14,6 +14,10 @@ import {
|
|||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from '@cfdm/ui/components/toggle-group'
|
||||||
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
|
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
|
||||||
import type { HealthLogStatus } from '@/lib/health-log'
|
import type { HealthLogStatus } from '@/lib/health-log'
|
||||||
|
|
||||||
@@ -261,6 +265,81 @@ export function HealthAggregateTiles({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleProviders(
|
||||||
|
active: HealthProvider[],
|
||||||
|
id: HealthProvider,
|
||||||
|
): HealthProvider[] {
|
||||||
|
if (active.includes(id)) {
|
||||||
|
if (active.length === 1) return active
|
||||||
|
return active.filter((item) => item !== id)
|
||||||
|
}
|
||||||
|
return [...active, id]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компактный мультивыбор типа пробы (toolbar в stacked Frame).
|
||||||
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/chart-17
|
||||||
|
* Docs: https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function HealthSourceFilterBar({
|
||||||
|
enabled,
|
||||||
|
selected,
|
||||||
|
statuses,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
enabled: HealthProvider[]
|
||||||
|
selected: HealthProvider[]
|
||||||
|
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||||
|
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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroup
|
||||||
|
multiple
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full min-w-0"
|
||||||
|
value={active}
|
||||||
|
aria-label="Тип пробы"
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const values = next.filter((value): value is HealthProvider =>
|
||||||
|
visible.some((item) => item.id === value),
|
||||||
|
)
|
||||||
|
if (values.length === 0) return
|
||||||
|
onChange(values)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={item.id}
|
||||||
|
value={item.id}
|
||||||
|
aria-label={item.title}
|
||||||
|
className="min-w-0 flex-1 gap-1.5"
|
||||||
|
>
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className={item.iconClassName}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</IconTile>
|
||||||
|
<span className="truncate">{item.title}</span>
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={statuses[item.id] ?? 'unknown'}
|
||||||
|
provider={item.id}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only status tiles for enabled probe sources; click filters the monitor.
|
* 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
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||||
@@ -282,15 +361,6 @@ export function HealthProviderStatusTiles({
|
|||||||
|
|
||||||
const active = selected.length > 0 ? selected : enabled
|
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 (
|
return (
|
||||||
<ChoiceFrame>
|
<ChoiceFrame>
|
||||||
{visible.map((item) => (
|
{visible.map((item) => (
|
||||||
@@ -309,7 +379,7 @@ export function HealthProviderStatusTiles({
|
|||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onActivate={() => toggle(item.id)}
|
onActivate={() => onChange(toggleProviders(active, item.id))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ChoiceFrame>
|
</ChoiceFrame>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export {
|
|||||||
HealthSourceTiles,
|
HealthSourceTiles,
|
||||||
HealthAggregateTiles,
|
HealthAggregateTiles,
|
||||||
HealthProviderStatusTiles,
|
HealthProviderStatusTiles,
|
||||||
|
HealthSourceFilterBar,
|
||||||
type HealthProvider,
|
type HealthProvider,
|
||||||
type HealthAggregate,
|
type HealthAggregate,
|
||||||
} from './health-source-tiles'
|
} from './health-source-tiles'
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useMemo, useState, type ReactNode } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { LayersIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -10,16 +8,8 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
|
||||||
import {
|
|
||||||
lastProbeLatency,
|
|
||||||
probeUptimePercent,
|
|
||||||
UptimeChart,
|
|
||||||
UPTIME_PERIODS,
|
|
||||||
type UptimePeriodKey,
|
|
||||||
} from '@/components/reui-kit/uptime-chart'
|
|
||||||
import { HEALTH_PROVIDER_ITEMS } from '@/components/reui-kit/health-source-tiles'
|
|
||||||
import {
|
import {
|
||||||
collapseStatusChanges,
|
collapseStatusChanges,
|
||||||
filterByPeriod,
|
filterByPeriod,
|
||||||
@@ -27,34 +17,13 @@ import {
|
|||||||
type HealthLogProbe,
|
type HealthLogProbe,
|
||||||
type HealthLogStatus,
|
type HealthLogStatus,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
const ALL_TAB = 'all' as const
|
|
||||||
type SourceTab = typeof ALL_TAB | HealthCheckProvider
|
|
||||||
|
|
||||||
function formatUptime(value: number | null): string {
|
|
||||||
if (value == null) return '—'
|
|
||||||
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLatency(ms: number | null): string {
|
|
||||||
if (ms == null) return 'нет проб'
|
|
||||||
return `${ms} мс`
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusTileClass(status: HealthLogStatus | undefined): string {
|
|
||||||
if (status === 'down') return 'text-destructive'
|
|
||||||
if (status === 'degraded') return 'text-warning'
|
|
||||||
if (status === 'up') return 'text-success'
|
|
||||||
return 'text-muted-foreground'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единый блок мониторинга: переключатель источников (dashboard-4) + график
|
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
|
||||||
* (chart-17) + таймлайн смен статуса (solution-ai-ops-1 / timeline).
|
* + график (chart-17) + таймлайн смен статуса.
|
||||||
*
|
*
|
||||||
* Preview: https://reui.io/preview/base/dashboard-4
|
* Preview: https://reui.io/preview/base/list-9
|
||||||
* Preview: https://reui.io/preview/base/chart-17
|
* Preview: https://reui.io/preview/base/chart-17
|
||||||
* Preview: https://reui.io/preview/base/solution-ai-ops-1
|
* Preview: https://reui.io/preview/base/solution-ai-ops-1
|
||||||
* Docs: https://reui.io/docs/components/base/frame
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
@@ -74,19 +43,20 @@ export function ServiceHealthMonitor({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
const [source, setSource] = useState<SourceTab>(ALL_TAB)
|
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
|
||||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
const enabledItems = useMemo(
|
const enabled = useMemo(
|
||||||
() => HEALTH_PROVIDER_ITEMS.filter((item) => enabledProviders.includes(item.id)),
|
() => [...enabledProviders],
|
||||||
[enabledProviders],
|
[enabledProviders],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedProviders = useMemo((): HealthCheckProvider[] => {
|
const activeProviders = useMemo((): HealthCheckProvider[] => {
|
||||||
if (source === ALL_TAB) return [...enabledProviders]
|
const picked = (selected ?? enabled).filter((provider) =>
|
||||||
if (enabledProviders.includes(source)) return [source]
|
enabled.includes(provider),
|
||||||
return [...enabledProviders]
|
)
|
||||||
}, [enabledProviders, source])
|
return picked.length > 0 ? picked : enabled
|
||||||
|
}, [enabled, selected])
|
||||||
|
|
||||||
const periodItems = useMemo(
|
const periodItems = useMemo(
|
||||||
() => filterByPeriod(items, days),
|
() => filterByPeriod(items, days),
|
||||||
@@ -94,59 +64,22 @@ export function ServiceHealthMonitor({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => filterByProviders(periodItems, selectedProviders),
|
() => filterByProviders(periodItems, activeProviders),
|
||||||
[periodItems, selectedProviders],
|
[periodItems, activeProviders],
|
||||||
)
|
)
|
||||||
|
|
||||||
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||||
|
|
||||||
const showAllTab = enabledItems.length > 1
|
|
||||||
const tabCount = enabledItems.length + (showAllTab ? 1 : 0)
|
|
||||||
const activeSource: SourceTab =
|
|
||||||
source === ALL_TAB || enabledProviders.includes(source)
|
|
||||||
? source
|
|
||||||
: ALL_TAB
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||||
<FrameHeader className="p-0!">
|
<FramePanel>
|
||||||
<div
|
<HealthSourceFilterBar
|
||||||
className={cn(
|
enabled={enabled}
|
||||||
'grid',
|
selected={activeProviders}
|
||||||
tabCount <= 2 && 'grid-cols-2',
|
statuses={statuses}
|
||||||
tabCount === 3 && 'grid-cols-1 sm:grid-cols-3',
|
onChange={setSelected}
|
||||||
tabCount >= 4 && 'grid-cols-2',
|
/>
|
||||||
)}
|
</FramePanel>
|
||||||
>
|
|
||||||
{showAllTab ? (
|
|
||||||
<SourceMetricButton
|
|
||||||
selected={activeSource === ALL_TAB}
|
|
||||||
icon={<LayersIcon />}
|
|
||||||
iconClassName="text-muted-foreground"
|
|
||||||
label="Все источники"
|
|
||||||
value={formatUptime(probeUptimePercent(periodItems))}
|
|
||||||
hint={`${periodItems.length} проб`}
|
|
||||||
onSelect={() => setSource(ALL_TAB)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{enabledItems.map((item) => {
|
|
||||||
const series = filterByProviders(periodItems, [item.id])
|
|
||||||
return (
|
|
||||||
<SourceMetricButton
|
|
||||||
key={item.id}
|
|
||||||
selected={activeSource === item.id}
|
|
||||||
icon={item.icon}
|
|
||||||
iconClassName={item.iconClassName}
|
|
||||||
label={item.title}
|
|
||||||
value={formatUptime(probeUptimePercent(series))}
|
|
||||||
hint={formatLatency(lastProbeLatency(series))}
|
|
||||||
status={statuses[item.id] ?? 'unknown'}
|
|
||||||
onSelect={() => setSource(item.id)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</FrameHeader>
|
|
||||||
|
|
||||||
<UptimeChart
|
<UptimeChart
|
||||||
items={filtered}
|
items={filtered}
|
||||||
@@ -185,65 +118,3 @@ export function ServiceHealthMonitor({
|
|||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceMetricButton({
|
|
||||||
selected,
|
|
||||||
icon,
|
|
||||||
iconClassName,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
hint,
|
|
||||||
status,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
selected: boolean
|
|
||||||
icon: ReactNode
|
|
||||||
iconClassName: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
hint: string
|
|
||||||
status?: HealthLogStatus
|
|
||||||
onSelect: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={selected}
|
|
||||||
aria-label={`${label}: ${value}`}
|
|
||||||
onClick={onSelect}
|
|
||||||
className={cn(
|
|
||||||
'focus-visible:ring-ring/50 hover:bg-muted/40 relative flex min-w-0 items-start gap-3 border-e border-b p-4 text-start transition-colors last:border-e-0 focus-visible:ring-2 focus-visible:outline-none sm:border-b-0',
|
|
||||||
selected && 'bg-muted/40',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconTile
|
|
||||||
variant="elevated"
|
|
||||||
className={cn(
|
|
||||||
'size-10.5',
|
|
||||||
status ? statusTileClass(status) : iconClassName,
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</IconTile>
|
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<span className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-muted-foreground text-sm font-medium">{label}</span>
|
|
||||||
{status ? (
|
|
||||||
<HealthCheckBadge status={status} size="xs" />
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{hint}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{status ? (
|
|
||||||
<span className="text-muted-foreground text-xs">{hint}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user