refactor: Enhance health-related components by integrating date formatting for last checked timestamps and improving tooltip display, ensuring better readability and user experience
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 7s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-25 23:12:20 +07:00
parent 5bdf4e774d
commit 934afa5c95
7 changed files with 59 additions and 26 deletions
@@ -3,6 +3,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { TableCard } from '@/components/table-card'
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
import { formatDate } from '@/lib/format'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
import {
@@ -45,7 +46,7 @@ const healthLabel: Record<IpHealthStatus['status'], string> = {
function IpHealthDot({ health }: { health: IpHealthStatus }) {
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
return (
<TooltipProvider>
@@ -61,7 +62,7 @@ function IpHealthDot({ health }: { health: IpHealthStatus }) {
/>
}
/>
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
@@ -4,6 +4,7 @@ import { EmptyState } from '@/components/empty-state'
import { StatusBadge } from '@/components/status-badge'
import { groupBindingsByHostname } from '@/lib/domain-ips'
import { useHealthRows } from '@/lib/use-aggregated-health'
import { formatDate } from '@/lib/format'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
@@ -52,7 +53,7 @@ const healthLabel: Record<IpHealthStatus['status'], string> = {
function IpHealthDot({ health }: { health: IpHealthStatus }) {
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`)
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
return (
<TooltipProvider>
@@ -68,7 +69,7 @@ function IpHealthDot({ health }: { health: IpHealthStatus }) {
/>
}
/>
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
@@ -3,6 +3,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/
import type { VariantProps } from 'class-variance-authority'
import { cn } from '@cfdm/ui/lib/utils'
import type { IpHealthStatus } from '@/lib/schemas'
import { formatDate } from '@/lib/format'
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
@@ -25,6 +26,7 @@ interface HealthCheckBadgeProps {
latencyMs?: number | null
lastCheckedAt?: string | null
lastError?: string | null
title?: string
className?: string
}
@@ -33,14 +35,17 @@ export function HealthCheckBadge({
latencyMs,
lastCheckedAt,
lastError,
title,
className,
}: HealthCheckBadgeProps) {
const variant = healthVariants[status]
const label = healthLabels[status]
const tooltipParts: string[] = [`Статус: ${label}`]
const tooltipParts: string[] = []
if (title) tooltipParts.push(title)
tooltipParts.push(`Статус: ${label}`)
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
if (lastCheckedAt) tooltipParts.push(`Проверка: ${lastCheckedAt}`)
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
return (
@@ -59,7 +64,7 @@ export function HealthCheckBadge({
{label}
</Badge>
</TooltipTrigger>
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
@@ -101,6 +101,7 @@ export function ServiceGroupHeader({
latencyMs={groupHealth.worstLatencyMs}
lastCheckedAt={groupHealth.lastCheckedAt}
lastError={groupHealth.lastError}
title={column.domain ?? undefined}
/>
) : null}
{!isOpen && isDragging && !dragDisabled ? (
@@ -10,7 +10,7 @@ import {
aggregateServiceSyncStatus,
serviceDisplayFqdn,
} from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import type { ServiceDomainBinding, ServiceView } from '@/lib/schemas'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
import { AppCheckbox } from '@/components/app-checkbox'
@@ -72,13 +72,8 @@ export const ServiceRow = memo(function ServiceRow({
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
const allFqdns = (service.domains ?? []).map((d) => bindingToFqdn(d))
const healthBinding = (service.domains ?? []).find(
(d) => d.record_type === 'A' && (d.target_ips?.length ?? 0) > 1 && d.health_check_enabled,
)
const { data: bindingHealth } = useAggregatedHealth(
'binding',
healthBinding?.binding_id,
Boolean(healthBinding),
const healthBindings = (service.domains ?? []).filter(
(d) => d.record_type === 'A' && (d.target_ips?.length ?? 0) > 0 && d.health_check_enabled,
)
const style = transform
@@ -163,14 +158,9 @@ export const ServiceRow = memo(function ServiceRow({
</div>
<div className="flex shrink-0 items-center gap-2">
{healthBinding && bindingHealth ? (
<HealthCheckBadge
status={bindingHealth.status}
latencyMs={bindingHealth.worstLatencyMs}
lastCheckedAt={bindingHealth.lastCheckedAt}
lastError={bindingHealth.lastError}
/>
) : null}
{healthBindings.map((binding) => (
<BindingHealthBadge key={binding.binding_id} binding={binding} />
))}
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<AppSpinner className="size-4" />
@@ -216,3 +206,22 @@ export const ServiceRow = memo(function ServiceRow({
</div>
)
})
function BindingHealthBadge({ binding }: { binding: ServiceDomainBinding }) {
const { data: health } = useAggregatedHealth(
'binding',
binding.binding_id,
true,
)
if (!health) return null
return (
<HealthCheckBadge
status={health.status}
latencyMs={health.worstLatencyMs}
lastCheckedAt={health.lastCheckedAt}
lastError={health.lastError}
title={bindingToFqdn(binding)}
className={health.status === 'unknown' ? 'opacity-60' : undefined}
/>
)
}
+15 -1
View File
@@ -5,9 +5,23 @@ const dateFormatter = new Intl.DateTimeFormat('ru-RU', {
const relativeFormatter = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' })
/**
* SQLite `datetime('now')` возвращает UTC в формате `YYYY-MM-DD HH:MM:SS` без суффикса `Z`.
* `new Date(...)` парсит такую строку как локальное время — отображение уезжает на TZ-сдвиг.
* Конвертируем в полноценный ISO с `Z`, чтобы `new Date(...)` трактовал время как UTC.
*/
export function sqliteUtcToIso(value: string | null | undefined): string | null {
if (!value) return null
const trimmed = value.trim()
if (trimmed.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(trimmed)) return trimmed
const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/.exec(trimmed)
if (!match) return trimmed
return `${match[1]}T${match[2]}Z`
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
const date = new Date(iso)
const date = new Date(sqliteUtcToIso(iso) ?? iso)
if (Number.isNaN(date.getTime())) return iso
return dateFormatter.format(date)
}
+4 -2
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { healthStatusQueryOptions } from '@/queries'
import { sqliteUtcToIso } from '@/lib/format'
import type { IpHealthStatus } from '@/lib/schemas'
export type HealthScope = 'binding' | 'group'
@@ -60,8 +61,9 @@ export function aggregateHealth(rows: IpHealthStatus[]): AggregatedHealth {
}
}
if (row.last_checked_at) {
if (!lastCheckedAt || row.last_checked_at > lastCheckedAt) {
lastCheckedAt = row.last_checked_at
const iso = sqliteUtcToIso(row.last_checked_at) ?? row.last_checked_at
if (!lastCheckedAt || iso > lastCheckedAt) {
lastCheckedAt = iso
}
}
if (row.last_error && !lastError) lastError = row.last_error