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
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 <[email protected]>
This commit is contained in:
1 parent
b078fa0a03
commit
0ef57c673c
48 files changed
+3534
-1418
No files matched your search
@@ -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 />
|
||||
|
||||
@@ -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 ? 'Синхронизация включена' : 'Синхронизация выключена'}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)],
|
||||
}
|
||||
}
|
||||
@@ -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) }),
|
||||
])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Generated
+3
@@ -319,6 +319,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: 1.1.1
|
||||
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
|
||||
Reference in new issue
Block a user