Enhance CDN Manager: Add Cloudflare API token support, update documentation, and introduce new routes for managing nodes, aliases, and topology. Improve UI components and status badges for better user experience.
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 27s

This commit is contained in:
Denozordec
2026-09-04 12:49:51 +07:00
parent d480325357
commit 8700dc2957
47 changed files with 10550 additions and 69 deletions
+12 -1
View File
@@ -1,5 +1,12 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { LayoutDashboardIcon, SettingsIcon } from 'lucide-react'
import {
CloudIcon,
LayoutDashboardIcon,
Link2Icon,
MapIcon,
ServerIcon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import {
@@ -17,6 +24,10 @@ import {
const mainNav = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
{ to: '/nodes', label: 'Ноды', icon: ServerIcon, exact: false },
{ to: '/aliases', label: 'Алиасы', icon: Link2Icon, exact: false },
{ to: '/topology', label: 'Топология', icon: MapIcon, exact: false },
{ to: '/zones', label: 'Зоны / Sync', icon: CloudIcon, exact: false },
{
to: '/settings/appearance',
label: 'Настройки',
@@ -0,0 +1,100 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cdnmanager/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
*/
export interface AttentionQueueColumn {
id: string
title: string
icon: LucideIcon
iconClassName?: string
count: number
countVariant?:
| 'destructive'
| 'warning'
| 'secondary'
| 'destructive-light'
| 'warning-light'
emptyTitle: string
emptyDescription: string
emptyAction?: ReactNode
children: ReactNode
}
interface AttentionQueueProps {
columns: AttentionQueueColumn[]
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
return (
<div
className={cn(
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
className,
)}
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
aria-hidden="true"
>
<Icon />
</IconTile>
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
{column.count > 0 ? (
<Badge
size="sm"
variant={column.countVariant ?? 'secondary'}
className="tabular-nums"
>
{column.count}
</Badge>
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<div className="flex min-h-28 items-center justify-center py-4">
<EmptyState
icon={Icon}
title={column.emptyTitle}
description={column.emptyDescription}
action={column.emptyAction}
centered={false}
stackedIcon={false}
/>
</div>
) : (
column.children
)}
</FramePanel>
</Frame>
)
})}
</div>
)
}
@@ -0,0 +1,183 @@
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
import { StatusBadge } from '@/components/status-badge'
import { EmptyState } from '@/components/empty-state'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cdnmanager/ui/components/chart'
import { cn } from '@cdnmanager/ui/lib/utils'
const syncChartConfig = {
count: { label: 'Записи' },
ok: { label: 'OK', color: 'var(--success)' },
drift: { label: 'Drift', color: 'var(--warning)' },
missing: { label: 'Нет в CF', color: 'var(--warning)' },
pending: { label: 'Ожидает', color: 'var(--info)' },
error: { label: 'Ошибка', color: 'var(--destructive)' },
} satisfies ChartConfig
const locationChartConfig = {
count: { label: 'Ноды', color: 'var(--chart-1)' },
} satisfies ChartConfig
function statusColor(status: string) {
return (
(syncChartConfig as Record<string, { color?: string }>)[status]?.color ??
'var(--chart-1)'
)
}
interface SyncStatusChartProps {
data: { status: string; count: number }[]
}
/** Pie of sync statuses — DNA from CFDM CertStatusChart */
export function SyncStatusChart({ data }: SyncStatusChartProps) {
const total = data.reduce((sum, entry) => sum + entry.count, 0)
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Статусы синхронизации</FrameTitle>
<FrameDescription>Ноды и алиасы по sync_status</FrameDescription>
</FrameHeader>
<FramePanel fit className="flex min-h-52 flex-col">
{data.length === 0 || total === 0 ? (
<div className="flex min-h-52 w-full flex-1 items-center justify-center py-6">
<EmptyState
title="Нет данных"
description="Добавьте ноды или выполните sync зоны"
centered={false}
/>
</div>
) : (
<div className="grid w-full gap-6 @md:grid-cols-[9rem_minmax(0,1fr)] @md:items-center">
<div className="relative mx-auto size-36 shrink-0">
<ChartContainer
config={syncChartConfig}
className="aspect-square size-36"
initialDimension={{ width: 144, height: 144 }}
>
<PieChart margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
<ChartTooltip
content={<ChartTooltipContent nameKey="status" hideLabel />}
/>
<Pie
data={data}
dataKey="count"
nameKey="status"
innerRadius={40}
outerRadius={64}
strokeWidth={2}
>
{data.map((entry) => (
<Cell
key={entry.status}
fill={statusColor(entry.status)}
/>
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-semibold tabular-nums">{total}</span>
<span className="text-muted-foreground text-xs">всего</span>
</div>
</div>
<ul className="flex flex-col gap-2">
{data.map((entry) => (
<li
key={entry.status}
className="flex items-center justify-between gap-2"
>
<StatusBadge status={entry.status} />
<span className="text-muted-foreground text-sm tabular-nums">
{entry.count}
</span>
</li>
))}
</ul>
</div>
)}
</FramePanel>
</Frame>
)
}
interface NodesByLocationChartProps {
data: { name: string; count: number }[]
}
/** Bar chart of nodes by location — DNA from CFDM GroupDomainsChart */
export function NodesByLocationChart({ data }: NodesByLocationChartProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Ноды по локациям</FrameTitle>
<FrameDescription>Распределение флота по кодам городов</FrameDescription>
</FrameHeader>
<FramePanel fit className="flex min-h-52 flex-col">
{data.length === 0 ? (
<div className="flex min-h-52 w-full flex-1 items-center justify-center py-6">
<EmptyState
title="Нет нод"
description="Создайте канонический хост в локации"
centered={false}
/>
</div>
) : (
<div className="flex w-full flex-col gap-4">
<ChartContainer
config={locationChartConfig}
className="aspect-auto h-52 w-full min-h-52"
initialDimension={{ width: 480, height: 208 }}
>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
interval={0}
height={40}
tickFormatter={(value: string) =>
value.length > 12 ? `${value.slice(0, 11)}` : value
}
/>
<ChartTooltip content={<ChartTooltipContent nameKey="count" />} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
<ul className="grid gap-2 @sm:grid-cols-2">
{data.map((entry) => (
<li
key={entry.name}
className={cn(
'bg-muted/40 flex items-center justify-between gap-2 rounded-lg border px-3 py-2',
)}
>
<span className="truncate text-sm font-medium">{entry.name}</span>
<span className="text-muted-foreground shrink-0 text-sm tabular-nums">
{entry.count}
</span>
</li>
))}
</ul>
</div>
)}
</FramePanel>
</Frame>
)
}
@@ -15,3 +15,11 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export {
AttentionQueue,
type AttentionQueueColumn,
} from './attention-queue'
export {
SyncStatusChart,
NodesByLocationChart,
} from './dashboard-analytics'
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon } from 'lucide-react'
import { CloudIcon, PaletteIcon } from 'lucide-react'
import { useIsMobile } from '@cdnmanager/ui/hooks/use-mobile'
import { cn } from '@cdnmanager/ui/lib/utils'
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'cloudflare',
to: '/settings/cloudflare',
label: 'Cloudflare',
icon: <CloudIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
@@ -31,7 +37,7 @@ interface SettingsShellProps {
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид приложения',
description = 'Внешний вид и параметры Cloudflare DNS',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
+6
View File
@@ -11,7 +11,10 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
synced: 'success-light',
ok: 'success-light',
up: 'success-light',
pending: 'secondary',
pending_push: 'secondary',
drift: 'warning-light',
missing: 'warning-light',
warning: 'warning-light',
degraded: 'warning-light',
conflict: 'destructive-light',
@@ -36,7 +39,10 @@ const DOT_COLOR: Record<string, string> = {
const STATUS_LABELS: Record<string, string> = {
active: 'Активен',
synced: 'Синхронизировано',
pending: 'Ожидает',
pending_push: 'Ожидает отправки',
drift: 'Drift',
missing: 'Нет в CF',
conflict: 'Конфликт',
error: 'Ошибка',
ok: 'OK',
+5
View File
@@ -5,10 +5,15 @@ export interface BreadcrumbCrumb {
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/nodes': 'Ноды',
'/aliases': 'Алиасы',
'/topology': 'Топология',
'/zones': 'Зоны / Sync',
}
const SETTINGS_SECTIONS: Record<string, string> = {
'/settings/appearance': 'Внешний вид',
'/settings/cloudflare': 'Cloudflare',
}
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
+1
View File
@@ -0,0 +1 @@
export { queryClient } from './queryClient'
+192
View File
@@ -0,0 +1,192 @@
import { queryOptions } from '@tanstack/react-query'
import type {
Alias,
AliasCreate,
AliasPatch,
AliasRetarget,
BindExport,
DashboardStats,
Location,
Node,
NodeCreate,
NodePatch,
Topology,
Zone,
ZoneCreate,
ZonePatch,
SyncJob,
CfZone,
} from '@cdnmanager/shared'
import { api } from '@/lib/api-client'
export const fleetKeys = {
all: ['fleet'] as const,
locations: () => [...fleetKeys.all, 'locations'] as const,
zones: () => [...fleetKeys.all, 'zones'] as const,
cfZones: () => [...fleetKeys.all, 'cf-zones'] as const,
nodes: (filters?: Record<string, string | undefined>) =>
[...fleetKeys.all, 'nodes', filters ?? {}] as const,
node: (id: string) => [...fleetKeys.all, 'node', id] as const,
aliases: (filters?: Record<string, string | undefined>) =>
[...fleetKeys.all, 'aliases', filters ?? {}] as const,
alias: (id: string) => [...fleetKeys.all, 'alias', id] as const,
dashboard: () => [...fleetKeys.all, 'dashboard'] as const,
topology: (zoneId?: string) =>
[...fleetKeys.all, 'topology', zoneId ?? 'all'] as const,
syncJobs: (zoneId: string) =>
[...fleetKeys.all, 'sync-jobs', zoneId] as const,
bindExport: (zoneId: string) =>
[...fleetKeys.all, 'bind', zoneId] as const,
namingPreview: (params: Record<string, string>) =>
[...fleetKeys.all, 'naming', params] as const,
}
function qs(filters?: Record<string, string | undefined>) {
if (!filters) return ''
const p = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v) p.set(k, v)
}
const s = p.toString()
return s ? `?${s}` : ''
}
export const locationsQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.locations(),
queryFn: () => api.get<Location[]>('/api/v1/locations'),
})
export const zonesQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.zones(),
queryFn: () => api.get<Zone[]>('/api/v1/zones'),
})
export const cfZonesQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.cfZones(),
queryFn: () => api.get<CfZone[]>('/api/v1/cloudflare/zones'),
retry: false,
})
export const nodesQueryOptions = (filters?: Record<string, string | undefined>) =>
queryOptions({
queryKey: fleetKeys.nodes(filters),
queryFn: () => api.get<Node[]>(`/api/v1/nodes${qs(filters)}`),
})
export const aliasesQueryOptions = (
filters?: Record<string, string | undefined>,
) =>
queryOptions({
queryKey: fleetKeys.aliases(filters),
queryFn: () => api.get<Alias[]>(`/api/v1/aliases${qs(filters)}`),
})
export const dashboardStatsQueryOptions = () =>
queryOptions({
queryKey: fleetKeys.dashboard(),
queryFn: () => api.get<DashboardStats>('/api/v1/dashboard/stats'),
staleTime: 15_000,
})
export const topologyQueryOptions = (zoneId?: string) =>
queryOptions({
queryKey: fleetKeys.topology(zoneId),
queryFn: () =>
api.get<Topology>(
`/api/v1/topology${zoneId ? `?zoneId=${encodeURIComponent(zoneId)}` : ''}`,
),
})
export const syncJobsQueryOptions = (zoneId: string) =>
queryOptions({
queryKey: fleetKeys.syncJobs(zoneId),
queryFn: () => api.get<SyncJob[]>(`/api/v1/zones/${zoneId}/sync-jobs`),
enabled: Boolean(zoneId),
})
export const bindExportQueryOptions = (zoneId: string) =>
queryOptions({
queryKey: fleetKeys.bindExport(zoneId),
queryFn: () =>
api.get<BindExport>(`/api/v1/zones/${zoneId}/export/bind`),
enabled: Boolean(zoneId),
})
export async function createZone(body: ZoneCreate) {
return api.post<Zone>('/api/v1/zones', body)
}
export async function patchZone(id: string, body: ZonePatch) {
return api.patch<Zone>(`/api/v1/zones/${id}`, body)
}
export async function removeZone(id: string) {
return api.delete(`/api/v1/zones/${id}`)
}
export async function syncZone(id: string) {
return api.post<SyncJob>(`/api/v1/zones/${id}/sync`, {})
}
export async function applyZone(id: string, opIds?: string[]) {
return api.post<SyncJob>(`/api/v1/zones/${id}/apply`, { opIds })
}
export async function createNode(body: NodeCreate) {
return api.post<Node>('/api/v1/nodes', body)
}
export async function patchNode(id: string, body: NodePatch) {
return api.patch<Node>(`/api/v1/nodes/${id}`, body)
}
export async function removeNode(id: string) {
return api.delete(`/api/v1/nodes/${id}`)
}
export async function createAlias(body: AliasCreate) {
return api.post<Alias>('/api/v1/aliases', body)
}
export async function patchAlias(id: string, body: AliasPatch) {
return api.patch<Alias>(`/api/v1/aliases/${id}`, body)
}
export async function retargetAliasApi(id: string, body: AliasRetarget) {
return api.post<Alias>(`/api/v1/aliases/${id}/retarget`, body)
}
export async function removeAlias(id: string) {
return api.delete(`/api/v1/aliases/${id}`)
}
export async function ignoreOrphan(
zoneId: string,
recordName: string,
recordType: string,
) {
return api.post(`/api/v1/zones/${zoneId}/orphans/ignore`, {
recordName,
recordType,
})
}
export async function previewHostname(params: {
zoneId: string
locationId: string
role: string
indexNum?: number
providerTag?: string
}) {
const p = new URLSearchParams({
zoneId: params.zoneId,
locationId: params.locationId,
role: params.role,
indexNum: String(params.indexNum ?? 1),
})
if (params.providerTag) p.set('providerTag', params.providerTag)
return api.get<{ hostname: string }>(`/api/v1/naming/preview?${p}`)
}
+1
View File
@@ -1 +1,2 @@
export * from '@/queries/app-switcher'
export * from '@/queries/fleet'
+111 -1
View File
@@ -13,8 +13,13 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthZonesRouteImport } from './routes/_auth/zones'
import { Route as AuthTopologyRouteImport } from './routes/_auth/topology'
import { Route as AuthNodesRouteImport } from './routes/_auth/nodes'
import { Route as AuthAliasesRouteImport } from './routes/_auth/aliases'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthSettingsCloudflareRouteImport } from './routes/_auth/settings/cloudflare'
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
const LoginRoute = LoginRouteImport.update({
@@ -36,6 +41,26 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthZonesRoute = AuthZonesRouteImport.update({
id: '/zones',
path: '/zones',
getParentRoute: () => AuthRoute,
} as any)
const AuthTopologyRoute = AuthTopologyRouteImport.update({
id: '/topology',
path: '/topology',
getParentRoute: () => AuthRoute,
} as any)
const AuthNodesRoute = AuthNodesRouteImport.update({
id: '/nodes',
path: '/nodes',
getParentRoute: () => AuthRoute,
} as any)
const AuthAliasesRoute = AuthAliasesRouteImport.update({
id: '/aliases',
path: '/aliases',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
id: '/settings',
path: '/settings',
@@ -46,6 +71,11 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsCloudflareRoute = AuthSettingsCloudflareRouteImport.update({
id: '/cloudflare',
path: '/cloudflare',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
id: '/appearance',
path: '/appearance',
@@ -56,15 +86,25 @@ export interface FileRoutesByFullPath {
'/': typeof AuthIndexRoute
'/login': typeof LoginRoute
'/settings': typeof AuthSettingsRouteRouteWithChildren
'/aliases': typeof AuthAliasesRoute
'/nodes': typeof AuthNodesRoute
'/topology': typeof AuthTopologyRoute
'/zones': typeof AuthZonesRoute
'/auth/callback': typeof AuthCallbackRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/aliases': typeof AuthAliasesRoute
'/nodes': typeof AuthNodesRoute
'/topology': typeof AuthTopologyRoute
'/zones': typeof AuthZonesRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/settings': typeof AuthSettingsIndexRoute
}
export interface FileRoutesById {
@@ -72,9 +112,14 @@ export interface FileRoutesById {
'/_auth': typeof AuthRouteWithChildren
'/login': typeof LoginRoute
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
'/_auth/aliases': typeof AuthAliasesRoute
'/_auth/nodes': typeof AuthNodesRoute
'/_auth/topology': typeof AuthTopologyRoute
'/_auth/zones': typeof AuthZonesRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
'/_auth/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRouteTypes {
@@ -83,19 +128,39 @@ export interface FileRouteTypes {
| '/'
| '/login'
| '/settings'
| '/aliases'
| '/nodes'
| '/topology'
| '/zones'
| '/auth/callback'
| '/settings/appearance'
| '/settings/cloudflare'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/auth/callback' | '/' | '/settings/appearance' | '/settings'
to:
| '/login'
| '/aliases'
| '/nodes'
| '/topology'
| '/zones'
| '/auth/callback'
| '/'
| '/settings/appearance'
| '/settings/cloudflare'
| '/settings'
id:
| '__root__'
| '/_auth'
| '/login'
| '/_auth/settings'
| '/_auth/aliases'
| '/_auth/nodes'
| '/_auth/topology'
| '/_auth/zones'
| '/auth/callback'
| '/_auth/'
| '/_auth/settings/appearance'
| '/_auth/settings/cloudflare'
| '/_auth/settings/'
fileRoutesById: FileRoutesById
}
@@ -135,6 +200,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/zones': {
id: '/_auth/zones'
path: '/zones'
fullPath: '/zones'
preLoaderRoute: typeof AuthZonesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/topology': {
id: '/_auth/topology'
path: '/topology'
fullPath: '/topology'
preLoaderRoute: typeof AuthTopologyRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/nodes': {
id: '/_auth/nodes'
path: '/nodes'
fullPath: '/nodes'
preLoaderRoute: typeof AuthNodesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/aliases': {
id: '/_auth/aliases'
path: '/aliases'
fullPath: '/aliases'
preLoaderRoute: typeof AuthAliasesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
@@ -149,6 +242,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/cloudflare': {
id: '/_auth/settings/cloudflare'
path: '/cloudflare'
fullPath: '/settings/cloudflare'
preLoaderRoute: typeof AuthSettingsCloudflareRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/appearance': {
id: '/_auth/settings/appearance'
path: '/appearance'
@@ -161,11 +261,13 @@ declare module '@tanstack/react-router' {
interface AuthSettingsRouteRouteChildren {
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
AuthSettingsCloudflareRoute: typeof AuthSettingsCloudflareRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
}
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
AuthSettingsCloudflareRoute: AuthSettingsCloudflareRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
}
@@ -174,11 +276,19 @@ const AuthSettingsRouteRouteWithChildren =
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthAliasesRoute: typeof AuthAliasesRoute
AuthNodesRoute: typeof AuthNodesRoute
AuthTopologyRoute: typeof AuthTopologyRoute
AuthZonesRoute: typeof AuthZonesRoute
AuthIndexRoute: typeof AuthIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
AuthAliasesRoute: AuthAliasesRoute,
AuthNodesRoute: AuthNodesRoute,
AuthTopologyRoute: AuthTopologyRoute,
AuthZonesRoute: AuthZonesRoute,
AuthIndexRoute: AuthIndexRoute,
}
+430
View File
@@ -0,0 +1,430 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { Link2Icon, PlusIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Alias, AliasMode, AliasPurpose } from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { FormSheet } from '@/components/form-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@cdnmanager/ui/components/button'
import { Input } from '@cdnmanager/ui/components/input'
import { Label } from '@cdnmanager/ui/components/label'
import { SelectField } from '@/components/select-field'
import { queryClient } from '@/lib/query-client'
import {
aliasesQueryOptions,
createAlias,
nodesQueryOptions,
patchAlias,
removeAlias,
retargetAliasApi,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/aliases')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(aliasesQueryOptions()),
queryClient.ensureQueryData(nodesQueryOptions()),
queryClient.ensureQueryData(zonesQueryOptions()),
]),
component: AliasesPage,
})
const PURPOSES: { value: AliasPurpose; label: string }[] = [
{ value: 'geo', label: 'geo' },
{ value: 'ix', label: 'ix' },
{ value: 'backup', label: 'backup' },
{ value: 'admin', label: 'admin' },
{ value: 'custom', label: 'custom' },
]
const MODES: { value: AliasMode; label: string }[] = [
{ value: 'primary', label: 'primary' },
{ value: 'pair', label: 'pair' },
]
const formSchema = z.object({
zoneId: z.string().min(1, 'Выберите зону'),
name: z.string().min(1, 'Укажите имя'),
purpose: z.enum(['geo', 'ix', 'backup', 'admin', 'custom']),
mode: z.enum(['primary', 'pair']),
targetNodeId: z.string().min(1, 'Выберите ноду'),
})
type FormValues = z.infer<typeof formSchema>
function AliasesPage() {
const qc = useQueryClient()
const { data: aliases = [], isLoading, isError, error, refetch } = useQuery(
aliasesQueryOptions(),
)
const { data: nodes = [] } = useQuery(nodesQueryOptions())
const { data: zones = [] } = useQuery(zonesQueryOptions())
const [filters, setFilters] = useState<Filter[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<Alias | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [retargetId, setRetargetId] = useState<string | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
zoneId: '',
name: '',
purpose: 'geo',
mode: 'primary',
targetNodeId: '',
},
})
const retargetForm = useForm<{ targetNodeId: string }>({
resolver: zodResolver(z.object({ targetNodeId: z.string().min(1) })),
defaultValues: { targetNodeId: '' },
})
const saveMutation = useMutation({
mutationFn: async (values: FormValues) => {
if (editing) {
return patchAlias(editing.id, {
name: values.name,
purpose: values.purpose,
mode: values.mode,
targetNodeId: values.targetNodeId,
})
}
return createAlias(values)
},
onSuccess: () => {
toast.success(editing ? 'Алиас обновлён' : 'Алиас создан')
setSheetOpen(false)
setEditing(null)
form.reset()
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => removeAlias(id),
onSuccess: () => {
toast.success('Алиас удалён')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const retargetMutation = useMutation({
mutationFn: ({ id, targetNodeId }: { id: string; targetNodeId: string }) =>
retargetAliasApi(id, { targetNodeId }),
onSuccess: () => {
toast.success('Цель алиаса изменена')
setRetargetId(null)
retargetForm.reset({ targetNodeId: '' })
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'q',
label: 'Поиск',
type: 'text',
placeholder: 'имя / hostname',
},
{
key: 'purpose',
label: 'Purpose',
type: 'select',
options: PURPOSES.map((p) => ({ value: p.value, label: p.label })),
},
{
key: 'syncStatus',
label: 'Sync',
type: 'select',
options: [
{ value: 'ok', label: 'ok' },
{ value: 'drift', label: 'drift' },
{ value: 'missing', label: 'missing' },
{ value: 'pending', label: 'pending' },
{ value: 'error', label: 'error' },
],
},
],
[],
)
const nodeOptions = nodes.map((n) => ({
value: n.id,
label: n.hostname,
}))
const columns: ColumnDef<Alias, unknown>[] = [
{
accessorKey: 'name',
header: 'Имя',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Link2Icon className="text-muted-foreground size-4 shrink-0" />
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: 'purpose',
header: 'Purpose',
},
{
accessorKey: 'mode',
header: 'Mode',
},
{
accessorKey: 'targetHostname',
header: 'Target',
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.targetHostname ?? '—'}
</span>
),
},
{
accessorKey: 'syncStatus',
header: 'Sync',
cell: ({ row }) => <StatusBadge status={row.original.syncStatus} />,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const a = row.original
setEditing(a)
form.reset({
zoneId: a.zoneId,
name: a.name,
purpose: a.purpose,
mode: a.mode,
targetNodeId: a.targetNodeId,
})
setSheetOpen(true)
}}
>
Изменить
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setRetargetId(row.original.id)
retargetForm.reset({
targetNodeId: row.original.targetNodeId,
})
}}
>
Retarget
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(row.original.id)}
>
Удалить
</Button>
</div>
),
},
]
return (
<PageShell>
<PageHeader
title="Алиасы"
description="CNAME на канонические ноды (geo / ix / backup)"
actions={
<Button
size="sm"
onClick={() => {
setEditing(null)
form.reset({
zoneId: zones[0]?.id ?? '',
name: '',
purpose: 'geo',
mode: 'primary',
targetNodeId: nodes[0]?.id ?? '',
})
setSheetOpen(true)
}}
disabled={zones.length === 0 || nodes.length === 0}
>
<PlusIcon data-icon="inline-start" />
Добавить алиас
</Button>
}
/>
<ResourcePage
title="Алиасы"
description="Desired-state CNAME"
hideHeader
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) => {
if (field === 'q') {
return `${item.name} ${item.targetHostname ?? ''}`
}
return (item as Record<string, unknown>)[field]
}}
columns={columns}
data={aliases}
getRowId={(r) => r.id}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
emptyState={{
title: 'Нет алиасов',
description: 'Создайте CNAME, указывающий на ноду флота',
}}
/>
<FormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={editing ? 'Изменить алиас' : 'Новый алиас'}
description="CNAME name → target node hostname"
form={form}
onSubmit={async (v) => {
await saveMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
}
>
<div className="flex flex-col gap-4">
{!editing ? (
<div className="flex flex-col gap-2">
<Label>Зона</Label>
<SelectField
value={form.watch('zoneId')}
onValueChange={(v) => form.setValue('zoneId', v ?? '')}
placeholder="Зона"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label>Имя (FQDN / label)</Label>
<Input {...form.register('name')} placeholder="msk.example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-2">
<Label>Purpose</Label>
<SelectField
value={form.watch('purpose')}
onValueChange={(v) =>
form.setValue('purpose', (v as AliasPurpose) ?? 'geo')
}
options={PURPOSES.map((p) => ({
value: p.value,
label: p.label,
}))}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Mode</Label>
<SelectField
value={form.watch('mode')}
onValueChange={(v) =>
form.setValue('mode', (v as AliasMode) ?? 'primary')
}
options={MODES.map((m) => ({
value: m.value,
label: m.label,
}))}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Target node</Label>
<SelectField
value={form.watch('targetNodeId')}
onValueChange={(v) => form.setValue('targetNodeId', v ?? '')}
placeholder="Нода"
options={nodeOptions}
/>
</div>
</div>
</FormSheet>
<FormSheet
open={Boolean(retargetId)}
onOpenChange={(o) => {
if (!o) {
setRetargetId(null)
retargetForm.reset({ targetNodeId: '' })
}
}}
title="Переназначить алиас"
description="Выберите новую целевую ноду"
form={retargetForm}
onSubmit={async (v) => {
if (!retargetId) return
await retargetMutation.mutateAsync({
id: retargetId,
targetNodeId: v.targetNodeId,
})
}}
footer={
<Button type="submit" disabled={retargetMutation.isPending}>
{retargetMutation.isPending ? 'Сохранение…' : 'Retarget'}
</Button>
}
>
<div className="flex flex-col gap-2">
<Label>Новая нода</Label>
<SelectField
value={retargetForm.watch('targetNodeId')}
onValueChange={(v) =>
retargetForm.setValue('targetNodeId', v ?? '')
}
placeholder="Нода"
options={nodeOptions}
/>
</div>
</FormSheet>
<ConfirmDialog
open={Boolean(deleteId)}
onOpenChange={(o) => !o && setDeleteId(null)}
title="Удалить алиас?"
description="CNAME будет удалён из desired-state и при следующем apply — из Cloudflare."
confirmLabel="Удалить"
onConfirm={() => deleteId && deleteMutation.mutate(deleteId)}
/>
</PageShell>
)
}
+329 -22
View File
@@ -1,71 +1,378 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LayoutDashboardIcon, SettingsIcon } from 'lucide-react'
import {
ActivityIcon,
AlertTriangleIcon,
CloudIcon,
GlobeIcon,
Link2Icon,
MapIcon,
RefreshCwIcon,
ServerIcon,
} from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import {
AttentionQueue,
NodesByLocationChart,
OpsDashboard,
QuickActionGrid,
SyncStatusChart,
type KpiStatCard,
type QuickActionItem,
} from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cdnmanager/ui/components/item'
import { Button } from '@cdnmanager/ui/components/button'
import { api } from '@/lib/api-client'
import { EmptyState } from '@/components/empty-state'
import { queryClient } from '@/lib/query-client'
import type { AppSettings } from '@cdnmanager/shared'
import {
aliasesQueryOptions,
dashboardStatsQueryOptions,
nodesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(dashboardStatsQueryOptions()),
queryClient.ensureQueryData(nodesQueryOptions()),
queryClient.ensureQueryData(aliasesQueryOptions()),
]),
component: DashboardPage,
})
function DashboardPage() {
const {
data: stats,
isLoading: statsLoading,
} = useQuery(dashboardStatsQueryOptions())
const { data: nodes = [], isLoading: nodesLoading } = useQuery(
nodesQueryOptions(),
)
const { data: aliases = [] } = useQuery(aliasesQueryOptions())
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
queryFn: () => api.get<AppSettings>('/api/v1/settings'),
})
const showQuickActions = appSettings?.showQuickActions !== false
const isLoading = statsLoading || nodesLoading
const locationChartData = useMemo(() => {
const counts = new Map<string, number>()
for (const node of nodes) {
const key = node.locationCode || '—'
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
}, [nodes])
const syncChartData = useMemo(() => {
const counts = new Map<string, number>()
for (const node of nodes) {
counts.set(node.syncStatus, (counts.get(node.syncStatus) ?? 0) + 1)
}
for (const alias of aliases) {
counts.set(alias.syncStatus, (counts.get(alias.syncStatus) ?? 0) + 1)
}
return [...counts.entries()]
.map(([status, count]) => ({ status, count }))
.sort((a, b) => b.count - a.count)
}, [nodes, aliases])
const driftItems = useMemo(
() =>
[
...nodes
.filter((n) => n.syncStatus === 'drift' || n.syncStatus === 'missing')
.map((n) => ({
id: `n-${n.id}`,
label: n.hostname,
status: n.syncStatus,
to: '/nodes' as const,
})),
...aliases
.filter((a) => a.syncStatus === 'drift' || a.syncStatus === 'missing')
.map((a) => ({
id: `a-${a.id}`,
label: a.name,
status: a.syncStatus,
to: '/aliases' as const,
})),
].slice(0, 8),
[nodes, aliases],
)
const proxyItems = useMemo(
() =>
aliases
.filter((a) => a.lastError?.toLowerCase().includes('prox'))
.slice(0, 8)
.map((a) => ({
id: a.id,
label: a.name,
status: a.syncStatus,
})),
[aliases],
)
const orphanHint = stats?.orphans ?? 0
const kpiCards: KpiStatCard[] = [
{
id: 'ready',
label: 'Статус',
value: 'Готов',
icon: <LayoutDashboardIcon aria-hidden />,
id: 'nodes',
label: 'Ноды',
value: stats?.nodes ?? nodes.length,
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info',
to: '/nodes',
},
{
id: 'aliases',
label: 'Алиасы',
value: stats?.aliases ?? aliases.length,
icon: <GlobeIcon aria-hidden />,
iconClassName: 'text-success',
hint: 'Скелет CDN Manager',
to: '/aliases',
},
{
id: 'syncOk',
label: 'Sync OK',
value: stats?.syncOk ?? 0,
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'drift',
label: 'Drift',
value: stats?.drift ?? 0,
variant: (stats?.drift ?? 0) > 0 ? 'warning' : 'default',
icon: <RefreshCwIcon aria-hidden />,
iconClassName: 'text-warning',
to: '/zones',
},
{
id: 'proxyViolations',
label: 'Proxy',
value: stats?.proxyViolations ?? 0,
variant: (stats?.proxyViolations ?? 0) > 0 ? 'destructive' : 'default',
icon: <AlertTriangleIcon aria-hidden />,
iconClassName: 'text-destructive',
to: '/aliases',
},
{
id: 'orphans',
label: 'Orphans',
value: stats?.orphans ?? 0,
variant: (stats?.orphans ?? 0) > 0 ? 'warning' : 'default',
icon: <CloudIcon aria-hidden />,
iconClassName: 'text-warning',
to: '/zones',
},
]
const quickActions: QuickActionItem[] = [
{
id: 'settings',
title: 'Настройки',
description: 'Внешний вид и параметры приложения.',
to: '/settings/appearance',
icon: <SettingsIcon aria-hidden />,
id: 'nodes',
title: 'Ноды',
description: 'Канонические A/AAAA хосты флота.',
to: '/nodes',
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info',
},
{
id: 'aliases',
title: 'Алиасы',
description: 'CNAME geo / ix / backup.',
to: '/aliases',
icon: <Link2Icon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'topology',
title: 'Топология',
description: 'Карта нод и рёбер.',
to: '/topology',
icon: <MapIcon aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'zones',
title: 'Зоны / Sync',
description: 'Синхронизация с Cloudflare.',
to: '/zones',
icon: <CloudIcon aria-hidden />,
iconClassName: 'text-warning',
},
]
return (
<PageShell>
<PageHeader
title="Панель управления"
description="CDN Manager — базовый каркас для разработки"
description="Обзор флота CDN: ноды, алиасы и синхронизация DNS"
/>
<OpsDashboard
isLoading={isLoading}
kpiCards={kpiCards}
afterKpi={
showQuickActions ? <QuickActionGrid actions={quickActions} /> : null
showQuickActions ? (
<QuickActionGrid
actions={quickActions}
description="Частые разделы управления флотом"
/>
) : null
}
charts={
<EmptyState
title="Пока пусто"
description="Доменная логика CDN будет добавлена позже."
<>
<NodesByLocationChart data={locationChartData} />
<SyncStatusChart data={syncChartData} />
</>
}
queueTitle="Требуют внимания"
queueDescription="Drift, proxy-нарушения и orphan-записи в Cloudflare"
queue={
<AttentionQueue
columns={[
{
id: 'drift',
title: 'Drift',
icon: RefreshCwIcon,
iconClassName: 'text-warning [&_svg]:text-current',
count: driftItems.length,
countVariant: 'warning-light',
emptyTitle: 'Нет drift',
emptyDescription: 'Ноды и алиасы совпадают с Cloudflare',
emptyAction: (
<Button variant="outline" size="sm" render={<Link to="/zones" />}>
К зонам
</Button>
),
children: (
<ItemGroup className="gap-2">
{driftItems.map((item) => (
<Item
key={item.id}
variant="outline"
size="sm"
render={<Link to={item.to} />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{item.label}
</ItemTitle>
</ItemContent>
<ItemActions>
<StatusBadge status={item.status} />
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
{
id: 'proxy',
title: 'Proxy',
icon: AlertTriangleIcon,
iconClassName: 'text-destructive [&_svg]:text-current',
count: stats?.proxyViolations ?? proxyItems.length,
countVariant: 'destructive-light',
emptyTitle: 'Нет нарушений',
emptyDescription: 'Proxied lock соблюдён',
emptyAction: (
<Button
variant="outline"
size="sm"
render={<Link to="/aliases" />}
>
К алиасам
</Button>
),
children: (
<ItemGroup className="gap-2">
{proxyItems.length > 0
? proxyItems.map((item) => (
<Item
key={item.id}
variant="outline"
size="sm"
render={<Link to="/aliases" />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{item.label}
</ItemTitle>
</ItemContent>
<ItemActions>
<StatusBadge status={item.status} />
</ItemActions>
</Item>
))
: (
<Item
variant="outline"
size="sm"
render={<Link to="/aliases" />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{(stats?.proxyViolations ?? 0) > 0
? `${stats?.proxyViolations} proxy-нарушений`
: 'См. алиасы'}
</ItemTitle>
</ItemContent>
</Item>
)}
</ItemGroup>
),
},
{
id: 'orphans',
title: 'Orphans',
icon: CloudIcon,
iconClassName: 'text-warning [&_svg]:text-current',
count: orphanHint,
countVariant: 'warning-light',
emptyTitle: 'Нет orphans',
emptyDescription: 'В Cloudflare нет лишних записей',
emptyAction: (
<Button variant="outline" size="sm" render={<Link to="/zones" />}>
К зонам
</Button>
),
children: (
<ItemGroup className="gap-2">
<Item
variant="outline"
size="sm"
render={<Link to="/zones" />}
>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{orphanHint} orphan-записей в CF
</ItemTitle>
</ItemContent>
<ItemActions>
<StatusBadge status="drift" label="orphan" />
</ItemActions>
</Item>
</ItemGroup>
),
},
]}
/>
}
queue={null}
queueTitle="Очередь"
queueDescription="Здесь появятся операционные события"
/>
</PageShell>
)
+483
View File
@@ -0,0 +1,483 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
CloudIcon,
MapPinIcon,
PlusIcon,
RefreshCwIcon,
ServerIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Node, NodeRole } from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { FormSheet } from '@/components/form-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@cdnmanager/ui/components/button'
import { Input } from '@cdnmanager/ui/components/input'
import { Label } from '@cdnmanager/ui/components/label'
import { SelectField } from '@/components/select-field'
import { queryClient } from '@/lib/query-client'
import {
createNode,
locationsQueryOptions,
nodesQueryOptions,
patchNode,
previewHostname,
removeNode,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/nodes')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(nodesQueryOptions()),
queryClient.ensureQueryData(locationsQueryOptions()),
queryClient.ensureQueryData(zonesQueryOptions()),
]),
component: NodesPage,
})
const formSchema = z.object({
zoneId: z.string().min(1, 'Выберите зону'),
locationId: z.string().min(1, 'Выберите локацию'),
role: z.enum(['hub', 'gw', 'edge', 'ix']),
indexNum: z.number().int().min(1).max(99),
ipv4: z.string().min(7),
ipv6: z.string().optional(),
providerTag: z.string().optional(),
notes: z.string().optional(),
hostname: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
const ROLES: { value: NodeRole; label: string }[] = [
{ value: 'hub', label: 'hub' },
{ value: 'gw', label: 'gw' },
{ value: 'edge', label: 'edge' },
{ value: 'ix', label: 'ix' },
]
function NodesPage() {
const qc = useQueryClient()
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
nodesQueryOptions(),
)
const { data: locations = [] } = useQuery(locationsQueryOptions())
const { data: zones = [] } = useQuery(zonesQueryOptions())
const [filters, setFilters] = useState<Filter[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<Node | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [preview, setPreview] = useState('')
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
zoneId: '',
locationId: '',
role: 'gw',
indexNum: 1,
ipv4: '',
ipv6: '',
providerTag: '',
notes: '',
hostname: '',
},
})
const watchZone = form.watch('zoneId')
const watchLoc = form.watch('locationId')
const watchRole = form.watch('role')
const watchIndex = form.watch('indexNum')
const watchProvider = form.watch('providerTag')
async function refreshPreview() {
if (!watchZone || !watchLoc || !watchRole) return
try {
const res = await previewHostname({
zoneId: watchZone,
locationId: watchLoc,
role: watchRole,
indexNum: Number(watchIndex) || 1,
providerTag: watchProvider || undefined,
})
setPreview(res.hostname)
} catch {
setPreview('')
}
}
const saveMutation = useMutation({
mutationFn: async (values: FormValues) => {
if (editing) {
return patchNode(editing.id, {
locationId: values.locationId,
role: values.role,
indexNum: values.indexNum,
ipv4: values.ipv4,
ipv6: values.ipv6 || null,
providerTag: values.providerTag || null,
notes: values.notes || null,
hostname: values.hostname || undefined,
})
}
return createNode({
zoneId: values.zoneId,
locationId: values.locationId,
role: values.role,
indexNum: values.indexNum,
ipv4: values.ipv4,
ipv6: values.ipv6 || null,
providerTag: values.providerTag || null,
notes: values.notes || null,
hostname: values.hostname || undefined,
})
},
onSuccess: () => {
toast.success(editing ? 'Нода обновлена' : 'Нода создана')
setSheetOpen(false)
setEditing(null)
form.reset()
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => removeNode(id),
onSuccess: () => {
toast.success('Нода удалена')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'q',
label: 'Поиск',
type: 'text',
placeholder: 'hostname / provider',
},
{
key: 'locationCode',
label: 'Локация',
type: 'select',
options: locations.map((l) => ({ value: l.code, label: l.code })),
},
{
key: 'role',
label: 'Роль',
type: 'select',
options: ROLES.map((r) => ({ value: r.value, label: r.label })),
},
{
key: 'syncStatus',
label: 'Sync',
type: 'select',
options: [
{ value: 'ok', label: 'ok' },
{ value: 'drift', label: 'drift' },
{ value: 'missing', label: 'missing' },
{ value: 'pending', label: 'pending' },
{ value: 'error', label: 'error' },
],
},
],
[locations],
)
const columns: ColumnDef<Node, unknown>[] = [
{
accessorKey: 'hostname',
header: 'FQDN',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
<span className="font-medium">{row.original.hostname}</span>
</div>
),
},
{
accessorKey: 'locationCode',
header: 'Локация',
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
<MapPinIcon className="size-3.5" />
{row.original.locationCode ?? '—'}
</span>
),
},
{
accessorKey: 'role',
header: 'Роль',
},
{
id: 'ip',
header: 'IPv4 / IPv6',
cell: ({ row }) => {
const v4 = row.original.addresses.find((a) => a.family === 'v4')?.ip
const v6 = row.original.addresses.find((a) => a.family === 'v6')?.ip
return (
<div className="flex flex-col gap-0.5 font-mono text-xs tabular-nums">
<span>{v4 ?? '—'}</span>
{v6 ? <span className="text-muted-foreground">{v6}</span> : null}
</div>
)
},
},
{
accessorKey: 'providerTag',
header: 'Provider',
cell: ({ row }) => row.original.providerTag || '—',
},
{
accessorKey: 'syncStatus',
header: 'Sync',
cell: ({ row }) => <StatusBadge status={row.original.syncStatus} />,
},
{
accessorKey: 'aliasCount',
header: 'CNAME→',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.aliasCount ?? 0}</span>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const n = row.original
setEditing(n)
form.reset({
zoneId: n.zoneId,
locationId: n.locationId,
role: n.role as NodeRole,
indexNum: n.indexNum,
ipv4: n.addresses.find((a) => a.family === 'v4')?.ip ?? '',
ipv6: n.addresses.find((a) => a.family === 'v6')?.ip ?? '',
providerTag: n.providerTag ?? '',
notes: n.notes ?? '',
hostname: n.hostname,
})
setPreview(n.hostname)
setSheetOpen(true)
}}
>
Изменить
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(row.original.id)}
>
Удалить
</Button>
</div>
),
},
]
return (
<PageShell>
<PageHeader
title="Ноды"
description="Канонические хосты A/AAAA (железо CHR/VPS)"
actions={
<Button
size="sm"
onClick={() => {
setEditing(null)
form.reset({
zoneId: zones[0]?.id ?? '',
locationId: locations[0]?.id ?? '',
role: 'gw',
indexNum: 1,
ipv4: '',
ipv6: '',
providerTag: '',
notes: '',
hostname: '',
})
setPreview('')
setSheetOpen(true)
void refreshPreview()
}}
disabled={zones.length === 0}
>
<PlusIcon data-icon="inline-start" />
Добавить ноду
</Button>
}
/>
{zones.length === 0 ? (
<p className="text-muted-foreground text-sm">
Сначала добавьте зону на странице{' '}
<Link to="/zones" className="text-primary underline">
Зоны / Sync
</Link>
.
</p>
) : null}
<ResourcePage
title="Инвентарь нод"
description="Desired-state канонических FQDN"
hideHeader
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) => {
if (field === 'q') return `${item.hostname} ${item.providerTag ?? ''}`
if (field === 'locationCode') return item.locationCode
return (item as Record<string, unknown>)[field]
}}
columns={columns}
data={nodes}
getRowId={(r) => r.id}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
emptyState={{
title: 'Нет нод',
description: 'Создайте первую каноническую ноду флота',
}}
/>
<FormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={editing ? 'Изменить ноду' : 'Новая нода'}
description="Имя собирается по шаблону зоны: {loc}-{role}{nn}.{zone}"
form={form}
onSubmit={async (v) => {
await saveMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
}
>
<div className="flex flex-col gap-4">
{!editing ? (
<div className="flex flex-col gap-2">
<Label>Зона</Label>
<SelectField
value={form.watch('zoneId')}
onValueChange={(v) => {
form.setValue('zoneId', v ?? '')
void refreshPreview()
}}
placeholder="Зона"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label>Локация</Label>
<SelectField
value={form.watch('locationId')}
onValueChange={(v) => {
form.setValue('locationId', v ?? '')
void refreshPreview()
}}
placeholder="Локация"
options={locations.map((l) => ({
value: l.id,
label: `${l.code}${l.name}`,
}))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-2">
<Label>Роль</Label>
<SelectField
value={form.watch('role')}
onValueChange={(v) => {
form.setValue('role', (v as NodeRole) ?? 'gw')
void refreshPreview()
}}
options={ROLES.map((r) => ({ value: r.value, label: r.label }))}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Индекс</Label>
<Input
type="number"
min={1}
max={99}
{...form.register('indexNum', { valueAsNumber: true })}
onBlur={() => void refreshPreview()}
/>
</div>
</div>
<div className="bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<CloudIcon className="size-4 shrink-0" />
<span className="text-muted-foreground">Preview:</span>
<code className="font-medium">{preview || '—'}</code>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => void refreshPreview()}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2">
<Label>IPv4</Label>
<Input {...form.register('ipv4')} placeholder="198.51.100.10" />
</div>
<div className="flex flex-col gap-2">
<Label>IPv6 (опц.)</Label>
<Input {...form.register('ipv6')} placeholder="2001:db8::10" />
</div>
<div className="flex flex-col gap-2">
<Label>Provider tag</Label>
<Input {...form.register('providerTag')} placeholder="ih / vv" />
</div>
<div className="flex flex-col gap-2">
<Label>Заметки</Label>
<Input {...form.register('notes')} />
</div>
</div>
</FormSheet>
<ConfirmDialog
open={Boolean(deleteId)}
onOpenChange={(o) => !o && setDeleteId(null)}
title="Удалить ноду?"
description="Алиасы, указывающие на ноду, должны быть удалены или переназначены заранее."
confirmLabel="Удалить"
onConfirm={() => deleteId && deleteMutation.mutate(deleteId)}
/>
</PageShell>
)
}
@@ -0,0 +1,129 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CloudIcon } from 'lucide-react'
import type { AppSettings, AppSettingsPatch } from '@cdnmanager/shared'
import { api } from '@/lib/api-client'
import { SettingRow } from '@/components/setting-row'
import { StatusBadge } from '@/components/status-badge'
import { Switch } from '@cdnmanager/ui/components/switch'
import { Input } from '@cdnmanager/ui/components/input'
import { FieldGroup } from '@cdnmanager/ui/components/field'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/settings/cloudflare')({
component: CloudflareSettingsPage,
})
function CloudflareSettingsPage() {
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<AppSettings>('/api/v1/settings'),
})
const patchMut = useMutation({
mutationFn: (patch: AppSettingsPatch) =>
api.patch<AppSettings>('/api/v1/settings', patch),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки Cloudflare сохранены')
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const configured = data?.cloudflareConfigured === true
return (
<div className="flex w-full flex-col gap-4">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<CloudIcon className="size-4" aria-hidden />
Cloudflare
</FrameTitle>
<FrameDescription>
Параметры DNS sync и naming template
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
<SettingRow
title="API token"
description="CLOUDFLARE_API_TOKEN задаётся только через env сервера API — в UI не хранится."
>
<StatusBadge
status={configured ? 'ok' : 'missing'}
label={configured ? 'Настроен' : 'Не задан'}
/>
</SettingRow>
<SettingRow
title="Default TTL"
description="TTL по умолчанию для A/AAAA и CNAME (6086400)."
labelFor="default-ttl"
>
<Input
id="default-ttl"
type="number"
min={60}
max={86400}
className="w-32"
disabled={isLoading || patchMut.isPending}
defaultValue={data?.defaultTtl ?? 300}
key={data?.defaultTtl ?? 'ttl'}
onBlur={(e) => {
const next = Number(e.target.value)
if (!Number.isFinite(next) || next === data?.defaultTtl) return
patchMut.mutate({ defaultTtl: next })
}}
/>
</SettingRow>
<SettingRow
title="Naming template"
description="Шаблон hostname: {loc}-{role}{nn}.{zone}"
labelFor="naming-template"
stacked
>
<Input
id="naming-template"
className="w-full max-w-md font-mono text-sm"
disabled={isLoading || patchMut.isPending}
defaultValue={data?.namingTemplate ?? ''}
key={data?.namingTemplate ?? 'tpl'}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === data?.namingTemplate) return
patchMut.mutate({ namingTemplate: next })
}}
/>
</SettingRow>
<SettingRow
title="Proxied lock"
description="Запретить orange-cloud (proxied=true) для записей флота."
last
>
<Switch
checked={data?.proxiedLock !== false}
disabled={isLoading || patchMut.isPending}
onCheckedChange={(checked) =>
patchMut.mutate({ proxiedLock: checked })
}
aria-label="Proxied lock"
/>
</SettingRow>
</FieldGroup>
</FramePanel>
</Frame>
</div>
)
}
+23 -2
View File
@@ -1,10 +1,31 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsShell } from '@/components/reui-kit'
import { CloudIcon, PaletteIcon } from 'lucide-react'
import { SettingsShell, type SettingsTabConfig } from '@/components/reui-kit'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsLayout,
})
const SETTINGS_TABS: SettingsTabConfig[] = [
{
id: 'appearance',
to: '/settings/appearance',
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'cloudflare',
to: '/settings/cloudflare',
label: 'Cloudflare',
icon: <CloudIcon className="size-4" aria-hidden="true" />,
},
]
function SettingsLayout() {
return <SettingsShell />
return (
<SettingsShell
description="Внешний вид и параметры Cloudflare DNS"
tabs={SETTINGS_TABS}
/>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link2Icon, MapIcon, ServerIcon } from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { SelectField } from '@/components/select-field'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Label } from '@cdnmanager/ui/components/label'
import { queryClient } from '@/lib/query-client'
import {
topologyQueryOptions,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/topology')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(topologyQueryOptions()),
queryClient.ensureQueryData(zonesQueryOptions()),
]),
component: TopologyPage,
})
function TopologyPage() {
const [zoneId, setZoneId] = useState<string | undefined>(undefined)
const { data: zones = [] } = useQuery(zonesQueryOptions())
const { data, isLoading, isError, refetch } = useQuery(
topologyQueryOptions(zoneId),
)
const nodes = data?.nodes ?? []
const edges = data?.edges ?? []
const byLocation = useMemo(() => {
const map = new Map<string, typeof nodes>()
for (const node of nodes) {
const key = node.locationCode || '—'
const list = map.get(key) ?? []
list.push(node)
map.set(key, list)
}
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b))
}, [nodes])
return (
<PageShell>
<PageHeader
title="Топология"
description="Ноды по локациям и CNAME-рёбра алиасов"
actions={
<div className="flex min-w-48 flex-col gap-1.5">
<Label className="sr-only">Зона</Label>
<SelectField
value={zoneId ?? null}
onValueChange={(v) => setZoneId(v ?? undefined)}
placeholder="Все зоны"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
/>
</div>
}
/>
{isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : isError ? (
<EmptyState
title="Ошибка загрузки"
description="Не удалось получить топологию"
action={
<button
type="button"
className="text-primary text-sm underline"
onClick={() => refetch()}
>
Повторить
</button>
}
/>
) : nodes.length === 0 ? (
<EmptyState
icon={MapIcon}
title="Нет нод"
description="Добавьте ноды, чтобы увидеть топологию флота"
/>
) : (
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{byLocation.map(([code, locNodes]) => (
<Frame key={code} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<MapIcon className="size-4" aria-hidden />
{code}
</FrameTitle>
<FrameDescription>
{locNodes.length}{' '}
{locNodes.length === 1 ? 'нода' : 'нод'}
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
{locNodes.map((node) => (
<div
key={node.id}
className="flex items-start gap-3 rounded-lg border px-3 py-2"
>
<IconTile
variant="elevated"
className="size-10.5 shrink-0 text-success [&_svg]:text-current"
aria-hidden
>
<ServerIcon />
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{node.hostname}
</span>
<Badge size="sm" variant="secondary">
{node.role}
</Badge>
<StatusBadge status={node.syncStatus} />
</div>
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{node.ipv4 ?? '—'}
</span>
</div>
</div>
))}
</FramePanel>
</Frame>
))}
</div>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<Link2Icon className="size-4" aria-hidden />
CNAME edges
</FrameTitle>
<FrameDescription>
Алиас целевой hostname ({edges.length})
</FrameDescription>
</FrameHeader>
<FramePanel>
{edges.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет алиасов</p>
) : (
<ul className="flex flex-col gap-2">
{edges.map((edge) => (
<li
key={edge.id}
className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<Badge size="sm" variant="outline">
{edge.purpose}
</Badge>
<span className="font-medium">{edge.aliasName}</span>
<span className="text-muted-foreground"></span>
<span className="font-mono text-xs">
{edge.toHostname}
</span>
</li>
))}
</ul>
)}
</FramePanel>
</Frame>
</div>
)}
</PageShell>
)
}
+497
View File
@@ -0,0 +1,497 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import {
CloudIcon,
DownloadIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
UploadIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Zone } from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { FormSheet } from '@/components/form-sheet'
import { StatusBadge } from '@/components/status-badge'
import { EmptyState } from '@/components/empty-state'
import { SelectField } from '@/components/select-field'
import { Button } from '@cdnmanager/ui/components/button'
import { Input } from '@cdnmanager/ui/components/input'
import { Label } from '@cdnmanager/ui/components/label'
import { Textarea } from '@cdnmanager/ui/components/textarea'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@cdnmanager/ui/components/sheet'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from '@cdnmanager/ui/components/item'
import { formatRelative } from '@/lib/format'
import { queryClient } from '@/lib/query-client'
import {
applyZone,
bindExportQueryOptions,
cfZonesQueryOptions,
createZone,
patchZone,
syncJobsQueryOptions,
syncZone,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/zones')({
loader: () => queryClient.ensureQueryData(zonesQueryOptions()),
component: ZonesPage,
})
const createSchema = z.object({
name: z.string().min(1, 'Укажите имя зоны'),
cfZoneId: z.string().optional(),
})
type CreateValues = z.infer<typeof createSchema>
const editSchema = z.object({
cfZoneId: z.string().optional(),
})
type EditValues = z.infer<typeof editSchema>
function ZonesPage() {
const qc = useQueryClient()
const { data: zones = [], isLoading, isError, refetch } = useQuery(
zonesQueryOptions(),
)
const { data: cfZones, isError: cfError } = useQuery({
...cfZonesQueryOptions(),
enabled: true,
})
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<Zone | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [bindZoneId, setBindZoneId] = useState<string | null>(null)
const createForm = useForm<CreateValues>({
resolver: zodResolver(createSchema),
defaultValues: { name: '', cfZoneId: '' },
})
const editForm = useForm<EditValues>({
resolver: zodResolver(editSchema),
defaultValues: { cfZoneId: '' },
})
const activeZoneId = selectedId ?? zones[0]?.id ?? ''
const { data: syncJobs = [] } = useQuery(syncJobsQueryOptions(activeZoneId))
const { data: bindExport, isFetching: bindLoading } = useQuery({
...bindExportQueryOptions(bindZoneId ?? ''),
enabled: Boolean(bindZoneId),
})
const latestJob = syncJobs[0]
const driftCount = useMemo(() => {
if (!latestJob?.diff) return 0
return latestJob.diff.filter(
(op) =>
op.kind === 'update' ||
op.kind === 'create' ||
op.kind === 'delete' ||
op.kind === 'orphan' ||
op.kind === 'proxy_violation',
).length
}, [latestJob])
const createMutation = useMutation({
mutationFn: (values: CreateValues) =>
createZone({
name: values.name,
role: 'routing',
cfZoneId: values.cfZoneId || null,
}),
onSuccess: () => {
toast.success('Зона создана')
setCreateOpen(false)
createForm.reset()
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const editMutation = useMutation({
mutationFn: (values: EditValues) => {
if (!editing) throw new Error('Нет зоны')
return patchZone(editing.id, {
cfZoneId: values.cfZoneId || null,
})
},
onSuccess: () => {
toast.success('Зона обновлена')
setEditing(null)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const syncMutation = useMutation({
mutationFn: (id: string) => syncZone(id),
onSuccess: (_, id) => {
toast.success('Sync запущен')
setSelectedId(id)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const applyMutation = useMutation({
mutationFn: (id: string) => applyZone(id),
onSuccess: (_, id) => {
toast.success('Apply запущен')
setSelectedId(id)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const cfOptions =
cfZones?.map((z) => ({ value: z.id, label: `${z.name} (${z.id})` })) ?? []
return (
<PageShell>
<PageHeader
title="Зоны / Sync"
description="Cloudflare DNS zones и desired-state синхронизация"
actions={
<Button
size="sm"
onClick={() => {
createForm.reset({ name: '', cfZoneId: '' })
setCreateOpen(true)
}}
>
<PlusIcon data-icon="inline-start" />
Добавить зону
</Button>
}
/>
{isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : isError ? (
<EmptyState
title="Не удалось загрузить зоны"
description="Проверьте API и повторите"
action={
<Button variant="outline" size="sm" onClick={() => refetch()}>
Повторить
</Button>
}
/>
) : zones.length === 0 ? (
<EmptyState
title="Нет зон"
description="Добавьте зону Cloudflare для управления DNS"
/>
) : (
<div className="flex flex-col gap-4">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Зоны</FrameTitle>
<FrameDescription>
Sync сравнивает desired-state с Cloudflare; Apply пушит diff
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ItemGroup className="gap-0 p-2">
{zones.map((zone) => {
const isActive = zone.id === activeZoneId
return (
<Item
key={zone.id}
variant="outline"
size="sm"
className={
isActive
? 'border-primary/40 bg-muted/40'
: undefined
}
onClick={() => setSelectedId(zone.id)}
>
<IconTile
variant="elevated"
className="size-10.5 text-info [&_svg]:text-current"
aria-hidden
>
<CloudIcon />
</IconTile>
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{zone.name}
</ItemTitle>
<ItemDescription className="flex flex-wrap items-center gap-2 text-xs">
<span>
Sync:{' '}
{zone.lastSyncAt
? formatRelative(zone.lastSyncAt)
: 'никогда'}
</span>
{zone.cfZoneId ? (
<Badge size="sm" variant="secondary">
CF
</Badge>
) : (
<Badge size="sm" variant="warning-light">
нет cfZoneId
</Badge>
)}
{isActive && driftCount > 0 ? (
<Badge size="sm" variant="warning-light">
drift {driftCount}
</Badge>
) : null}
</ItemDescription>
</ItemContent>
<ItemActions className="flex flex-wrap gap-1">
<Button
variant="outline"
size="sm"
disabled={syncMutation.isPending}
onClick={(e) => {
e.stopPropagation()
syncMutation.mutate(zone.id)
}}
>
<RefreshCwIcon className="size-3.5" />
Sync
</Button>
<Button
variant="outline"
size="sm"
disabled={applyMutation.isPending}
onClick={(e) => {
e.stopPropagation()
applyMutation.mutate(zone.id)
}}
>
<UploadIcon className="size-3.5" />
Apply
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setBindZoneId(zone.id)
}}
>
<DownloadIcon className="size-3.5" />
BIND
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setEditing(zone)
editForm.reset({
cfZoneId: zone.cfZoneId ?? '',
})
}}
>
<PencilIcon className="size-3.5" />
</Button>
</ItemActions>
</Item>
)
})}
</ItemGroup>
</FramePanel>
</Frame>
{activeZoneId ? (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Последние sync jobs</FrameTitle>
<FrameDescription>
Зона:{' '}
{zones.find((z) => z.id === activeZoneId)?.name ?? activeZoneId}
</FrameDescription>
</FrameHeader>
<FramePanel>
{syncJobs.length === 0 ? (
<p className="text-muted-foreground text-sm">
Ещё не было sync для этой зоны
</p>
) : (
<ul className="flex flex-col gap-2">
{syncJobs.slice(0, 8).map((job) => (
<li
key={job.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<StatusBadge status={job.status} />
<span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(job.createdAt)}
</span>
</div>
<span className="text-muted-foreground text-xs">
{job.diff?.length
? `${job.diff.length} ops`
: job.error || '—'}
</span>
</li>
))}
</ul>
)}
</FramePanel>
</Frame>
) : null}
</div>
)}
<FormSheet
open={createOpen}
onOpenChange={setCreateOpen}
title="Новая зона"
description="Имя зоны DNS; CF Zone ID — опционально"
form={createForm}
onSubmit={async (v) => {
await createMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Имя зоны</Label>
<Input {...createForm.register('name')} placeholder="example.com" />
</div>
<div className="flex flex-col gap-2">
<Label>Cloudflare Zone ID</Label>
{!cfError && cfOptions.length > 0 ? (
<SelectField
value={createForm.watch('cfZoneId') || null}
onValueChange={(v) =>
createForm.setValue('cfZoneId', v ?? '')
}
placeholder="Выберите из CF"
options={cfOptions}
/>
) : (
<Input
{...createForm.register('cfZoneId')}
placeholder="опционально"
/>
)}
{cfError ? (
<p className="text-muted-foreground text-xs">
CF API недоступен введите Zone ID вручную (токен в env)
</p>
) : null}
</div>
</div>
</FormSheet>
<FormSheet
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
title="Изменить зону"
description={editing?.name}
form={editForm}
onSubmit={async (v) => {
await editMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={editMutation.isPending}>
{editMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
}
>
<div className="flex flex-col gap-2">
<Label>Cloudflare Zone ID</Label>
{!cfError && cfOptions.length > 0 ? (
<SelectField
value={editForm.watch('cfZoneId') || null}
onValueChange={(v) => editForm.setValue('cfZoneId', v ?? '')}
placeholder="Выберите из CF"
options={cfOptions}
/>
) : (
<Input
{...editForm.register('cfZoneId')}
placeholder="cf zone id"
/>
)}
</div>
</FormSheet>
<Sheet
open={Boolean(bindZoneId)}
onOpenChange={(o) => !o && setBindZoneId(null)}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-lg">
<SheetHeader>
<SheetTitle>Export BIND</SheetTitle>
<SheetDescription>
Текстовый snapshot desired-state для зоны
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
{bindLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : (
<>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
if (!bindExport?.content) return
await navigator.clipboard.writeText(bindExport.content)
toast.success('Скопировано')
}}
>
Копировать
</Button>
</div>
<Textarea
readOnly
className="min-h-80 font-mono text-xs"
value={bindExport?.content ?? ''}
/>
</>
)}
</div>
</SheetContent>
</Sheet>
</PageShell>
)
}