feat(web): перенести оболочку и карточки на дизайн CF Domain Manager
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m29s
quality / api (push) Successful in 1m10s
CD / quality (push) Successful in 2m54s
CD / publish (push) Successful in 42s

Собрать панель, списки и настройки в одной оболочке, чтобы статусы, очередь и свежесть данных были видны на доменах, сервисах и группах.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 18:33:27 +07:00
1 parent b078fa0a03
commit 0ef57c673c
48 files changed
+3534 -1418

No files matched your search

+269 -50
View File
@@ -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<string[]>(() => 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 (
<SidebarMenu>
{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 (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(pathname, item.to, item.exact, item.matchPrefix)}
render={<Link to={item.to} activeOptions={{ exact: item.exact }} />}
>
<item.icon />
<span className="truncate">{item.label}</span>
{showDot ? (
<span
className={cn(
'ml-auto size-1.5 shrink-0 rounded-full group-data-[collapsible=icon]:hidden',
showServicesDot ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
{showDot ? (
<span className="sr-only">
{showServicesDot ? ', есть недоступные' : ', истекают в ближайшие 14 дней'}
</span>
) : null}
</SidebarMenuButton>
{showDot ? (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute top-1 right-1 hidden size-1.5 rounded-full group-data-[collapsible=icon]:block',
showServicesDot ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
<SidebarMenuAction
showOnHover={!isPinned}
aria-label={isPinned ? `Открепить «${item.label}»` : `Закрепить «${item.label}»`}
onClick={() => onTogglePin(item.to)}
>
<PinIcon />
</SidebarMenuAction>
</SidebarMenuItem>
)
})}
</SidebarMenu>
)
}
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 (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(
pathname,
item.to,
item.exact,
item.matchPrefix,
)}
render={
<Link
to={item.to}
activeOptions={{ exact: item.exact }}
/>
}
>
<item.icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<Collapsible
open={open}
onOpenChange={(nextOpen) => {
if (forceOpen) return
if (nextOpen === open) return
onToggleCollapsed(id)
}}
className="group/nav-section"
>
<SidebarGroup>
<SidebarGroupLabel className="w-full" render={<CollapsibleTrigger />}>
<span>{label}</span>
<ChevronRightIcon
className={cn(
'ml-auto transition-transform duration-200 motion-reduce:transition-none',
open && 'rotate-90',
)}
/>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<NavItems
items={items}
pathname={pathname}
pinned={pinned}
onTogglePin={onTogglePin}
servicesDown={servicesDown}
certsExpiring={certsExpiring}
/>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
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 (
<Sidebar collapsible="icon">
@@ -107,9 +298,37 @@ export function AppSidebar() {
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
{pinnedItems.length > 0 ? (
<SidebarGroup>
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<NavItems
items={pinnedItems}
pathname={pathname}
pinned={pins.values}
onTogglePin={pins.toggle}
servicesDown={attention.servicesDown}
certsExpiring={attention.certsExpiring}
/>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{NAV_GROUPS.map((group) => (
<NavSection
key={group.id}
id={group.id}
label={group.label}
items={group.items}
pathname={pathname}
collapsed={collapsed.values.includes(group.id)}
forceOpen={forceOpen}
onToggleCollapsed={collapsed.toggle}
pinned={pins.values}
onTogglePin={pins.toggle}
servicesDown={attention.servicesDown}
certsExpiring={attention.certsExpiring}
/>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
+8 -1
View File
@@ -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({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
<AlertDialogAction
variant={confirmVariant}
onClick={onConfirm}
disabled={disabled}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
@@ -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<typeof groupFormSchema>
interface DomainDetailActions {
openCreateSubdomain: () => void
}
function DomainChromeSkeleton() {
return (
<div className="flex flex-col gap-4">
<TableSkeleton rows={1} cols={2} />
<div className="grid gap-4 md:grid-cols-4">
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
</div>
</div>
)
}
/**
* Общая шапка, 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<ServiceBinding | null>(null)
const domain = page.domain
const groupForm = useForm<GroupFormValues>({
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: <GlobeIcon />,
label: 'Статус',
value: domain.status,
hint: <StatusBadge status={domain.status} />,
},
{
id: 'group',
icon: <FolderTreeIcon />,
label: 'Группа',
value: groupName ?? '—',
hint: groupName ? 'В группе' : 'Без группы',
},
{
id: 'bindings',
icon: <Link2Icon />,
label: 'Привязки',
value: page.bindings.length,
hint: 'Сервисы в зоне',
},
{
id: 'sync',
icon: <ServerIcon />,
label: 'Синхронизация',
value: formatDate(domain.last_synced_at),
hint: 'Последняя синхронизация',
},
]
: []
const bindings = page.bindings
return (
<QueryState
isLoading={page.isLoading}
isError={page.isError}
error={page.error}
onRetry={page.refetch}
skeleton={<DomainChromeSkeleton />}
>
{domain ? (
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Карточка домена"
actions={
<>
<Button type="button" variant="outline" onClick={() => setGroupOpen(true)}>
Сменить группу
</Button>
{bindings.length === 1 ? (
<Button
type="button"
variant="outline"
onClick={() => setChangeIp(bindings[0] ?? null)}
>
Сменить IP
</Button>
) : bindings.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
Сменить IP
</Button>
}
/>
<DropdownMenuContent align="end">
{bindings.map((binding) => (
<DropdownMenuItem
key={binding.id}
onClick={() => setChangeIp(binding)}
>
{binding.hostname}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button type="button" onClick={openCreateSubdomain}>
Добавить поддомен
</Button>
</>
}
/>
<KpiStatGrid cards={kpiCards} />
<CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
label: 'Поддомены',
count: page.subdomainRows.length,
},
{ id: 'bindings', label: 'Привязки', count: bindings.length },
{
id: 'dns',
label: 'DNS',
count: dnsQuery.data ? dnsQuery.data.length : undefined,
},
]}
value={activeTab}
onValueChange={handleTabChange}
className="flex w-full flex-col gap-4"
>
{activeTab === 'overview' ? <DomainOverviewFacts domainId={id} /> : null}
{children({ openCreateSubdomain })}
</CountedLineTabs>
<FormSheet
open={groupOpen}
onOpenChange={setGroupOpen}
title="Сменить группу"
description={`Назначить группу для ${domain.zone_name}`}
form={groupForm}
onSubmit={(values) => {
const next = values.group_id === 'none' ? null : Number(values.group_id)
updateGroupMutation.mutate(next)
}}
footer={
<LoadingButton
type="submit"
className="w-full"
isLoading={updateGroupMutation.isPending}
loadingLabel="Сохранение…"
>
Сохранить
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple label="Группа" htmlFor="domain-group-id">
<Controller
control={groupForm.control}
name="group_id"
render={({ field }) => (
<Select
items={groupItems}
value={field.value}
onValueChange={(value) => field.onChange(value ?? 'none')}
>
<SelectTrigger id="domain-group-id" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FormFieldSimple>
</FieldGroup>
</FormSheet>
<SubdomainEditSheet
mode="create"
subdomain={null}
zoneName={domain.zone_name}
services={page.services}
serviceGroupById={page.serviceGroupById}
currentServiceId="none"
open={createOpen}
isSaving={page.createSubdomainMutation.isPending}
onOpenChange={setCreateOpen}
onSubmit={handleCreateSubdomain}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.id ?? null}
serviceId={changeIp?.service_id ?? null}
currentIp={changeIp?.target_ip ?? changeIp?.target_ips?.[0]}
/>
</DetailPanel>
) : null}
</QueryState>
)
}
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 (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Зона</FrameTitle>
<FrameDescription>Параметры зоны Cloudflare</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Зона">
<span className="text-sm font-medium">{domain.zone_name}</span>
</SettingRow>
<SettingRow title="Группа">
{domain.group_id ? (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link to="/groups/$groupId" params={{ groupId: String(domain.group_id) }} />
}
>
{groupName ?? 'Открыть группу'}
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</SettingRow>
<SettingRow title="Zone ID">
<span className="font-mono text-sm break-all">{domain.cf_zone_id}</span>
</SettingRow>
<SettingRow title="Последняя синхронизация" last>
<span className="text-sm tabular-nums">{formatDate(domain.last_synced_at)}</span>
</SettingRow>
</FramePanel>
</Frame>
)
}
@@ -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'
@@ -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 (
<Alert variant="destructive">
<CircleAlertIcon aria-hidden />
<AlertTitle>{title}</AlertTitle>
{description ? <AlertDescription>{description}</AlertDescription> : null}
<AlertAction>
<Button type="button" variant="outline" size="xs" onClick={onShow}>
Показать
</Button>
</AlertAction>
</Alert>
)
}
@@ -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 (
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
{hasUnsaved ? (
<Alert variant="warning" role="status">
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>Есть несохранённые изменения</AlertTitle>
<AlertDescription>
URL, токен или синхронизация отличаются от последних сохранённых
значений.
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={syncBadgeVariant}>
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
+30 -21
View File
@@ -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 (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<SearchMenu hotkeyOnly />
</SidebarProvider>
<SystemAttentionProvider>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</SystemAttentionProvider>
</TooltipProvider>
)
}
@@ -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 (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Палитра команд"
description="Разделы и действия приложения"
className="sm:max-w-md"
>
<Command key={open ? 'open' : 'closed'}>
<CommandInput autoFocus placeholder="Перейти к разделу или действию…" />
<CommandList>
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Разделы">
{SECTION_ITEMS.map((item) => (
<CommandItem
key={item.to}
value={`${item.label} ${item.keywords}`}
onSelect={() => go(item.to)}
>
<item.icon />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Действия">
{ACTION_ITEMS.map((item) => (
<CommandItem
key={item.label}
value={`${item.label} ${item.keywords}`}
onSelect={() => go(item.to)}
>
<item.icon />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
@@ -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 (
<div className="flex items-center gap-2">
<p className="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex">
<span className="tabular-nums">
Обновлено{' '}
<time dateTime={updatedAt ? new Date(updatedAt).toISOString() : undefined}>
{label}
</time>
</span>
<span aria-hidden="true">·</span>
<span>{live ? 'Live' : 'Пауза'}</span>
</p>
<Switch
size="sm"
checked={live}
onCheckedChange={handleCheckedChange}
aria-label={live ? 'Выключить автообновление' : 'Включить автообновление'}
/>
</div>
)
}
@@ -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 : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Поиск"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<SearchIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Поиск</DialogTitle>
<DialogDescription>
Переход к разделам приложения
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
<div className="relative flex items-center gap-3 border-b px-4 py-2">
<SearchIcon
aria-hidden="true"
className="pointer-events-none size-4 opacity-60 select-none"
/>
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Перейти к разделу…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) {
e.preventDefault()
goTo(filtered[0].to)
}
}}
/>
</div>
<ItemGroup className="max-h-72 overflow-y-auto p-2">
{filtered.length === 0 ? (
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
Ничего не найдено
</p>
) : (
filtered.map((item) => (
<Item
key={item.to}
size="sm"
variant="muted"
className="cursor-pointer border-0"
render={<Link to={item.to} onClick={() => setOpen(false)} />}
>
<ItemMedia variant="icon">
<item.icon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>{item.label}</ItemTitle>
</ItemContent>
</Item>
))
)}
</ItemGroup>
</DialogContent>
</Dialog>
</>
)
}
@@ -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() {
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<DataFreshness />
<AppsMenu />
<SystemMonitorPopover />
</div>
@@ -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 (
<SystemAttentionContext.Provider value={attention}>
{children}
</SystemAttentionContext.Provider>
)
}
@@ -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 = (
<>
<span
aria-hidden="true"
className={cn(
'size-1.5 shrink-0 rounded-full',
incident.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
)}
/>
<span className="min-w-0 flex-1 truncate">{incident.title}</span>
<span className="text-muted-foreground shrink-0">{incident.detail}</span>
</>
)
if (incident.href.to === '/certificates') {
return (
<Link to="/certificates" className={className}>
{body}
</Link>
)
}
return (
<Link
to="/services/$serviceId"
params={{ serviceId: incident.href.serviceId }}
className={className}
>
{body}
</Link>
)
}
/** Live system monitor popover (app-shell pattern, CFDM API data). */
export function SystemMonitorPopover() {
const readyQ = useQuery({
queryKey: ['ready'],
queryFn: async (): Promise<ReadyResponse> => {
const res = await fetch('/ready')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<ReadyResponse>
},
refetchInterval: 30_000,
})
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 })
const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 })
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 })
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 })
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError
let healthDown = 0
let healthDegraded = 0
for (const group of servicesQ.data?.groups ?? []) {
for (const service of group.services) {
if (service.health_status === 'down') healthDown += 1
if (service.health_status === 'degraded') healthDegraded += 1
}
}
for (const service of servicesQ.data?.ungrouped ?? []) {
if (service.health_status === 'down') healthDown += 1
if (service.health_status === 'degraded') healthDegraded += 1
}
const healthAlert = healthDown > 0
const healthWarn = healthDegraded > 0
const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error'])
const failedJobs = (notifyQ.data ?? []).filter((n) =>
/fail|error/i.test(`${n.kind} ${n.title} ${n.message}`),
).length
const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length
const attentionCount = healthDown + healthDegraded
const attention = useSystemAttention()
const visibleIncidents = attention.incidents.slice(0, VISIBLE_INCIDENTS)
const hiddenIncidents = attention.incidents.length - visibleIncidents.length
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'ready',
label: 'Ready',
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
value: attention.readyOk ? 'OK' : attention.readyDegraded ? '!' : '—',
unit: '',
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
percent: attention.readyOk ? 100 : attention.readyDegraded ? 40 : 0,
icon: <HeartPulse aria-hidden />,
tone: readyOk ? 'success' : 'destructive',
alert: !readyOk,
tone: attention.readyOk ? 'success' : 'destructive',
alert: !attention.readyOk,
},
{
id: 'health',
label: 'Health-check',
value: String(healthDown + healthDegraded),
value: String(attention.servicesDown + attention.servicesDegraded),
unit: 'шт.',
percent: Math.min(100, (healthDown + healthDegraded) * 20),
percent: Math.min(100, (attention.servicesDown + attention.servicesDegraded) * 20),
icon: <Server aria-hidden />,
tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success',
alert: healthAlert || healthWarn,
tone: attention.servicesDown > 0 ? 'destructive' : attention.servicesDegraded > 0 ? 'warning' : 'success',
alert: attention.servicesDown > 0 || attention.servicesDegraded > 0,
},
{
id: 'certs',
label: 'Сертификаты',
value: String(certWarnings),
value: String(attention.certWarnings),
unit: 'шт.',
percent: Math.min(100, certWarnings * 20),
percent: Math.min(100, attention.certWarnings * 20),
icon: <ShieldCheck aria-hidden />,
tone: certWarnings > 0 ? 'warning' : 'success',
alert: certWarnings > 0,
tone: attention.certWarnings > 0 ? 'warning' : 'success',
alert: attention.certWarnings > 0,
},
{
id: 'jobs',
label: 'Ошибки',
value: String(failedJobs),
value: String(attention.failedJobs),
unit: 'шт.',
percent: Math.min(100, failedJobs * 15),
percent: Math.min(100, attention.failedJobs * 15),
icon: <ListChecks aria-hidden />,
tone: failedJobs > 0 ? 'warning' : 'success',
alert: failedJobs > 0,
tone: attention.failedJobs > 0 ? 'warning' : 'success',
alert: attention.failedJobs > 0,
},
],
[
certWarnings,
failedJobs,
healthAlert,
healthDegraded,
healthDown,
healthWarn,
readyDegraded,
readyOk,
],
[attention],
)
const spiking = metrics.some((m) => m.alert)
const spiking = metrics.some((metric) => metric.alert)
return (
<Popover>
@@ -242,12 +218,29 @@ export function SystemMonitorPopover() {
</div>
))}
</div>
<div className="border-border flex flex-col gap-1 border-t px-2 py-2">
<span className="text-muted-foreground px-1.5 text-xs font-medium">Инциденты</span>
{visibleIncidents.length === 0 ? (
<p className="text-muted-foreground px-1.5 py-1 text-xs">Нет инцидентов</p>
) : (
<div className="flex flex-col gap-0.5">
{visibleIncidents.map((incident) => (
<IncidentLink key={incident.id} incident={incident} />
))}
</div>
)}
{hiddenIncidents > 0 ? (
<p className="text-muted-foreground px-1.5 text-xs">Ещё {hiddenIncidents}</p>
) : null}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
Без группы:{' '}
<span className="text-foreground font-medium tabular-nums">{ungroupedCount}</span>
<span className="text-foreground font-medium tabular-nums">{attention.ungroupedCount}</span>
{' · '}
Внимание:{' '}
<span className="text-foreground font-medium tabular-nums">{attentionCount}</span>
<span className="text-foreground font-medium tabular-nums">
{attention.servicesDown + attention.servicesDegraded}
</span>
</div>
</PopoverContent>
</Popover>
@@ -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 (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
@@ -71,8 +82,30 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<FramePanel className={cn('min-w-0', !isEmpty && !isError && 'p-0')}>
{isError ? (
<div className="flex min-h-28 items-center py-4">
<Alert variant="destructive" className="w-full">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col items-start gap-2">
<span>
{column.errorMessage ?? 'Не удалось загрузить данные'}
</span>
{column.onRetry ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={column.onRetry}
>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
</div>
) : isEmpty ? (
<div className="flex min-h-28 items-center justify-center py-4">
<EmptyState
icon={Icon}
@@ -0,0 +1,314 @@
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
import type { Certificate, DomainListItem, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
/**
* Compact queue tables for the dashboard. Parent Frame owns the shell —
* this grid does not render another Frame.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Docs: https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame
*/
function formatWhen(value: string | null | undefined) {
if (!value) return '—'
return formatRelative(sqliteUtcToIso(value) ?? value)
}
function nodeLabel(service: ServiceView) {
const health = service.ip_health
const total = Math.max(service.ips.length, health.length)
if (total === 0) return '—'
if (health.length === 0) return String(total)
const healthy = health.filter((row) => 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<T extends object>({
data,
columns,
getRowId,
}: {
data: T[]
columns: ColumnDef<T, unknown>[]
getRowId: (row: T) => string
}) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
getRowId,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<DataGrid
table={table}
recordCount={data.length}
tableLayout={{
dense: true,
headerBorder: true,
rowBorder: true,
width: 'auto',
}}
>
<DataGridScrollArea orientation="horizontal">
<DataGridTable />
</DataGridScrollArea>
</DataGrid>
)
}
interface HealthProblemGridProps {
services: ServiceView[]
}
export function HealthProblemGrid({ services }: HealthProblemGridProps) {
const columns = useMemo<ColumnDef<ServiceView, unknown>[]>(
() => [
{
id: 'name',
accessorKey: 'name',
size: 140,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервис" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link to="/services" search={{ serviceId: row.original.id }} />
}
>
{row.original.name}
</Button>
),
},
{
id: 'status',
accessorKey: 'health_status',
size: 96,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => (
<HealthCheckBadge status={row.original.health_status} size="xs" />
),
},
{
id: 'latency',
accessorKey: 'health_latency_ms',
size: 88,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Задержка" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.health_latency_ms != null
? `${row.original.health_latency_ms} мс`
: '—'}
</span>
),
},
{
id: 'nodes',
accessorFn: (row) => Math.max(row.ips.length, row.ip_health.length),
size: 72,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Ноды" />
),
cell: ({ row }) => (
<span className="tabular-nums">{nodeLabel(row.original)}</span>
),
},
{
id: 'checked',
accessorFn: (row) => latestCheck(row) ?? '',
size: 104,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Когда" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(latestCheck(row.original))}
</span>
),
},
],
[],
)
return (
<QueueGrid
data={services}
columns={columns}
getRowId={(row) => String(row.id)}
/>
)
}
interface ExpiringCertGridProps {
certs: Certificate[]
}
export function ExpiringCertGrid({ certs }: ExpiringCertGridProps) {
const columns = useMemo<ColumnDef<Certificate, unknown>[]>(
() => [
{
id: 'hostname',
accessorKey: 'hostname',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Хост" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={<Link to="/certificates" />}
>
{row.original.hostname}
</Button>
),
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'service',
accessorFn: (row) => row.service_name ?? '',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервис" />
),
cell: ({ row }) => (
<span className="truncate">
{row.original.service_name?.trim() || '—'}
</span>
),
},
{
id: 'expires',
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Истекает" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(row.original.expires_at)}
</span>
),
},
],
[],
)
return (
<QueueGrid data={certs} columns={columns} getRowId={(row) => String(row.id)} />
)
}
interface UngroupedDomainGridProps {
domains: DomainListItem[]
}
export function UngroupedDomainGrid({ domains }: UngroupedDomainGridProps) {
const columns = useMemo<ColumnDef<DomainListItem, unknown>[]>(
() => [
{
id: 'zone_name',
accessorKey: 'zone_name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Домен" />
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(row.original.id) }}
/>
}
>
{row.original.zone_name}
</Button>
),
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'services',
accessorKey: 'service_count',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.service_count}</span>
),
},
{
id: 'synced',
accessorKey: 'last_synced_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Синхронизация" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatWhen(row.original.last_synced_at)}
</span>
),
},
],
[],
)
return (
<QueueGrid
data={domains}
columns={columns}
getRowId={(row) => String(row.id)}
/>
)
}
@@ -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'
@@ -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<T extends object> {
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<T extends object>({
onRetry,
primaryAction,
emptyState,
filteredEmptyState,
onFilteredEmptyReset,
errorState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
@@ -224,6 +252,25 @@ export function ResourcePage<T extends object>({
}
if (isError) {
if (errorState) {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
icon={errorState.icon ?? CircleAlertIcon}
title={errorState.title}
description={
errorState.description ??
error?.message ??
'Не удалось загрузить данные'
}
action={errorState.action}
/>
</FramePanel>
</Frame>
)
}
return (
<Alert variant="destructive">
<CircleAlertIcon />
@@ -262,6 +309,7 @@ export function ResourcePage<T extends object>({
) : null}
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
icon={emptyState.icon}
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
@@ -271,7 +319,30 @@ export function ResourcePage<T extends object>({
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
const emptyMessage = filteredEmptyState ? (
<EmptyState
className="text-foreground"
icon={filteredEmptyState.icon}
title={filteredEmptyState.title}
description={filteredEmptyState.description}
action={
filteredEmptyState.resetLabel ? (
<Button
type="button"
variant="outline"
onClick={() => {
handleClear()
onFilteredEmptyReset?.()
}}
>
{filteredEmptyState.resetLabel}
</Button>
) : null
}
/>
) : (
'Нет записей по выбранным фильтрам.'
)
return (
<div className="w-full">
@@ -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<string | null>(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<ServiceView>(`/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 (
<QueryState
isLoading={viewQuery.isLoading}
isError={viewQuery.isError}
error={viewQuery.error}
onRetry={() => {
void viewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : (
<ServiceDetailGrid
service={service}
nodes={nodes}
probes={probes}
section={section}
togglingIp={togglingIp}
isLoading={nodesQuery.isLoading}
onToggleIp={(ip, enabled) => {
setTogglingIp(ip)
toggleIpMutation.mutate({ ip, enabled })
}}
onChangeIp={(row: ServiceFqdnRow) => actions.openChangeIp(row)}
onChangeDomain={() => actions.openChangeDomain()}
onAddNode={actions.openAddNode}
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
/>
)}
</QueryState>
)
}
@@ -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<ServiceDetailActions | null>(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
}
@@ -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<Filter[]>(() => [
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 (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={fqdnFilterFields}
filters={fqdnFilters}
@@ -711,8 +732,8 @@ export function ServiceDetailGrid({
if (tab === 'nodes') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={nodeFilterFields}
filters={nodeFilters}
@@ -733,10 +754,25 @@ export function ServiceDetailGrid({
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
section ? undefined : (
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
)
}
emptyState={
section === 'nodes'
? {
title: 'Нет нод',
description: 'Добавьте ноду в пул сервиса.',
action: (
<Button type="button" variant="outline" onClick={onAddNode}>
Добавить ноду
</Button>
),
}
: undefined
}
/>
)
@@ -745,8 +781,12 @@ export function ServiceDetailGrid({
if (tab === 'ssl') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN, ноды и SSL этого сервиса"
title={section === 'ssl' ? 'SSL' : 'Активы сервиса'}
description={
section === 'ssl'
? 'Сертификаты привязанных FQDN'
: 'IP, FQDN, ноды и SSL этого сервиса'
}
{...sharedTabs}
filterFields={sslFilterFields}
filters={sslFilters}
@@ -790,8 +830,8 @@ export function ServiceDetailGrid({
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
title={heading.title}
description={heading.description}
{...sharedTabs}
filterFields={ipFilterFields}
filters={ipFilters}
@@ -0,0 +1,134 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, UnplugIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { QueryState } from '@/components/query-state'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { ServiceHealthMonitor } from '@/components/reui-kit'
import {
enabledHealthProviders,
providerHealthStatuses,
type HealthLogProbe,
} from '@/lib/health-log'
import type { FailoverHistoryItem } from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import {
serviceFailoverLogQueryOptions,
serviceHealthLogQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
function failoverHistory(items: readonly FailoverLogEntry[]): FailoverHistoryItem[] {
return items.map((item) => ({
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 (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void logQuery.refetch()
void failoverQuery.refetch()
}}
>
<div className="flex flex-col gap-4">
{showLatency ? (
<ServiceHealthMonitor
items={probes}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isFetching}
/>
) : (
<EmptyState
icon={ActivityIcon}
title="Нет ряда задержки"
description="Журнал health-log не содержит замеров latency. График появится после проверок с задержкой."
stackedIcon={false}
centered={false}
/>
)}
{history.length > 0 ? (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>События журнала смены адресов</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={[]} history={history} />
</FramePanel>
</Frame>
) : (
<EmptyState
icon={UnplugIcon}
title="Нет событий failover"
description="Журнал failover пуст — timeline строится только по его записям."
stackedIcon={false}
centered={false}
/>
)}
</div>
</QueryState>
)
}
@@ -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<ServiceView['lb_mode'], string> = {
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 (
<div className="flex flex-col gap-4">
{service ? (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Маршрутизация</FrameTitle>
<FrameDescription>Режим балансировки и активные адреса</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Режим">
<Badge variant="outline" size="sm">
{LB_LABEL[service.lb_mode]}
</Badge>
</SettingRow>
<SettingRow title="Стратегия">
<span className="text-sm">
{overview?.routing_strategy ?? LB_LABEL[service.lb_mode]}
</span>
</SettingRow>
<SettingRow title="Активные адреса" last>
<span className="text-sm font-mono">{active.join(', ') || '—'}</span>
</SettingRow>
</FramePanel>
</Frame>
) : null}
<ServiceAssetSection serviceId={serviceId} section="ip" />
</div>
)
}
@@ -249,11 +249,11 @@ export function ServicesGroupedCatalog({
return (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
title="Нет сервисов"
description={
domainLabel
? `Нет сервисов с привязками к ${domainLabel}`
: 'Сначала создайте группу, затем добавьте сервисы.'
: 'Создайте сервис и добавьте поддомен с нодами.'
}
action={emptyAction}
/>
@@ -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<SystemAttention | null>(null)
export function useSystemAttentionData(): SystemAttention {
const readyQ = useQuery({
queryKey: ['ready'],
queryFn: async (): Promise<ReadyResponse> => {
const res = await fetch('/ready')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<ReadyResponse>
},
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
}
+25
View File
@@ -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)
+16 -2
View File
@@ -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<string, string> = {
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 }]
: []),
]
}
+43
View File
@@ -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()
})
})
+70
View File
@@ -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<IntervalObserver, RefetchInterval>()
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)
})
}
+81
View File
@@ -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',
})
})
})
+188
View File
@@ -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)],
}
}
+15 -1
View File
@@ -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<string, unknown>) {
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) }),
])
}
+104 -5
View File
@@ -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<Filter[]>(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 = (
<LoadingButton
@@ -77,6 +84,13 @@ function CertificatesPage() {
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
actions={primaryAction}
/>
{expiredCert ? (
<IncidentAlert
title={expiredCertTitle(expiredCert)}
description={expiredCertDescription(expiredCert)}
onShow={() => showCertificate(expiredCert, navigate, setActiveTab)}
/>
) : null}
<CertKpiStats
summary={summary}
total={certs?.length}
@@ -104,12 +118,97 @@ function CertificatesPage() {
isError={isError}
error={error}
onRetry={refetch}
errorState={{
icon: CircleAlertIcon,
title: 'Не удалось загрузить сертификаты',
description: error?.message ?? 'Повторите запрос.',
action: (
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
</Button>
),
}}
filteredEmptyState={{
icon: SearchIcon,
title: 'Нет совпадений',
description: 'Измените поиск или сбросьте фильтры.',
resetLabel: 'Сбросить фильтры',
}}
onFilteredEmptyReset={() => setActiveTab('all')}
emptyState={{
title: 'Сертификаты не найдены',
description: 'Сертификаты появятся после проверки доменов',
action: primaryAction,
icon: ShieldCheckIcon,
title: 'Нет данных',
description: 'Сертификаты появятся после первой проверки сервисов.',
action: (
<Button
type="button"
variant="outline"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
Проверить
</Button>
),
}}
/>
</PageShell>
)
}
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<typeof useNavigate>,
setActiveTab: (tabId: string) => void,
): void {
if (cert.service_id != null) {
navigate({
to: '/services/$serviceId',
params: { serviceId: String(cert.service_id) },
})
return
}
setActiveTab('expired')
}
@@ -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 = (
<>
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
<LoadingButton
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
)
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title={`${domain?.zone_name ?? ''} — DNS`}
description={
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
}
actions={headerActions}
>
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link to="/domains/$domainId" params={{ domainId }} />
}
>
← Обзор домена
</Button>
</DetailPanel.Header>
<DetailPanel.Section>
<ResourcePage
title="DNS-записи"
description={domain?.zone_name ?? ''}
hideHeader
tabs={DNS_TABS.map((tab) => ({ ...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: (
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel>
<DomainDetailChrome domainId={domainId} activeTab="dns">
{() => (
<div className="flex flex-col gap-4">
{host ? (
<p className="text-muted-foreground text-sm">Фильтр по хосту: {host}</p>
) : null}
<ResourcePage
title="DNS-записи"
description={domain?.zone_name ?? ''}
hideHeader
tabs={DNS_TABS.map((tab) => ({ ...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={
<>
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
<LoadingButton
variant="outline"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
}
emptyState={{
title: 'DNS-записи не найдены',
description: 'Синхронизируйте зону или создайте запись вручную',
action: (
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
),
}}
/>
</div>
)}
</DomainDetailChrome>
<FormSheet
open={sheetOpen}
@@ -1,19 +1,9 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
FolderTreeIcon,
GlobeIcon,
Link2Icon,
ServerIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { Filter } from '@/components/reui/filters'
import {
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
healthStatusKeys,
runHealthCheck,
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
@@ -21,9 +11,7 @@ import {
import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import { ResourcePage } from '@/components/reui-kit'
import {
SUBDOMAIN_TABS,
createDefaultSubdomainFilters,
@@ -38,15 +26,23 @@ import {
} from '@/components/subdomain-edit-sheet'
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { StatusBadge } from '@/components/status-badge'
import { formatDate } from '@/lib/format'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
import { DomainDetailChrome } from '@/components/domains/domain-detail-chrome'
import { type DomainSectionTab } from '@/components/domains/domain-detail-tabs'
import { Button } from '@cfdm/ui/components/button'
import { TabsContent } from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
validateSearch: (search: Record<string, unknown>): { 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 (
<div className="flex flex-col gap-4">
<TableSkeleton rows={1} cols={2} />
<div className="grid gap-4 md:grid-cols-3">
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
</div>
<TableSkeleton rows={5} cols={5} />
</div>
)
}
function DomainOverviewPage() {
const { domainId } = Route.useParams()
const { tab } = Route.useSearch()
const id = Number(domainId)
const queryClient = useQueryClient()
const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editOpen, setEditOpen] = useState(false)
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(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: <GlobeIcon aria-hidden="true" />,
label: 'Статус зоны',
description: domain.status,
footer: <StatusBadge status={domain.status} />,
},
{
id: 'group',
icon: <FolderTreeIcon aria-hidden="true" />,
label: 'Группа',
description: domain.group_id ? 'Домен в группе' : 'Без группы',
footer: domain.group_id ? (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
Открыть группу
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
),
},
{
id: 'bindings',
icon: <Link2Icon aria-hidden="true" />,
label: 'Привязки',
description: 'Сервисы в зоне',
footer: (
<span className="text-foreground text-lg font-semibold tabular-nums">
{bindings.length}
</span>
),
},
{
id: 'sync',
icon: <ServerIcon aria-hidden="true" />,
label: 'Синхронизация',
description: 'Последняя синхронизация с Cloudflare',
footer: (
<span className="text-foreground text-sm font-medium tabular-nums">
{formatDate(domain.last_synced_at)}
</span>
),
},
]
}, [bindings.length, domain])
const headerActions = (
<>
<LoadingButton
variant="outline"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать
</LoadingButton>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
DNS
</Button>
<LoadingButton
variant="outline"
onClick={() => runCheckMutation.mutate()}
isLoading={runCheckMutation.isPending}
loadingLabel="Проверка…"
>
Проверить сейчас
</LoadingButton>
</>
)
const activeTab: DomainSectionTab = tab ?? 'overview'
return (
<PageShell>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<DomainPageSkeleton />}
>
{domain && (
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Карточка домена"
actions={headerActions}
/>
<CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
label: 'Поддомены',
count: subdomainRows.length,
},
{
id: 'bindings',
label: 'Привязки',
count: bindings.length,
},
]}
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
>
<TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} />
</TabsContent>
<TabsContent value="availability" className="flex flex-col gap-4">
<DomainAvailabilityPanel domainId={id} />
</TabsContent>
<TabsContent value="subdomains" className="flex flex-col gap-4">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() =>
setFilters(createDefaultSubdomainFilters())
}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<Button type="button" onClick={openCreateSheet}>
<DomainDetailChrome domainId={domainId} activeTab={activeTab}>
{({ openCreateSubdomain }) => (
<>
{activeTab === 'availability' ? (
<DomainAvailabilityPanel domainId={id} />
) : null}
{activeTab === 'subdomains' && domain ? (
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((item) => ({ ...item }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSubdomainFilters())}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" variant="outline" onClick={openCreateSubdomain}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<DomainBindingsPanel bindings={bindings} />
</TabsContent>
</CountedLineTabs>
<SubdomainEditSheet
mode={sheetMode}
subdomain={editTarget?.subdomain ?? null}
zoneName={domain.zone_name}
services={services}
serviceGroupById={serviceGroupById}
currentServiceId={
editTarget ? resolveServiceId(editTarget) : 'none'
}
serviceLinks={editTarget?.serviceLinks ?? []}
open={sheetOpen}
isSaving={isSheetSaving}
onOpenChange={setSheetOpen}
onSubmit={handleSheetSubmit}
/>
</DetailPanel>
),
}}
/>
) : null}
{activeTab === 'bindings' ? (
<DomainBindingsPanel bindings={bindings} />
) : null}
{domain ? (
<SubdomainEditSheet
mode="edit"
subdomain={editTarget?.subdomain ?? null}
zoneName={domain.zone_name}
services={services}
serviceGroupById={serviceGroupById}
currentServiceId={editTarget ? resolveServiceId(editTarget) : 'none'}
serviceLinks={editTarget?.serviceLinks ?? []}
open={editOpen}
isSaving={isSheetSaving}
onOpenChange={setEditOpen}
onSubmit={handleEditSubmit}
/>
) : null}
</>
)}
</QueryState>
</DomainDetailChrome>
</PageShell>
)
}
+120 -11
View File
@@ -1,18 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { CircleAlertIcon, GlobeIcon, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput } from '@/lib/schemas'
import { formatDate, sqliteUtcToIso } from '@/lib/format'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput, type DomainListItem } from '@/lib/schemas'
import {
bulkUpdateDomains,
dnsKeys,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
serviceBindingKeys,
subdomainKeys,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
@@ -41,7 +45,6 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
import type { DomainListItem } from '@/lib/schemas'
export const Route = createFileRoute('/_auth/domains/')({
loader: ({ context: { queryClient } }) =>
@@ -54,6 +57,7 @@ export const Route = createFileRoute('/_auth/domains/')({
function DomainsPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [syncOpen, setSyncOpen] = useState(false)
const [importGroupId, setImportGroupId] = useState('')
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
@@ -112,6 +116,22 @@ function DomainsPage() {
},
})
const syncMutation = useMutation({
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: dnsKeys.all }),
queryClient.invalidateQueries({ queryKey: subdomainKeys.all }),
])
setSyncOpen(false)
toast.success('Синхронизация завершена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
},
})
const bulkMutation = useMutation({
mutationFn: (body: BulkUpdateDomainsInput) => bulkUpdateDomains(body),
onSuccess: (data) => {
@@ -142,17 +162,36 @@ function DomainsPage() {
form.setValue('group_id', next)
}
const freshness = domainFreshnessLine(domains)
const primaryAction = (
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
<>
<Button type="button" variant="outline" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
<LoadingButton
type="button"
onClick={() => setSyncOpen(true)}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
)
return (
<PageShell>
<PageHeader
title="Домены"
description="Импортированные зоны Cloudflare"
description={
<span className="flex flex-col gap-1">
<span>Импортированные зоны Cloudflare</span>
{freshness ? (
<span className="tabular-nums">{freshness}</span>
) : null}
</span>
}
actions={primaryAction}
/>
<DomainKpiStats
@@ -181,6 +220,33 @@ function DomainsPage() {
isError={isError}
error={error}
onRetry={refetch}
errorState={{
icon: CircleAlertIcon,
title: 'Ошибка Cloudflare API',
description: cloudflareErrorDescription(error),
action: (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
</Button>
<Button
type="button"
variant="outline"
nativeButton={false}
render={<Link to="/settings/integrations" />}
>
Открыть интеграции
</Button>
</div>
),
}}
filteredEmptyState={{
icon: SearchIcon,
title: 'Нет совпадений',
description: 'Измените поиск или сбросьте фильтры.',
resetLabel: 'Сбросить фильтры',
}}
onFilteredEmptyReset={() => setActiveTab('all')}
enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => (
<DomainsBulkToolbar
@@ -209,11 +275,17 @@ function DomainsPage() {
/>
)}
emptyState={{
title: 'Домены не импортированы',
description: 'Импортируйте зону из аккаунта Cloudflare',
icon: GlobeIcon,
title: 'Нет доменов',
description:
'Подключите токен Cloudflare в настройках и запустите синхронизацию.',
action: (
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
<Button
type="button"
variant="outline"
onClick={() => setSyncOpen(true)}
>
Синхронизировать
</Button>
),
}}
@@ -271,6 +343,17 @@ function DomainsPage() {
</FieldGroup>
</FormSheet>
<ConfirmDialog
open={syncOpen}
onOpenChange={setSyncOpen}
title="Синхронизировать с Cloudflare?"
description="Будут запрошены зоны и записи DNS. Локальные привязки сервисов не изменятся."
confirmLabel="Синхронизировать"
confirmVariant="default"
onConfirm={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -290,3 +373,29 @@ function DomainsPage() {
</PageShell>
)
}
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}`
}
+10 -2
View File
@@ -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 <Outlet />
return <GroupsPage />
}
function GroupsPage() {
const { view: viewParam } = Route.useSearch()
const view = viewParam === 'board' ? 'board' : 'catalog'
+104 -167
View File
@@ -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: <GlobeIcon aria-hidden />,
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: <ActivityIcon aria-hidden />,
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: <AlertTriangleIcon aria-hidden />,
iconClassName: 'text-destructive',
},
{
id: 'failovers',
label: 'Failover',
value: ops?.active_failovers ?? 0,
hint: 'активные переключения',
variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default',
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning',
},
]
return (
@@ -289,6 +291,16 @@ function DashboardPage() {
<PageHeader
title="Панель управления"
description="Обзор доменов, групп, сервисов и сертификатов Cloudflare"
actions={
<LoadingButton
type="button"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать
</LoadingButton>
}
/>
<OpsDashboard
isLoading={isLoading}
@@ -303,6 +315,7 @@ function DashboardPage() {
}
charts={
<>
{/* health-log — журнал проб сервиса, не ряд Healthy/Unhealthy за 24 часа */}
<CertStatusChart data={statusChartData} />
<GroupDomainsChart data={groupChartData} />
</>
@@ -319,6 +332,11 @@ function DashboardPage() {
countVariant: 'destructive-light',
emptyTitle: 'Нет проблем',
emptyDescription: 'Нет сервисов со статусом Down или Slow',
isError: servicesFailed && serviceData == null,
errorMessage: errorMessage(servicesQueryError),
onRetry: () => {
void refetchServices()
},
emptyAction: (
<Button
variant="outline"
@@ -329,45 +347,7 @@ function DashboardPage() {
К сервисам
</Button>
),
children: (
<ItemGroup className="gap-2">
{attentionServices.map((service) => (
<Item key={service.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{service.name}
</ItemTitle>
</ItemContent>
<ItemActions>
<HealthCheckBadge
status={
(service.health_status as
| 'up'
| 'down'
| 'degraded'
| 'unknown') ?? 'unknown'
}
latencyMs={service.health_latency_ms}
size="xs"
/>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/services"
search={{ serviceId: service.id }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
children: <HealthProblemGrid services={attentionServices} />,
},
{
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: (
<Button
variant="outline"
@@ -388,33 +373,7 @@ function DashboardPage() {
К сертификатам
</Button>
),
children: (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
<Item key={cert.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{cert.hostname}
</ItemTitle>
<span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(new Date(ts).toISOString())}
</span>
</ItemContent>
<ItemActions>
<StatusBadge status={cert.status} />
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/certificates" />}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
children: <ExpiringCertGrid certs={expiringCerts} />,
},
{
id: 'ungrouped',
@@ -425,6 +384,11 @@ function DashboardPage() {
countVariant: 'warning-light',
emptyTitle: 'Всё сгруппировано',
emptyDescription: 'Все домены в группах',
isError: domainsFailed && domains == null,
errorMessage: errorMessage(domainsQueryError),
onRetry: () => {
void refetchDomains()
},
emptyAction: (
<Button
variant="outline"
@@ -435,34 +399,7 @@ function DashboardPage() {
К доменам
</Button>
),
children: (
<ItemGroup className="gap-2">
{ungroupedDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="min-w-0">
<ItemTitle className="truncate font-medium">
{domain.zone_name}
</ItemTitle>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
children: <UngroupedDomainGrid domains={ungroupedDomains} />,
},
]}
/>
@@ -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 <ServiceHealthSection serviceId={serviceId} />
}
@@ -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<string | null>(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<ServiceView>(`/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<ServiceView>(`/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 (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void viewQuery.refetch()
void overviewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : (
<div className="@container flex w-full flex-col gap-4 md:gap-6">
<PageHeader
title={service.name}
description="Domain → Service → Node → Health → Failover"
actions={
<>
<LbModeTile
mode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
/>
<HealthCheckBadge status={displayHealth} />
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Изменить"
onClick={() => setEditOpen(true)}
/>
}
>
<PencilIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>Изменить</TooltipContent>
</Tooltip>
</>
}
/>
<KpiStatGrid
cards={[
{
id: 'status',
icon: <ActivityIcon />,
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: <HealthCheckBadge status={displayHealth} size="xs" />,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
},
{
id: 'ip',
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: showPoolPanel
? `${service.active_ips.length} в пуле`
: uniqueEnabledIps.length <= 1
? 'без балансировки'
: service.ips.join(', ') || 'нет',
},
{
id: 'pool',
icon: <ServerIcon />,
label: showPoolPanel ? 'Активный пул' : 'Адреса',
value: String(
(overview?.active_addresses ?? service.active_ips).length,
),
hint:
(overview?.active_addresses ?? service.active_ips).join(', ') ||
'нет',
},
]}
/>
<section
aria-label="Мониторинг"
className={
showPoolPanel
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
: 'grid min-w-0 items-start gap-2'
}
>
<ServiceHealthMonitor
items={logItems}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
{showPoolPanel ? (
<ServiceFailoverPanel
lbMode={service.lb_mode}
hasPool={uniqueIpCount(service.ips) >= 2}
ipHealth={service.ip_health}
bindings={failoverBindings}
history={failoverHistory}
probes={logItems}
/>
) : null}
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
/>
) : (
<ServiceDetailGrid
service={service}
nodes={nodes}
probes={logItems}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) => {
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}
/>
)}
<ServiceEditSheet
mode="edit"
service={service}
groups={groups}
open={editOpen}
knownDomains={domainsQuery.data ?? []}
isSaving={saving}
isDeleting={deleteMutation.isPending}
onOpenChange={setEditOpen}
onSave={(_serviceId, body) => {
setSaving(true)
updateMutation.mutate({ body })
}}
onDelete={() => deleteMutation.mutate()}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={service.domains[0]?.domain_id ?? null}
/>
<FormSheet
open={addNodeOpen}
onOpenChange={setAddNodeOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={nodeForm}
onSubmit={(values) => createNodeMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</div>
)}
</QueryState>
)
return <ServiceAssetSection serviceId={serviceId} section="nodes" />
}
@@ -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 <ServiceAssetSection serviceId={serviceId} section="nodes" />
}
@@ -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<ServiceView>(`/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<ServiceView>(`/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 (
<PageShell>
<Outlet />
<QueryState
isLoading={viewQuery.isLoading}
isError={viewQuery.isError}
error={viewQuery.error}
onRetry={() => {
void viewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : (
<ServiceDetailActionsProvider
value={{
openAddNode: () => setAddNodeOpen(true),
openChangeIp: (row: ServiceFqdnRow) =>
setChangeIp({ bindingId: row.binding_id, ip: row.target_ips[0] }),
openChangeDomain: () => setChangeDomain(true),
}}
>
<DetailPanel>
<DetailPanel.Header
title={service.name}
description="Ноды, проверки и маршрутизация"
actions={
<>
<Button type="button" variant="outline" onClick={() => setEditOpen(true)}>
Изменить
</Button>
{service.enabled ? (
<ConfirmDialog
title="Выключить сервис?"
description="Сервис будет выключен, связанные DNS-записи будут сняты."
confirmLabel="Выключить"
trigger={
<Button
type="button"
variant="outline"
disabled={disableMutation.isPending}
>
Выключить
</Button>
}
onConfirm={() => disableMutation.mutate()}
/>
) : null}
<Button type="button" onClick={() => setAddNodeOpen(true)}>
Добавить ноду
</Button>
</>
}
/>
<KpiStatGrid
cards={[
{
id: 'status',
icon: <ActivityIcon />,
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: <HealthCheckBadge status={displayHealth} size="xs" />,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
},
{
id: 'ip',
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: showPoolPanel
? `${service.active_ips.length} в пуле`
: uniqueEnabledIps.length <= 1
? 'без балансировки'
: service.ips.join(', ') || 'нет',
},
{
id: 'pool',
icon: <ServerIcon />,
label: showPoolPanel ? 'Активный пул' : 'Адреса',
value: String(service.active_ips.length),
hint: service.active_ips.join(', ') || 'нет',
},
]}
/>
<nav aria-label="Разделы сервиса" className="flex gap-5 overflow-x-auto">
{SECTION_TABS.map((tab) => {
const isActive = section === tab.id
const count = counts[tab.id]
return (
<Link
key={tab.id}
to={tab.to}
params={{ serviceId }}
aria-current={isActive ? 'page' : undefined}
className={cn(
'text-muted-foreground hover:text-foreground relative inline-flex h-auto shrink-0 items-center gap-2 px-0 pb-3 text-sm font-medium transition-colors',
'after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground after:opacity-0',
isActive && 'text-foreground after:opacity-100',
)}
>
<span>{tab.label}</span>
{count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{count}
</span>
) : null}
</Link>
)
})}
</nav>
<Outlet />
</DetailPanel>
<ServiceEditSheet
mode="edit"
service={service}
groups={groups}
open={editOpen}
knownDomains={domainsQuery.data ?? []}
isSaving={saving}
isDeleting={deleteMutation.isPending}
onOpenChange={setEditOpen}
onSave={(_serviceId, body) => {
setSaving(true)
updateMutation.mutate({ body })
}}
onDelete={() => deleteMutation.mutate()}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={service.domains[0]?.domain_id ?? null}
/>
<FormSheet
open={addNodeOpen}
onOpenChange={setAddNodeOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={nodeForm}
onSubmit={(values) => createNodeMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="node-address">
<Input
id="node-address"
{...nodeForm.register('address')}
placeholder="10.0.0.10"
/>
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="node-port" hint="Необязательно">
<Input id="node-port" {...nodeForm.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</ServiceDetailActionsProvider>
)}
</QueryState>
</PageShell>
)
}
@@ -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 <ServiceRoutingSection serviceId={serviceId} />
}
@@ -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 (
<div className="flex flex-col gap-4">
<ServiceAssetSection serviceId={serviceId} section="fqdn" />
<ServiceAssetSection serviceId={serviceId} section="ssl" />
</div>
)
}
+95 -9
View File
@@ -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() {
<PageHeader title="Сервисы" description={pageDescription} />
<EmptyState
title="Не удалось загрузить сервисы"
description={error?.message}
description={
error?.message ?? 'Не удалось загрузить сервисы. Повторите запрос.'
}
action={
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
@@ -726,6 +735,18 @@ function ServicesPage() {
description={pageDescription}
actions={headerActions}
/>
{downService ? (
<IncidentAlert
title={downServiceTitle(downService)}
description={downServiceDescription(downService)}
onShow={() =>
navigate({
to: '/services/$serviceId',
params: { serviceId: String(downService.id) },
})
}
/>
) : null}
{view === 'board' ? (
<>
<ServicesBulkToolbar
@@ -738,16 +759,20 @@ function ServicesPage() {
{catalogRows.length === 0 && (data?.groups.length ?? 0) === 0 ? (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
title="Нет сервисов"
description={
filteredDomain
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
: 'Сначала создайте группу, затем добавьте сервисы.'
: 'Создайте сервис и добавьте поддомен с нодами.'
}
action={
<div className="flex flex-wrap justify-center gap-2">
{addMenu}
</div>
<Button
type="button"
variant="outline"
onClick={() => handleOpenCreateService()}
>
Создать сервис
</Button>
}
/>
) : (
@@ -800,9 +825,13 @@ function ServicesPage() {
onDeleteGroup={setDeletingGroup}
onAddServiceToGroup={handleOpenCreateService}
emptyAction={
<div className="flex flex-wrap justify-center gap-2">
{addMenu}
</div>
<Button
type="button"
variant="outline"
onClick={() => handleOpenCreateService()}
>
Создать сервис
</Button>
}
/>
</>
@@ -811,3 +840,60 @@ function ServicesPage() {
</PageShell>
)
}
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
}
+84 -23
View File
@@ -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<typeof formSchema>
type GlobalpingValues = z.infer<typeof globalpingSchema>
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 (
<div className="flex w-full flex-col gap-4">
{healthDirty || globalpingDirty ? (
<Alert variant="warning" role="status">
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>Есть несохранённые изменения</AlertTitle>
<AlertDescription>
Поля health-check или Globalping отличаются от последних сохранённых
значений. Сохраните их или сбросьте к данным с сервера.
</AlertDescription>
</Alert>
) : null}
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
@@ -360,11 +389,27 @@ function HealthSettingsPage() {
/>
</SettingRow>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<FrameFooter className="flex flex-row justify-end gap-2">
<ConfirmDialog
title="Сбросить изменения?"
description="Поля health-check вернутся к последним сохранённым значениям с сервера. Несохранённые правки будут отброшены."
confirmLabel="Сбросить"
disabled={!healthDirty || saveMut.isPending}
onConfirm={() => form.reset()}
trigger={
<Button
type="button"
variant="outline"
disabled={!healthDirty || saveMut.isPending}
>
Сбросить
</Button>
}
/>
<LoadingButton
type="submit"
isLoading={saveMut.isPending}
disabled={isLoading || !form.formState.isDirty}
disabled={isLoading || !healthDirty}
>
Сохранить
</LoadingButton>
@@ -571,11 +616,27 @@ function HealthSettingsPage() {
/>
</SettingRow>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<FrameFooter className="flex flex-row justify-end gap-2">
<ConfirmDialog
title="Сбросить изменения?"
description="Локации, число проб и поле токена вернутся к последним сохранённым значениям. Введённый новый токен будет очищен."
confirmLabel="Сбросить"
disabled={!globalpingDirty || saveGpMut.isPending}
onConfirm={() => gpForm.reset()}
trigger={
<Button
type="button"
variant="outline"
disabled={!globalpingDirty || saveGpMut.isPending}
>
Сбросить
</Button>
}
/>
<LoadingButton
type="submit"
isLoading={saveGpMut.isPending}
disabled={isLoading || !gpForm.formState.isDirty}
disabled={isLoading || !globalpingDirty}
>
Сохранить
</LoadingButton>
+1
View File
@@ -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",
+6 -2
View File
@@ -143,12 +143,16 @@ function AlertDialogDescription({
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof Button>) {
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button
<AlertDialogPrimitive.Close
data-slot="alert-dialog-action"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
+3
View File
@@ -319,6 +319,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: 1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
date-fns:
specifier: ^4.4.0
version: 4.4.0