From 0ef57c673c015617e11160081850f3fa08793208 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 2 Nov 2026 18:33:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20=D0=BE=D0=B1=D0=BE=D0=BB=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=20CF=20Domain=20Manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Собрать панель, списки и настройки в одной оболочке, чтобы статусы, очередь и свежесть данных были видны на доменах, сервисах и группах. Co-authored-by: Cursor --- apps/web/src/components/app-sidebar.tsx | 319 ++++++++++-- apps/web/src/components/confirm-dialog.tsx | 9 +- .../domains/domain-detail-chrome.tsx | 427 +++++++++++++++++ .../components/domains/domain-detail-tabs.ts | 9 + apps/web/src/components/incident-alert.tsx | 33 ++ .../vps-tracker-integration-card.tsx | 14 + apps/web/src/components/layout/app-shell.tsx | 51 +- .../src/components/layout/command-palette.tsx | 152 ++++++ .../src/components/layout/data-freshness.tsx | 90 ++++ .../web/src/components/layout/search-menu.tsx | 189 -------- .../web/src/components/layout/site-header.tsx | 6 +- .../layout/system-attention-provider.tsx | 15 + .../layout/system-monitor-popover.tsx | 163 +++---- .../components/reui-kit/attention-queue.tsx | 45 +- .../reui-kit/dashboard-queue-grid.tsx | 314 ++++++++++++ apps/web/src/components/reui-kit/index.ts | 5 + .../src/components/reui-kit/resource-page.tsx | 77 ++- .../services/service-asset-section.tsx | 115 +++++ .../services/service-detail-context.ts | 29 ++ .../services/service-detail-grid.tsx | 76 ++- .../services/service-health-section.tsx | 134 ++++++ .../services/service-routing-section.tsx | 67 +++ .../services/services-grouped-catalog.tsx | 4 +- apps/web/src/hooks/use-system-attention.ts | 68 +++ apps/web/src/lib/breadcrumbs.test.ts | 25 + apps/web/src/lib/breadcrumbs.ts | 18 +- apps/web/src/lib/live-refresh.test.ts | 43 ++ apps/web/src/lib/live-refresh.ts | 70 +++ apps/web/src/lib/system-attention.test.ts | 81 ++++ apps/web/src/lib/system-attention.ts | 188 ++++++++ apps/web/src/queries/services.ts | 16 +- apps/web/src/routes/_auth/certificates.tsx | 109 ++++- .../routes/_auth/domains/$domainId/dns.tsx | 129 +++-- .../routes/_auth/domains/$domainId/index.tsx | 340 +++---------- apps/web/src/routes/_auth/domains/index.tsx | 131 ++++- apps/web/src/routes/_auth/groups.tsx | 12 +- apps/web/src/routes/_auth/index.tsx | 271 ++++------- .../_auth/services/$serviceId/health.tsx | 17 +- .../_auth/services/$serviceId/index.tsx | 452 +----------------- .../_auth/services/$serviceId/nodes.tsx | 17 +- .../_auth/services/$serviceId/route.tsx | 360 +++++++++++++- .../_auth/services/$serviceId/routing.tsx | 17 +- .../_auth/services/$serviceId/subdomains.tsx | 22 +- apps/web/src/routes/_auth/services/index.tsx | 104 +++- apps/web/src/routes/_auth/settings/health.tsx | 107 ++++- packages/ui/package.json | 1 + packages/ui/src/components/alert-dialog.tsx | 8 +- pnpm-lock.yaml | 3 + 48 files changed, 3534 insertions(+), 1418 deletions(-) create mode 100644 apps/web/src/components/domains/domain-detail-chrome.tsx create mode 100644 apps/web/src/components/domains/domain-detail-tabs.ts create mode 100644 apps/web/src/components/incident-alert.tsx create mode 100644 apps/web/src/components/layout/command-palette.tsx create mode 100644 apps/web/src/components/layout/data-freshness.tsx delete mode 100644 apps/web/src/components/layout/search-menu.tsx create mode 100644 apps/web/src/components/layout/system-attention-provider.tsx create mode 100644 apps/web/src/components/reui-kit/dashboard-queue-grid.tsx create mode 100644 apps/web/src/components/services/service-asset-section.tsx create mode 100644 apps/web/src/components/services/service-detail-context.ts create mode 100644 apps/web/src/components/services/service-health-section.tsx create mode 100644 apps/web/src/components/services/service-routing-section.tsx create mode 100644 apps/web/src/hooks/use-system-attention.ts create mode 100644 apps/web/src/lib/live-refresh.test.ts create mode 100644 apps/web/src/lib/live-refresh.ts create mode 100644 apps/web/src/lib/system-attention.test.ts create mode 100644 apps/web/src/lib/system-attention.ts diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 154eb23..90e23ee 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,14 +1,24 @@ +import { useCallback, useState } from 'react' import { Link, useRouterState } from '@tanstack/react-router' import { - LayoutDashboardIcon, - GlobeIcon, + ChevronRightIcon, FolderTreeIcon, + GlobeIcon, + LayoutDashboardIcon, + PinIcon, ServerIcon, - ShieldCheckIcon, SettingsIcon, + ShieldCheckIcon, } from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' import { NavUser } from '@/components/nav-user' +import { useSystemAttention } from '@/hooks/use-system-attention' +import { cn } from '@cfdm/ui/lib/utils' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@cfdm/ui/components/collapsible' import { Sidebar, SidebarContent, @@ -18,25 +28,114 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, + useSidebar, } from '@cfdm/ui/components/sidebar' -const infrastructureNav = [ +const PINNED_KEY = 'cfdm.sidebar.pinned' +const COLLAPSED_KEY = 'cfdm.sidebar.collapsed' + +type NavAttention = 'services-down' | 'certs-expiring' + +type AppNavTo = + | '/' + | '/domains' + | '/services' + | '/groups' + | '/certificates' + | '/settings/appearance' + +type NavItem = { + to: AppNavTo + label: string + icon: typeof GlobeIcon + exact: boolean + matchPrefix?: string + attention?: NavAttention +} + +const infrastructureNav: NavItem[] = [ { to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true }, { to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false }, -] as const +] -const operationsNav = [ - { to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false }, - { to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' }, -] as const +const operationsNav: NavItem[] = [ + { + to: '/services', + label: 'Сервисы', + icon: ServerIcon, + exact: false, + attention: 'services-down', + }, + { + to: '/settings/appearance', + label: 'Настройки', + icon: SettingsIcon, + exact: false, + matchPrefix: '/settings', + }, +] -const secondaryNav = [ +const secondaryNav: NavItem[] = [ { to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false }, - { to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false }, + { + to: '/certificates', + label: 'Сертификаты', + icon: ShieldCheckIcon, + exact: false, + attention: 'certs-expiring', + }, +] + +const NAV_GROUPS = [ + { id: 'infrastructure', label: 'Инфраструктура', items: infrastructureNav }, + { id: 'operations', label: 'Операции', items: operationsNav }, + { id: 'secondary', label: 'Прочее', items: secondaryNav }, ] as const +const NAV_ITEMS = NAV_GROUPS.flatMap((group) => group.items) + +function readStringList(key: string) { + try { + const raw = localStorage.getItem(key) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter((item): item is string => typeof item === 'string') + } catch { + return [] + } +} + +function writeStringList(key: string, values: string[]) { + try { + localStorage.setItem(key, JSON.stringify(values)) + } catch { + // хранилище недоступно — состояние остаётся в памяти + } +} + +function useStringList(key: string) { + const [values, setValues] = useState(() => readStringList(key)) + + const toggle = useCallback( + (id: string) => { + setValues((current) => { + const next = current.includes(id) + ? current.filter((item) => item !== id) + : [...current, id] + writeStringList(key, next) + return next + }) + }, + [key], + ) + + return { values, toggle } +} + function isNavActive( pathname: string, to: string, @@ -50,56 +149,148 @@ function isNavActive( return pathname === to || pathname.startsWith(`${to}/`) } +function NavItems({ + items, + pathname, + pinned, + onTogglePin, + servicesDown, + certsExpiring, +}: { + items: readonly NavItem[] + pathname: string + pinned: readonly string[] + onTogglePin: (to: string) => void + servicesDown: number + certsExpiring: number +}) { + return ( + + {items.map((item) => { + const isPinned = pinned.includes(item.to) + const showServicesDot = item.attention === 'services-down' && servicesDown > 0 + const showCertsDot = item.attention === 'certs-expiring' && certsExpiring > 0 + const showDot = showServicesDot || showCertsDot + return ( + + } + > + + {item.label} + {showDot ? ( + + ) : null} + {showDot ? ( + + {showServicesDot ? ', есть недоступные' : ', истекают в ближайшие 14 дней'} + + ) : null} + + {showDot ? ( + + ) + })} + + ) +} + function NavSection({ + id, label, items, pathname, + collapsed, + forceOpen, + onToggleCollapsed, + pinned, + onTogglePin, + servicesDown, + certsExpiring, }: { + id: string label: string - items: readonly { - to: string - label: string - icon: typeof GlobeIcon - exact: boolean - matchPrefix?: string - }[] + items: readonly NavItem[] pathname: string + collapsed: boolean + forceOpen: boolean + onToggleCollapsed: (id: string) => void + pinned: readonly string[] + onTogglePin: (to: string) => void + servicesDown: number + certsExpiring: number }) { + const open = forceOpen || !collapsed + return ( - - {label} - - - {items.map((item) => ( - - - } - > - - {item.label} - - - ))} - - - + { + if (forceOpen) return + if (nextOpen === open) return + onToggleCollapsed(id) + }} + className="group/nav-section" + > + + }> + {label} + + + + + + + + + ) } export function AppSidebar() { const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { state, isMobile } = useSidebar() + const attention = useSystemAttention() + const pins = useStringList(PINNED_KEY) + const collapsed = useStringList(COLLAPSED_KEY) + const forceOpen = state === 'collapsed' && !isMobile + const pinnedItems = pins.values + .map((to) => NAV_ITEMS.find((item) => item.to === to)) + .filter((item): item is NavItem => item != null) return ( @@ -107,9 +298,37 @@ export function AppSidebar() { - - - + {pinnedItems.length > 0 ? ( + + Закреплённые + + + + + ) : null} + {NAV_GROUPS.map((group) => ( + + ))} diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx index 53aceaa..e993539 100644 --- a/apps/web/src/components/confirm-dialog.tsx +++ b/apps/web/src/components/confirm-dialog.tsx @@ -19,6 +19,8 @@ interface ConfirmDialogProps { description: string confirmLabel?: string cancelLabel?: string + /** Кнопка подтверждения. Удаление остаётся destructive. */ + confirmVariant?: 'default' | 'destructive' onConfirm: () => void disabled?: boolean } @@ -31,6 +33,7 @@ export function ConfirmDialog({ description, confirmLabel = 'Удалить', cancelLabel = 'Отмена', + confirmVariant = 'destructive', onConfirm, disabled, }: ConfirmDialogProps) { @@ -46,7 +49,11 @@ export function ConfirmDialog({ {cancelLabel} - + {confirmLabel} diff --git a/apps/web/src/components/domains/domain-detail-chrome.tsx b/apps/web/src/components/domains/domain-detail-chrome.tsx new file mode 100644 index 0000000..aec07c6 --- /dev/null +++ b/apps/web/src/components/domains/domain-detail-chrome.tsx @@ -0,0 +1,427 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { Link, useNavigate } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Controller, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { FolderTreeIcon, GlobeIcon, Link2Icon, ServerIcon } from 'lucide-react' +import { toast } from 'sonner' + +import { ChangeIpSheet } from '@/components/change-ip-sheet' +import { CountedLineTabs } from '@/components/counted-line-tabs' +import { FormFieldSimple } from '@/components/form-field' +import { FormSheet } from '@/components/form-sheet' +import { LoadingButton } from '@/components/loading-button' +import { QueryState } from '@/components/query-state' +import { SettingRow } from '@/components/setting-row' +import { StatusBadge } from '@/components/status-badge' +import { + SubdomainEditSheet, + type SubdomainEditValues, +} from '@/components/subdomain-edit-sheet' +import { TableSkeleton } from '@/components/skeletons' +import { DetailPanel, KpiStatGrid } from '@/components/reui-kit' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' +import { useDomainPage } from '@/hooks/use-domain-page' +import { + type DomainDetailTab, +} from '@/components/domains/domain-detail-tabs' +import { formatDate } from '@/lib/format' +import type { ServiceBinding } from '@/lib/schemas' +import { + dnsListQueryOptions, + groupsQueryOptions, + groupKeys, + domainKeys, + updateDomain, +} from '@/queries' +import { Button } from '@cfdm/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' +import { FieldGroup } from '@cfdm/ui/components/field' + +const groupFormSchema = z.object({ + group_id: z.string(), +}) + +type GroupFormValues = z.infer + +interface DomainDetailActions { + openCreateSubdomain: () => void +} + +function DomainChromeSkeleton() { + return ( +
+ +
+ + + + +
+
+ ) +} + +/** + * Общая шапка, KPI и полоса вкладок карточки домена. + * Preview: https://reui.io/preview/base/stats-12 + * Preview: https://reui.io/preview/base/form-7 + * Preview: https://reui.io/preview/base/settings-16 + * Docs: https://reui.io/docs/components/base/frame + * Docs: https://reui.io/docs/components/base/icon-tile + */ +export function DomainDetailChrome({ + domainId, + activeTab, + children, +}: { + domainId: string + activeTab: DomainDetailTab + children: (actions: DomainDetailActions) => ReactNode +}) { + const id = Number(domainId) + const navigate = useNavigate() + const queryClient = useQueryClient() + const page = useDomainPage(id) + const groupsQuery = useQuery(groupsQueryOptions()) + const dnsQuery = useQuery(dnsListQueryOptions(id)) + + const [groupOpen, setGroupOpen] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [changeIp, setChangeIp] = useState(null) + + const domain = page.domain + const groupForm = useForm({ + resolver: zodResolver(groupFormSchema), + defaultValues: { group_id: 'none' }, + }) + + useEffect(() => { + if (!groupOpen || !domain) return + groupForm.reset({ + group_id: domain.group_id != null ? String(domain.group_id) : 'none', + }) + }, [groupOpen, domain, groupForm]) + + const groupName = useMemo(() => { + if (!domain?.group_id) return null + return groupsQuery.data?.find((group) => group.id === domain.group_id)?.name ?? null + }, [domain, groupsQuery.data]) + + const groupItems = useMemo( + () => [ + { label: 'Без группы', value: 'none' }, + ...(groupsQuery.data ?? []).map((group) => ({ + label: group.name, + value: String(group.id), + })), + ], + [groupsQuery.data], + ) + + const updateGroupMutation = useMutation({ + mutationFn: (groupId: number | null) => updateDomain(id, { group_id: groupId }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: domainKeys.detail(id) }), + queryClient.invalidateQueries({ queryKey: domainKeys.all }), + queryClient.invalidateQueries({ queryKey: groupKeys.all }), + ]) + setGroupOpen(false) + toast.success('Группа домена обновлена') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось сменить группу') + }, + }) + + function openCreateSubdomain() { + setCreateOpen(true) + } + + async function handleCreateSubdomain(values: SubdomainEditValues) { + await page.createSubdomainMutation.mutateAsync(values.name) + setCreateOpen(false) + } + + function handleTabChange(next: string) { + if (next === 'dns') { + void navigate({ + to: '/domains/$domainId/dns', + params: { domainId }, + search: { host: undefined }, + }) + return + } + if ( + next === 'overview' || + next === 'availability' || + next === 'subdomains' || + next === 'bindings' + ) { + void navigate({ + to: '/domains/$domainId', + params: { domainId }, + search: { tab: next }, + }) + } + } + + const kpiCards = domain + ? [ + { + id: 'status', + icon: , + label: 'Статус', + value: domain.status, + hint: , + }, + { + id: 'group', + icon: , + label: 'Группа', + value: groupName ?? '—', + hint: groupName ? 'В группе' : 'Без группы', + }, + { + id: 'bindings', + icon: , + label: 'Привязки', + value: page.bindings.length, + hint: 'Сервисы в зоне', + }, + { + id: 'sync', + icon: , + label: 'Синхронизация', + value: formatDate(domain.last_synced_at), + hint: 'Последняя синхронизация', + }, + ] + : [] + + const bindings = page.bindings + + return ( + } + > + {domain ? ( + + + + {bindings.length === 1 ? ( + + ) : bindings.length > 1 ? ( + + + Сменить IP + + } + /> + + {bindings.map((binding) => ( + setChangeIp(binding)} + > + {binding.hostname} + + ))} + + + ) : null} + + + } + /> + + + + + {activeTab === 'overview' ? : null} + {children({ openCreateSubdomain })} + + + { + const next = values.group_id === 'none' ? null : Number(values.group_id) + updateGroupMutation.mutate(next) + }} + footer={ + + Сохранить + + } + > + + + ( + + )} + /> + + + + + + + { + if (!open) setChangeIp(null) + }} + bindingId={changeIp?.id ?? null} + serviceId={changeIp?.service_id ?? null} + currentIp={changeIp?.target_ip ?? changeIp?.target_ips?.[0]} + /> + + ) : null} + + ) +} + +function DomainOverviewFacts({ domainId }: { domainId: number }) { + const page = useDomainPage(domainId) + const groupsQuery = useQuery(groupsQueryOptions()) + const domain = page.domain + if (!domain) return null + + const groupName = domain.group_id + ? groupsQuery.data?.find((group) => group.id === domain.group_id)?.name + : null + + return ( + + + Зона + Параметры зоны Cloudflare + + + + {domain.zone_name} + + + {domain.group_id ? ( + + ) : ( + Без группы + )} + + + {domain.cf_zone_id} + + + {formatDate(domain.last_synced_at)} + + + + ) +} diff --git a/apps/web/src/components/domains/domain-detail-tabs.ts b/apps/web/src/components/domains/domain-detail-tabs.ts new file mode 100644 index 0000000..c3a36b1 --- /dev/null +++ b/apps/web/src/components/domains/domain-detail-tabs.ts @@ -0,0 +1,9 @@ +export const DOMAIN_SECTION_TABS = [ + 'overview', + 'availability', + 'subdomains', + 'bindings', +] as const + +export type DomainSectionTab = (typeof DOMAIN_SECTION_TABS)[number] +export type DomainDetailTab = DomainSectionTab | 'dns' diff --git a/apps/web/src/components/incident-alert.tsx b/apps/web/src/components/incident-alert.tsx new file mode 100644 index 0000000..055c4f1 --- /dev/null +++ b/apps/web/src/components/incident-alert.tsx @@ -0,0 +1,33 @@ +import { CircleAlertIcon } from 'lucide-react' +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' +import { Button } from '@cfdm/ui/components/button' + +interface IncidentAlertProps { + title: string + description?: string + onShow: () => void +} + +/** + * Баннер самого тяжёлого инцидента. + * Preview: https://reui.io/docs/components/base/alert + */ +export function IncidentAlert({ title, description, onShow }: IncidentAlertProps) { + return ( + + + {title} + {description ? {description} : null} + + + + + ) +} diff --git a/apps/web/src/components/integrations/vps-tracker-integration-card.tsx b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx index 0a260bc..2589c86 100644 --- a/apps/web/src/components/integrations/vps-tracker-integration-card.tsx +++ b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx @@ -2,8 +2,10 @@ import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' +import { TriangleAlertIcon } from 'lucide-react' import { Badge } from '@/components/reui/badge' +import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { FieldGroup } from '@cfdm/ui/components/field' import { Input } from '@cfdm/ui/components/input' import { FormFieldSimple } from '@/components/form-field' @@ -57,11 +59,23 @@ export function VpsTrackerIntegrationCard({ const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary' + const hasUnsaved = form.formState.isDirty + return (
void form.handleSubmit((values) => onSave(values))(e)} > + {hasUnsaved ? ( + + + ) : null}
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 29da359..4e558e1 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -1,34 +1,43 @@ -import type { CSSProperties, ReactNode } from 'react' +import { useEffect, type CSSProperties, type ReactNode } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { AppSidebar } from '@/components/app-sidebar' +import { CommandPalette } from '@/components/layout/command-palette' import { SiteHeader } from '@/components/layout/site-header' -import { SearchMenu } from '@/components/layout/search-menu' +import { SystemAttentionProvider } from '@/components/layout/system-attention-provider' +import { bindLiveRefresh } from '@/lib/live-refresh' import { TooltipProvider } from '@cfdm/ui/components/tooltip' import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar' import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' /** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */ export function AppShell({ children }: { children: ReactNode }) { + const queryClient = useQueryClient() + + useEffect(() => bindLiveRefresh(queryClient), [queryClient]) + return ( - - - К содержимому - - - - -
- {children} -
-
- -
+ + + + К содержимому + + + + +
+ {children} +
+
+ +
+
) } diff --git a/apps/web/src/components/layout/command-palette.tsx b/apps/web/src/components/layout/command-palette.tsx new file mode 100644 index 0000000..4e07291 --- /dev/null +++ b/apps/web/src/components/layout/command-palette.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { + BlocksIcon, + FolderTreeIcon, + GlobeIcon, + HeartPulseIcon, + LayoutDashboardIcon, + PaletteIcon, + ServerIcon, + SettingsIcon, + ShieldCheckIcon, +} from 'lucide-react' +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@cfdm/ui/components/command' + +const SECTION_ITEMS = [ + { + to: '/', + label: 'Панель', + keywords: 'dashboard панель управление обзор', + icon: LayoutDashboardIcon, + }, + { + to: '/domains', + label: 'Домены', + keywords: 'domains зоны cloudflare', + icon: GlobeIcon, + }, + { + to: '/services', + label: 'Сервисы', + keywords: 'services сервисы fqdn', + icon: ServerIcon, + }, + { + to: '/groups', + label: 'Группы', + keywords: 'groups группы доменов', + icon: FolderTreeIcon, + }, + { + to: '/certificates', + label: 'Сертификаты', + keywords: 'certificates ssl tls', + icon: ShieldCheckIcon, + }, + { + to: '/settings/appearance', + label: 'Настройки', + keywords: 'settings настройки', + icon: SettingsIcon, + }, +] as const + +const ACTION_ITEMS = [ + { + to: '/settings/health', + label: 'Health-check', + keywords: 'health проверка пороги cron', + icon: HeartPulseIcon, + }, + { + to: '/settings/integrations', + label: 'Интеграции', + keywords: 'integrations app switcher портал', + icon: BlocksIcon, + }, + { + to: '/settings/appearance', + label: 'Внешний вид', + keywords: 'appearance тема быстрые действия', + icon: PaletteIcon, + }, +] as const + +type PaletteTarget = + | (typeof SECTION_ITEMS)[number]['to'] + | (typeof ACTION_ITEMS)[number]['to'] + +/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */ +export function CommandPalette() { + const [open, setOpen] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.repeat) return + if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return + event.preventDefault() + setOpen((current) => !current) + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + function go(to: PaletteTarget) { + setOpen(false) + void navigate({ to }) + } + + return ( + + + + + Ничего не найдено + + {SECTION_ITEMS.map((item) => ( + go(item.to)} + > + + {item.label} + + ))} + + + + {ACTION_ITEMS.map((item) => ( + go(item.to)} + > + + {item.label} + + ))} + + + + + ) +} diff --git a/apps/web/src/components/layout/data-freshness.tsx b/apps/web/src/components/layout/data-freshness.tsx new file mode 100644 index 0000000..fa1eccd --- /dev/null +++ b/apps/web/src/components/layout/data-freshness.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' + +import { Switch } from '@cfdm/ui/components/switch' +import { formatRelative } from '@/lib/format' +import { isLiveRefresh, setLiveRefresh } from '@/lib/live-refresh' + +function useLatestDataUpdatedAt() { + const queryClient = useQueryClient() + const [updatedAt, setUpdatedAt] = useState(0) + + useEffect(() => { + const cache = queryClient.getQueryCache() + let queued = false + let active = true + const read = () => { + let latest = 0 + for (const query of cache.getAll()) { + if (query.getObserversCount() === 0) continue + if (query.state.dataUpdatedAt > latest) latest = query.state.dataUpdatedAt + } + setUpdatedAt((prev) => (prev === latest ? prev : latest)) + } + // Cache notifies synchronously while another route renders (observerAdded). + const schedule = () => { + if (queued) return + queued = true + queueMicrotask(() => { + queued = false + if (active) read() + }) + } + read() + const unsubscribe = cache.subscribe(schedule) + return () => { + active = false + unsubscribe() + } + }, [queryClient]) + + return updatedAt +} + +function formatUpdatedAt(updatedAt: number) { + if (updatedAt === 0) return '—' + return formatRelative(new Date(updatedAt).toISOString()) +} + +/** Компактный статус свежести слева от AppsMenu. */ +export function DataFreshness() { + const queryClient = useQueryClient() + const updatedAt = useLatestDataUpdatedAt() + const [live, setLive] = useState(() => isLiveRefresh()) + const [label, setLabel] = useState('—') + + useEffect(() => { + const read = () => setLabel(formatUpdatedAt(updatedAt)) + read() + if (updatedAt === 0) return + const timer = window.setInterval(read, 15_000) + return () => window.clearInterval(timer) + }, [updatedAt]) + + function handleCheckedChange(checked: boolean) { + const next = Boolean(checked) + setLiveRefresh(queryClient, next) + setLive(next) + } + + return ( +
+

+ + Обновлено{' '} + + + + {live ? 'Live' : 'Пауза'} +

+ +
+ ) +} diff --git a/apps/web/src/components/layout/search-menu.tsx b/apps/web/src/components/layout/search-menu.tsx deleted file mode 100644 index d69356f..0000000 --- a/apps/web/src/components/layout/search-menu.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useEffect, useId, useMemo, useState } from 'react' -import { Link, useNavigate } from '@tanstack/react-router' -import { - FolderTreeIcon, - GlobeIcon, - HeartPulseIcon, - LayoutDashboardIcon, - SearchIcon, - ServerIcon, - SettingsIcon, - ShieldCheckIcon, -} from 'lucide-react' -import { Button } from '@cfdm/ui/components/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@cfdm/ui/components/dialog' -import { Input } from '@cfdm/ui/components/input' -import { - Item, - ItemContent, - ItemGroup, - ItemMedia, - ItemTitle, -} from '@cfdm/ui/components/item' - -const NAV_ITEMS = [ - { - to: '/', - label: 'Панель управления', - keywords: ['dashboard', 'панель', 'обзор'], - icon: LayoutDashboardIcon, - }, - { - to: '/domains', - label: 'Домены', - keywords: ['domains', 'зоны', 'cloudflare'], - icon: GlobeIcon, - }, - { - to: '/groups', - label: 'Группы доменов', - keywords: ['groups', 'группы', 'канбан'], - icon: FolderTreeIcon, - }, - { - to: '/services', - label: 'Сервисы', - keywords: ['services', 'сервисы', 'fqdn'], - icon: ServerIcon, - }, - { - to: '/certificates', - label: 'Сертификаты', - keywords: ['certificates', 'ssl', 'tls'], - icon: ShieldCheckIcon, - }, - { - to: '/settings/health', - label: 'Health-check', - keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'], - icon: HeartPulseIcon, - }, - { - to: '/settings/integrations', - label: 'Настройки', - keywords: ['settings', 'интеграции', 'app switcher'], - icon: SettingsIcon, - }, -] as const - -/** Command-K search — hotkey dialog (no header chrome trigger). Etalon: EvoBGP CommandPalette hotkeyOnly. */ -export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const searchInputId = useId() - const navigate = useNavigate() - - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - setOpen(true) - } - } - - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, []) - - useEffect(() => { - if (!open) setQuery('') - }, [open]) - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - if (!q) return NAV_ITEMS - return NAV_ITEMS.filter( - (item) => - item.label.toLowerCase().includes(q) || - item.keywords.some((k) => k.includes(q)), - ) - }, [query]) - - function goTo(to: string) { - setOpen(false) - void navigate({ to }) - } - - return ( - <> - {hotkeyOnly ? null : ( - - )} - - - - Поиск - - Переход к разделам приложения - - - -
-
- - {filtered.length === 0 ? ( -

- Ничего не найдено -

- ) : ( - filtered.map((item) => ( - setOpen(false)} />} - > - - - - {item.label} - - - )) - )} -
-
-
- - ) -} diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 29ba086..7bd41a8 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -9,8 +9,9 @@ import { BreadcrumbSeparator, } from '@cfdm/ui/components/breadcrumb' import { Separator } from '@cfdm/ui/components/separator' -import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { AppsMenu } from '@/components/layout/apps-menu' +import { DataFreshness } from '@/components/layout/data-freshness' +import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { SidebarTrigger } from '@cfdm/ui/components/sidebar' import { getBreadcrumbs } from '@/lib/breadcrumbs' @@ -32,7 +33,7 @@ function useDynamicBreadcrumbLabels() { }, [matches]) } -/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */ +/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */ export function SiteHeader() { const pathname = useRouterState({ select: (s) => s.location.pathname }) const dynamicLabels = useDynamicBreadcrumbLabels() @@ -71,6 +72,7 @@ export function SiteHeader() {
+
diff --git a/apps/web/src/components/layout/system-attention-provider.tsx b/apps/web/src/components/layout/system-attention-provider.tsx new file mode 100644 index 0000000..868d76c --- /dev/null +++ b/apps/web/src/components/layout/system-attention-provider.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' + +import { + SystemAttentionContext, + useSystemAttentionData, +} from '@/hooks/use-system-attention' + +export function SystemAttentionProvider({ children }: { children: ReactNode }) { + const attention = useSystemAttentionData() + return ( + + {children} + + ) +} diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index edb4a8d..3d76aac 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -1,5 +1,5 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react' -import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' import { Activity, HeartPulse, @@ -9,17 +9,14 @@ import { } from 'lucide-react' import { Badge } from '@/components/reui/badge' +import { useSystemAttention } from '@/hooks/use-system-attention' +import type { SystemIncident } from '@/lib/system-attention' import { cn } from '@cfdm/ui/lib/utils' import { Item, ItemMedia } from '@cfdm/ui/components/item' import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover' import { Progress } from '@cfdm/ui/components/progress' -import { - certSummaryQueryOptions, - domainsListQueryOptions, - notificationLogQueryOptions, - serviceGroupsQueryOptions, -} from '@/queries' +const VISIBLE_INCIDENTS = 8 type MonitorMetric = { id: string @@ -32,12 +29,6 @@ type MonitorMetric = { alert: boolean } -type ReadyResponse = { - status: string - database?: boolean - cloudflare?: boolean -} - function toneColor(tone: MonitorMetric['tone']) { switch (tone) { case 'success': @@ -81,110 +72,95 @@ function MetricCell({ metric }: { metric: MonitorMetric }) { ) } -function countByStatus(summary: [string, number][] | undefined, statuses: string[]) { - if (!summary) return 0 - return summary - .filter(([status]) => statuses.includes(status)) - .reduce((sum, [, count]) => sum + count, 0) +function IncidentLink({ incident }: { incident: SystemIncident }) { + const className = + 'hover:bg-muted flex items-center gap-2 rounded-md px-1.5 py-1 text-xs' + const body = ( + <> +
))} +
+ Инциденты + {visibleIncidents.length === 0 ? ( +

Нет инцидентов

+ ) : ( +
+ {visibleIncidents.map((incident) => ( + + ))} +
+ )} + {hiddenIncidents > 0 ? ( +

Ещё {hiddenIncidents}

+ ) : null} +
Без группы:{' '} - {ungroupedCount} + {attention.ungroupedCount} {' · '} Внимание:{' '} - {attentionCount} + + {attention.servicesDown + attention.servicesDegraded} +
diff --git a/apps/web/src/components/reui-kit/attention-queue.tsx b/apps/web/src/components/reui-kit/attention-queue.tsx index 8a78484..e5b2bf4 100644 --- a/apps/web/src/components/reui-kit/attention-queue.tsx +++ b/apps/web/src/components/reui-kit/attention-queue.tsx @@ -1,6 +1,11 @@ import type { ReactNode } from 'react' -import type { LucideIcon } from 'lucide-react' +import { CircleAlertIcon, type LucideIcon } from 'lucide-react' import { EmptyState } from '@/components/empty-state' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' import { Badge } from '@/components/reui/badge' import { Frame, @@ -9,12 +14,14 @@ import { FrameTitle, } from '@/components/reui/frame' import { IconTile } from '@/components/reui/icon-tile' +import { Button } from '@cfdm/ui/components/button' import { cn } from '@cfdm/ui/lib/utils' /** * Sibling Frame columns for dashboard attention queue. - * Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12 - * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile + * Each column is its own Frame; children must not wrap another Frame. + * Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/dashboard-1 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid */ export interface AttentionQueueColumn { id: string @@ -26,6 +33,9 @@ export interface AttentionQueueColumn { emptyTitle: string emptyDescription: string emptyAction?: ReactNode + isError?: boolean + errorMessage?: string + onRetry?: () => void children: ReactNode } @@ -46,7 +56,8 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) { > {columns.map((column) => { const Icon = column.icon - const isEmpty = column.count === 0 + const isError = column.isError === true + const isEmpty = !isError && column.count === 0 return ( @@ -71,8 +82,30 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) { ) : null} - - {isEmpty ? ( + + {isError ? ( +
+ + + Ошибка загрузки + + + {column.errorMessage ?? 'Не удалось загрузить данные'} + + {column.onRetry ? ( + + ) : null} + + +
+ ) : isEmpty ? (
row.status === 'up').length + return `${healthy}/${total}` +} + +function latestCheck(service: ServiceView) { + let checkedAt: string | null = null + for (const row of service.ip_health) { + if (!row.last_checked_at) continue + if (!checkedAt || row.last_checked_at > checkedAt) checkedAt = row.last_checked_at + } + return checkedAt +} + +function QueueGrid({ + data, + columns, + getRowId, +}: { + data: T[] + columns: ColumnDef[] + getRowId: (row: T) => string +}) { + const [sorting, setSorting] = useState([]) + const table = useReactTable({ + data, + columns, + getRowId, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + return ( + + + + + + ) +} + +interface HealthProblemGridProps { + services: ServiceView[] +} + +export function HealthProblemGrid({ services }: HealthProblemGridProps) { + const columns = useMemo[]>( + () => [ + { + id: 'name', + accessorKey: 'name', + size: 140, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + id: 'status', + accessorKey: 'health_status', + size: 96, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + id: 'latency', + accessorKey: 'health_latency_ms', + size: 88, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.health_latency_ms != null + ? `${row.original.health_latency_ms} мс` + : '—'} + + ), + }, + { + id: 'nodes', + accessorFn: (row) => Math.max(row.ips.length, row.ip_health.length), + size: 72, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {nodeLabel(row.original)} + ), + }, + { + id: 'checked', + accessorFn: (row) => latestCheck(row) ?? '', + size: 104, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatWhen(latestCheck(row.original))} + + ), + }, + ], + [], + ) + + return ( + String(row.id)} + /> + ) +} + +interface ExpiringCertGridProps { + certs: Certificate[] +} + +export function ExpiringCertGrid({ certs }: ExpiringCertGridProps) { + const columns = useMemo[]>( + () => [ + { + id: 'hostname', + accessorKey: 'hostname', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: 'service', + accessorFn: (row) => row.service_name ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.service_name?.trim() || '—'} + + ), + }, + { + id: 'expires', + accessorKey: 'expires_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatWhen(row.original.expires_at)} + + ), + }, + ], + [], + ) + + return ( + String(row.id)} /> + ) +} + +interface UngroupedDomainGridProps { + domains: DomainListItem[] +} + +export function UngroupedDomainGrid({ domains }: UngroupedDomainGridProps) { + const columns = useMemo[]>( + () => [ + { + id: 'zone_name', + accessorKey: 'zone_name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: 'services', + accessorKey: 'service_count', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.service_count} + ), + }, + { + id: 'synced', + accessorKey: 'last_synced_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatWhen(row.original.last_synced_at)} + + ), + }, + ], + [], + ) + + return ( + String(row.id)} + /> + ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index b741c4c..b7e142d 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -17,6 +17,11 @@ export { } from './kpi-stat-grid' export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' export { AttentionQueue, type AttentionQueueColumn } from './attention-queue' +export { + ExpiringCertGrid, + HealthProblemGrid, + UngroupedDomainGrid, +} from './dashboard-queue-grid' export { OpsDashboard } from './ops-dashboard' export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' export { DetailPanel, type DetailMetricCard } from './detail-panel' diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index d875cec..b7d836f 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -9,7 +9,12 @@ import { type RowSelectionState, type SortingState, } from '@tanstack/react-table' -import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react' +import { + CircleAlertIcon, + FilterIcon, + FunnelXIcon, + type LucideIcon, +} from 'lucide-react' import { CountedLineTabs } from '@/components/counted-line-tabs' import { Badge } from '@/components/reui/badge' @@ -67,7 +72,27 @@ export interface ResourcePageProps { error?: Error | null onRetry?: () => void primaryAction?: ReactNode - emptyState?: { title: string; description?: string; action?: ReactNode } + emptyState?: { + title: string + description?: string + action?: ReactNode + icon?: LucideIcon + } + /** Пустой результат фильтров или вкладки. Preview: https://reui.io/preview/base/empty-state-12 */ + filteredEmptyState?: { + title: string + description?: string + resetLabel?: string + icon?: LucideIcon + } + onFilteredEmptyReset?: () => void + /** Ошибка загрузки через EmptyState вместо общего Alert. */ + errorState?: { + title: string + description?: string + action?: ReactNode + icon?: LucideIcon + } pageSize?: number enableRowSelection?: boolean selectionToolbar?: (ctx: { @@ -119,6 +144,9 @@ export function ResourcePage({ onRetry, primaryAction, emptyState, + filteredEmptyState, + onFilteredEmptyReset, + errorState, pageSize = 10, enableRowSelection = false, selectionToolbar, @@ -224,6 +252,25 @@ export function ResourcePage({ } if (isError) { + if (errorState) { + return ( + + + + + + ) + } + return ( @@ -262,6 +309,7 @@ export function ResourcePage({ ) : null} ({ ) } - const emptyMessage = 'Нет записей по выбранным фильтрам.' + const emptyMessage = filteredEmptyState ? ( + { + handleClear() + onFilteredEmptyReset?.() + }} + > + {filteredEmptyState.resetLabel} + + ) : null + } + /> + ) : ( + 'Нет записей по выбранным фильтрам.' + ) return (
diff --git a/apps/web/src/components/services/service-asset-section.tsx b/apps/web/src/components/services/service-asset-section.tsx new file mode 100644 index 0000000..87c0a43 --- /dev/null +++ b/apps/web/src/components/services/service-asset-section.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { EmptyState } from '@/components/empty-state' +import { QueryState } from '@/components/query-state' +import { + ServiceDetailGrid, + type ServiceFqdnRow, +} from '@/components/services/service-detail-grid' +import { useServiceDetailActions } from '@/components/services/service-detail-context' +import { api } from '@/lib/api-client' +import type { ServiceView } from '@/lib/schemas' +import { + deleteServiceNode, + invalidateServiceDetail, + serviceHealthLogQueryOptions, + serviceNodesQueryOptions, + serviceViewQueryOptions, +} from '@/queries' + +interface ServiceNodeRow { + id: number + address: string + protocol: string + port: number | null + health_status: string + weight: number + priority: number +} + +export function ServiceAssetSection({ + serviceId, + section, +}: { + serviceId: string + section: 'ip' | 'fqdn' | 'nodes' | 'ssl' +}) { + const id = Number(serviceId) + const queryClient = useQueryClient() + const actions = useServiceDetailActions() + const viewQuery = useQuery(serviceViewQueryOptions(id)) + const logQuery = useQuery(serviceHealthLogQueryOptions(id)) + const nodesQuery = useQuery(serviceNodesQueryOptions(id)) + const [togglingIp, setTogglingIp] = useState(null) + + const service = viewQuery.data + const probes = logQuery.data?.items ?? [] + const nodes = Array.isArray(nodesQuery.data) + ? (nodesQuery.data as ServiceNodeRow[]) + : [] + + const toggleIpMutation = useMutation({ + mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) => + api.patch(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }), + onSuccess: async (_data, { enabled }) => { + await invalidateServiceDetail(queryClient, id) + toast.success( + enabled + ? 'IP включён и добавлен в DNS-привязки' + : 'IP выключен и снят с DNS-привязок', + ) + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP') + }, + onSettled: () => setTogglingIp(null), + }) + + const deleteNodeMut = useMutation({ + mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId), + onSuccess: async () => { + toast.success('Нода удалена') + await invalidateServiceDetail(queryClient, id) + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось удалить ноду') + }, + }) + + return ( + { + void viewQuery.refetch() + }} + > + {!service ? ( + + ) : ( + { + setTogglingIp(ip) + toggleIpMutation.mutate({ ip, enabled }) + }} + onChangeIp={(row: ServiceFqdnRow) => actions.openChangeIp(row)} + onChangeDomain={() => actions.openChangeDomain()} + onAddNode={actions.openAddNode} + onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)} + /> + )} + + ) +} diff --git a/apps/web/src/components/services/service-detail-context.ts b/apps/web/src/components/services/service-detail-context.ts new file mode 100644 index 0000000..a3108cc --- /dev/null +++ b/apps/web/src/components/services/service-detail-context.ts @@ -0,0 +1,29 @@ +import { createContext, createElement, useContext, type ReactNode } from 'react' + +import type { ServiceFqdnRow } from '@/components/services/service-detail-grid' + +export interface ServiceDetailActions { + openAddNode: () => void + openChangeIp: (row: ServiceFqdnRow) => void + openChangeDomain: () => void +} + +const ServiceDetailActionsContext = createContext(null) + +export function ServiceDetailActionsProvider({ + value, + children, +}: { + value: ServiceDetailActions + children: ReactNode +}) { + return createElement(ServiceDetailActionsContext.Provider, { value }, children) +} + +export function useServiceDetailActions() { + const value = useContext(ServiceDetailActionsContext) + if (!value) { + throw new Error('Действия сервиса доступны только внутри карточки сервиса') + } + return value +} diff --git a/apps/web/src/components/services/service-detail-grid.tsx b/apps/web/src/components/services/service-detail-grid.tsx index 3abf4cd..83a76c3 100644 --- a/apps/web/src/components/services/service-detail-grid.tsx +++ b/apps/web/src/components/services/service-detail-grid.tsx @@ -228,6 +228,8 @@ interface ServiceDetailGridProps { onAddNode: () => void onDeleteNode: (nodeId: number) => void isLoading?: boolean + /** Фиксирует одну секцию без внутренних вкладок IP / FQDN / Ноды / SSL. */ + section?: 'ip' | 'fqdn' | 'nodes' | 'ssl' } export function ServiceDetailGrid({ @@ -241,8 +243,10 @@ export function ServiceDetailGrid({ onAddNode, onDeleteNode, isLoading = false, + section, }: ServiceDetailGridProps) { - const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip') + const [tabState, setTab] = useState<(typeof TABS)[number]['id']>(section ?? 'ip') + const tab = section ?? tabState const [ipFilters, setIpFilters] = useState(() => [ createFilter('ip', 'contains', ['']), createFilter('status', 'is', ['']), @@ -671,17 +675,34 @@ export function ServiceDetailGrid({ [patchCertMonitoring], ) - const sharedTabs = { - tabs, - activeTab: tab, - onTabChange: (id: string) => setTab(id as typeof tab), - } + const heading = + section === 'nodes' + ? { title: 'Ноды', description: 'Адреса пула и состояние проверок' } + : section === 'fqdn' + ? { title: 'Поддомены', description: 'FQDN, привязанные к сервису' } + : section === 'ip' + ? { + title: 'Маршрутизация', + description: 'Веса, приоритеты и включённые адреса', + } + : { + title: 'Активы сервиса', + description: 'IP, FQDN и ноды этого сервиса', + } + + const sharedTabs = section + ? {} + : { + tabs, + activeTab: tab, + onTabChange: (id: string) => setTab(id as typeof tab), + } if (tab === 'fqdn') { return ( row.id} isLoading={isLoading} primaryAction={ - + section ? undefined : ( + + ) + } + emptyState={ + section === 'nodes' + ? { + title: 'Нет нод', + description: 'Добавьте ноду в пул сервиса.', + action: ( + + ), + } + : undefined } /> ) @@ -745,8 +781,12 @@ export function ServiceDetailGrid({ if (tab === 'ssl') { return ( ({ + id: `dns:${item.id}`, + ip: item.ip, + fqdn: item.fqdn, + action: item.action, + created_at: item.created_at, + copy: + item.action === 'removed' + ? `${item.ip} убрана с ${item.fqdn}` + : `${item.ip} добавлена на ${item.fqdn}`, + source: 'dns', + })) +} + +function hasLatencySeries(items: readonly HealthLogProbe[]) { + return items.some((item) => item.latency_ms != null && item.checked_at.length > 0) +} + +/** + * Health карточки сервиса: график задержки и timeline failover + * только по строкам журналов, без синтетических точек. + * + * Preview: https://reui.io/preview/base/chart-17 + * Preview: https://reui.io/preview/base/components/c-timeline-10 + * Docs: https://reui.io/docs/components/base/timeline + * Docs: https://reui.io/docs/components/base/frame + */ +export function ServiceHealthSection({ serviceId }: { serviceId: string }) { + const id = Number(serviceId) + const viewQuery = useQuery(serviceViewQueryOptions(id)) + const logQuery = useQuery(serviceHealthLogQueryOptions(id)) + const failoverQuery = useQuery(serviceFailoverLogQueryOptions(id)) + + const probes = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items]) + const failoverItems = useMemo( + () => failoverQuery.data?.items ?? [], + [failoverQuery.data?.items], + ) + const history = useMemo(() => failoverHistory(failoverItems), [failoverItems]) + const showLatency = hasLatencySeries(probes) + + const enabledProviders = useMemo( + () => enabledHealthProviders(viewQuery.data?.domains ?? []), + [viewQuery.data?.domains], + ) + const providerStatuses = useMemo( + () => providerHealthStatuses(probes, enabledProviders), + [probes, enabledProviders], + ) + + const isLoading = logQuery.isLoading || failoverQuery.isLoading + const isError = logQuery.isError || failoverQuery.isError + const error = logQuery.error ?? failoverQuery.error + + return ( + { + void logQuery.refetch() + void failoverQuery.refetch() + }} + > +
+ {showLatency ? ( + + ) : ( + + )} + + {history.length > 0 ? ( + + + Failover + События журнала смены адресов + + + + + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/components/services/service-routing-section.tsx b/apps/web/src/components/services/service-routing-section.tsx new file mode 100644 index 0000000..b77793a --- /dev/null +++ b/apps/web/src/components/services/service-routing-section.tsx @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query' + +import { SettingRow } from '@/components/setting-row' +import { ServiceAssetSection } from '@/components/services/service-asset-section' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' +import type { ServiceView } from '@/lib/schemas' +import { serviceOverviewQueryOptions, serviceViewQueryOptions } from '@/queries' + +const LB_LABEL: Record = { + round_robin: 'Round robin', + failover: 'Failover', + weighted: 'По весам', +} + +interface OverviewRouting { + routing_strategy?: string + active_addresses?: string[] +} + +/** + * Preview: https://reui.io/preview/base/settings-16 + * Docs: https://reui.io/docs/components/base/frame + */ +export function ServiceRoutingSection({ serviceId }: { serviceId: string }) { + const id = Number(serviceId) + const viewQuery = useQuery(serviceViewQueryOptions(id)) + const overviewQuery = useQuery(serviceOverviewQueryOptions(id)) + const service = viewQuery.data + const overview = overviewQuery.data as OverviewRouting | undefined + const active = overview?.active_addresses ?? service?.active_ips ?? [] + + return ( +
+ {service ? ( + + + Маршрутизация + Режим балансировки и активные адреса + + + + + {LB_LABEL[service.lb_mode]} + + + + + {overview?.routing_strategy ?? LB_LABEL[service.lb_mode]} + + + + {active.join(', ') || '—'} + + + + ) : null} + +
+ ) +} diff --git a/apps/web/src/components/services/services-grouped-catalog.tsx b/apps/web/src/components/services/services-grouped-catalog.tsx index 99192e9..e2b463f 100644 --- a/apps/web/src/components/services/services-grouped-catalog.tsx +++ b/apps/web/src/components/services/services-grouped-catalog.tsx @@ -249,11 +249,11 @@ export function ServicesGroupedCatalog({ return ( diff --git a/apps/web/src/hooks/use-system-attention.ts b/apps/web/src/hooks/use-system-attention.ts new file mode 100644 index 0000000..ab550ce --- /dev/null +++ b/apps/web/src/hooks/use-system-attention.ts @@ -0,0 +1,68 @@ +import { createContext, useContext, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' + +import { deriveSystemAttention, type SystemAttention } from '@/lib/system-attention' +import { + certificatesQueryOptions, + certSummaryQueryOptions, + domainsListQueryOptions, + notificationLogQueryOptions, + serviceGroupsQueryOptions, +} from '@/queries' + +const POLL_MS = 30_000 + +type ReadyResponse = { + status: string +} + +export const SystemAttentionContext = createContext(null) + +export function useSystemAttentionData(): SystemAttention { + const readyQ = useQuery({ + queryKey: ['ready'], + queryFn: async (): Promise => { + const res = await fetch('/ready') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() as Promise + }, + refetchInterval: POLL_MS, + }) + const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: POLL_MS }) + const certSummaryQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: POLL_MS }) + const certificatesQ = useQuery({ ...certificatesQueryOptions(), refetchInterval: POLL_MS }) + const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: POLL_MS }) + const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: POLL_MS }) + + return useMemo( + () => + deriveSystemAttention({ + services: servicesQ.data, + certificates: certificatesQ.data, + certSummary: certSummaryQ.data, + notifications: notifyQ.data, + domains: domainsQ.data, + readyStatus: readyQ.data?.status, + readyIsSuccess: readyQ.isSuccess, + readyIsError: readyQ.isError, + }), + [ + certificatesQ.data, + certSummaryQ.data, + domainsQ.data, + notifyQ.data, + readyQ.data?.status, + readyQ.isError, + readyQ.isSuccess, + servicesQ.data, + ], + ) +} + +export function useSystemAttention() { + const attention = useContext(SystemAttentionContext) + if (!attention) { + throw new Error('useSystemAttention должен вызываться внутри SystemAttentionProvider') + } + return attention +} diff --git a/apps/web/src/lib/breadcrumbs.test.ts b/apps/web/src/lib/breadcrumbs.test.ts index d77ca3c..651b4a8 100644 --- a/apps/web/src/lib/breadcrumbs.test.ts +++ b/apps/web/src/lib/breadcrumbs.test.ts @@ -18,6 +18,31 @@ describe('getBreadcrumbs', () => { ]) }) + it('keeps the service name on nested service tabs', () => { + expect( + getBreadcrumbs('/services/9/health', { '/services/9': '[IHOR] Москва' }), + ).toEqual([ + { label: 'Сервисы', href: '/services' }, + { label: '[IHOR] Москва', href: '/services/9' }, + { label: 'Health', href: '/services/9/health' }, + ]) + expect(getBreadcrumbs('/services/9/routing')).toEqual([ + { label: 'Сервисы', href: '/services' }, + { label: 'Сервис', href: '/services/9' }, + { label: 'Маршрутизация', href: '/services/9/routing' }, + ]) + expect(getBreadcrumbs('/services/9/nodes')).toEqual([ + { label: 'Сервисы', href: '/services' }, + { label: 'Сервис', href: '/services/9' }, + { label: 'Ноды', href: '/services/9/nodes' }, + ]) + expect(getBreadcrumbs('/services/9/subdomains')).toEqual([ + { label: 'Сервисы', href: '/services' }, + { label: 'Сервис', href: '/services/9' }, + { label: 'Поддомены', href: '/services/9/subdomains' }, + ]) + }) + it('does not reuse the section href for the parent crumb', () => { const crumbs = getBreadcrumbs('/settings/appearance') const hrefs = crumbs.map((crumb) => crumb.href) diff --git a/apps/web/src/lib/breadcrumbs.ts b/apps/web/src/lib/breadcrumbs.ts index 6ba51be..c03ed46 100644 --- a/apps/web/src/lib/breadcrumbs.ts +++ b/apps/web/src/lib/breadcrumbs.ts @@ -38,10 +38,24 @@ export function getBreadcrumbs( return [{ label: 'Панель управления', href: '/' }] } - if (path.match(/^\/services\/\d+$/)) { + const serviceMatch = path.match( + /^\/services\/(\d+)(?:\/(nodes|health|routing|subdomains))?$/, + ) + if (serviceMatch) { + const servicePath = `/services/${serviceMatch[1]}` + const section = serviceMatch[2] + const sectionLabels: Record = { + nodes: 'Ноды', + health: 'Health', + routing: 'Маршрутизация', + subdomains: 'Поддомены', + } return [ { label: 'Сервисы', href: '/services' }, - { label: dynamicLabels[path] ?? 'Сервис', href: path }, + { label: dynamicLabels[servicePath] ?? 'Сервис', href: servicePath }, + ...(section + ? [{ label: sectionLabels[section] ?? section, href: path }] + : []), ] } diff --git a/apps/web/src/lib/live-refresh.test.ts b/apps/web/src/lib/live-refresh.test.ts new file mode 100644 index 0000000..436d1d5 --- /dev/null +++ b/apps/web/src/lib/live-refresh.test.ts @@ -0,0 +1,43 @@ +import { QueryClient, QueryObserver } from '@tanstack/react-query' +import { afterEach, describe, expect, it } from 'vitest' + +import { bindLiveRefresh, isLiveRefresh, setLiveRefresh } from './live-refresh' + +describe('setLiveRefresh', () => { + afterEach(() => { + if (isLiveRefresh()) return + const client = new QueryClient() + setLiveRefresh(client, true) + client.clear() + }) + + it('гасит интервал и возвращает его, даже если наблюдатель ставит свой снова', () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const unbind = bindLiveRefresh(client) + const observer = new QueryObserver(client, { + queryKey: ['live-refresh'], + queryFn: async () => 'ok', + refetchInterval: 10_000, + }) + const unsubscribe = observer.subscribe(() => {}) + + setLiveRefresh(client, false) + expect(observer.options.refetchInterval).toBe(false) + + observer.setOptions({ + queryKey: ['live-refresh'], + queryFn: async () => 'ok', + refetchInterval: 10_000, + }) + expect(observer.options.refetchInterval).toBe(false) + + setLiveRefresh(client, true) + expect(observer.options.refetchInterval).toBe(10_000) + + unsubscribe() + unbind() + client.clear() + }) +}) diff --git a/apps/web/src/lib/live-refresh.ts b/apps/web/src/lib/live-refresh.ts new file mode 100644 index 0000000..1b919bc --- /dev/null +++ b/apps/web/src/lib/live-refresh.ts @@ -0,0 +1,70 @@ +import type { QueryClient } from '@tanstack/react-query' + +const STORAGE_KEY = 'cfdm.live-refresh' + +type RefetchInterval = number | false | undefined | ((query: unknown) => number | false | undefined) + +type IntervalObserver = { + options: { refetchInterval?: RefetchInterval } + setOptions: (options: IntervalObserver['options']) => void +} + +const intendedInterval = new WeakMap() + +function readStoredLiveRefresh() { + try { + return localStorage.getItem(STORAGE_KEY) !== '0' + } catch { + return true + } +} + +let liveEnabled = readStoredLiveRefresh() + +export function isLiveRefresh() { + return liveEnabled +} + +function asObserver(observer: object): IntervalObserver { + return observer as IntervalObserver +} + +function remember(observer: IntervalObserver) { + if (!intendedInterval.has(observer)) { + intendedInterval.set(observer, observer.options.refetchInterval) + } +} + +function syncObserver(observer: IntervalObserver, live: boolean) { + remember(observer) + const next = live ? intendedInterval.get(observer) : false + if (Object.is(observer.options.refetchInterval, next)) return + observer.setOptions({ ...observer.options, refetchInterval: next }) +} + +function applyLiveRefresh(queryClient: QueryClient, live: boolean) { + for (const query of queryClient.getQueryCache().getAll()) { + for (const observer of query.observers) { + syncObserver(asObserver(observer), live) + } + } +} + +/** Пауза и возобновление уже заданных refetchInterval. Новые запросы без интервала не запускают опрос. */ +export function setLiveRefresh(queryClient: QueryClient, enabled: boolean) { + liveEnabled = enabled + try { + localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0') + } catch { + // приватный режим без хранилища — переключатель живёт до перезагрузки + } + applyLiveRefresh(queryClient, enabled) +} + +export function bindLiveRefresh(queryClient: QueryClient) { + applyLiveRefresh(queryClient, liveEnabled) + return queryClient.getQueryCache().subscribe((event) => { + if (event.type !== 'observerAdded' && event.type !== 'observerOptionsUpdated') return + syncObserver(asObserver(event.observer), liveEnabled) + }) +} diff --git a/apps/web/src/lib/system-attention.test.ts b/apps/web/src/lib/system-attention.test.ts new file mode 100644 index 0000000..aa809a5 --- /dev/null +++ b/apps/web/src/lib/system-attention.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { deriveSystemAttention, isCertExpiringWithin } from './system-attention' + +const NOW = Date.parse('2026-11-02T00:00:00.000Z') + +function inDays(days: number) { + return new Date(NOW + days * 86_400_000).toISOString() +} + +describe('deriveSystemAttention', () => { + it('считает Down и деградацию в группах и без группы', () => { + const attention = deriveSystemAttention({ + now: NOW, + readyIsSuccess: true, + readyIsError: false, + readyStatus: 'ready', + services: { + groups: [ + { + services: [ + { id: 1, name: 'Альфа', health_status: 'down' }, + { id: 2, name: 'Бета', health_status: 'up' }, + ], + }, + ], + ungrouped: [{ id: 3, name: 'Гамма', health_status: 'degraded' }], + }, + }) + + expect(attention.servicesDown).toBe(1) + expect(attention.servicesDegraded).toBe(1) + expect(attention.incidents.map((item) => item.title)).toEqual(['Альфа', 'Гамма']) + expect(attention.incidents[0]).toMatchObject({ + tone: 'destructive', + href: { to: '/services/$serviceId', serviceId: '1' }, + }) + }) + + it('берёт сертификаты с остатком не больше 14 дней, включая истёкшие', () => { + expect(isCertExpiringWithin(inDays(14), 14, NOW)).toBe(true) + expect(isCertExpiringWithin(inDays(14.01), 14, NOW)).toBe(false) + expect(isCertExpiringWithin(inDays(-1), 14, NOW)).toBe(true) + + const attention = deriveSystemAttention({ + now: NOW, + readyIsSuccess: false, + readyIsError: false, + certificates: [ + { id: 10, hostname: 'soon.example', expires_at: inDays(3), service_id: 7 }, + { id: 11, hostname: 'later.example', expires_at: inDays(20), service_id: 7 }, + { id: 12, hostname: 'dead.example', expires_at: inDays(-2), service_id: null }, + { id: 13, hostname: 'unknown.example', expires_at: null }, + ], + certSummary: [ + ['warning', 2], + ['expired', 1], + ['ok', 4], + ], + notifications: [ + { kind: 'sync', title: 'Sync failed', message: 'timeout' }, + { kind: 'info', title: 'Готово', message: 'ok' }, + ], + domains: [{ group_id: null }, { group_id: 1 }], + }) + + expect(attention.certsExpiring).toBe(2) + expect(attention.certWarnings).toBe(3) + expect(attention.failedJobs).toBe(1) + expect(attention.ungroupedCount).toBe(1) + expect(attention.incidents.map((item) => item.title)).toEqual([ + 'dead.example', + 'soon.example', + ]) + expect(attention.incidents[0]?.href).toEqual({ to: '/certificates' }) + expect(attention.incidents[1]?.href).toEqual({ + to: '/services/$serviceId', + serviceId: '7', + }) + }) +}) diff --git a/apps/web/src/lib/system-attention.ts b/apps/web/src/lib/system-attention.ts new file mode 100644 index 0000000..df589b9 --- /dev/null +++ b/apps/web/src/lib/system-attention.ts @@ -0,0 +1,188 @@ +/** Общее окно «истекает» для точек сайдбара и инцидентов монитора. */ +export const CERT_EXPIRING_WITHIN_DAYS = 14 + +const MS_PER_DAY = 86_400_000 + +export type AttentionService = { + id: number + name: string + health_status: string +} + +export type AttentionServiceGroups = { + groups: { services: AttentionService[] }[] + ungrouped: AttentionService[] +} + +export type AttentionCertificate = { + id: number + hostname: string + expires_at: string | null + service_id?: number | null +} + +export type AttentionNotification = { + kind: string + title: string + message: string +} + +export type AttentionDomain = { + group_id: number | null +} + +export type SystemIncident = { + id: string + title: string + detail: string + tone: 'destructive' | 'warning' + href: + | { to: '/services/$serviceId'; serviceId: string } + | { to: '/certificates' } +} + +export type SystemAttention = { + servicesDown: number + servicesDegraded: number + certWarnings: number + certsExpiring: number + failedJobs: number + ungroupedCount: number + readyOk: boolean + readyDegraded: boolean + incidents: SystemIncident[] +} + +type DeriveInput = { + services?: AttentionServiceGroups + certificates?: AttentionCertificate[] + certSummary?: [string, number][] + notifications?: AttentionNotification[] + domains?: AttentionDomain[] + readyStatus?: string + readyIsSuccess: boolean + readyIsError: boolean + now?: number +} + +function countByStatus( + summary: [string, number][] | undefined, + statuses: string[], +) { + if (!summary) return 0 + return summary + .filter(([status]) => statuses.includes(status)) + .reduce((sum, [, count]) => sum + count, 0) +} + +function daysUntilExpiry(expiresAt: string, now: number): number | null { + const ts = new Date(expiresAt).getTime() + if (Number.isNaN(ts)) return null + return (ts - now) / MS_PER_DAY +} + +/** Истёкшие тоже входят: дней до срока ≤ 14, включая отрицательные. */ +export function isCertExpiringWithin( + expiresAt: string | null, + withinDays: number, + now: number, +) { + if (!expiresAt) return false + const days = daysUntilExpiry(expiresAt, now) + return days != null && days <= withinDays +} + +function eachService( + data: AttentionServiceGroups | undefined, + visit: (service: AttentionService) => void, +) { + if (!data) return + for (const group of data.groups) { + for (const service of group.services) visit(service) + } + for (const service of data.ungrouped) visit(service) +} + +function certDetail(days: number) { + if (days < 0) return 'Истёк' + if (days < 1) return 'Сегодня' + return `${Math.ceil(days)} дн.` +} + +function compareRu(a: string, b: string) { + return a.localeCompare(b, 'ru') +} + +export function deriveSystemAttention(input: DeriveInput): SystemAttention { + const now = input.now ?? Date.now() + let servicesDown = 0 + let servicesDegraded = 0 + const serviceIncidents: SystemIncident[] = [] + + eachService(input.services, (service) => { + if (service.health_status === 'down') { + servicesDown += 1 + serviceIncidents.push({ + id: `service:${service.id}`, + title: service.name || `Сервис ${service.id}`, + detail: 'Недоступен', + tone: 'destructive', + href: { to: '/services/$serviceId', serviceId: String(service.id) }, + }) + return + } + if (service.health_status === 'degraded') { + servicesDegraded += 1 + serviceIncidents.push({ + id: `service:${service.id}`, + title: service.name || `Сервис ${service.id}`, + detail: 'Деградация', + tone: 'warning', + href: { to: '/services/$serviceId', serviceId: String(service.id) }, + }) + } + }) + + serviceIncidents.sort((a, b) => { + if (a.tone !== b.tone) return a.tone === 'destructive' ? -1 : 1 + return compareRu(a.title, b.title) + }) + + const certIncidents: { incident: SystemIncident; days: number }[] = [] + for (const cert of input.certificates ?? []) { + if (!cert.expires_at) continue + const days = daysUntilExpiry(cert.expires_at, now) + if (days == null || days > CERT_EXPIRING_WITHIN_DAYS) continue + const serviceId = cert.service_id + certIncidents.push({ + days, + incident: { + id: `cert:${cert.id}`, + title: cert.hostname, + detail: certDetail(days), + tone: days < 0 ? 'destructive' : 'warning', + href: + serviceId != null + ? { to: '/services/$serviceId', serviceId: String(serviceId) } + : { to: '/certificates' }, + }, + }) + } + certIncidents.sort((a, b) => a.days - b.days || compareRu(a.incident.title, b.incident.title)) + + const failedJobs = (input.notifications ?? []).filter((entry) => + /fail|error/i.test(`${entry.kind} ${entry.title} ${entry.message}`), + ).length + + return { + servicesDown, + servicesDegraded, + certWarnings: countByStatus(input.certSummary, ['warning', 'expired', 'error']), + certsExpiring: certIncidents.length, + failedJobs, + ungroupedCount: (input.domains ?? []).filter((domain) => domain.group_id == null).length, + readyOk: input.readyStatus === 'ready' && input.readyIsSuccess, + readyDegraded: input.readyStatus === 'degraded' || input.readyIsError, + incidents: [...serviceIncidents, ...certIncidents.map((item) => item.incident)], + } +} diff --git a/apps/web/src/queries/services.ts b/apps/web/src/queries/services.ts index 6f26953..7d90e5b 100644 --- a/apps/web/src/queries/services.ts +++ b/apps/web/src/queries/services.ts @@ -1,4 +1,4 @@ -import { queryOptions } from '@tanstack/react-query' +import { queryOptions, type QueryClient } from '@tanstack/react-query' import { api } from '@/lib/api-client' import { failoverLogSchema, @@ -210,3 +210,17 @@ export async function createOriginHealthCheck(body: Record) { export async function listOriginHealthChecks() { return api.get('/api/v1/health-checks') } + +export function invalidateServiceDetail(queryClient: QueryClient, id: number) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: serviceKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }), + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }), + queryClient.invalidateQueries({ queryKey: ['domains'] }), + queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }), + queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }), + queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }), + queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }), + queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }), + ]) +} diff --git a/apps/web/src/routes/_auth/certificates.tsx b/apps/web/src/routes/_auth/certificates.tsx index af07f46..9d8887d 100644 --- a/apps/web/src/routes/_auth/certificates.tsx +++ b/apps/web/src/routes/_auth/certificates.tsx @@ -1,12 +1,16 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useCallback, useState } from 'react' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useCallback, useMemo, useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { CircleAlertIcon, SearchIcon, ShieldCheckIcon } from 'lucide-react' import { toast } from 'sonner' import type { Filter } from '@/components/reui/filters' import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries' import { api } from '@/lib/api-client' +import { sqliteUtcToIso } from '@/lib/format' +import type { Certificate } from '@/lib/schemas' import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' +import { IncidentAlert } from '@/components/incident-alert' import { ResourcePage } from '@/components/reui-kit' import { CertKpiStats } from '@/components/cert-kpi-stats' import { @@ -18,6 +22,7 @@ import { useCertFilterFields, } from '@/components/columns/certificates-columns' import { LoadingButton } from '@/components/loading-button' +import { Button } from '@cfdm/ui/components/button' export const Route = createFileRoute('/_auth/certificates')({ loader: ({ context: { queryClient } }) => @@ -29,6 +34,7 @@ export const Route = createFileRoute('/_auth/certificates')({ }) function CertificatesPage() { + const navigate = useNavigate() const [filters, setFilters] = useState(createDefaultCertFilters) const [activeTab, setActiveTab] = useState('all') const queryClient = useQueryClient() @@ -59,6 +65,7 @@ function CertificatesPage() { const filterFields = useCertFilterFields() const columns = useCertificateColumns() + const expiredCert = useMemo(() => pickHeaviestExpired(certs), [certs]) const primaryAction = ( + {expiredCert ? ( + showCertificate(expiredCert, navigate, setActiveTab)} + /> + ) : null} refetch()}> + Повторить + + ), + }} + filteredEmptyState={{ + icon: SearchIcon, + title: 'Нет совпадений', + description: 'Измените поиск или сбросьте фильтры.', + resetLabel: 'Сбросить фильтры', + }} + onFilteredEmptyReset={() => setActiveTab('all')} emptyState={{ - title: 'Сертификаты не найдены', - description: 'Сертификаты появятся после проверки доменов', - action: primaryAction, + icon: ShieldCheckIcon, + title: 'Нет данных', + description: 'Сертификаты появятся после первой проверки сервисов.', + action: ( + + ), }} /> ) } + +function pickHeaviestExpired( + certs: Certificate[] | undefined, +): Certificate | null { + const expired = (certs ?? []).filter((cert) => cert.status === 'expired') + if (expired.length === 0) return null + return [...expired].sort((left, right) => { + const delta = expiryMs(left.expires_at) - expiryMs(right.expires_at) + if (delta !== 0) return delta + return left.hostname.localeCompare(right.hostname, 'ru') + })[0] +} + +function expiryMs(value: string | null): number { + if (!value) return Number.POSITIVE_INFINITY + const ms = new Date(sqliteUtcToIso(value) ?? value).getTime() + return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms +} + +function expiredCertTitle(cert: Certificate): string { + const ms = expiryMs(cert.expires_at) + if (!Number.isFinite(ms)) return `Сертификат ${cert.hostname} истёк.` + const days = Math.floor((Date.now() - ms) / 86_400_000) + if (days <= 0) return `Сертификат ${cert.hostname} истёк.` + return `Сертификат ${cert.hostname} истёк ${days} ${pluralDays(days)} назад.` +} + +function expiredCertDescription(cert: Certificate): string | undefined { + const parts = [ + cert.service_name ? `Сервис ${cert.service_name}` : null, + cert.last_error, + ].filter((part): part is string => Boolean(part)) + if (parts.length === 0) return undefined + return `${parts.join('. ')}.` +} + +function pluralDays(days: number): string { + const mod10 = days % 10 + const mod100 = days % 100 + if (mod10 === 1 && mod100 !== 11) return 'день' + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'дня' + return 'дней' +} + +function showCertificate( + cert: Certificate, + navigate: ReturnType, + setActiveTab: (tabId: string) => void, +): void { + if (cert.service_id != null) { + navigate({ + to: '/services/$serviceId', + params: { serviceId: String(cert.service_id) }, + }) + return + } + setActiveTab('expired') +} diff --git a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx index 56cafd7..31253da 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useForm, Controller } from 'react-hook-form' @@ -10,7 +10,8 @@ import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas' import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, domainServiceBindingsQueryOptions, subdomainKeys } from '@/queries' import { useDomainHealthByIp } from '@/hooks/use-domain-health' import { PageShell } from '@/components/page-shell' -import { DetailPanel, ResourcePage } from '@/components/reui-kit' +import { ResourcePage } from '@/components/reui-kit' +import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome' import { DNS_TABS, createDefaultDnsFilters, @@ -154,77 +155,63 @@ function DnsPage() { return list.filter((r) => r.name.toLowerCase().includes(query)) }, [records, host]) - const headerActions = ( - <> - - syncMutation.mutate()} - isLoading={syncMutation.isPending} - loadingLabel="Синхронизация…" - > - Синхронизировать с Cloudflare - - - ) - return ( - - - - - - - ({ ...tab }))} - tabFilter={dnsTabFilter} - filterFields={filterFields} - filters={filters} - onFiltersChange={setFilters} - onClearFilters={() => setFilters(createDefaultDnsFilters())} - getFilterFieldValue={dnsFilterFieldValue} - columns={columns} - data={displayRecords} - getRowId={(row) => String(row.id)} - isLoading={isLoading} - isError={isError} - error={error} - onRetry={() => { - void refetchDomain() - void refetchRecords() - }} - emptyState={{ - title: 'DNS-записи не найдены', - description: 'Синхронизируйте зону или создайте запись вручную', - action: ( - - ), - }} - /> - - + + {() => ( +
+ {host ? ( +

Фильтр по хосту: {host}

+ ) : null} + ({ ...tab }))} + tabFilter={dnsTabFilter} + filterFields={filterFields} + filters={filters} + onFiltersChange={setFilters} + onClearFilters={() => setFilters(createDefaultDnsFilters())} + getFilterFieldValue={dnsFilterFieldValue} + columns={columns} + data={displayRecords} + getRowId={(row) => String(row.id)} + isLoading={isLoading} + isError={isError} + error={error} + onRetry={() => { + void refetchDomain() + void refetchRecords() + }} + toolbarExtra={ + <> + + syncMutation.mutate()} + isLoading={syncMutation.isPending} + loadingLabel="Синхронизация…" + > + Синхронизировать с Cloudflare + + + } + emptyState={{ + title: 'DNS-записи не найдены', + description: 'Синхронизируйте зону или создайте запись вручную', + action: ( + + ), + }} + /> +
+ )} +
): { tab?: DomainSectionTab } => { + const tab = search.tab + if ( + tab === 'availability' || + tab === 'subdomains' || + tab === 'bindings' || + tab === 'overview' + ) { + return { tab } + } + return {} + }, loader: async ({ context: { queryClient }, params }) => { const id = Number(params.domainId) const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id)) @@ -61,70 +57,28 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({ component: DomainOverviewPage, }) -function DomainPageSkeleton() { - return ( -
- -
- - - -
- -
- ) -} - function DomainOverviewPage() { const { domainId } = Route.useParams() + const { tab } = Route.useSearch() const id = Number(domainId) - const queryClient = useQueryClient() const [filters, setFilters] = useState(createDefaultSubdomainFilters) - const [sheetOpen, setSheetOpen] = useState(false) - const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create') + const [editOpen, setEditOpen] = useState(false) const [editTarget, setEditTarget] = useState(null) - const [activeTab, setActiveTab] = useState('overview') const { domain, - bindings, services, serviceGroupById, subdomainRows, - isLoading, - isError, - error, - refetch, - syncMutation, - createSubdomainMutation, + bindings, updateSubdomainMutation, deleteSubdomainMutation, linkServiceMutation, } = useDomainPage(id) - const runCheckMutation = useMutation({ - mutationFn: runHealthCheck, - onSuccess: async (data) => { - toast.success(`Проверено IP: ${data.checked}`) - await queryClient.invalidateQueries({ queryKey: healthStatusKeys.all }) - }, - onError: (err) => { - toast.error( - err instanceof Error ? err.message : 'Не удалось запустить проверку', - ) - }, - }) - - function openCreateSheet() { - setSheetMode('create') - setEditTarget(null) - setSheetOpen(true) - } - function openEditSheet(row: SubdomainTableRow) { - setSheetMode('edit') setEditTarget(row) - setSheetOpen(true) + setEditOpen(true) } const filterFields = useSubdomainFilterFields() @@ -146,13 +100,7 @@ function DomainOverviewPage() { return String(row.serviceLinks[0].serviceId) } - async function handleSheetSubmit(values: SubdomainEditValues) { - if (sheetMode === 'create') { - await createSubdomainMutation.mutateAsync(values.name) - setSheetOpen(false) - return - } - + async function handleEditSubmit(values: SubdomainEditValues) { if (!editTarget) return const nameChanged = values.name !== editTarget.subdomain.name @@ -185,207 +133,69 @@ function DomainOverviewPage() { }) } - setSheetOpen(false) + setEditOpen(false) } const isSheetSaving = - createSubdomainMutation.isPending || - updateSubdomainMutation.isPending || - linkServiceMutation.isPending + updateSubdomainMutation.isPending || linkServiceMutation.isPending - const metricCards = useMemo(() => { - if (!domain) return [] - return [ - { - id: 'status', - icon:
+ ), + }} + filteredEmptyState={{ + icon: SearchIcon, + title: 'Нет совпадений', + description: 'Измените поиск или сбросьте фильтры.', + resetLabel: 'Сбросить фильтры', + }} + onFilteredEmptyReset={() => setActiveTab('all')} enableRowSelection selectionToolbar={({ selectedIds, clearSelection }) => ( )} emptyState={{ - title: 'Домены не импортированы', - description: 'Импортируйте зону из аккаунта Cloudflare', + icon: GlobeIcon, + title: 'Нет доменов', + description: + 'Подключите токен Cloudflare в настройках и запустите синхронизацию.', action: ( - ), }} @@ -271,6 +343,17 @@ function DomainsPage() { + syncMutation.mutate()} + disabled={syncMutation.isPending} + /> + { @@ -290,3 +373,29 @@ function DomainsPage() { ) } + +function domainFreshnessLine( + domains: DomainListItem[] | undefined, +): string | null { + if (!domains) return null + const zoneCount = domains.length + let latestMs = Number.NEGATIVE_INFINITY + let latest: string | null = null + for (const domain of domains) { + if (!domain.last_synced_at) continue + const ms = new Date( + sqliteUtcToIso(domain.last_synced_at) ?? domain.last_synced_at, + ).getTime() + if (Number.isNaN(ms) || ms <= latestMs) continue + latestMs = ms + latest = domain.last_synced_at + } + if (!latest) return `Синхронизация ещё не выполнялась · зон ${zoneCount}` + return `Синхронизация ${formatDate(latest)} · зон ${zoneCount}` +} + +function cloudflareErrorDescription(error: Error | null): string { + const hint = 'Проверьте токен в «Настройки → Интеграции».' + if (!error?.message) return hint + return `${error.message} ${hint}` +} diff --git a/apps/web/src/routes/_auth/groups.tsx b/apps/web/src/routes/_auth/groups.tsx index 2c0cb74..5600477 100644 --- a/apps/web/src/routes/_auth/groups.tsx +++ b/apps/web/src/routes/_auth/groups.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import { FolderTreeIcon } from 'lucide-react' @@ -57,9 +57,17 @@ export const Route = createFileRoute('/_auth/groups')({ queryClient.ensureQueryData(domainsListQueryOptions()), queryClient.ensureQueryData(serviceBindingsQueryOptions()), ]), - component: GroupsPage, + component: GroupsRoute, }) +function GroupsRoute() { + const isDetail = useRouterState({ + select: (state) => /^\/groups\/[^/]+/.test(state.location.pathname), + }) + if (isDetail) return + return +} + function GroupsPage() { const { view: viewParam } = Route.useSearch() const view = viewParam === 'board' ? 'board' : 'catalog' diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 289a211..10dfe5a 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMemo, useState, useEffect } from 'react' +import { toast } from 'sonner' import { ActivityIcon, AlertTriangleIcon, @@ -14,39 +15,37 @@ import { import { certificatesQueryOptions, certSummaryQueryOptions, + dnsKeys, + domainKeys, domainsListQueryOptions, groupsQueryOptions, opsSummaryQueryOptions, serviceGroupsQueryOptions, + subdomainKeys, } from '@/queries' import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' +import { LoadingButton } from '@/components/loading-button' import { CertStatusChart, GroupDomainsChart, OpsDashboard, QuickActionGrid, AttentionQueue, + ExpiringCertGrid, + HealthProblemGrid, + UngroupedDomainGrid, type KpiStatCard, type QuickActionItem, } from '@/components/reui-kit' -import { HealthCheckBadge } from '@/components/health-check-badge' -import { StatusBadge } from '@/components/status-badge' -import { - Item, - ItemActions, - ItemContent, - ItemGroup, - ItemTitle, -} from '@cfdm/ui/components/item' import { Button } from '@cfdm/ui/components/button' -import { formatRelative } from '@/lib/format' import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log' import { api } from '@/lib/api-client' +import type { ServiceView } from '@/lib/schemas' export const Route = createFileRoute('/_auth/')({ loader: ({ context: { queryClient } }) => - Promise.all([ + Promise.allSettled([ queryClient.ensureQueryData(domainsListQueryOptions()), queryClient.ensureQueryData(certSummaryQueryOptions()), queryClient.ensureQueryData(groupsQueryOptions()), @@ -57,13 +56,50 @@ export const Route = createFileRoute('/_auth/')({ component: DashboardPage, }) +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : undefined +} + function DashboardPage() { - const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions()) + const queryClient = useQueryClient() + const { + data: domains, + isLoading: domainsLoading, + isError: domainsFailed, + error: domainsQueryError, + refetch: refetchDomains, + } = useQuery(domainsListQueryOptions()) const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions()) - const { data: certs } = useQuery(certificatesQueryOptions()) + const { + data: certs, + isError: certsFailed, + error: certsQueryError, + refetch: refetchCerts, + } = useQuery(certificatesQueryOptions()) const { data: groups } = useQuery(groupsQueryOptions()) - const { data: serviceData } = useQuery(serviceGroupsQueryOptions()) + const { + data: serviceData, + isError: servicesFailed, + error: servicesQueryError, + refetch: refetchServices, + } = useQuery(serviceGroupsQueryOptions()) const { data: ops } = useQuery(opsSummaryQueryOptions()) + const syncMutation = useMutation({ + mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'), + onSuccess: async () => { + toast.success('Синхронизация завершена') + await Promise.all([ + queryClient.invalidateQueries({ queryKey: domainKeys.all }), + queryClient.invalidateQueries({ queryKey: subdomainKeys.all }), + queryClient.invalidateQueries({ queryKey: dnsKeys.all }), + ]) + }, + onError: (error: unknown) => { + toast.error( + error instanceof Error ? error.message : 'Не удалось синхронизировать домены', + ) + }, + }) const { data: appSettings } = useQuery({ queryKey: ['app-settings'], queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'), @@ -147,54 +183,33 @@ function DashboardPage() { const expiringCerts = useMemo(() => { const warnMs = 14 * 24 * 60 * 60 * 1000 return (certs ?? []) - .filter((c) => c.expires_at) - .map((c) => ({ cert: c, ts: new Date(c.expires_at as string).getTime() })) - .filter((e) => !Number.isNaN(e.ts) && e.ts - nowMs <= warnMs) + .filter((cert) => cert.expires_at) + .map((cert) => ({ + cert, + ts: new Date(cert.expires_at as string).getTime(), + })) + .filter((entry) => !Number.isNaN(entry.ts) && entry.ts - nowMs <= warnMs) .sort((a, b) => a.ts - b.ts) - .slice(0, 8) + .map((entry) => entry.cert) }, [certs, nowMs]) const ungroupedDomains = useMemo( - () => (domains ?? []).filter((d) => d.group_id == null).slice(0, 5), + () => (domains ?? []).filter((domain) => domain.group_id == null), [domains], ) const attentionServices = useMemo(() => { - const list: Array<{ - id: number - name: string - health_status: string - health_latency_ms: number | null - }> = [] + const list: ServiceView[] = [] + const pushIfUnhealthy = (service: ServiceView) => { + if (service.health_status === 'down' || service.health_status === 'degraded') { + list.push(service) + } + } 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 group.services) pushIfUnhealthy(service) } - 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) + for (const service of serviceData?.ungrouped ?? []) pushIfUnhealthy(service) + return list }, [serviceData]) const attentionCount = attentionServices.length @@ -224,11 +239,13 @@ function DashboardPage() { { id: 'domains', label: 'Домены', - value: domains?.length ?? 0, + value: domainsFailed && domains == null ? '—' : (domains?.length ?? 0), hint: - ungroupedCount > 0 - ? `${ungroupedCount} без группы` - : 'Все в группах', + domainsFailed && domains == null + ? undefined + : ungroupedCount > 0 + ? `${ungroupedCount} без группы` + : 'Все в группах', icon: , iconClassName: 'text-info', variant: ungroupedCount > 0 ? 'warning' : 'default', @@ -258,30 +275,15 @@ function DashboardPage() { iconClassName: 'text-info', to: '/services', }, - { - id: 'healthy', - label: 'Healthy', - value: ops?.healthy ?? 0, - icon: , - iconClassName: 'text-success', - }, { id: 'unhealthy', label: 'Unhealthy', value: ops?.unhealthy ?? 0, + hint: `активных failover: ${ops?.active_failovers ?? 0}`, variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default', icon: , iconClassName: 'text-destructive', }, - { - id: 'failovers', - label: 'Failover', - value: ops?.active_failovers ?? 0, - hint: 'активные переключения', - variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default', - icon: , - iconClassName: 'text-warning', - }, ] return ( @@ -289,6 +291,16 @@ function DashboardPage() { syncMutation.mutate()} + isLoading={syncMutation.isPending} + loadingLabel="Синхронизация…" + > + Синхронизировать + + } /> + {/* health-log — журнал проб сервиса, не ряд Healthy/Unhealthy за 24 часа */} @@ -319,6 +332,11 @@ function DashboardPage() { countVariant: 'destructive-light', emptyTitle: 'Нет проблем', emptyDescription: 'Нет сервисов со статусом Down или Slow', + isError: servicesFailed && serviceData == null, + errorMessage: errorMessage(servicesQueryError), + onRetry: () => { + void refetchServices() + }, emptyAction: ( - - - ))} - - ), + children: , }, { id: 'certs', @@ -378,6 +358,11 @@ function DashboardPage() { countVariant: 'warning-light', emptyTitle: 'Нет истечений', emptyDescription: 'В пределах 14 дней истечений нет', + isError: certsFailed && certs == null, + errorMessage: errorMessage(certsQueryError), + onRetry: () => { + void refetchCerts() + }, emptyAction: ( - - - ))} - - ), + children: , }, { id: 'ungrouped', @@ -425,6 +384,11 @@ function DashboardPage() { countVariant: 'warning-light', emptyTitle: 'Всё сгруппировано', emptyDescription: 'Все домены в группах', + isError: domainsFailed && domains == null, + errorMessage: errorMessage(domainsQueryError), + onRetry: () => { + void refetchDomains() + }, emptyAction: ( - - - ))} - - ), + children: , }, ]} /> diff --git a/apps/web/src/routes/_auth/services/$serviceId/health.tsx b/apps/web/src/routes/_auth/services/$serviceId/health.tsx index b179199..8ad1b6f 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/health.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/health.tsx @@ -1,11 +1,12 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' + +import { ServiceHealthSection } from '@/components/services/service-health-section' export const Route = createFileRoute('/_auth/services/$serviceId/health')({ - beforeLoad: ({ params }) => { - throw redirect({ - to: '/services/$serviceId', - params, - }) - }, - component: () => null, + component: ServiceHealthPage, }) + +function ServiceHealthPage() { + const { serviceId } = Route.useParams() + return +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx index eb005d9..5be6ddb 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/index.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx @@ -1,458 +1,12 @@ -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, - GlobeIcon, - NetworkIcon, - PencilIcon, - ServerIcon, -} from 'lucide-react' +import { createFileRoute } from '@tanstack/react-router' -import { ChangeDomainSheet } from '@/components/change-domain-sheet' -import { ChangeIpSheet } from '@/components/change-ip-sheet' -import { EmptyState } from '@/components/empty-state' -import { FormFieldSimple } from '@/components/form-field' -import { FormSheet } from '@/components/form-sheet' -import { HealthCheckBadge } from '@/components/health-check-badge' -import { LoadingButton } from '@/components/loading-button' -import { PageHeader } from '@/components/page-header' -import { QueryState } from '@/components/query-state' -import { ServiceEditSheet } from '@/components/service-edit-sheet' -import { - ServiceDetailGrid, - type ServiceFqdnRow, -} from '@/components/services/service-detail-grid' -import { LbModeTile } from '@/components/services/service-unit-card' -import { - KpiStatGrid, - ServiceFailoverPanel, - ServiceHealthMonitor, -} from '@/components/reui-kit' -import { api } from '@/lib/api-client' -import { hasSharedPool, uniqueIpCount } from '@/lib/failover-events' -import { - enabledHealthProviders, - providerHealthStatuses, - resolveServiceDisplayHealth, -} from '@/lib/health-log' -import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas' -import { - createServiceNode, - deleteServiceNode, - domainKeys, - domainsListQueryOptions, - serviceBindingKeys, - serviceDetailKeys, - serviceFailoverLogQueryOptions, - serviceGroupKeys, - serviceGroupsQueryOptions, - serviceHealthLogQueryOptions, - serviceKeys, - serviceNodesQueryOptions, - serviceOverviewQueryOptions, - serviceViewQueryOptions, -} 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' +import { ServiceAssetSection } from '@/components/services/service-asset-section' export const Route = createFileRoute('/_auth/services/$serviceId/')({ component: ServiceDetailPage, }) -interface OverviewPayload { - routing_strategy?: string - active_addresses?: string[] - nodes?: Array<{ - id: number - address: string - protocol: string - port: number | null - health_status: string - weight: number - priority: number - consecutive_failures: number - last_failure_reason: string | null - last_check_at?: string | null - }> -} - function ServiceDetailPage() { const { serviceId } = Route.useParams() - const id = Number(serviceId) - const navigate = useNavigate() - const queryClient = useQueryClient() - - const viewQuery = useQuery(serviceViewQueryOptions(id)) - const overviewQuery = useQuery(serviceOverviewQueryOptions(id)) - const logQuery = useQuery(serviceHealthLogQueryOptions(id)) - const failoverLogQuery = useQuery(serviceFailoverLogQueryOptions(id)) - const nodesQuery = useQuery(serviceNodesQueryOptions(id)) - const groupsQuery = useQuery(serviceGroupsQueryOptions()) - const domainsQuery = useQuery(domainsListQueryOptions()) - - const service = viewQuery.data - const overview = overviewQuery.data as OverviewPayload | undefined - const logItems = useMemo( - () => logQuery.data?.items ?? [], - [logQuery.data?.items], - ) - const failoverHistory = useMemo( - () => failoverLogQuery.data?.items ?? [], - [failoverLogQuery.data?.items], - ) - const failoverBindings = useMemo( - () => - (service?.domains ?? []).map((domain) => ({ - fqdn: domain.fqdn, - configured: domain.target_ips, - active: domain.active_ips ?? [], - })), - [service?.domains], - ) - const uniqueEnabledIps = useMemo( - () => - (service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false), - [service?.ips, service?.ip_enabled], - ) - const displayHealth = useMemo( - () => - resolveServiceDisplayHealth( - service?.health_status, - service?.ip_health ?? [], - logItems, - ), - [service?.health_status, service?.ip_health, logItems], - ) - const showPoolPanel = - uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings) - const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? [] - - const [editOpen, setEditOpen] = useState(false) - const [saving, setSaving] = useState(false) - const [togglingIp, setTogglingIp] = useState(null) - const [changeIp, setChangeIp] = useState<{ - bindingId: number - ip?: string - } | null>(null) - const [changeDomain, setChangeDomain] = useState(false) - const [addNodeOpen, setAddNodeOpen] = useState(false) - const nodeForm = useForm<{ address: string; port: string }>({ - defaultValues: { address: '', port: '' }, - }) - - const groups = groupsQuery.data - ? [...groupsQuery.data.groups] - : [] - - async function invalidateService() { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: serviceKeys.all }), - queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }), - queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }), - queryClient.invalidateQueries({ queryKey: domainKeys.all }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }), - queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }), - ]) - } - - const updateMutation = useMutation({ - mutationFn: ({ body }: { body: UpdateServiceConfigInput }) => - api.patch(`/api/v1/services/${id}`, body), - onSuccess: async () => { - await invalidateService() - setEditOpen(false) - toast.success('Сервис сохранён') - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис') - }, - onSettled: () => setSaving(false), - }) - - const deleteMutation = useMutation({ - mutationFn: () => api.delete(`/api/v1/services/${id}`), - onSuccess: async () => { - await invalidateService() - toast.success('Сервис удалён') - await navigate({ to: '/services' }) - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис') - }, - }) - - const toggleIpMutation = useMutation({ - mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) => - api.patch(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }), - onSuccess: async (_data, { enabled }) => { - await invalidateService() - toast.success( - enabled - ? 'IP включён и добавлен в DNS-привязки' - : 'IP выключен и снят с DNS-привязок', - ) - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP') - }, - onSettled: () => setTogglingIp(null), - }) - - const createNodeMut = useMutation({ - mutationFn: (values: { address: string; port: string }) => - createServiceNode(id, { - address: values.address.trim(), - port: values.port ? Number(values.port) : null, - }), - onSuccess: async () => { - toast.success('Нода добавлена, статус CHECKING') - await invalidateService() - setAddNodeOpen(false) - nodeForm.reset() - }, - onError: (e: unknown) => - toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'), - }) - - const deleteNodeMut = useMutation({ - mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId), - onSuccess: async () => { - toast.success('Нода удалена') - await invalidateService() - }, - }) - - const isLoading = viewQuery.isLoading || overviewQuery.isLoading - const isError = viewQuery.isError || overviewQuery.isError - const error = viewQuery.error ?? overviewQuery.error - - const enabledProviders = useMemo( - () => enabledHealthProviders(service?.domains ?? []), - [service], - ) - const providerStatuses = useMemo( - () => providerHealthStatuses(logItems, enabledProviders), - [logItems, enabledProviders], - ) - - return ( - { - void viewQuery.refetch() - void overviewQuery.refetch() - }} - > - {!service ? ( - - ) : ( -
- - = 2} - /> - - - setEditOpen(true)} - /> - } - > - - Изменить - - - } - /> - - , - label: 'Статус', - value: displayHealth === 'up' ? 'OK' : displayHealth, - variant: - displayHealth === 'down' - ? 'destructive' - : displayHealth === 'degraded' - ? 'warning' - : 'default', - iconClassName: - displayHealth === 'down' - ? 'text-destructive' - : displayHealth === 'degraded' - ? 'text-warning' - : 'text-success', - hint: , - }, - { - id: 'fqdn', - icon: , - label: 'FQDN', - value: String(service.domains.length), - hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN', - }, - { - id: 'ip', - icon: , - label: 'IP', - value: String(service.ips.length), - hint: showPoolPanel - ? `${service.active_ips.length} в пуле` - : uniqueEnabledIps.length <= 1 - ? 'без балансировки' - : service.ips.join(', ') || 'нет', - }, - { - id: 'pool', - icon: , - label: showPoolPanel ? 'Активный пул' : 'Адреса', - value: String( - (overview?.active_addresses ?? service.active_ips).length, - ), - hint: - (overview?.active_addresses ?? service.active_ips).join(', ') || - 'нет', - }, - ]} - /> - -
- - {showPoolPanel ? ( - = 2} - ipHealth={service.ip_health} - bindings={failoverBindings} - history={failoverHistory} - probes={logItems} - /> - ) : null} -
- - {service.ips.length === 0 && service.domains.length === 0 ? ( - - ) : ( - { - setTogglingIp(ip) - toggleIpMutation.mutate({ ip, enabled }) - }} - onChangeIp={(row: ServiceFqdnRow) => - setChangeIp({ - bindingId: row.binding_id, - ip: row.target_ips[0], - }) - } - onChangeDomain={() => setChangeDomain(true)} - onAddNode={() => setAddNodeOpen(true)} - onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)} - isLoading={nodesQuery.isLoading} - /> - )} - - { - setSaving(true) - updateMutation.mutate({ body }) - }} - onDelete={() => deleteMutation.mutate()} - /> - { - if (!open) setChangeIp(null) - }} - bindingId={changeIp?.bindingId ?? null} - serviceId={id} - currentIp={changeIp?.ip} - /> - - createNodeMut.mutate(values)} - footer={ - - Добавить - - } - > - - - - - - - -
- )} -
- ) + return } diff --git a/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx b/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx index 68a6709..0ca2705 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx @@ -1,11 +1,12 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' + +import { ServiceAssetSection } from '@/components/services/service-asset-section' export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({ - beforeLoad: ({ params }) => { - throw redirect({ - to: '/services/$serviceId', - params, - }) - }, - component: () => null, + component: ServiceNodesPage, }) + +function ServiceNodesPage() { + const { serviceId } = Route.useParams() + return +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/route.tsx b/apps/web/src/routes/_auth/services/$serviceId/route.tsx index 10e8ab8..37f3c99 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/route.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/route.tsx @@ -1,12 +1,42 @@ -import { createFileRoute, Outlet } from '@tanstack/react-router' +import { createFileRoute, Link, Outlet, useNavigate, useRouterState } 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, GlobeIcon, NetworkIcon, ServerIcon } from 'lucide-react' + +import { ChangeDomainSheet } from '@/components/change-domain-sheet' +import { ChangeIpSheet } from '@/components/change-ip-sheet' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { EmptyState } from '@/components/empty-state' +import { FormFieldSimple } from '@/components/form-field' +import { FormSheet } from '@/components/form-sheet' +import { HealthCheckBadge } from '@/components/health-check-badge' +import { LoadingButton } from '@/components/loading-button' +import { QueryState } from '@/components/query-state' +import { ServiceEditSheet } from '@/components/service-edit-sheet' +import type { ServiceFqdnRow } from '@/components/services/service-detail-grid' +import { ServiceDetailActionsProvider } from '@/components/services/service-detail-context' import { PageShell } from '@/components/page-shell' +import { DetailPanel, KpiStatGrid } from '@/components/reui-kit' +import { api } from '@/lib/api-client' +import { hasSharedPool } from '@/lib/failover-events' +import { resolveServiceDisplayHealth } from '@/lib/health-log' +import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas' import { + createServiceNode, + domainsListQueryOptions, + invalidateServiceDetail, serviceFailoverLogQueryOptions, + serviceGroupsQueryOptions, serviceHealthLogQueryOptions, serviceNodesQueryOptions, serviceOverviewQueryOptions, serviceViewQueryOptions, } from '@/queries' +import { Button } from '@cfdm/ui/components/button' +import { Input } from '@cfdm/ui/components/input' +import { cn } from '@cfdm/ui/lib/utils' export const Route = createFileRoute('/_auth/services/$serviceId')({ loader: async ({ context: { queryClient }, params }) => { @@ -23,10 +53,336 @@ export const Route = createFileRoute('/_auth/services/$serviceId')({ component: ServiceLayout, }) +const SECTION_TABS = [ + { id: 'nodes', label: 'Ноды', to: '/services/$serviceId/nodes' }, + { id: 'health', label: 'Health', to: '/services/$serviceId/health' }, + { id: 'routing', label: 'Маршрутизация', to: '/services/$serviceId/routing' }, + { id: 'subdomains', label: 'Поддомены', to: '/services/$serviceId/subdomains' }, +] as const + +function activeSection(pathname: string, serviceId: string) { + const path = pathname.replace(/\/$/, '') + const base = `/services/${serviceId}` + if (path === `${base}/health`) return 'health' + if (path === `${base}/routing`) return 'routing' + if (path === `${base}/subdomains`) return 'subdomains' + return 'nodes' +} + +/** + * Общая полоса вкладок карточки сервиса поверх существующих маршрутов. + * Preview: https://reui.io/preview/base/stats-12 + * Preview: https://reui.io/preview/base/form-7 + * Preview: https://reui.io/docs/components/base/timeline + * Docs: https://reui.io/docs/components/base/frame + */ function ServiceLayout() { + const { serviceId } = Route.useParams() + const id = Number(serviceId) + const navigate = useNavigate() + const queryClient = useQueryClient() + const pathname = useRouterState({ select: (state) => state.location.pathname }) + const section = activeSection(pathname, serviceId) + + const viewQuery = useQuery(serviceViewQueryOptions(id)) + const logQuery = useQuery(serviceHealthLogQueryOptions(id)) + const nodesQuery = useQuery(serviceNodesQueryOptions(id)) + const groupsQuery = useQuery(serviceGroupsQueryOptions()) + const domainsQuery = useQuery(domainsListQueryOptions()) + + const service = viewQuery.data + const logItems = useMemo(() => logQuery.data?.items ?? [], [logQuery.data?.items]) + const nodeCount = Array.isArray(nodesQuery.data) ? nodesQuery.data.length : undefined + + const displayHealth = useMemo( + () => + resolveServiceDisplayHealth( + service?.health_status, + service?.ip_health ?? [], + logItems, + ), + [service?.health_status, service?.ip_health, logItems], + ) + const failoverBindings = useMemo( + () => + (service?.domains ?? []).map((domain) => ({ + fqdn: domain.fqdn, + configured: domain.target_ips, + active: domain.active_ips ?? [], + })), + [service?.domains], + ) + const uniqueEnabledIps = useMemo( + () => (service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false), + [service?.ips, service?.ip_enabled], + ) + const showPoolPanel = + uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings) + + const [editOpen, setEditOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [addNodeOpen, setAddNodeOpen] = useState(false) + const [changeIp, setChangeIp] = useState<{ bindingId: number; ip?: string } | null>(null) + const [changeDomain, setChangeDomain] = useState(false) + const nodeForm = useForm<{ address: string; port: string }>({ + defaultValues: { address: '', port: '' }, + }) + + const groups = groupsQuery.data ? [...groupsQuery.data.groups] : [] + + const updateMutation = useMutation({ + mutationFn: ({ body }: { body: UpdateServiceConfigInput }) => + api.patch(`/api/v1/services/${id}`, body), + onSuccess: async () => { + await invalidateServiceDetail(queryClient, id) + setEditOpen(false) + toast.success('Сервис сохранён') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис') + }, + onSettled: () => setSaving(false), + }) + + const deleteMutation = useMutation({ + mutationFn: () => api.delete(`/api/v1/services/${id}`), + onSuccess: async () => { + await invalidateServiceDetail(queryClient, id) + toast.success('Сервис удалён') + await navigate({ to: '/services' }) + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис') + }, + }) + + const disableMutation = useMutation({ + mutationFn: () => api.patch(`/api/v1/services/${id}/toggle`, { enabled: false }), + onSuccess: async () => { + await invalidateServiceDetail(queryClient, id) + toast.success('Сервис выключен') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось выключить сервис') + }, + }) + + const createNodeMut = useMutation({ + mutationFn: (values: { address: string; port: string }) => + createServiceNode(id, { + address: values.address.trim(), + port: values.port ? Number(values.port) : null, + }), + onSuccess: async () => { + toast.success('Нода добавлена, статус CHECKING') + await invalidateServiceDetail(queryClient, id) + setAddNodeOpen(false) + nodeForm.reset() + }, + onError: (err: unknown) => + toast.error(err instanceof Error ? err.message : 'Не удалось добавить ноду'), + }) + + const counts: Record<(typeof SECTION_TABS)[number]['id'], number | undefined> = { + nodes: nodeCount, + health: undefined, + routing: service?.ips.length, + subdomains: service?.domains.length, + } + return ( - + { + void viewQuery.refetch() + }} + > + {!service ? ( + + ) : ( + setAddNodeOpen(true), + openChangeIp: (row: ServiceFqdnRow) => + setChangeIp({ bindingId: row.binding_id, ip: row.target_ips[0] }), + openChangeDomain: () => setChangeDomain(true), + }} + > + + + + {service.enabled ? ( + + Выключить + + } + onConfirm={() => disableMutation.mutate()} + /> + ) : null} + + + } + /> + + , + label: 'Статус', + value: displayHealth === 'up' ? 'OK' : displayHealth, + variant: + displayHealth === 'down' + ? 'destructive' + : displayHealth === 'degraded' + ? 'warning' + : 'default', + iconClassName: + displayHealth === 'down' + ? 'text-destructive' + : displayHealth === 'degraded' + ? 'text-warning' + : 'text-success', + hint: , + }, + { + id: 'fqdn', + icon: , + label: 'FQDN', + value: String(service.domains.length), + hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN', + }, + { + id: 'ip', + icon: , + label: 'IP', + value: String(service.ips.length), + hint: showPoolPanel + ? `${service.active_ips.length} в пуле` + : uniqueEnabledIps.length <= 1 + ? 'без балансировки' + : service.ips.join(', ') || 'нет', + }, + { + id: 'pool', + icon: , + label: showPoolPanel ? 'Активный пул' : 'Адреса', + value: String(service.active_ips.length), + hint: service.active_ips.join(', ') || 'нет', + }, + ]} + /> + + + + + + + { + setSaving(true) + updateMutation.mutate({ body }) + }} + onDelete={() => deleteMutation.mutate()} + /> + { + if (!open) setChangeIp(null) + }} + bindingId={changeIp?.bindingId ?? null} + serviceId={id} + currentIp={changeIp?.ip} + /> + + createNodeMut.mutate(values)} + footer={ + + Добавить + + } + > + + + + + + + + + )} + ) } diff --git a/apps/web/src/routes/_auth/services/$serviceId/routing.tsx b/apps/web/src/routes/_auth/services/$serviceId/routing.tsx index d820664..b7daef0 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/routing.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/routing.tsx @@ -1,11 +1,12 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' + +import { ServiceRoutingSection } from '@/components/services/service-routing-section' export const Route = createFileRoute('/_auth/services/$serviceId/routing')({ - beforeLoad: ({ params }) => { - throw redirect({ - to: '/services/$serviceId', - params, - }) - }, - component: () => null, + component: ServiceRoutingPage, }) + +function ServiceRoutingPage() { + const { serviceId } = Route.useParams() + return +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx b/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx index 9fafbf7..df143db 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx @@ -1,11 +1,17 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' + +import { ServiceAssetSection } from '@/components/services/service-asset-section' export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({ - beforeLoad: ({ params }) => { - throw redirect({ - to: '/services/$serviceId', - params, - }) - }, - component: () => null, + component: ServiceSubdomainsPage, }) + +function ServiceSubdomainsPage() { + const { serviceId } = Route.useParams() + return ( +
+ + +
+ ) +} diff --git a/apps/web/src/routes/_auth/services/index.tsx b/apps/web/src/routes/_auth/services/index.tsx index 15d8547..c0df894 100644 --- a/apps/web/src/routes/_auth/services/index.tsx +++ b/apps/web/src/routes/_auth/services/index.tsx @@ -12,6 +12,8 @@ import { serviceKeys, } from '@/queries' import { api } from '@/lib/api-client' +import { formatDate, sqliteUtcToIso } from '@/lib/format' +import { serviceDisplayFqdns } from '@/lib/service-utils' import type { CreateServiceGroupInput, CreateServiceWithConfigInput, @@ -23,6 +25,7 @@ import type { import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' import { ServiceKpiStats } from '@/components/service-kpi-stats' +import { IncidentAlert } from '@/components/incident-alert' import { EmptyState } from '@/components/empty-state' import { ConfirmDialog } from '@/components/confirm-dialog' import { ServiceEditSheet } from '@/components/service-edit-sheet' @@ -223,6 +226,10 @@ function ServicesPage() { () => flattenServices(data, domainId), [data, domainId], ) + const downService = useMemo( + () => pickHeaviestDown(flattenServices(data)), + [data], + ) useEffect(() => { if (serviceId == null || !data || editingService) return @@ -707,7 +714,9 @@ function ServicesPage() { refetch()}> Повторить @@ -726,6 +735,18 @@ function ServicesPage() { description={pageDescription} actions={headerActions} /> + {downService ? ( + + navigate({ + to: '/services/$serviceId', + params: { serviceId: String(downService.id) }, + }) + } + /> + ) : null} {view === 'board' ? ( <> - {addMenu} -
+ } /> ) : ( @@ -800,9 +825,13 @@ function ServicesPage() { onDeleteGroup={setDeletingGroup} onAddServiceToGroup={handleOpenCreateService} emptyAction={ -
- {addMenu} -
+ } /> @@ -811,3 +840,60 @@ function ServicesPage() { ) } + +function pickHeaviestDown(rows: ServiceCatalogRow[]): ServiceView | null { + const downs = rows + .map((row) => row.service) + .filter((service) => service.health_status === 'down') + if (downs.length === 0) return null + return downs.sort((left, right) => { + const weight = downWeight(right) - downWeight(left) + if (weight !== 0) return weight + return left.name.localeCompare(right.name, 'ru') + })[0] +} + +function downWeight(service: ServiceView): number { + const rows = service.ip_health ?? [] + const down = rows.filter((row) => row.status === 'down').length + const allDown = rows.length > 0 && down === rows.length + return (allDown ? 1000 : 0) + down +} + +function downServiceTitle(service: ServiceView): string { + const rows = service.ip_health ?? [] + const allDown = + rows.length > 0 && rows.every((row) => row.status === 'down') + return allDown + ? `${service.name}: все ноды недоступны.` + : `${service.name} недоступен.` +} + +function downServiceDescription(service: ServiceView): string { + const fqdn = serviceDisplayFqdns(service).find((value) => value && value !== '—') + const checkedAt = latestCheck(service) + const errorText = (service.ip_health ?? []).find( + (row) => row.status === 'down' && row.last_error, + )?.last_error + const parts = [ + fqdn, + checkedAt ? `последняя проверка ${formatDate(checkedAt)}` : null, + errorText, + ].filter((part): part is string => Boolean(part)) + return parts.join(' · ') || 'Статус Down.' +} + +function latestCheck(service: ServiceView): string | null { + let latestMs = Number.NEGATIVE_INFINITY + let latest: string | null = null + for (const row of service.ip_health ?? []) { + if (!row.last_checked_at) continue + const ms = new Date( + sqliteUtcToIso(row.last_checked_at) ?? row.last_checked_at, + ).getTime() + if (Number.isNaN(ms) || ms <= latestMs) continue + latestMs = ms + latest = row.last_checked_at + } + return latest +} diff --git a/apps/web/src/routes/_auth/settings/health.tsx b/apps/web/src/routes/_auth/settings/health.tsx index 95dc9db..a50c670 100644 --- a/apps/web/src/routes/_auth/settings/health.tsx +++ b/apps/web/src/routes/_auth/settings/health.tsx @@ -5,9 +5,10 @@ import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' -import { HeartPulseIcon, GlobeIcon } from 'lucide-react' +import { HeartPulseIcon, GlobeIcon, TriangleAlertIcon } from 'lucide-react' import { api } from '@/lib/api-client' +import { ConfirmDialog } from '@/components/confirm-dialog' import { SettingRow } from '@/components/setting-row' import { LoadingButton } from '@/components/loading-button' import { @@ -57,6 +58,33 @@ type FormValues = z.infer type GlobalpingValues = z.infer type HealthWorkerStatus = 'missing' | 'ready' | 'error' +function toHealthFormValues(data: { + healthCheckCron: string + healthDegradedFailures: number + healthDownFailures: number + healthLatencyWarnMs: number + healthSuccessRecoveries: number +}): FormValues { + return { + healthCheckCron: data.healthCheckCron, + healthDegradedFailures: data.healthDegradedFailures, + healthDownFailures: data.healthDownFailures, + healthLatencyWarnMs: data.healthLatencyWarnMs, + healthSuccessRecoveries: data.healthSuccessRecoveries, + } +} + +function toGlobalpingFormValues(data: { + globalpingLocations?: string + globalpingLimit?: number +}): GlobalpingValues { + return { + globalpingToken: '', + globalpingLocations: data.globalpingLocations || 'World', + globalpingLimit: data.globalpingLimit ?? 3, + } +} + type SettingsResponse = FormValues & { id: string healthWorkerUrl?: string @@ -161,18 +189,8 @@ function HealthSettingsPage() { useEffect(() => { if (!data) return - form.reset({ - healthCheckCron: data.healthCheckCron, - healthDegradedFailures: data.healthDegradedFailures, - healthDownFailures: data.healthDownFailures, - healthLatencyWarnMs: data.healthLatencyWarnMs, - healthSuccessRecoveries: data.healthSuccessRecoveries, - }) - gpForm.reset({ - globalpingToken: '', - globalpingLocations: data.globalpingLocations || 'World', - globalpingLimit: data.globalpingLimit ?? 3, - }) + form.reset(toHealthFormValues(data)) + gpForm.reset(toGlobalpingFormValues(data)) }, [data, form, gpForm]) const saveMut = useMutation({ @@ -184,7 +202,8 @@ function HealthSettingsPage() { healthLatencyWarnMs: values.healthLatencyWarnMs, healthSuccessRecoveries: values.healthSuccessRecoveries, }), - onSuccess: () => { + onSuccess: (saved) => { + form.reset(toHealthFormValues(saved)) void queryClient.invalidateQueries({ queryKey: ['app-settings'] }) toast.success('Настройки health-check сохранены') }, @@ -201,13 +220,10 @@ function HealthSettingsPage() { ? { globalpingToken: values.globalpingToken.trim() } : {}), }), - onSuccess: () => { + onSuccess: (saved) => { + gpForm.reset(toGlobalpingFormValues(saved)) void queryClient.invalidateQueries({ queryKey: ['app-settings'] }) toast.success('Настройки Globalping сохранены') - gpForm.reset({ - ...gpForm.getValues(), - globalpingToken: '', - }) }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'), @@ -224,8 +240,21 @@ function HealthSettingsPage() { toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'), }) + const healthDirty = Boolean(data) && form.formState.isDirty + const globalpingDirty = Boolean(data) && gpForm.formState.isDirty + return (
+ {healthDirty || globalpingDirty ? ( + + + ) : null} @@ -360,11 +389,27 @@ function HealthSettingsPage() { /> - + + form.reset()} + trigger={ + + } + /> Сохранить @@ -571,11 +616,27 @@ function HealthSettingsPage() { /> - + + gpForm.reset()} + trigger={ + + } + /> Сохранить diff --git a/packages/ui/package.json b/packages/ui/package.json index daeff97..7929993 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-tabs": "^1.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "1.1.1", "date-fns": "^4.4.0", "lucide-react": "^1.18.0", "next-themes": "^0.4.6", diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index e31cdf1..b44bb7f 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -143,12 +143,16 @@ function AlertDialogDescription({ function AlertDialogAction({ className, + variant = "default", + size = "default", ...props -}: React.ComponentProps) { +}: AlertDialogPrimitive.Close.Props & + Pick, "variant" | "size">) { return ( -