feat(api, web): integrate health status management for services and domains
Build, Test, and Push CFDM Docker Image / test (push) Successful in 10m17s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

- Added health status and latency fields to service and domain schemas, enhancing monitoring capabilities.
- Implemented health status aggregation for services in the API, allowing for improved health checks and reporting.
- Updated web components to display health status using HealthCheckBadge, improving user visibility of service health.
- Refactored service and domain management components to incorporate health status in various views, enhancing overall functionality.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 02:55:49 +07:00
co-authored by Cursor
parent 64585ccd47
commit 1f710bbe8b
48 changed files with 4258 additions and 246 deletions
@@ -22,8 +22,6 @@ import {
} from '@/queries'
import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
import { aggregateHealth } from '@/lib/use-aggregated-health'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
@@ -41,7 +39,6 @@ import {
} from '@/components/subdomain-edit-sheet'
import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
@@ -120,12 +117,6 @@ function DomainOverviewPage() {
linkServiceMutation,
} = useDomainPage(id)
const healthByIp = useDomainHealthByIp(bindings)
const aggregatedHealth = useMemo(
() => aggregateHealth(Object.values(healthByIp)),
[healthByIp],
)
const runCheckMutation = useMutation({
mutationFn: runHealthCheck,
onSuccess: async (data) => {
@@ -334,23 +325,7 @@ function DomainOverviewPage() {
title={domain.zone_name}
description="Карточка домена"
actions={headerActions}
>
<div className="flex flex-wrap items-center gap-2">
<HealthCheckBadge
status={aggregatedHealth.status}
latencyMs={aggregatedHealth.worstLatencyMs}
lastCheckedAt={aggregatedHealth.lastCheckedAt}
lastError={aggregatedHealth.lastError}
title="Агрегат по привязкам"
showLatency
/>
{aggregatedHealth.total > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{aggregatedHealth.upCount}/{aggregatedHealth.total} OK
</span>
) : null}
</div>
</DetailPanel.Header>
/>
<Tabs
value={activeTab}
+60 -31
View File
@@ -101,21 +101,45 @@ function DashboardPage() {
[domains],
)
const attentionDomains = useMemo(
() =>
(domains ?? [])
.filter((d) => d.health_status === 'down' || d.health_status === 'degraded')
.slice(0, 8),
[domains],
)
const attentionServices = useMemo(() => {
const list: Array<{
id: number
name: string
health_status: string
health_latency_ms: number | null
}> = []
for (const group of serviceData?.groups ?? []) {
for (const service of group.services) {
if (
service.health_status === 'down' ||
service.health_status === 'degraded'
) {
list.push({
id: service.id,
name: service.name,
health_status: service.health_status,
health_latency_ms: service.health_latency_ms,
})
}
}
}
for (const service of serviceData?.ungrouped ?? []) {
if (
service.health_status === 'down' ||
service.health_status === 'degraded'
) {
list.push({
id: service.id,
name: service.name,
health_status: service.health_status,
health_latency_ms: service.health_latency_ms,
})
}
}
return list.slice(0, 8)
}, [serviceData])
const attentionCount = useMemo(
() =>
(domains ?? []).filter(
(d) => d.health_status === 'down' || d.health_status === 'degraded',
).length,
[domains],
)
const attentionCount = attentionServices.length
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
const certOk = countByStatus(summary, ['active', 'ok'])
@@ -146,11 +170,9 @@ function DashboardPage() {
label: 'Домены',
value: domains?.length ?? 0,
hint:
attentionCount > 0
? `${attentionCount} требуют внимания`
: ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
to: '/domains',
},
{
@@ -164,9 +186,11 @@ function DashboardPage() {
id: 'services',
label: 'Сервисы',
value: serviceCount,
hint: ungroupedServiceCount
? `${ungroupedServiceCount} без группы`
: undefined,
hint: attentionCount
? `${attentionCount} требуют внимания`
: ungroupedServiceCount
? `${ungroupedServiceCount} без группы`
: undefined,
to: '/services',
search: { domainId: undefined },
},
@@ -208,27 +232,32 @@ function DashboardPage() {
</span>
) : null}
</div>
{attentionDomains.length === 0 ? (
{attentionServices.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет доменов со статусом Down или Slow
Нет сервисов со статусом Down или Slow
</p>
) : (
<ItemGroup className="gap-2">
{attentionDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
{attentionServices.map((service) => (
<Item key={service.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
to="/services"
className="hover:underline"
>
{domain.zone_name}
{service.name}
</Link>
</ItemTitle>
<HealthCheckBadge
status={domain.health_status ?? 'unknown'}
latencyMs={domain.health_latency_ms}
status={
(service.health_status as
| 'up'
| 'down'
| 'degraded'
| 'unknown') ?? 'unknown'
}
latencyMs={service.health_latency_ms}
size="xs"
/>
</ItemContent>
+48 -75
View File
@@ -27,24 +27,18 @@ import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
import { type ServiceCatalogRow } from '@/components/columns/services-columns'
import {
createDefaultServiceFilters,
SERVICE_TABS,
serviceFilterFieldValue,
serviceTabFilter,
useServiceColumns,
useServiceFilterFields,
type ServiceCatalogRow,
} from '@/components/columns/services-columns'
ServicesAddMenu,
ServicesGroupedCatalog,
} from '@/components/services/services-grouped-catalog'
import { useServicesKanban } from '@/hooks/use-services-kanban'
import { useServicesSelection } from '@/hooks/use-services-selection'
import {
ResourcePage,
KanbanBoard,
KanbanBoardSkeleton,
} from '@/components/reui-kit'
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
import type { Filter } from '@/components/reui/filters'
import { Button } from '@cfdm/ui/components/button'
export const Route = createFileRoute('/_auth/services')({
@@ -152,7 +146,6 @@ function ServicesPage() {
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const [filters, setFilters] = useState<Filter[]>(() => createDefaultServiceFilters())
const queryClient = useQueryClient()
const selection = useServicesSelection()
const {
@@ -186,8 +179,6 @@ function ServicesPage() {
[data, domainId],
)
const filterFields = useServiceFilterFields()
function setView(next: 'catalog' | 'board') {
navigate({
search: (prev) => ({
@@ -366,13 +357,6 @@ function ServicesPage() {
deleteGroupMutation.mutate(id)
}
const { columns } = useServiceColumns({
onEdit: setEditingService,
onDelete: setDeletingService,
onToggle: handleServiceToggle,
togglingId: togglingServiceId,
})
async function handleBulkToggle(enabled: boolean) {
const ids = Array.from(selection.selectedIds)
if (ids.length === 0) return
@@ -422,9 +406,19 @@ function ServicesPage() {
id: column.id,
title: column.title,
description: column.domain ?? undefined,
healthStatus: column.group?.health_status,
healthLatencyMs: column.group?.health_latency_ms,
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
addLabel: 'Добавить сервис',
onAdd: () => handleOpenCreateService(column.groupId),
onEdit:
column.group != null
? () => setEditingGroup(column.group!)
: undefined,
onDelete:
column.group != null
? () => setDeletingGroup(column.group!)
: undefined,
})),
[board.columns],
)
@@ -433,15 +427,11 @@ function ServicesPage() {
? 'Перетаскивание отключено при фильтре по домену'
: 'Перетащите сервис между группами или измените порядок в списке'
const createServiceButton = (
<Button type="button" onClick={() => handleOpenCreateService()}>
Добавить сервис
</Button>
)
const createGroupButton = (
<Button type="button" variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
const addMenu = (
<ServicesAddMenu
onAddService={() => handleOpenCreateService()}
onAddGroup={() => setCreateGroupSheetOpen(true)}
/>
)
const sheets = (
@@ -538,17 +528,16 @@ function ServicesPage() {
{view === 'board' ? (
<KanbanBoardSkeleton columns={3} />
) : (
<ResourcePage
title="Сервисы"
description="Каталог сервисов"
<ServicesGroupedCatalog
data={{ groups: [], ungrouped: [] }}
isLoading
filterFields={[]}
filters={[]}
onFiltersChange={() => {}}
getFilterFieldValue={() => ''}
columns={[]}
data={[]}
getRowId={() => ''}
togglingId={null}
onEditService={() => {}}
onDeleteService={() => {}}
onToggleService={() => {}}
onEditGroup={() => {}}
onDeleteGroup={() => {}}
onAddServiceToGroup={() => {}}
/>
)}
{sheets}
@@ -591,12 +580,11 @@ function ServicesPage() {
description={
filteredDomain
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
: 'Создайте сервис — например VPN Panel или Home Assistant.'
: 'Сначала создайте группу, затем добавьте сервисы.'
}
action={
<div className="flex flex-wrap justify-center gap-2">
{createServiceButton}
{createGroupButton}
{addMenu}
<CatalogBoardToggle view="board" onViewChange={setView} />
</div>
}
@@ -614,8 +602,7 @@ function ServicesPage() {
onValueChange={dragDisabled ? () => {} : handleKanbanValueChange}
toolbarActions={
<>
{createServiceButton}
{createGroupButton}
{addMenu}
<CatalogBoardToggle view="board" onViewChange={setView} />
</>
}
@@ -634,42 +621,28 @@ function ServicesPage() {
)}
</>
) : (
<ResourcePage
title="Сервисы"
description={
filteredDomain
? `Каталог сервисов с привязками к ${filteredDomain.zone_name}`
: 'Каталог сервисов и привязок'
}
tabs={SERVICE_TABS.map((tab) => ({ ...tab }))}
tabFilter={serviceTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultServiceFilters())}
getFilterFieldValue={serviceFilterFieldValue}
columns={columns}
data={catalogRows}
getRowId={(row) => String(row.id)}
<ServicesGroupedCatalog
data={data ?? { groups: [], ungrouped: [] }}
domainId={domainId}
domainLabel={filteredDomain?.zone_name}
togglingId={togglingServiceId}
onEditService={setEditingService}
onDeleteService={setDeletingService}
onToggleService={handleServiceToggle}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddServiceToGroup={handleOpenCreateService}
primaryAction={
<>
{createServiceButton}
{createGroupButton}
{addMenu}
<CatalogBoardToggle view="catalog" onViewChange={setView} />
</>
}
emptyState={{
title: 'Сервисы не найдены',
description: filteredDomain
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
: 'Создайте сервис или группу сервисов.',
action: (
<div className="flex flex-wrap justify-center gap-2">
{createServiceButton}
{createGroupButton}
</div>
),
}}
emptyAction={
<div className="flex flex-wrap justify-center gap-2">
{addMenu}
</div>
}
/>
)}
{sheets}