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
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user