diff --git a/.cursor/rules/frontend-shadcn.mdc b/.cursor/rules/frontend-shadcn.mdc index 304f05e..6b3129b 100644 --- a/.cursor/rules/frontend-shadcn.mdc +++ b/.cursor/rules/frontend-shadcn.mdc @@ -8,11 +8,11 @@ alwaysApply: false **Источник истины — MCP shadcn + официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить MCP и CLI. -Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc). +Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc). UI patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). | Документ | URL | |----------|-----| -| **Components** | https://ui.shadcn.com/docs/components | +| **Components (primary catalog)** | https://ui.shadcn.com/docs/components | | **Blocks** | https://ui.shadcn.com/blocks | | **Installation** | https://ui.shadcn.com/docs/installation | | **Monorepo** | https://ui.shadcn.com/docs/monorepo | @@ -30,6 +30,28 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl **Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add `. +## Docs workflow + +1. MCP `plugin-shadcn-shadcn` — search → examples → add command +2. `pnpm dlx shadcn@latest docs ` — fetch URLs, сверить API +3. Skill `.agents/skills/shadcn/SKILL.md` — critical rules +4. Context7 — только TanStack / Recharts + +## Shared components (обязательно) + +| Component | Файл | +|-----------|------| +| `PageShell` | `page-shell.tsx` | +| `PageHeader` | `page-header.tsx` | +| `EmptyState` | `empty-state.tsx` | +| `QueryState` | `query-state.tsx` | +| `ConfirmDialog` | `confirm-dialog.tsx` | +| `DataTableCard` | `data-table-card.tsx` | +| `SectionCards` | `section-cards.tsx` | +| `StatusBadge` | `status-badge.tsx` | + +**Overlay:** Sheet — forms; AlertDialog — destructive confirm. + ## Шаг 1 — CLI (обязательно) ```bash @@ -50,7 +72,7 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y # тема — ТОЛЬКО 1. Установленный `@cfdm/ui/components/*` 2. Block из registry (адаптация под TanStack Router) -3. Shared проекта: `PageHeader`, `StatusBadge`, `ResourceList`, `DataTableCard` +3. Shared проекта: `PageShell`, `PageHeader`, `EmptyState`, `QueryState`, `ConfirmDialog`, `DataTableCard`, `SectionCards`, `StatusBadge` 4. Domain-обёртка — последний уровень кастомизации ### Запрещено diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc new file mode 100644 index 0000000..9c4dcd9 --- /dev/null +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -0,0 +1,88 @@ +--- +description: Единые UI-паттерны web — shared components, docs workflow, матрица стандартизации +globs: apps/web/**/* +alwaysApply: false +--- + +# Frontend UI Patterns + +См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks). + +## Docs workflow (обязательно) + +1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection +2. MCP `plugin-shadcn-shadcn` — search → examples → add command +3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs ` → fetch URLs +4. Код по examples + docs API +5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives) +6. MCP `get_audit_checklist` — перед merge + +## Иерархия компонентов + +``` +@cfdm/ui/components/* ← только CLI (packages/ui) +apps/web/src/components/ ← shared + domain + layout + page-shell.tsx ← обёртка страницы + page-header.tsx + empty-state.tsx + query-state.tsx + confirm-dialog.tsx + data-table-card.tsx + section-cards.tsx + status-badge.tsx + layout/ ← app-shell, site-header + domain-* ← бизнес-компоненты +``` + +## Матрица стандартизации + +| Элемент | Shared | Primitive | +|---------|--------|-----------| +| Page wrapper | `PageShell` | — | +| Page title | `PageHeader` | — | +| Stat metrics | `SectionCards` | `Card` | +| Data list | `DataTableCard` | `Table`, `InputGroup` | +| Empty | `EmptyState` | `Empty` | +| Loading / Error | `QueryState` | `Skeleton`, `Alert` | +| Status | `StatusBadge` | `Badge` | +| Create/Edit | `*-sheet.tsx` | `Sheet`, `Field` | +| Delete confirm | `ConfirmDialog` | `AlertDialog` | +| List row | — | `Item variant="outline" size="sm"` | +| Nav | `AppSidebar` | `Sidebar` | +| Breadcrumbs | `SiteHeader` | `Breadcrumb` | +| Dates | `lib/format.ts` | — | + +## Overlay selection + +| Сценарий | Компонент | +|----------|-----------| +| Create/edit форма | `Sheet` | +| Destructive confirm | `AlertDialog` via `ConfirmDialog` | +| Modal preview | `Dialog` | + +## Block registry + +| Зона | Block | +|------|-------| +| Shell | sidebar-07 | +| Dashboard | dashboard-01 | +| Login | login-03 | + +## Spacing + +``` +PageShell: gap-4 md:gap-6, px-4 lg:px-6 py-4 md:py-6 +Card grid: gap-4 +FieldGroup: gap-4 +Item list: gap-2 +Toolbar: gap-2 +``` + +**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner. + +## Button hierarchy (max 1 primary per screen) + +1. `default` — главный CTA +2. `outline` — вторичные действия +3. `ghost` / `link` — навигация, cancel +4. `destructive` — только с `ConfirmDialog` diff --git a/.cursor/rules/shadcn-mcp.mdc b/.cursor/rules/shadcn-mcp.mdc index eff47a0..8af2516 100644 --- a/.cursor/rules/shadcn-mcp.mdc +++ b/.cursor/rules/shadcn-mcp.mdc @@ -17,6 +17,9 @@ CLI и docs — **после** MCP, по команде из `get_add_command_fo 4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...` 5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc)) 6. Адаптировать пример под TanStack Router / Query → `apps/web/src/` +7. **CLI docs:** `cd apps/web && pnpm dlx shadcn@latest docs ` — fetch URLs, сверить API +8. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–7 для примитивов) +9. **`get_audit_checklist`** — перед merge PR ## Когда вызывать MCP diff --git a/.cursor/rules/shadcn-ui-production.mdc b/.cursor/rules/shadcn-ui-production.mdc index 59d0009..dbe36f3 100644 --- a/.cursor/rules/shadcn-ui-production.mdc +++ b/.cursor/rules/shadcn-ui-production.mdc @@ -11,7 +11,7 @@ UI строится **исключительно** по [shadcn/ui](https://ui.s ## Разработка frontend -**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`). +**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`). Кратко: docs → CLI из `apps/web` → Block → композиция → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**. diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 8eb238e..d00f703 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -21,14 +21,54 @@ import { SidebarRail, } from '@cfdm/ui/components/sidebar' -const navItems = [ - { to: '/', label: 'Панель управления', icon: LayoutDashboardIcon }, - { to: '/domains', label: 'Домены', icon: GlobeIcon }, - { to: '/groups', label: 'Группы', icon: FolderTreeIcon }, - { to: '/services', label: 'Сервисы', icon: ServerIcon }, - { to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon }, +const infrastructureNav = [ + { to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true }, + { to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false }, + { to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false }, ] as const +const operationsNav = [ + { to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false }, + { to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false }, +] as const + +function NavSection({ + label, + items, +}: { + label: string + items: readonly { + to: string + label: string + icon: typeof GlobeIcon + exact: boolean + }[] +}) { + return ( + + {label} + + {items.map((item) => ( + + + } + > + + {item.label} + + + ))} + + + ) +} + export function AppSidebar() { return ( @@ -48,22 +88,8 @@ export function AppSidebar() { - - Навигация - - {navItems.map((item) => ( - - } - > - - {item.label} - - - ))} - - + + diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..1ab6e66 --- /dev/null +++ b/apps/web/src/components/confirm-dialog.tsx @@ -0,0 +1,50 @@ +import type { ReactElement } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@cfdm/ui/components/alert-dialog' + +interface ConfirmDialogProps { + trigger: ReactElement + title: string + description: string + confirmLabel?: string + cancelLabel?: string + onConfirm: () => void + disabled?: boolean +} + +export function ConfirmDialog({ + trigger, + title, + description, + confirmLabel = 'Удалить', + cancelLabel = 'Отмена', + onConfirm, + disabled, +}: ConfirmDialogProps) { + return ( + + + + + {title} + {description} + + + {cancelLabel} + + {confirmLabel} + + + + + ) +} diff --git a/apps/web/src/components/data-table-card.tsx b/apps/web/src/components/data-table-card.tsx index d2f9457..1413f48 100644 --- a/apps/web/src/components/data-table-card.tsx +++ b/apps/web/src/components/data-table-card.tsx @@ -1,3 +1,5 @@ +import { InboxIcon, type LucideIcon } from 'lucide-react' +import { EmptyState } from '@/components/empty-state' import { Card, CardContent, @@ -10,8 +12,11 @@ interface DataTableCardProps { title: string description?: string children: React.ReactNode + toolbar?: React.ReactNode emptyTitle?: string emptyDescription?: string + emptyIcon?: LucideIcon + emptyAction?: React.ReactNode isEmpty?: boolean } @@ -19,8 +24,11 @@ export function DataTableCard({ title, description, children, + toolbar, emptyTitle, emptyDescription, + emptyIcon: EmptyIcon = InboxIcon, + emptyAction, isEmpty, }: DataTableCardProps) { return ( @@ -29,12 +37,17 @@ export function DataTableCard({ {title} {description && {description}} + {toolbar && !isEmpty && ( +
{toolbar}
+ )} {isEmpty && emptyTitle ? ( -
-

{emptyTitle}

- {emptyDescription &&

{emptyDescription}

} -
+ ) : ( children )} diff --git a/apps/web/src/components/dns-records-table.tsx b/apps/web/src/components/dns-records-table.tsx index f133270..530df53 100644 --- a/apps/web/src/components/dns-records-table.tsx +++ b/apps/web/src/components/dns-records-table.tsx @@ -1,4 +1,5 @@ import { StatusBadge } from '@/components/status-badge' +import { ConfirmDialog } from '@/components/confirm-dialog' import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping' import type { DnsRecord } from '@/lib/schemas' import { Badge } from '@cfdm/ui/components/badge' @@ -36,14 +37,16 @@ function DnsRecordCells({ - + + Удалить + + } + title="Удалить DNS-запись?" + description={`Запись ${record.name} (${record.record_type}) будет удалена из зоны.`} + onConfirm={() => onDelete(record.id)} + /> ) diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-card.tsx index 25bc970..3ce4fd7 100644 --- a/apps/web/src/components/domain-bindings-card.tsx +++ b/apps/web/src/components/domain-bindings-card.tsx @@ -103,7 +103,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { @@ -118,7 +118,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
{entries.length > 0 && ( - diff --git a/apps/web/src/components/domains-data-table.tsx b/apps/web/src/components/domains-data-table.tsx index e170649..ac969f1 100644 --- a/apps/web/src/components/domains-data-table.tsx +++ b/apps/web/src/components/domains-data-table.tsx @@ -148,7 +148,12 @@ export function DomainsDataTable({ @@ -199,6 +204,7 @@ export function DomainsDataTable({ } > diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx new file mode 100644 index 0000000..e56a20b --- /dev/null +++ b/apps/web/src/components/empty-state.tsx @@ -0,0 +1,38 @@ +import type { LucideIcon } from 'lucide-react' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@cfdm/ui/components/empty' + +interface EmptyStateProps { + icon: LucideIcon + title: string + description?: string + action?: React.ReactNode + className?: string +} + +export function EmptyState({ + icon: Icon, + title, + description, + action, + className, +}: EmptyStateProps) { + return ( + + + + + + {title} + {description && {description}} + + {action && {action}} + + ) +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 0def801..6355c41 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -5,13 +5,18 @@ import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar' export function AppShell({ children }: { children: ReactNode }) { return ( - + -
- {children} -
+
{children}
) diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 8a7e48f..da985e8 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -1,4 +1,5 @@ -import { Link, useRouterState } from '@tanstack/react-router' +import { Link, useMatches, useRouterState } from '@tanstack/react-router' +import { useMemo } from 'react' import { Breadcrumb, BreadcrumbItem, @@ -10,31 +11,39 @@ import { import { Separator } from '@cfdm/ui/components/separator' import { SidebarTrigger } from '@cfdm/ui/components/sidebar' +export interface RouteBreadcrumbLoaderData { + breadcrumb?: string +} + const routeTitles: Record = { '/': 'Панель управления', '/domains': 'Домены', - '/groups': 'Группы', + '/groups': 'Группы доменов', '/services': 'Сервисы', '/certificates': 'Сертификаты', } -function getBreadcrumbs(pathname: string) { +function getBreadcrumbs( + pathname: string, + dynamicLabels: Record, +) { if (pathname === '/') { return [{ label: 'Панель управления', href: '/' }] } if (pathname.match(/^\/groups\/\d+$/)) { return [ - { label: 'Группы', href: '/groups' }, - { label: 'Группа', href: pathname }, + { label: 'Группы доменов', href: '/groups' }, + { label: dynamicLabels[pathname] ?? 'Группа', href: pathname }, ] } if (pathname.match(/^\/domains\/\d+\/dns$/)) { const domainId = pathname.split('/')[2] + const domainPath = `/domains/${domainId}` return [ { label: 'Домены', href: '/domains' }, - { label: 'Домен', href: `/domains/${domainId}` }, + { label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath }, { label: 'DNS', href: pathname }, ] } @@ -42,7 +51,7 @@ function getBreadcrumbs(pathname: string) { if (pathname.match(/^\/domains\/\d+$/)) { return [ { label: 'Домены', href: '/domains' }, - { label: 'Обзор домена', href: pathname }, + { label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname }, ] } @@ -54,9 +63,24 @@ function getBreadcrumbs(pathname: string) { return [{ label: 'Панель управления', href: '/' }] } +function useDynamicBreadcrumbLabels() { + const matches = useMatches() + return useMemo(() => { + const labels: Record = {} + for (const match of matches) { + const data = match.loaderData as RouteBreadcrumbLoaderData | undefined + if (data?.breadcrumb && match.pathname) { + labels[match.pathname] = data.breadcrumb + } + } + return labels + }, [matches]) +} + export function SiteHeader() { const pathname = useRouterState({ select: (s) => s.location.pathname }) - const crumbs = getBreadcrumbs(pathname) + const dynamicLabels = useDynamicBreadcrumbLabels() + const crumbs = getBreadcrumbs(pathname, dynamicLabels) return (
@@ -72,8 +96,12 @@ export function SiteHeader() { const isLast = index === crumbs.length - 1 return ( - {index > 0 && } - + {index > 0 && ( + + )} + {isLast ? ( {crumb.label} ) : ( diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx index d268728..c306db6 100644 --- a/apps/web/src/components/page-header.tsx +++ b/apps/web/src/components/page-header.tsx @@ -1,17 +1,8 @@ -import { Link } from '@tanstack/react-router' -import { Button } from '@cfdm/ui/components/button' import { cn } from '@cfdm/ui/lib/utils' -interface PageHeaderBack { - to: string - label: string - params?: Record -} - interface PageHeaderProps { title: string description?: string - back?: PageHeaderBack actions?: React.ReactNode className?: string } @@ -19,35 +10,31 @@ interface PageHeaderProps { export function PageHeader({ title, description, - back, actions, className, }: PageHeaderProps) { return ( -
-
- {back && ( - - )} -

+
+
+

{title}

{description && ( -

{description}

+

+ {description} +

)}
- {actions} + {actions && ( +
+ {actions} +
+ )}
) } diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx new file mode 100644 index 0000000..97297c6 --- /dev/null +++ b/apps/web/src/components/page-shell.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { cn } from '@cfdm/ui/lib/utils' + +interface PageShellProps { + children: ReactNode + className?: string +} + +export function PageShell({ children, className }: PageShellProps) { + return ( +
+ {children} +
+ ) +} diff --git a/apps/web/src/components/query-state.tsx b/apps/web/src/components/query-state.tsx new file mode 100644 index 0000000..9380b57 --- /dev/null +++ b/apps/web/src/components/query-state.tsx @@ -0,0 +1,60 @@ +import { CircleAlertIcon } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert' +import { Button } from '@cfdm/ui/components/button' +import { Skeleton } from '@cfdm/ui/components/skeleton' + +interface QueryStateProps { + isLoading?: boolean + isError?: boolean + error?: Error | null + onRetry?: () => void + skeleton?: React.ReactNode + children: React.ReactNode +} + +function DefaultSkeleton() { + return ( +
+ + + +
+ ) +} + +export function QueryState({ + isLoading, + isError, + error, + onRetry, + skeleton, + children, +}: QueryStateProps) { + if (isLoading) { + return skeleton ?? + } + + if (isError) { + return ( + + + Не удалось загрузить данные + + {error?.message ?? 'Произошла ошибка при загрузке.'} + + {onRetry && ( + + )} + + ) + } + + return children +} diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx new file mode 100644 index 0000000..0e3f0a8 --- /dev/null +++ b/apps/web/src/components/section-cards.tsx @@ -0,0 +1,147 @@ +import { Link } from '@tanstack/react-router' +import { + FolderTreeIcon, + GlobeIcon, + ServerIcon, + ShieldCheckIcon, +} from 'lucide-react' +import { StatusBadge } from '@/components/status-badge' +import { Button } from '@cfdm/ui/components/button' +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@cfdm/ui/components/card' +import { + Item, + ItemContent, + ItemGroup, + ItemTitle, +} from '@cfdm/ui/components/item' + +interface SectionCardsProps { + domainCount: number + certCount: number + groupCount: number + serviceCount: number + certSummary?: [string, number][] +} + +export function SectionCards({ + domainCount, + certCount, + groupCount, + serviceCount, + certSummary, +}: SectionCardsProps) { + return ( +
+
+ + + Домены + + {domainCount} + + + + + + + + + + + + Группы доменов + + {groupCount} + + + + + + + + + + + + Сервисы + + {serviceCount} + + + + + + + + + + + + Сертификаты + + {certCount} + + + + + + + + + +
+ {certSummary && certSummary.length > 0 ? ( + + + Статусы сертификатов + Сводка по последней проверке + + + + {certSummary.map(([status, count]) => ( + + + + {count} + + + ))} + + + + ) : null} +
+ ) +} diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index c5171eb..9ea50ce 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { PlusIcon, Trash2Icon } from 'lucide-react' +import { ConfirmDialog } from '@/components/confirm-dialog' import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' import type { @@ -32,6 +33,12 @@ import { SheetTitle, } from '@cfdm/ui/components/sheet' import { Spinner } from '@cfdm/ui/components/spinner' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@cfdm/ui/components/tabs' import { Select, SelectContent, @@ -199,123 +206,138 @@ export function ServiceEditSheet({
- - - Название - setName(e.target.value)} - /> - - - Slug - setSlug(e.target.value)} - /> - - - Группа сервисов - - - - IP-адреса сервиса - - - - -
-
- Привязки доменов - -
- {bindings.length === 0 ? ( -

- Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится - автоматически. -

- ) : ( - - {bindings.map((binding, index) => ( - - - - FQDN - handleFqdnChange(index, tags)} - placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'} - maxItems={1} - /> - - - IP - handleIpsChange(index, targetIps)} - /> - - - - - - - ))} - - )} -
+ + + Основное + Привязки + + + + + Название + setName(e.target.value)} + /> + + + Slug + setSlug(e.target.value)} + /> + + + Группа сервисов + + + + IP-адреса сервиса + + + + + +
+
+ Привязки доменов + +
+ {bindings.length === 0 ? ( +

+ Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится + автоматически. +

+ ) : ( + + {bindings.map((binding, index) => ( + + + + FQDN + handleFqdnChange(index, tags)} + placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'} + maxItems={1} + /> + + + IP + handleIpsChange(index, targetIps)} + /> + + + + + + + ))} + + )} +
+
+
{!isCreate ? ( - + + {isDeleting && } + + Удалить + + } + title="Удалить сервис?" + description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо." + onConfirm={handleDelete} + /> ) : null}

Последняя синхронизация - {domain?.last_synced_at ?? '—'} + {formatDate(domain?.last_synced_at)}
@@ -153,23 +153,35 @@ function DomainOverviewPage() { {s.fqdn} + + + {index < subdomains.length - 1 && }
))} ) : ( - - - Поддомены не найдены - - Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare - - - + )} - + ) } diff --git a/apps/web/src/routes/_auth/domains/index.tsx b/apps/web/src/routes/_auth/domains/index.tsx index 044808a..05a5bf8 100644 --- a/apps/web/src/routes/_auth/domains/index.tsx +++ b/apps/web/src/routes/_auth/domains/index.tsx @@ -14,16 +14,11 @@ import { serviceBindingsQueryOptions, } from '@/queries' import { PageHeader } from '@/components/page-header' +import { PageShell } from '@/components/page-shell' import { DataTableCard } from '@/components/data-table-card' import { DomainsDataTable } from '@/components/domains-data-table' import { Button } from '@cfdm/ui/components/button' import { Input } from '@cfdm/ui/components/input' -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' import { Field, FieldGroup, @@ -137,10 +132,10 @@ function DomainsPage() { ) return ( -
+ d.ips.length > 0).length} с IP · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`} actions={ <>
+ ) } diff --git a/apps/web/src/routes/_auth/groups.tsx b/apps/web/src/routes/_auth/groups.tsx index e7720b7..4184177 100644 --- a/apps/web/src/routes/_auth/groups.tsx +++ b/apps/web/src/routes/_auth/groups.tsx @@ -1,8 +1,9 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' +import { FolderTreeIcon } from 'lucide-react' import { toast } from 'sonner' import { domainKeys, @@ -14,6 +15,9 @@ import { import { api } from '@/lib/api-client' import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas' import { PageHeader } from '@/components/page-header' +import { PageShell } from '@/components/page-shell' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { TableToolbar } from '@/components/table-toolbar' import { KanbanBoard } from '@/components/kanban-board' import { DomainGroupCard } from '@/components/domain-group-card' import { DataTableCard } from '@/components/data-table-card' @@ -70,6 +74,7 @@ function parseGroupColumnId(columnId: string): number | null { } function GroupsPage() { + const [catalogSearch, setCatalogSearch] = useState('') const queryClient = useQueryClient() const { data: groups } = useQuery(groupsQueryOptions()) const { data: domains } = useQuery(domainsListQueryOptions()) @@ -163,10 +168,24 @@ function GroupsPage() { createMutation.mutate(values) }) + const filteredGroups = useMemo(() => { + const list = groups ?? [] + const query = catalogSearch.trim().toLowerCase() + if (!query) return list + return list.filter( + (g) => + g.name.toLowerCase().includes(query) || + g.slug.toLowerCase().includes(query), + ) + }, [groups, catalogSearch]) + + const isCatalogFilteredEmpty = + (groups?.length ?? 0) > 0 && filteredGroups.length === 0 + return ( -
+ @@ -237,9 +256,23 @@ function GroupsPage() { + } > @@ -250,7 +283,7 @@ function GroupsPage() { - {groups?.map((group) => ( + {filteredGroups.map((group) => ( {group.name} {group.slug} @@ -268,14 +301,20 @@ function GroupsPage() { > Открыть - + + Удалить + + } + title="Удалить группу?" + description={`Группа «${group.name}» будет удалена. Домены останутся без группы.`} + onConfirm={() => deleteMutation.mutate(group.id)} + /> @@ -285,6 +324,6 @@ function GroupsPage() { - + ) } diff --git a/apps/web/src/routes/_auth/groups/$groupId.tsx b/apps/web/src/routes/_auth/groups/$groupId.tsx index 67aa468..2a0e792 100644 --- a/apps/web/src/routes/_auth/groups/$groupId.tsx +++ b/apps/web/src/routes/_auth/groups/$groupId.tsx @@ -1,12 +1,16 @@ import { createFileRoute, Link } from '@tanstack/react-router' +import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { GlobeIcon } from 'lucide-react' import { domainsListQueryOptions, groupDetailQueryOptions, } from '@/queries' import { PageHeader } from '@/components/page-header' +import { PageShell } from '@/components/page-shell' import { StatusBadge } from '@/components/status-badge' import { DataTableCard } from '@/components/data-table-card' +import { TableToolbar } from '@/components/table-toolbar' import { Button } from '@cfdm/ui/components/button' import { Table, @@ -18,24 +22,33 @@ import { } from '@cfdm/ui/components/table' export const Route = createFileRoute('/_auth/groups/$groupId')({ - loader: ({ context: { queryClient }, params }) => { + loader: async ({ context: { queryClient }, params }) => { const id = Number(params.groupId) - return Promise.all([ - queryClient.ensureQueryData(groupDetailQueryOptions(id)), - queryClient.ensureQueryData(domainsListQueryOptions(id)), - ]) + const group = await queryClient.ensureQueryData(groupDetailQueryOptions(id)) + await queryClient.ensureQueryData(domainsListQueryOptions(id)) + return { breadcrumb: group.name } }, component: GroupDetailPage, }) function GroupDetailPage() { + const [searchQuery, setSearchQuery] = useState('') const { groupId } = Route.useParams() const id = Number(groupId) const { data: group } = useQuery(groupDetailQueryOptions(id)) const { data: domains } = useQuery(domainsListQueryOptions(id)) + const filteredDomains = useMemo(() => { + const list = domains ?? [] + const query = searchQuery.trim().toLowerCase() + if (!query) return list + return list.filter((d) => d.zone_name.toLowerCase().includes(query)) + }, [domains, searchQuery]) + + const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0 + return ( -
+ + } >
@@ -70,13 +96,26 @@ function GroupDetailPage() { - {domains?.map((domain) => ( + {filteredDomains.map((domain) => ( {domain.zone_name} - {domain.service_count} + + +
-
+ ) } diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 837a22a..1f54d7d 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -1,89 +1,72 @@ -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' -import { Button } from '@cfdm/ui/components/button' -import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions } from '@/queries' +import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions, groupsQueryOptions, serviceGroupsQueryOptions } from '@/queries' import { PageHeader } from '@/components/page-header' +import { PageShell } from '@/components/page-shell' +import { QueryState } from '@/components/query-state' +import { SectionCards } from '@/components/section-cards' export const Route = createFileRoute('/_auth/')({ loader: ({ context: { queryClient } }) => Promise.all([ queryClient.ensureQueryData(domainsListQueryOptions()), queryClient.ensureQueryData(certSummaryQueryOptions()), + queryClient.ensureQueryData(groupsQueryOptions()), + queryClient.ensureQueryData(serviceGroupsQueryOptions()), ]), component: DashboardPage, }) function DashboardPage() { - const { data: domains } = useQuery(domainsListQueryOptions()) - const { data: summary } = useQuery(certSummaryQueryOptions()) + const { + data: domains, + isLoading: domainsLoading, + isError: domainsError, + error: domainsErr, + refetch: refetchDomains, + } = useQuery(domainsListQueryOptions()) + const { + data: summary, + isLoading: summaryLoading, + isError: summaryError, + error: summaryErr, + refetch: refetchSummary, + } = useQuery(certSummaryQueryOptions()) const { data: certs } = useQuery(certificatesQueryOptions()) + const { data: groups } = useQuery(groupsQueryOptions()) + const { data: serviceData } = useQuery(serviceGroupsQueryOptions()) + + const serviceCount = + (serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) + + (serviceData?.ungrouped.length ?? 0) + + const isLoading = domainsLoading || summaryLoading + const isError = domainsError || summaryError + const error = domainsErr ?? summaryErr return ( -
+ -
- - - Домены - - {domains?.length ?? 0} - - - - - - - - - Сертификаты - - {certs?.length ?? 0} - - - - - - - - - Статусы сертификатов - Сводка - - -
    - {summary?.map(([status, count]) => ( -
  • - {status} - {count} -
  • - ))} -
-
-
-
-
+ { + void refetchDomains() + void refetchSummary() + }} + > + + + ) } diff --git a/apps/web/src/routes/_auth/services.tsx b/apps/web/src/routes/_auth/services.tsx index e13807f..74e8f11 100644 --- a/apps/web/src/routes/_auth/services.tsx +++ b/apps/web/src/routes/_auth/services.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { ServerIcon } from 'lucide-react' import { toast } from 'sonner' import { domainKeys, @@ -20,6 +21,9 @@ import type { UpdateServiceConfigInput, } from '@/lib/schemas' import { PageHeader } from '@/components/page-header' +import { PageShell } from '@/components/page-shell' +import { QueryState } from '@/components/query-state' +import { EmptyState } from '@/components/empty-state' import { ServiceEditSheet } from '@/components/service-edit-sheet' import { ServiceGroupCard } from '@/components/service-group-card' import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet' @@ -31,17 +35,16 @@ import { CardHeader, CardTitle, } from '@cfdm/ui/components/card' -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from '@cfdm/ui/components/empty' import { ItemGroup } from '@cfdm/ui/components/item' -import { Spinner } from '@cfdm/ui/components/spinner' +import { Skeleton } from '@cfdm/ui/components/skeleton' export const Route = createFileRoute('/_auth/services')({ + validateSearch: (search: Record) => ({ + domainId: + search.domainId != null && search.domainId !== '' + ? Number(search.domainId) + : undefined, + }), loader: ({ context: { queryClient } }) => Promise.all([ queryClient.ensureQueryData(serviceGroupsQueryOptions()), @@ -88,7 +91,13 @@ function setGroupEnabled( } } +function serviceMatchesDomain(service: ServiceView, domainId?: number) { + if (!domainId) return true + return service.domains?.some((d) => d.domain_id === domainId) ?? false +} + function ServicesPage() { + const { domainId } = Route.useSearch() const [createSheetOpen, setCreateSheetOpen] = useState(false) const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false) const [editingGroup, setEditingGroup] = useState(null) @@ -98,9 +107,19 @@ function ServicesPage() { const [togglingServiceId, setTogglingServiceId] = useState(null) const [togglingGroupId, setTogglingGroupId] = useState(null) const queryClient = useQueryClient() - const { data, isLoading } = useQuery(serviceGroupsQueryOptions()) + const { + data, + isLoading, + isError, + error, + refetch, + } = useQuery(serviceGroupsQueryOptions()) const { data: domains } = useQuery(domainsListQueryOptions()) + const filteredDomain = domainId + ? domains?.find((d) => d.id === domainId) + : undefined + function invalidateAll() { queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }) queryClient.invalidateQueries({ queryKey: serviceKeys.all }) @@ -286,15 +305,34 @@ function ServicesPage() { toggleGroupMutation.mutate({ id: groupId, enabled }) } - const groups = data?.groups ?? [] - const ungrouped = data?.ungrouped ?? [] + const groups = useMemo(() => { + const list = data?.groups ?? [] + if (!domainId) return list + return list + .map((group) => ({ + ...group, + services: group.services.filter((s) => serviceMatchesDomain(s, domainId)), + })) + .filter((group) => group.services.length > 0) + }, [data?.groups, domainId]) + + const ungrouped = useMemo(() => { + const list = data?.ungrouped ?? [] + if (!domainId) return list + return list.filter((s) => serviceMatchesDomain(s, domainId)) + }, [data?.ungrouped, domainId]) + const isEmpty = groups.length === 0 && ungrouped.length === 0 return ( -
+ - -
- - - ) : ( + + + + + } + > + {isEmpty ? ( + + + + + } + /> + ) : (
{groups.map((group) => ( ) : null}
- )} + )} +
updateGroupMutation.mutate({ id, body })} /> - + ) } diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx new file mode 100644 index 0000000..2278dc2 --- /dev/null +++ b/packages/ui/src/components/alert-dialog.tsx @@ -0,0 +1,185 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +