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