feat(web): выровнять палитру и KPI под макет
CD / update-wiki (push) Successful in 5s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m3s
CD / publish (push) Successful in 1m38s

Светлая тема и сайдбар совпадают с артбордом, показатели собраны в одну рамку с цветом статуса.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 21:36:39 +07:00
1 parent aa07f08684
commit 8b028d04b4
15 files changed
+371 -192

No files matched your search

+1 -5
View File
@@ -93,15 +93,11 @@ export function CertKpiStats({
]
}, [summary, total, activeTab, onSelectTab])
const hasData = (total ?? totalFromSummary(summary)) > 0
return (
<KpiStatGrid
cards={hasData ? cards : []}
cards={cards}
isLoading={isLoading}
skeletonCount={4}
emptyIcon={<ShieldCheckIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет данных — запустите проверку сертификатов"
/>
)
}
@@ -207,7 +207,7 @@ export function useDomainColumns({
},
{
id: 'status',
header: 'Зона CF',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
@@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table'
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -18,6 +19,8 @@ export interface GroupCatalogRow {
name: string
slug: string
domainCount: number
serviceCount: number
createdAt: string
}
export const GROUP_TABS = [
@@ -114,9 +117,32 @@ export function useGroupColumns({
<span className="tabular-nums">{row.original.domainCount}</span>
),
},
{
id: 'serviceCount',
accessorKey: 'serviceCount',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Сервисы" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.serviceCount}</span>
),
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Создана" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatRelative(sqliteUtcToIso(row.original.createdAt) ?? row.original.createdAt)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DropdownMenu>
@@ -287,12 +287,12 @@ export function DomainDetailChrome({
label: 'Поддомены',
count: page.subdomainRows.length,
},
{ id: 'bindings', label: 'Привязки', count: bindings.length },
{
id: 'dns',
label: 'DNS',
count: dnsQuery.data ? dnsQuery.data.length : undefined,
},
{ id: 'bindings', label: 'Привязки', count: bindings.length },
]}
value={activeTab}
onValueChange={handleTabChange}
@@ -140,6 +140,20 @@ export function VpsTrackerIntegrationCard({
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить
</LoadingButton>
<Button
type="button"
variant="outline"
disabled={!hasUnsaved}
onClick={() =>
form.reset({
vpsTrackerUrl: settings?.vpsTrackerUrl ?? '',
vpsTrackerIntegrationToken: '',
vpsTrackerSyncEnabled: settings?.vpsTrackerSyncEnabled ?? false,
})
}
>
Сбросить
</Button>
<Button type="button" variant="outline" onClick={() => void handleTest()}>
Проверить связь
</Button>
+12 -13
View File
@@ -1,6 +1,6 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { MenuIcon } from 'lucide-react'
import { SearchIcon } from 'lucide-react'
import {
Breadcrumb,
BreadcrumbItem,
@@ -10,7 +10,6 @@ import {
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { AppsMenu } from '@/components/layout/apps-menu'
import { DataFreshness } from '@/components/layout/data-freshness'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { Button } from '@cfdm/ui/components/button'
@@ -36,7 +35,7 @@ function useDynamicBreadcrumbLabels() {
}, [matches])
}
/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
/** Шапка макета: триггер, крошки, свежесть и состояние. Приложения — в сайдбаре. */
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
@@ -48,15 +47,6 @@ export function SiteHeader() {
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Меню и поиск"
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
>
<MenuIcon aria-hidden />
</Button>
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
@@ -85,7 +75,16 @@ export function SiteHeader() {
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<DataFreshness />
<AppsMenu />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра"
className="md:hidden"
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
>
<SearchIcon aria-hidden />
</Button>
<SystemMonitorPopover />
</div>
</header>
@@ -204,7 +204,7 @@ export function SystemMonitorPopover() {
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор CFDM</span>
<span className="text-foreground text-sm font-semibold">Состояние системы</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
+1 -1
View File
@@ -11,7 +11,7 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
@@ -2,9 +2,9 @@ import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -39,17 +39,69 @@ export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground'
type KpiTone = 'neutral' | 'ok' | 'in' | 'wn' | 'er' | 'primary'
const HINT_VARIANT: Record<KpiStatVariant, 'outline' | 'warning-light' | 'destructive-light'> = {
default: 'outline',
warning: 'warning-light',
destructive: 'destructive-light',
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
primary: 'text-primary',
}
/** stats-12 tile: https://reui.io/preview/base/stats-12 */
const STAT_ICON_CLASS =
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
primary: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'info-light' | 'warning-light' | 'destructive-light' | 'primary-light'> = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
primary: 'primary-light',
}
const GLYPH: Record<KpiTone, string> = {
neutral: '',
ok: '●',
in: '',
primary: '',
wn: '▲',
er: '✕',
}
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
function iconTone(item: KpiStatItem): KpiTone {
if (item.iconClassName?.includes('text-destructive')) return 'er'
if (item.iconClassName?.includes('text-warning')) return 'wn'
if (item.iconClassName?.includes('text-success')) return 'ok'
if (item.iconClassName?.includes('text-info')) return 'in'
if (item.iconClassName?.includes('text-primary')) return 'primary'
if (item.variant === 'destructive') return 'er'
if (item.variant === 'warning') return 'wn'
return 'neutral'
}
function statusTone(item: KpiStatItem): KpiTone {
if (item.variant === 'destructive') return 'er'
if (item.variant === 'warning') return 'wn'
return iconTone(item)
}
/** Полоса KPI: одна рамка, иконка слева, значение и чип. https://reui.io/preview/base/stats-12 */
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
@@ -66,15 +118,16 @@ function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
function resolveFooter(item: KpiStatItem, tone: KpiTone): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge
variant={HINT_VARIANT[item.variant ?? 'default']}
size="sm"
className="max-w-full truncate"
>
<Badge variant={BADGE[tone]} size="sm" className="max-w-full whitespace-nowrap">
{GLYPH[tone] ? (
<span aria-hidden className="text-[0.55rem]">
{GLYPH[tone]}
</span>
) : null}
{item.hint}
</Badge>
)
@@ -84,45 +137,43 @@ function resolveFooter(item: KpiStatItem): ReactNode {
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const tone = iconTone(item)
const status = statusTone(item)
const footer = resolveFooter(item, status)
return (
<div className="relative z-10 flex h-full min-w-0 flex-col items-start gap-6">
<div className="flex min-w-0 items-center gap-3">
{item.icon ? (
<Item
className={cn(STAT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[tone])}>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-col gap-0.5">
<div className="text-foreground min-w-0 text-2xl leading-none font-bold break-all tabular-nums">
{item.value}
</div>
<div className="text-muted-foreground min-w-0 text-sm font-medium">
{item.label}
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[status])}>
{item.value}
</span>
{footer}
</div>
</div>
{footer}
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
function panelClassName(item: KpiStatItem, className?: string, flush = false) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
flush
? 'bg-card flex h-full min-w-0 items-center'
: 'relative isolate flex h-full min-w-0 flex-col',
clickable && 'hover:bg-muted/40 cursor-pointer transition-colors',
!flush && clickable && 'focus-within:ring-ring focus-within:ring-2',
selected && (flush ? 'bg-accent/70' : 'ring-primary/30 bg-muted/30 ring-1'),
className,
)
}
@@ -131,28 +182,31 @@ function panelClassName(item: KpiStatItem, className?: string) {
export function KpiStatCardTile({
item,
embedded = false,
flush = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
flush?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
const panelClass = panelClassName(item, className, flush)
const Shell = flush ? 'div' : FramePanel
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<Shell className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none w-full">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
</Shell>
)
} else if (onActivate) {
panel = (
<FramePanel
<Shell
className={panelClass}
onClick={onActivate}
role="button"
@@ -160,16 +214,18 @@ export function KpiStatCardTile({
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
</Shell>
)
} else {
panel = (
<FramePanel className={panelClass}>
<Shell className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
</Shell>
)
}
if (flush) return panel
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
@@ -191,27 +247,26 @@ export function KpiStatCard({
}
function statGridClass(count: number) {
if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
return COLS[Math.min(Math.max(count, 1), 4)] ?? COLS[4]
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<div className={cn('@container grid w-full', statGridClass(count))}>
{Array.from({ length: count }).map((_, index) => (
<Frame key={index}>
<FramePanel className="flex flex-col items-start gap-6">
<Skeleton className="size-10.5 rounded-lg" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-7 w-16" />
<Skeleton className="h-4 w-24" />
<Frame spacing="sm" className="w-full min-w-0">
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', statGridClass(count))}>
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="bg-card flex items-center gap-3 px-[18px] py-3.5">
<Skeleton className="size-10 rounded-lg" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="h-7 w-12" />
</div>
</div>
<Skeleton className="h-5 w-28 rounded-full" />
</FramePanel>
</Frame>
))}
</div>
))}
</div>
</FramePanel>
</Frame>
)
}
@@ -243,7 +298,6 @@ export function KpiStatGrid({
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
@@ -266,17 +320,21 @@ export function KpiStatGrid({
}
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid', statGridClass(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile
key={kpiStatItemKey(item, index)}
item={item}
embedded={embedded}
/>
))}
</div>
</section>
<Frame spacing="sm" aria-label={ariaLabel} className={cn('w-full min-w-0', className)}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', statGridClass(list.length || 1))}>
{list.map((item, index) => (
<div key={kpiStatItemKey(item, index)} className="bg-card min-w-0">
<KpiStatCardTile
item={item}
flush
className="bg-card px-[18px] py-3.5 shadow-none ring-0"
/>
</div>
))}
</div>
</FramePanel>
</Frame>
)
}
@@ -8,17 +8,21 @@ import {
type PaginationState,
type RowSelectionState,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table'
import {
CircleAlertIcon,
Columns3Icon,
FilterIcon,
FunnelXIcon,
SearchIcon,
type LucideIcon,
} from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
@@ -36,6 +40,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Separator } from '@cfdm/ui/components/separator'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import {
@@ -157,6 +162,8 @@ export function ResourcePage<T extends object>({
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [query, setQuery] = useState('')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@@ -171,11 +178,15 @@ export function ResourcePage<T extends object>({
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
const needle = query.trim().toLowerCase()
if (needle) {
result = result.filter((item) => JSON.stringify(item).toLowerCase().includes(needle))
}
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
}, [data, filters, getFilterFieldValue, query, tabs, tabFilter, activeTab])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
@@ -205,9 +216,10 @@ export function ResourcePage<T extends object>({
data: filteredData,
columns,
getRowId,
state: { sorting, rowSelection, pagination },
state: { sorting, columnVisibility, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
@@ -409,19 +421,46 @@ export function ResourcePage<T extends object>({
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<div className="relative w-full max-w-xs">
<SearchIcon
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
value={query}
onChange={(event) => {
setQuery(event.target.value)
resetPagination()
}}
aria-label="Поиск"
placeholder="Поиск"
className="pl-8"
/>
</div>
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<DataGridColumnVisibility
table={table}
trigger={
<Button type="button" variant="outline" aria-label="Колонки">
<Columns3Icon className="size-4" aria-hidden />
Колонки
</Button>
}
/>
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
@@ -26,7 +26,7 @@ function DataGridColumnVisibility<TData>({
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
Колонки
</DropdownMenuLabel>
{table
.getAllColumns()
+46 -36
View File
@@ -44,6 +44,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
export const Route = createFileRoute('/_auth/domains/')({
loader: ({ context: { queryClient } }) =>
@@ -55,10 +56,9 @@ export const Route = createFileRoute('/_auth/domains/')({
})
function DomainsPage() {
const [groupOpen, setGroupOpen] = useState(false)
const [groupValue, setGroupValue] = useState('none')
const selectedIdsRef = useRef<string[]>([])
const [syncOpen, setSyncOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
const [activeTab, setActiveTab] = useState('all')
@@ -84,7 +84,8 @@ function DomainsPage() {
const filterFields = useDomainFilterFields(groupItems)
const form = useForm({ defaultValues: { group_id: '' } })
const [importGroup, setImportGroup] = useState('none')
const importForm = useForm({ defaultValues: { zone_name: '' } })
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
@@ -99,6 +100,21 @@ function DomainsPage() {
},
})
const importMutation = useMutation({
mutationFn: (body: { zone_name: string; group_id: number | null }) =>
api.post('/api/v1/domains', body),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: domainKeys.all })
setImportOpen(false)
setImportGroup('none')
importForm.reset({ zone_name: '' })
toast.success('Домен импортирован')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
},
})
const syncMutation = useMutation({
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
onSuccess: async () => {
@@ -134,18 +150,10 @@ function DomainsPage() {
const freshness = domainFreshnessLine(domains)
function openGroupSheet() {
if (selectedIdsRef.current.length === 0) {
toast.message('Выберите домены в таблице')
return
}
setGroupOpen(true)
}
const primaryAction = (
<>
<Button type="button" variant="outline" onClick={openGroupSheet}>
Сменить группу
<Button type="button" variant="outline" onClick={() => setImportOpen(true)}>
Импортировать домен
</Button>
<LoadingButton
type="button"
@@ -153,7 +161,7 @@ function DomainsPage() {
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
Синхронизировать
</LoadingButton>
</>
)
@@ -262,41 +270,43 @@ function DomainsPage() {
/>
<FormSheet
open={groupOpen}
onOpenChange={setGroupOpen}
title="Сменить группу"
description="Группа применится к выбранным доменам"
form={form}
onSubmit={() => {
const ids = selectedIdsRef.current.map(Number)
if (ids.length === 0) return
bulkMutation.mutate(
{
ids,
group_id: groupValue === 'none' ? null : Number(groupValue),
},
{ onSuccess: () => setGroupOpen(false) },
)
open={importOpen}
onOpenChange={setImportOpen}
title="Импортировать домен"
description="Зона должна уже существовать в Cloudflare"
form={importForm}
onSubmit={(values) => {
importMutation.mutate({
zone_name: values.zone_name.trim(),
group_id: importGroup === 'none' ? null : Number(importGroup),
})
}}
footer={
<LoadingButton
type="submit"
className="w-full"
isLoading={bulkMutation.isPending}
loadingLabel="Применение…"
isLoading={importMutation.isPending}
loadingLabel="Импорт…"
>
Применить
Импортировать
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple label="Группа" htmlFor="change_group_id" hint="Домены без группы можно оставить без изменений">
<FormFieldSimple label="Зона" htmlFor="import_zone_name">
<Input
id="import_zone_name"
placeholder="example.com"
{...importForm.register('zone_name', { required: true })}
/>
</FormFieldSimple>
<FormFieldSimple label="Группа" htmlFor="import_group_id">
<Select
items={groupItems}
value={groupValue}
onValueChange={(value) => setGroupValue(value ?? 'none')}
value={importGroup}
onValueChange={(value) => setImportGroup(value ?? 'none')}
>
<SelectTrigger id="change_group_id" className="w-full">
<SelectTrigger id="import_group_id" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
+7 -1
View File
@@ -101,8 +101,14 @@ function GroupsPage() {
name: group.name,
slug: group.slug,
domainCount: (domains ?? []).filter((d) => d.group_id === group.id).length,
serviceCount: new Set(
(bindings ?? [])
.filter((binding) => binding.group_id === group.id)
.map((binding) => binding.service_id),
).size,
createdAt: group.created_at,
}))
}, [groups, domains])
}, [groups, domains, bindings])
const groupById = useMemo(() => {
const map = new Map((groups ?? []).map((g) => [g.id, g]))
@@ -1,7 +1,8 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { LogOutIcon, PaletteIcon } from 'lucide-react'
import { LogOutIcon, PaletteIcon, TriangleAlertIcon } from 'lucide-react'
import { api } from '@/lib/api-client'
import {
@@ -21,6 +22,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
type SettingsResponse = {
id: string
@@ -38,6 +40,7 @@ export const Route = createFileRoute('/_auth/settings/appearance')({
function AppearanceSettingsPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [draftQuickActions, setDraftQuickActions] = useState<boolean | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
@@ -47,6 +50,7 @@ function AppearanceSettingsPage() {
mutationFn: (patch: { showQuickActions: boolean }) =>
api.patch<SettingsResponse>('/api/v1/settings', patch),
onSuccess: () => {
setDraftQuickActions(null)
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки интерфейса сохранены')
},
@@ -54,7 +58,9 @@ function AppearanceSettingsPage() {
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const showQuickActions = data?.showQuickActions !== false
const serverQuickActions = data?.showQuickActions !== false
const showQuickActions = draftQuickActions ?? serverQuickActions
const appearanceDirty = data != null && draftQuickActions != null && draftQuickActions !== serverQuickActions
const handleLogout = () => {
clearToken()
@@ -68,6 +74,33 @@ function AppearanceSettingsPage() {
return (
<div className="flex w-full flex-col gap-4">
{appearanceDirty ? (
<Alert variant="warning" role="status">
<TriangleAlertIcon aria-hidden />
<AlertTitle>Есть несохранённые изменения</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>Внешний вид отличается от последнего ответа сервера.</span>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
disabled={patchMut.isPending}
onClick={() => patchMut.mutate({ showQuickActions })}
>
Сохранить
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDraftQuickActions(null)}
>
Сбросить
</Button>
</div>
</AlertDescription>
</Alert>
) : null}
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
@@ -86,9 +119,7 @@ function AppearanceSettingsPage() {
<Switch
checked={showQuickActions}
disabled={isLoading || patchMut.isPending}
onCheckedChange={(checked) =>
patchMut.mutate({ showQuickActions: checked })
}
onCheckedChange={(checked) => setDraftQuickActions(checked)}
aria-label="Показывать быстрые действия"
/>
</SettingRow>