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
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:
@@ -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()
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { queryClient } from './queryClient'
|
||||
@@ -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 +1,2 @@
|
||||
export * from '@/queries/app-switcher'
|
||||
export * from '@/queries/fleet'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 (60–86400)."
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user