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
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:
1 parent
aa07f08684
commit
8b028d04b4
15 files changed
+371
-192
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user