From 5f7b3ac38890ae9e2e2669bea9daddc30e2d561f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 15 Jun 2026 16:41:10 +0700 Subject: [PATCH] Update pnpm-lock.yaml to include new dependencies for @dnd-kit packages; enhance frontend documentation with MCP patterns and shadcn guidelines; refactor route handling for groups and services; implement new DataTableCard and KanbanBoard components for better domain and service management; add service binding functionality and improve DNS record management with new schemas and queries. --- .cursor/rules/backend-api-ui.mdc | 2 + .cursor/rules/frontend-shadcn.mdc | 14 +- .cursor/rules/shadcn-mcp.mdc | 43 ++ .cursor/rules/shadcn-ui-production.mdc | 2 + apps/web/package.json | 3 + apps/web/src/components/data-table-card.tsx | 17 +- apps/web/src/components/dns-records-table.tsx | 155 +++++++ .../src/components/domain-bindings-card.tsx | 128 ++++++ apps/web/src/components/domain-group-card.tsx | 87 ++++ apps/web/src/components/domain-ip-badges.tsx | 69 ++++ .../web/src/components/domains-data-table.tsx | 352 ++++++++++++++++ apps/web/src/components/kanban-board.tsx | 96 +++++ apps/web/src/components/kanban-column.tsx | 81 ++++ .../web/src/components/layout/site-header.tsx | 7 + .../src/components/service-binding-card.tsx | 127 ++++++ apps/web/src/components/status-badge.tsx | 2 + apps/web/src/lib/dns-grouping.ts | 46 +++ apps/web/src/lib/domain-ips.ts | 32 ++ apps/web/src/lib/schemas.ts | 42 ++ apps/web/src/queries/index.ts | 46 ++- apps/web/src/routeTree.gen.ts | 41 +- .../routes/_auth/domains/$domainId/dns.tsx | 171 ++++---- .../routes/_auth/domains/$domainId/index.tsx | 76 +++- apps/web/src/routes/_auth/domains/index.tsx | 201 +++++----- apps/web/src/routes/_auth/groups.tsx | 280 ++++++++++--- apps/web/src/routes/_auth/groups/$groupId.tsx | 101 +++++ apps/web/src/routes/_auth/services.tsx | 377 +++++++++++++++--- backend/migrations/002_service_bindings.sql | 23 ++ backend/src/api/handlers/domains.rs | 2 +- backend/src/api/handlers/groups.rs | 4 +- backend/src/api/handlers/mod.rs | 1 + backend/src/api/handlers/service_bindings.rs | 53 +++ backend/src/api/router.rs | 16 +- backend/src/domain/entities.rs | 53 +++ backend/src/repositories/domains.rs | 33 +- backend/src/repositories/groups.rs | 22 +- backend/src/repositories/mod.rs | 1 + backend/src/repositories/service_bindings.rs | 135 +++++++ backend/src/repositories/services.rs | 30 -- backend/src/services/binding_service.rs | 175 ++++++++ backend/src/services/domain_service.rs | 15 +- backend/src/services/mod.rs | 1 + packages/ui/src/components/button.tsx | 4 - packages/ui/src/components/input-group.tsx | 156 ++++++++ packages/ui/src/components/item.tsx | 201 ++++++++++ packages/ui/src/components/scroll-area.tsx | 52 +++ packages/ui/src/components/separator.tsx | 2 - packages/ui/src/components/tabs.tsx | 80 ++++ packages/ui/src/components/textarea.tsx | 18 + pnpm-lock.yaml | 56 +++ 50 files changed, 3364 insertions(+), 367 deletions(-) create mode 100644 .cursor/rules/shadcn-mcp.mdc create mode 100644 apps/web/src/components/dns-records-table.tsx create mode 100644 apps/web/src/components/domain-bindings-card.tsx create mode 100644 apps/web/src/components/domain-group-card.tsx create mode 100644 apps/web/src/components/domain-ip-badges.tsx create mode 100644 apps/web/src/components/domains-data-table.tsx create mode 100644 apps/web/src/components/kanban-board.tsx create mode 100644 apps/web/src/components/kanban-column.tsx create mode 100644 apps/web/src/components/service-binding-card.tsx create mode 100644 apps/web/src/lib/dns-grouping.ts create mode 100644 apps/web/src/lib/domain-ips.ts create mode 100644 apps/web/src/routes/_auth/groups/$groupId.tsx create mode 100644 backend/migrations/002_service_bindings.sql create mode 100644 backend/src/api/handlers/service_bindings.rs create mode 100644 backend/src/repositories/service_bindings.rs create mode 100644 backend/src/services/binding_service.rs create mode 100644 packages/ui/src/components/input-group.tsx create mode 100644 packages/ui/src/components/item.tsx create mode 100644 packages/ui/src/components/scroll-area.tsx create mode 100644 packages/ui/src/components/tabs.tsx create mode 100644 packages/ui/src/components/textarea.tsx diff --git a/.cursor/rules/backend-api-ui.mdc b/.cursor/rules/backend-api-ui.mdc index 153098f..1121914 100644 --- a/.cursor/rules/backend-api-ui.mdc +++ b/.cursor/rules/backend-api-ui.mdc @@ -26,6 +26,8 @@ Rust backend: `backend/src/` (Axum, sqlx). Frontend потребляет API ч ## Рекомендуемые shadcn-паттерны для типовых API +Перед выбором паттерна — **MCP** `search_items_in_registries` ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)). + | API-данные | UI (из docs) | |------------|--------------| | Список сущностей | `Table` в `DataTableCard` или `data-table` block | diff --git a/.cursor/rules/frontend-shadcn.mdc b/.cursor/rules/frontend-shadcn.mdc index 473fe00..304f05e 100644 --- a/.cursor/rules/frontend-shadcn.mdc +++ b/.cursor/rules/frontend-shadcn.mdc @@ -6,9 +6,9 @@ alwaysApply: false # Frontend — shadcn/ui (обязательно) -**Источник истины — только официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить CLI. +**Источник истины — MCP shadcn + официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить MCP и CLI. -Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). +Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc). | Документ | URL | |----------|-----| @@ -22,10 +22,11 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ## Шаг 0 — перед любым UI-кодом -1. Открыть **Components** или **Blocks** — найти готовое решение -2. `cd apps/web && pnpm dlx shadcn@latest docs ` — API и примеры -3. `cd apps/web && pnpm dlx shadcn@latest search ""` — если компонент неочевиден -4. Только потом писать код +1. **MCP `plugin-shadcn-shadcn`:** `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items` ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)) +2. Открыть **Components** или **Blocks** — найти готовое решение +3. `cd apps/web && pnpm dlx shadcn@latest docs ` — API и примеры +4. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/"` — если MCP не дал результат +5. Только потом писать код **Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add `. @@ -141,6 +142,7 @@ packages/ui/src/ ## Чеклист перед завершением задачи +- [ ] MCP shadcn: search + examples (+ add command при новых примитивах) - [ ] Решение есть в https://ui.shadcn.com/docs/components или /blocks - [ ] Компоненты добавлены через `pnpm dlx shadcn@latest add` из `apps/web` - [ ] Нет кастомного CSS и raw HTML-примитивов diff --git a/.cursor/rules/shadcn-mcp.mdc b/.cursor/rules/shadcn-mcp.mdc new file mode 100644 index 0000000..eff47a0 --- /dev/null +++ b/.cursor/rules/shadcn-mcp.mdc @@ -0,0 +1,43 @@ +--- +description: ВСЕГДА использовать MCP-плагин shadcn UI перед любым UI-кодом +alwaysApply: true +--- + +# shadcn MCP — обязательно + +Перед **любой** задачей с UI (новый экран, компонент, стили, рефакторинг внешнего вида) — **сначала MCP** `plugin-shadcn-shadcn`, не память и не веб-поиск. + +CLI и docs — **после** MCP, по команде из `get_add_command_for_items`. + +## Порядок (строго) + +1. **`get_project_registries`** — какие registry доступны в проекте +2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`) +3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX +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/` + +## Когда вызывать MCP + +| Задача | MCP | +|--------|-----| +| Новая страница / layout | `search` → `types: ["block"]` → examples → add block | +| Нет примитива в `@cfdm/ui` | `search` → `get_add_command_for_items` → add | +| Сомнение в API/props | `get_item_examples_from_registries` | +| Ревью UI перед сдачей | `get_audit_checklist` | + +## Запрещено + +- Писать UI по памяти, не проверив MCP +- Копипаст с ui.shadcn.com без examples/add из MCP +- Самописные примитивы, если есть item в registry +- Пропускать MCP «потому что компонент простой» + +## Сервер и инструменты + +- **MCP server:** `plugin-shadcn-shadcn` +- **Инструменты:** `get_project_registries`, `search_items_in_registries`, `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist` +- Перед вызовом — прочитать schema в `mcps/plugin-shadcn-shadcn/tools/` + +Связанные правила: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc). diff --git a/.cursor/rules/shadcn-ui-production.mdc b/.cursor/rules/shadcn-ui-production.mdc index 626b8a5..82b1eb0 100644 --- a/.cursor/rules/shadcn-ui-production.mdc +++ b/.cursor/rules/shadcn-ui-production.mdc @@ -7,6 +7,8 @@ alwaysApply: true UI строится **исключительно** по [shadcn/ui](https://ui.shadcn.com/docs/installation): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [Monorepo](https://ui.shadcn.com/docs/monorepo). +**Первый шаг любой UI-задачи — MCP `plugin-shadcn-shadcn`** (см. [`shadcn-mcp.mdc`](shadcn-mcp.mdc)): search → examples → add command → CLI. + ## Разработка frontend **Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`). diff --git a/apps/web/package.json b/apps/web/package.json index dd6cf82..20a9d16 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,9 @@ }, "dependencies": { "@cfdm/ui": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@tailwindcss/vite": "^4.3.1", "@tanstack/react-query": "^5.101.0", diff --git a/apps/web/src/components/data-table-card.tsx b/apps/web/src/components/data-table-card.tsx index ba03625..d2f9457 100644 --- a/apps/web/src/components/data-table-card.tsx +++ b/apps/web/src/components/data-table-card.tsx @@ -10,12 +10,18 @@ interface DataTableCardProps { title: string description?: string children: React.ReactNode + emptyTitle?: string + emptyDescription?: string + isEmpty?: boolean } export function DataTableCard({ title, description, children, + emptyTitle, + emptyDescription, + isEmpty, }: DataTableCardProps) { return ( @@ -23,7 +29,16 @@ export function DataTableCard({ {title} {description && {description}} - {children} + + {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 new file mode 100644 index 0000000..f133270 --- /dev/null +++ b/apps/web/src/components/dns-records-table.tsx @@ -0,0 +1,155 @@ +import { StatusBadge } from '@/components/status-badge' +import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping' +import type { DnsRecord } from '@/lib/schemas' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' +import { cn } from '@cfdm/ui/lib/utils' + +interface DnsRecordsTableProps { + records: DnsRecord[] + onDelete: (recordId: number) => void + isDeleting?: boolean +} + +function DnsRecordCells({ + record, + onDelete, + isDeleting, +}: { + record: DnsRecord + onDelete: (recordId: number) => void + isDeleting?: boolean +}) { + return ( + <> + {record.content} + {record.ttl} + + + + + + + + ) +} + +function SingleRecordRow({ + record, + onDelete, + isDeleting, + isFirstGroup, +}: { + record: DnsRecord + onDelete: (recordId: number) => void + isDeleting?: boolean + isFirstGroup: boolean +}) { + return ( + + {record.record_type} + {record.name} + + + ) +} + +function MultiValueGroupRows({ + group, + onDelete, + isDeleting, + isFirstGroup, +}: { + group: DnsRecordGroup + onDelete: (recordId: number) => void + isDeleting?: boolean + isFirstGroup: boolean +}) { + const [first, ...rest] = group.records + + return ( + <> + + + {group.recordType} + + +
+ {group.name} + + {group.records.length} адресов + +
+
+ +
+ {rest.map((record) => ( + + + + ))} + + ) +} + +export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTableProps) { + const groups = groupDnsRecords(records) + + return ( +
+ + + + Тип + Имя + Значение + TTL + Синхронизация + + + + + {groups.map((group, index) => + group.isMultiValue ? ( + + ) : ( + + ), + )} + +
+
+ ) +} diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-card.tsx new file mode 100644 index 0000000..25bc970 --- /dev/null +++ b/apps/web/src/components/domain-bindings-card.tsx @@ -0,0 +1,128 @@ +import { Link } from '@tanstack/react-router' +import { StatusBadge } from '@/components/status-badge' +import { groupBindingsByHostname } from '@/lib/domain-ips' +import type { ServiceBinding } from '@/lib/schemas' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@cfdm/ui/components/card' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@cfdm/ui/components/empty' +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemSeparator, + ItemTitle, +} from '@cfdm/ui/components/item' + +interface DomainBindingsCardProps { + bindings: ServiceBinding[] +} + +function uniqueIps(bindings: ServiceBinding[]): string[] { + return [ + ...new Set( + bindings.map((b) => b.target_ip).filter((ip): ip is string => Boolean(ip)), + ), + ] +} + +export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { + const byHostname = groupBindingsByHostname(bindings) + const entries = [...byHostname.entries()] + + return ( + + + Привязки сервисов + IP-адреса, назначенные сервисам в этой зоне + + + {entries.length === 0 ? ( + + + Нет привязок + + Создайте привязку на странице сервисов — IP появится здесь после синхронизации DNS + + + + ) : ( + + {entries.map(([hostname, hostnameBindings], index) => { + const ips = uniqueIps(hostnameBindings) + const services = [...new Set(hostnameBindings.map((b) => b.service_name))] + + return ( +
+ + + {hostname} + +
+ {ips.length > 0 ? ( + ips.map((ip) => ( + + {ip} + + )) + ) : ( + IP не задан + )} +
+
+
+ {services.map((name) => ( + {name} + ))} + {[ + ...new Set( + hostnameBindings + .map((b) => b.sync_status) + .filter((s): s is string => Boolean(s)), + ), + ].map((status) => ( + + ))} +
+
+ + + +
+ {index < entries.length - 1 && } +
+ ) + })} +
+ )} +
+ {entries.length > 0 && ( + + + + )} +
+ ) +} diff --git a/apps/web/src/components/domain-group-card.tsx b/apps/web/src/components/domain-group-card.tsx new file mode 100644 index 0000000..76a64b1 --- /dev/null +++ b/apps/web/src/components/domain-group-card.tsx @@ -0,0 +1,87 @@ +import { useDraggable } from '@dnd-kit/core' +import { CSS } from '@dnd-kit/utilities' +import { GlobeIcon } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { StatusBadge } from '@/components/status-badge' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + Card, + CardAction, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@cfdm/ui/components/card' +import { cn } from '@cfdm/ui/lib/utils' +import type { DomainListItem } from '@/lib/schemas' + +interface DomainGroupCardProps { + domain: DomainListItem + serviceLabels?: string[] +} + +export function DomainGroupCard({ domain, serviceLabels = [] }: DomainGroupCardProps) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: String(domain.id), + }) + + const style = transform + ? { transform: CSS.Translate.toString(transform) } + : undefined + + return ( + + + + + {domain.zone_name} + + + + + + {(serviceLabels.length > 0 || domain.service_count > 0) && ( + +
+ {serviceLabels.length > 0 + ? serviceLabels.map((label) => ( + + {label} + + )) + : ( + + {domain.service_count} сервис(ов) + + )} +
+
+ )} + + + +
+ ) +} diff --git a/apps/web/src/components/domain-ip-badges.tsx b/apps/web/src/components/domain-ip-badges.tsx new file mode 100644 index 0000000..c4ddf36 --- /dev/null +++ b/apps/web/src/components/domain-ip-badges.tsx @@ -0,0 +1,69 @@ +import { Badge } from '@cfdm/ui/components/badge' +import { + Item, + ItemContent, + ItemGroup, + ItemSeparator, + ItemTitle, +} from '@cfdm/ui/components/item' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' + +interface DomainIpBadgesProps { + ips: string[] +} + +function IpBadge({ ip }: { ip: string }) { + return ( + + {ip} + + ) +} + +export function DomainIpBadges({ ips }: DomainIpBadgesProps) { + if (ips.length === 0) { + return + } + + if (ips.length === 1) { + return + } + + const [first, ...rest] = ips + + return ( + +
+ + + + } + > + +{rest.length} + + + + {ips.map((ip, index) => ( +
+ + + {ip} + + + {index < ips.length - 1 && } +
+ ))} +
+
+
+
+
+ ) +} diff --git a/apps/web/src/components/domains-data-table.tsx b/apps/web/src/components/domains-data-table.tsx new file mode 100644 index 0000000..e170649 --- /dev/null +++ b/apps/web/src/components/domains-data-table.tsx @@ -0,0 +1,352 @@ +import { useMemo, useState } from 'react' +import { Link } from '@tanstack/react-router' +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type ColumnFiltersState, + type SortingState, +} from '@tanstack/react-table' +import { + ArrowUpDownIcon, + GlobeIcon, + MoreHorizontalIcon, + SearchIcon, +} from 'lucide-react' +import { DomainIpBadges } from '@/components/domain-ip-badges' +import { StatusBadge } from '@/components/status-badge' +import type { DomainListItem } from '@/lib/schemas' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@cfdm/ui/components/empty' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from '@cfdm/ui/components/input-group' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' + +export interface DomainTableRow extends DomainListItem { + ips: string[] +} + +interface GroupFilterItem { + label: string + value: string +} + +interface DomainsDataTableProps { + data: DomainTableRow[] + groupFilterItems: GroupFilterItem[] + groupFilterValue: string + onGroupFilterChange: (value: string | null) => void +} + +export function DomainsDataTable({ + data, + groupFilterItems, + groupFilterValue, + onGroupFilterChange, +}: DomainsDataTableProps) { + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'zone_name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + id: 'group', + accessorFn: (row) => row.group_name ?? 'Без группы', + header: 'Группа', + cell: ({ row }) => { + const domain = row.original + if (domain.group_id && domain.group_name) { + return ( + + ) + } + return Без группы + }, + }, + { + id: 'ips', + accessorFn: (row) => row.ips.join(' '), + header: 'IP-адреса', + enableSorting: false, + cell: ({ row }) => , + }, + { + accessorKey: 'service_count', + header: 'Сервисы', + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'status', + header: 'Статус', + cell: ({ row }) => , + }, + { + accessorKey: 'last_synced_at', + header: 'Синхронизация', + cell: ({ row }) => ( + + {row.original.last_synced_at ?? '—'} + + ), + }, + { + id: 'actions', + enableHiding: false, + header: () => Действия, + cell: ({ row }) => ( +
+ + + } + > + + Действия + + + + } + > + Обзор + + + } + > + DNS + + + +
+ ), + }, + ], + [], + ) + + const table = useReactTable({ + data, + columns, + state: { sorting, columnFilters }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { pageSize: 10 }, + }, + }) + + const filterGroupItems = useMemo( + () => [{ label: 'Все группы', value: 'all' }, ...groupFilterItems], + [groupFilterItems], + ) + + return ( +
+
+ + + + + + table.getColumn('zone_name')?.setFilterValue(event.target.value) + } + /> + + +
+ + + {table.getFilteredRowModel().rows.length} зон + +
+
+ +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + + + Домены не найдены + + Импортируйте зону из Cloudflare или измените фильтры + + + + + + )} + +
+
+
+ + {table.getPageCount() > 1 && ( +
+ + Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()} + + + +
+ )} +
+ ) +} diff --git a/apps/web/src/components/kanban-board.tsx b/apps/web/src/components/kanban-board.tsx new file mode 100644 index 0000000..46bde33 --- /dev/null +++ b/apps/web/src/components/kanban-board.tsx @@ -0,0 +1,96 @@ +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core' +import { useState, type ReactNode } from 'react' +import { KanbanColumn } from '@/components/kanban-column' +import { ScrollArea, ScrollBar } from '@cfdm/ui/components/scroll-area' + +export interface KanbanColumnDef { + id: string + title: string + description?: string + href?: string + items: T[] +} + +interface KanbanBoardProps { + columns: KanbanColumnDef[] + getItemId: (item: T) => string + renderCard: (item: T) => ReactNode + renderOverlay?: (item: T) => ReactNode + onMove: (itemId: string, fromColumnId: string, toColumnId: string) => void +} + +function findColumnForItem( + columns: KanbanColumnDef[], + itemId: string, + getItemId: (item: T) => string, +): string | undefined { + return columns.find((col) => col.items.some((item) => getItemId(item) === itemId))?.id +} + +export function KanbanBoard({ + columns, + getItemId, + renderCard, + renderOverlay, + onMove, +}: KanbanBoardProps) { + const [activeId, setActiveId] = useState(null) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + + const activeItem = activeId + ? columns.flatMap((c) => c.items).find((item) => getItemId(item) === activeId) + : undefined + + function handleDragStart(event: DragStartEvent) { + setActiveId(String(event.active.id)) + } + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + setActiveId(null) + if (!over) return + + const itemId = String(active.id) + const fromColumnId = findColumnForItem(columns, itemId, getItemId) + const toColumnId = String(over.id) + if (!fromColumnId || fromColumnId === toColumnId) return + onMove(itemId, fromColumnId, toColumnId) + } + + return ( + + +
+ {columns.map((column) => ( + + {column.items.map((item) => ( +
{renderCard(item)}
+ ))} +
+ ))} +
+ +
+ + {activeItem && (renderOverlay ? renderOverlay(activeItem) : renderCard(activeItem))} + +
+ ) +} diff --git a/apps/web/src/components/kanban-column.tsx b/apps/web/src/components/kanban-column.tsx new file mode 100644 index 0000000..9b92412 --- /dev/null +++ b/apps/web/src/components/kanban-column.tsx @@ -0,0 +1,81 @@ +import { useDroppable } from '@dnd-kit/core' +import { Link } from '@tanstack/react-router' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@cfdm/ui/components/empty' +import { cn } from '@cfdm/ui/lib/utils' +import type { ReactNode } from 'react' + +interface KanbanColumnProps { + id: string + title: string + description?: string + href?: string + count: number + children: ReactNode +} + +export function KanbanColumn({ + id, + title, + description, + href, + count, + children, +}: KanbanColumnProps) { + const { setNodeRef, isOver } = useDroppable({ id }) + const isEmpty = count === 0 + + return ( +
+
+
+ {href ? ( + + ) : ( + {title} + )} + {description && ( + {description} + )} +
+ {count} +
+
+ {children} + {isEmpty && ( + + + Пусто + + Перетащите домен сюда + + + + )} +
+
+ ) +} diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 8b0449c..8a7e48f 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -23,6 +23,13 @@ function getBreadcrumbs(pathname: string) { return [{ label: 'Панель управления', href: '/' }] } + if (pathname.match(/^\/groups\/\d+$/)) { + return [ + { label: 'Группы', href: '/groups' }, + { label: 'Группа', href: pathname }, + ] + } + if (pathname.match(/^\/domains\/\d+\/dns$/)) { const domainId = pathname.split('/')[2] return [ diff --git a/apps/web/src/components/service-binding-card.tsx b/apps/web/src/components/service-binding-card.tsx new file mode 100644 index 0000000..a4fe716 --- /dev/null +++ b/apps/web/src/components/service-binding-card.tsx @@ -0,0 +1,127 @@ +import { useDraggable } from '@dnd-kit/core' +import { CSS } from '@dnd-kit/utilities' +import { useEffect, useState } from 'react' +import { Link } from '@tanstack/react-router' +import { StatusBadge } from '@/components/status-badge' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + Card, + CardAction, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@cfdm/ui/components/card' +import { + Field, + FieldGroup, + FieldLabel, +} from '@cfdm/ui/components/field' +import { Input } from '@cfdm/ui/components/input' +import { cn } from '@cfdm/ui/lib/utils' +import type { ServiceBinding } from '@/lib/schemas' + +interface ServiceBindingCardProps { + binding: ServiceBinding + onIpChange: (id: number, targetIp: string) => void + onHostnameChange: (id: number, hostname: string) => void +} + +export function ServiceBindingCard({ + binding, + onIpChange, + onHostnameChange, +}: ServiceBindingCardProps) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: String(binding.id), + }) + const [ip, setIp] = useState(binding.target_ip ?? '') + const [hostname, setHostname] = useState(binding.hostname) + + useEffect(() => { + setIp(binding.target_ip ?? '') + setHostname(binding.hostname) + }, [binding.target_ip, binding.hostname]) + + const style = transform + ? { transform: CSS.Translate.toString(transform) } + : undefined + + return ( + + + {binding.zone_name} + + {binding.group_name ? ( + {binding.group_name} + ) : ( + Без группы + )} + + + + + + Hostname + e.stopPropagation()} + onChange={(e) => setHostname(e.target.value)} + onBlur={() => { + if (hostname !== binding.hostname) { + onHostnameChange(binding.id, hostname) + } + }} + /> + + + IPv4 + e.stopPropagation()} + onChange={(e) => setIp(e.target.value)} + onBlur={() => { + if (ip !== (binding.target_ip ?? '')) { + onIpChange(binding.id, ip) + } + }} + /> + + +
+ {binding.service_name} + {binding.sync_status && } +
+
+ + + +
+ ) +} diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 8325ba0..b534811 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -5,6 +5,7 @@ import { cn } from '@cfdm/ui/lib/utils' type BadgeVariant = NonNullable['variant']> const statusVariants: Record = { + active: 'default', synced: 'default', ok: 'default', pending_push: 'secondary', @@ -16,6 +17,7 @@ const statusVariants: Record = { } const labels: Record = { + active: 'Активен', synced: 'Синхронизировано', pending_push: 'Ожидает отправки', conflict: 'Конфликт', diff --git a/apps/web/src/lib/dns-grouping.ts b/apps/web/src/lib/dns-grouping.ts new file mode 100644 index 0000000..16441fa --- /dev/null +++ b/apps/web/src/lib/dns-grouping.ts @@ -0,0 +1,46 @@ +import type { DnsRecord } from '@/lib/schemas' + +export interface DnsRecordGroup { + key: string + recordType: string + name: string + records: DnsRecord[] + isMultiValue: boolean +} + +const MULTI_VALUE_TYPES = new Set(['A', 'AAAA']) + +function groupKey(record: DnsRecord): string { + return `${record.record_type}:${record.name}` +} + +export function groupDnsRecords(records: DnsRecord[]): DnsRecordGroup[] { + const map = new Map() + + for (const record of records) { + const key = groupKey(record) + const list = map.get(key) ?? [] + list.push(record) + map.set(key, list) + } + + return [...map.entries()].map(([key, groupRecords]) => { + const [recordType, ...nameParts] = key.split(':') + const name = nameParts.join(':') + const isMultiValue = + MULTI_VALUE_TYPES.has(recordType) && groupRecords.length > 1 + + return { + key, + recordType, + name, + records: groupRecords, + isMultiValue, + } + }) +} + +export function allSameValue(values: T[]): boolean { + if (values.length <= 1) return true + return values.every((v) => v === values[0]) +} diff --git a/apps/web/src/lib/domain-ips.ts b/apps/web/src/lib/domain-ips.ts new file mode 100644 index 0000000..b373fe8 --- /dev/null +++ b/apps/web/src/lib/domain-ips.ts @@ -0,0 +1,32 @@ +import type { ServiceBinding } from '@/lib/schemas' + +export function getDomainIps(bindings: ServiceBinding[], domainId: number): string[] { + const ips = bindings + .filter((b) => b.domain_id === domainId && b.target_ip) + .map((b) => b.target_ip as string) + return [...new Set(ips)] +} + +export function groupBindingsByHostname( + bindings: ServiceBinding[], +): Map { + const map = new Map() + for (const binding of bindings) { + const list = map.get(binding.hostname) ?? [] + list.push(binding) + map.set(binding.hostname, list) + } + return map +} + +export function buildIpsByDomainId(bindings: ServiceBinding[]): Map { + const map = new Map() + for (const binding of bindings) { + if (!binding.target_ip) continue + const existing = map.get(binding.domain_id) ?? [] + if (!existing.includes(binding.target_ip)) { + map.set(binding.domain_id, [...existing, binding.target_ip]) + } + } + return map +} diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index c8df323..c2ed73a 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -8,6 +8,10 @@ export const groupSchema = z.object({ updated_at: z.string(), }) +export const groupWithStatsSchema = groupSchema.extend({ + domain_count: z.number(), +}) + export const serviceSchema = z.object({ id: z.number(), name: z.string(), @@ -27,6 +31,28 @@ export const domainSchema = z.object({ updated_at: z.string(), }) +export const domainListItemSchema = domainSchema.extend({ + group_name: z.string().nullable(), + service_count: z.number(), +}) + +export const serviceBindingSchema = z.object({ + id: z.number(), + domain_id: z.number(), + service_id: z.number(), + hostname: z.string(), + dns_record_id: z.number().nullable(), + zone_name: z.string(), + group_id: z.number().nullable(), + group_name: z.string().nullable(), + service_name: z.string(), + service_slug: z.string(), + target_ip: z.string().nullable(), + sync_status: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + export const dnsRecordSchema = z.object({ id: z.number(), domain_id: z.number(), @@ -58,8 +84,11 @@ export const certificateSchema = z.object({ }) export type Group = z.infer +export type GroupWithStats = z.infer export type Service = z.infer export type Domain = z.infer +export type DomainListItem = z.infer +export type ServiceBinding = z.infer export type DnsRecord = z.infer export type Certificate = z.infer @@ -73,6 +102,18 @@ export const createServiceSchema = z.object({ slug: z.string().min(1, 'Укажите slug'), }) +export const createServiceBindingSchema = z.object({ + domain_id: z.string().min(1, 'Выберите домен'), + service_id: z.string().min(1, 'Выберите сервис'), + hostname: z.string().optional(), + target_ip: z.string().optional(), +}) + +export const updateDomainGroupSchema = z.object({ + group_id: z.number().nullable(), + status: z.string().optional(), +}) + export const createDomainSchema = z.object({ zone_name: z.string().min(1, 'Укажите имя зоны'), group_id: z.string(), @@ -93,6 +134,7 @@ export const createDnsRecordSchema = z.object({ export type CreateGroupInput = z.infer export type CreateServiceInput = z.infer +export type CreateServiceBindingInput = z.infer export type CreateDomainInput = z.infer export type LoginInput = z.infer export type CreateDnsRecordInput = z.infer diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 679780b..d869986 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -1,11 +1,21 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' -import { certificateSchema, dnsRecordSchema, domainSchema, groupSchema, serviceSchema } from '@/lib/schemas' +import { + certificateSchema, + dnsRecordSchema, + domainListItemSchema, + domainSchema, + groupSchema, + groupWithStatsSchema, + serviceBindingSchema, + serviceSchema, +} from '@/lib/schemas' import { subdomainSchema } from '@/lib/schemas-ext' import { z } from 'zod' export const groupKeys = { all: ['groups'] as const, + detail: (id: number) => [...groupKeys.all, 'detail', id] as const, } export const groupsQueryOptions = () => @@ -17,6 +27,15 @@ export const groupsQueryOptions = () => }, }) +export const groupDetailQueryOptions = (id: number) => + queryOptions({ + queryKey: groupKeys.detail(id), + queryFn: async () => { + const data = await api.get(`/api/v1/groups/${id}`) + return groupWithStatsSchema.parse(data) + }, + }) + export const serviceKeys = { all: ['services'] as const, } @@ -30,6 +49,29 @@ export const servicesQueryOptions = () => }, }) +export const serviceBindingKeys = { + all: ['service-bindings'] as const, + byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const, +} + +export const serviceBindingsQueryOptions = () => + queryOptions({ + queryKey: serviceBindingKeys.all, + queryFn: async () => { + const data = await api.get('/api/v1/service-bindings') + return z.array(serviceBindingSchema).parse(data) + }, + }) + +export const domainServiceBindingsQueryOptions = (domainId: number) => + queryOptions({ + queryKey: serviceBindingKeys.byDomain(domainId), + queryFn: async () => { + const data = await api.get(`/api/v1/domains/${domainId}/service-bindings`) + return z.array(serviceBindingSchema).parse(data) + }, + }) + export const domainKeys = { all: ['domains'] as const, list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const, @@ -42,7 +84,7 @@ export const domainsListQueryOptions = (groupId?: number) => queryFn: async () => { const qs = groupId ? `?group_id=${groupId}` : '' const data = await api.get(`/api/v1/domains${qs}`) - return z.array(domainSchema).parse(data) + return z.array(domainListItemSchema).parse(data) }, }) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 50179e8..94edb59 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AuthServicesRouteImport } from './routes/_auth/services' import { Route as AuthGroupsRouteImport } from './routes/_auth/groups' import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates' import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index' +import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId' import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index' import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns' @@ -53,6 +54,11 @@ const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({ path: '/domains/', getParentRoute: () => AuthRoute, } as any) +const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({ + id: '/$groupId', + path: '/$groupId', + getParentRoute: () => AuthGroupsRoute, +} as any) const AuthDomainsDomainIdIndexRoute = AuthDomainsDomainIdIndexRouteImport.update({ id: '/domains/$domainId/', @@ -69,8 +75,9 @@ export interface FileRoutesByFullPath { '/': typeof AuthIndexRoute '/login': typeof LoginRoute '/certificates': typeof AuthCertificatesRoute - '/groups': typeof AuthGroupsRoute + '/groups': typeof AuthGroupsRouteWithChildren '/services': typeof AuthServicesRoute + '/groups/$groupId': typeof AuthGroupsGroupIdRoute '/domains/': typeof AuthDomainsIndexRoute '/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute '/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute @@ -78,9 +85,10 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/login': typeof LoginRoute '/certificates': typeof AuthCertificatesRoute - '/groups': typeof AuthGroupsRoute + '/groups': typeof AuthGroupsRouteWithChildren '/services': typeof AuthServicesRoute '/': typeof AuthIndexRoute + '/groups/$groupId': typeof AuthGroupsGroupIdRoute '/domains': typeof AuthDomainsIndexRoute '/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute '/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute @@ -90,9 +98,10 @@ export interface FileRoutesById { '/_auth': typeof AuthRouteWithChildren '/login': typeof LoginRoute '/_auth/certificates': typeof AuthCertificatesRoute - '/_auth/groups': typeof AuthGroupsRoute + '/_auth/groups': typeof AuthGroupsRouteWithChildren '/_auth/services': typeof AuthServicesRoute '/_auth/': typeof AuthIndexRoute + '/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute '/_auth/domains/': typeof AuthDomainsIndexRoute '/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute '/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute @@ -105,6 +114,7 @@ export interface FileRouteTypes { | '/certificates' | '/groups' | '/services' + | '/groups/$groupId' | '/domains/' | '/domains/$domainId/dns' | '/domains/$domainId/' @@ -115,6 +125,7 @@ export interface FileRouteTypes { | '/groups' | '/services' | '/' + | '/groups/$groupId' | '/domains' | '/domains/$domainId/dns' | '/domains/$domainId' @@ -126,6 +137,7 @@ export interface FileRouteTypes { | '/_auth/groups' | '/_auth/services' | '/_auth/' + | '/_auth/groups/$groupId' | '/_auth/domains/' | '/_auth/domains/$domainId/dns' | '/_auth/domains/$domainId/' @@ -187,6 +199,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthDomainsIndexRouteImport parentRoute: typeof AuthRoute } + '/_auth/groups/$groupId': { + id: '/_auth/groups/$groupId' + path: '/$groupId' + fullPath: '/groups/$groupId' + preLoaderRoute: typeof AuthGroupsGroupIdRouteImport + parentRoute: typeof AuthGroupsRoute + } '/_auth/domains/$domainId/': { id: '/_auth/domains/$domainId/' path: '/domains/$domainId' @@ -204,9 +223,21 @@ declare module '@tanstack/react-router' { } } +interface AuthGroupsRouteChildren { + AuthGroupsGroupIdRoute: typeof AuthGroupsGroupIdRoute +} + +const AuthGroupsRouteChildren: AuthGroupsRouteChildren = { + AuthGroupsGroupIdRoute: AuthGroupsGroupIdRoute, +} + +const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren( + AuthGroupsRouteChildren, +) + interface AuthRouteChildren { AuthCertificatesRoute: typeof AuthCertificatesRoute - AuthGroupsRoute: typeof AuthGroupsRoute + AuthGroupsRoute: typeof AuthGroupsRouteWithChildren AuthServicesRoute: typeof AuthServicesRoute AuthIndexRoute: typeof AuthIndexRoute AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute @@ -216,7 +247,7 @@ interface AuthRouteChildren { const AuthRouteChildren: AuthRouteChildren = { AuthCertificatesRoute: AuthCertificatesRoute, - AuthGroupsRoute: AuthGroupsRoute, + AuthGroupsRoute: AuthGroupsRouteWithChildren, AuthServicesRoute: AuthServicesRoute, AuthIndexRoute: AuthIndexRoute, AuthDomainsIndexRoute: AuthDomainsIndexRoute, diff --git a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx index 7648401..885283d 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' @@ -7,17 +8,10 @@ import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } import { api } from '@/lib/api-client' import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas' import { PageHeader } from '@/components/page-header' -import { StatusBadge } from '@/components/status-badge' import { DataTableCard } from '@/components/data-table-card' +import { DnsRecordsTable } from '@/components/dns-records-table' import { Button } from '@cfdm/ui/components/button' import { Input } from '@cfdm/ui/components/input' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' import { Field, FieldGroup, @@ -30,19 +24,21 @@ import { SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' -import { Switch } from '@cfdm/ui/components/switch' import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@cfdm/ui/components/sheet' +import { Switch } from '@cfdm/ui/components/switch' import { Spinner } from '@cfdm/ui/components/spinner' const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const +const dnsTypeItems = DNS_TYPES.map((type) => ({ label: type, value: type })) + export const Route = createFileRoute('/_auth/domains/$domainId/dns')({ loader: ({ context: { queryClient }, params }) => { const id = Number(params.domainId) @@ -55,6 +51,7 @@ export const Route = createFileRoute('/_auth/domains/$domainId/dns')({ }) function DnsPage() { + const [sheetOpen, setSheetOpen] = useState(false) const { domainId } = Route.useParams() const id = Number(domainId) const queryClient = useQueryClient() @@ -97,6 +94,7 @@ function DnsPage() { ttl: 1, proxied: false, }) + setSheetOpen(false) toast.success('DNS-запись создана') }, onError: (err) => { @@ -119,46 +117,78 @@ function DnsPage() { createMutation.mutate(values) }) + const hasRecords = useMemo(() => (records?.length ?? 0) > 0, [records]) + return ( -
+
syncMutation.mutate()} - disabled={syncMutation.isPending} - > - {syncMutation.isPending && ( - - )} - {syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'} - + <> + + + } /> - - - Новая запись - Добавить DNS-запись в зону - - -
- + + + {hasRecords && ( +
+ deleteMutation.mutate(recordId)} + isDeleting={deleteMutation.isPending} + /> +
+ )} +
+ + + + + Новая DNS-запись + + Добавить запись в зону {domain?.zone_name ?? ''} + + + + Тип ( - - {DNS_TYPES.map((type) => ( - - {type} + {dnsTypeItems.map((item) => ( + + {item.label} ))} @@ -168,11 +198,11 @@ function DnsPage() { Имя - + - + Значение - + TTL @@ -182,7 +212,7 @@ function DnsPage() { {...form.register('ttl', { valueAsNumber: true })} /> - + Прокси Cloudflare - - - + + + -
-
- - - - - Тип - Имя - Значение - TTL - Синхронизация - - - - - {records?.map((r) => ( - - {r.record_type} - {r.name} - {r.content} - {r.ttl} - - - - - - - - ))} - -
-
+ +
) } diff --git a/apps/web/src/routes/_auth/domains/$domainId/index.tsx b/apps/web/src/routes/_auth/domains/$domainId/index.tsx index 371138f..546d7ee 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/index.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/index.tsx @@ -1,9 +1,18 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { domainDetailQueryOptions, domainKeys, subdomainKeys, subdomainsListQueryOptions } from '@/queries' +import { + domainDetailQueryOptions, + domainKeys, + domainServiceBindingsQueryOptions, + subdomainKeys, + subdomainsListQueryOptions, +} from '@/queries' import { api } from '@/lib/api-client' import { PageHeader } from '@/components/page-header' +import { StatusBadge } from '@/components/status-badge' +import { DomainBindingsCard } from '@/components/domain-bindings-card' +import { Badge } from '@cfdm/ui/components/badge' import { Button } from '@cfdm/ui/components/button' import { Card, @@ -18,6 +27,13 @@ import { EmptyHeader, EmptyTitle, } from '@cfdm/ui/components/empty' +import { + Item, + ItemContent, + ItemGroup, + ItemSeparator, + ItemTitle, +} from '@cfdm/ui/components/item' import { Spinner } from '@cfdm/ui/components/spinner' export const Route = createFileRoute('/_auth/domains/$domainId/')({ @@ -26,6 +42,7 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({ return Promise.all([ queryClient.ensureQueryData(domainDetailQueryOptions(id)), queryClient.ensureQueryData(subdomainsListQueryOptions(id)), + queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)), ]) }, component: DomainOverviewPage, @@ -37,12 +54,14 @@ function DomainOverviewPage() { const queryClient = useQueryClient() const { data: domain } = useQuery(domainDetailQueryOptions(id)) const { data: subdomains } = useQuery(subdomainsListQueryOptions(id)) + const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id)) const syncMutation = useMutation({ mutationFn: () => api.post(`/api/v1/domains/${id}/sync`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) }) queryClient.invalidateQueries({ queryKey: domainKeys.all }) + queryClient.invalidateQueries({ queryKey: ['service-bindings'] }) toast.success('Синхронизация завершена') }, onError: (err) => { @@ -82,6 +101,44 @@ function DomainOverviewPage() { } /> +
+ + + Сводка + Основные параметры зоны + + +
+ Статус + {domain ? : '—'} +
+
+ Группа + {domain?.group_id ? ( + + ) : ( + Без группы + )} +
+
+ Последняя синхронизация + {domain?.last_synced_at ?? '—'} +
+
+
+ +
Поддомены @@ -89,13 +146,18 @@ function DomainOverviewPage() { {subdomains?.length ? ( -
    - {subdomains.map((s) => ( -
  • - {s.fqdn} -
  • + + {subdomains.map((s, index) => ( +
    + + + {s.fqdn} + + + {index < subdomains.length - 1 && } +
    ))} -
+ ) : ( diff --git a/apps/web/src/routes/_auth/domains/index.tsx b/apps/web/src/routes/_auth/domains/index.tsx index ecd20c5..044808a 100644 --- a/apps/web/src/routes/_auth/domains/index.tsx +++ b/apps/web/src/routes/_auth/domains/index.tsx @@ -6,15 +6,20 @@ import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' import { api } from '@/lib/api-client' import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas' -import { domainKeys, domainsListQueryOptions, groupsQueryOptions } from '@/queries' +import { buildIpsByDomainId } from '@/lib/domain-ips' +import { + domainKeys, + domainsListQueryOptions, + groupsQueryOptions, + serviceBindingsQueryOptions, +} from '@/queries' import { PageHeader } from '@/components/page-header' -import { StatusBadge } from '@/components/status-badge' 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, - CardContent, CardDescription, CardHeader, CardTitle, @@ -32,13 +37,13 @@ import { SelectValue, } from '@cfdm/ui/components/select' import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@cfdm/ui/components/sheet' import { Spinner } from '@cfdm/ui/components/spinner' export const Route = createFileRoute('/_auth/domains/')({ @@ -46,16 +51,26 @@ export const Route = createFileRoute('/_auth/domains/')({ Promise.all([ queryClient.ensureQueryData(domainsListQueryOptions()), queryClient.ensureQueryData(groupsQueryOptions()), + queryClient.ensureQueryData(serviceBindingsQueryOptions()), ]), component: DomainsPage, }) function DomainsPage() { - const [groupId, setGroupId] = useState('') - const filterGroupId = groupId ? Number(groupId) : undefined + const [sheetOpen, setSheetOpen] = useState(false) + const [importGroupId, setImportGroupId] = useState('') + const [filterGroupId, setFilterGroupId] = useState('') + const listGroupId = + filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined const queryClient = useQueryClient() - const { data: domains } = useQuery(domainsListQueryOptions(filterGroupId)) + const { data: domains } = useQuery(domainsListQueryOptions(listGroupId)) const { data: groups } = useQuery(groupsQueryOptions()) + const { data: bindings } = useQuery(serviceBindingsQueryOptions()) + + const ipsByDomainId = useMemo( + () => buildIpsByDomainId(bindings ?? []), + [bindings], + ) const groupItems = useMemo( () => [ @@ -75,7 +90,8 @@ function DomainsPage() { api.post('/api/v1/domains', body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: domainKeys.all }) - form.reset({ zone_name: '', group_id: groupId }) + form.reset({ zone_name: '', group_id: importGroupId }) + setSheetOpen(false) toast.success('Домен импортирован') }, onError: (err) => { @@ -86,31 +102,89 @@ function DomainsPage() { const handleCreate = form.handleSubmit((values) => { createMutation.mutate({ zone_name: values.zone_name.trim(), - group_id: groupId ? Number(groupId) : undefined, + group_id: importGroupId ? Number(importGroupId) : undefined, }) }) - const handleGroupChange = (value: string | null) => { + const handleImportGroupChange = (value: string | null) => { const next = value === 'none' || !value ? '' : value - setGroupId(next) + setImportGroupId(next) form.setValue('group_id', next) } + const handleFilterGroupChange = (value: string | null) => { + if (!value || value === 'all') { + setFilterGroupId('') + return + } + setFilterGroupId(value === 'none' ? 'none' : value) + } + + const filteredDomains = useMemo(() => { + if (filterGroupId === 'none') { + return domains?.filter((d) => d.group_id === null) ?? [] + } + return domains ?? [] + }, [domains, filterGroupId]) + + const tableData = useMemo( + () => + filteredDomains.map((domain) => ({ + ...domain, + ips: ipsByDomainId.get(domain.id) ?? [], + })), + [filteredDomains, ipsByDomainId], + ) + return ( -
+
+ + + + } /> - - - Добавить домен - Импортировать зону из Cloudflare + + + + Быстрый обзор + + {tableData.length} зон ·{' '} + {tableData.filter((d) => d.ips.length > 0).length} с IP-адресами ·{' '} + {tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов + - -
- - + + + + + + + + + + Импорт домена + + Добавить зону из аккаунта Cloudflare в менеджер + + + + + Имя зоны - - Группа + + Группа - - + -
-
- - - - - Зона - Статус - Последняя синхронизация - Действия - - - - {domains?.map((d) => ( - - {d.zone_name} - - - - {d.last_synced_at ?? '—'} - -
- - -
-
-
- ))} -
-
-
+ +
) } diff --git a/apps/web/src/routes/_auth/groups.tsx b/apps/web/src/routes/_auth/groups.tsx index 757dcd0..e7720b7 100644 --- a/apps/web/src/routes/_auth/groups.tsx +++ b/apps/web/src/routes/_auth/groups.tsx @@ -1,13 +1,22 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' -import { groupsQueryOptions, groupKeys, domainKeys } from '@/queries' +import { + domainKeys, + domainsListQueryOptions, + groupKeys, + groupsQueryOptions, + serviceBindingsQueryOptions, +} from '@/queries' import { api } from '@/lib/api-client' -import { createGroupSchema, type CreateGroupInput } from '@/lib/schemas' +import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas' import { PageHeader } from '@/components/page-header' -import { ResourceList } from '@/components/resource-list' +import { KanbanBoard } from '@/components/kanban-board' +import { DomainGroupCard } from '@/components/domain-group-card' +import { DataTableCard } from '@/components/data-table-card' import { Button } from '@cfdm/ui/components/button' import { Input } from '@cfdm/ui/components/input' import { @@ -22,22 +31,92 @@ import { FieldGroup, FieldLabel, } from '@cfdm/ui/components/field' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@cfdm/ui/components/tabs' import { Spinner } from '@cfdm/ui/components/spinner' export const Route = createFileRoute('/_auth/groups')({ - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(groupsQueryOptions()), + loader: ({ context: { queryClient } }) => + Promise.all([ + queryClient.ensureQueryData(groupsQueryOptions()), + queryClient.ensureQueryData(domainsListQueryOptions()), + queryClient.ensureQueryData(serviceBindingsQueryOptions()), + ]), component: GroupsPage, }) +const UNGROUPED_COLUMN_ID = 'ungrouped' + +function groupColumnId(groupId: number) { + return `group-${groupId}` +} + +function parseGroupColumnId(columnId: string): number | null { + if (columnId === UNGROUPED_COLUMN_ID) return null + const match = columnId.match(/^group-(\d+)$/) + return match ? Number(match[1]) : null +} + function GroupsPage() { const queryClient = useQueryClient() const { data: groups } = useQuery(groupsQueryOptions()) + const { data: domains } = useQuery(domainsListQueryOptions()) + const { data: bindings } = useQuery(serviceBindingsQueryOptions()) const form = useForm({ resolver: zodResolver(createGroupSchema), defaultValues: { name: '', slug: '' }, }) + const serviceLabelsByDomain = useMemo(() => { + const map = new Map() + for (const binding of bindings ?? []) { + const list = map.get(binding.domain_id) ?? [] + if (!list.includes(binding.service_name)) { + list.push(binding.service_name) + } + map.set(binding.domain_id, list) + } + return map + }, [bindings]) + + const columns = useMemo(() => { + const groupColumns = + groups?.map((group) => ({ + id: groupColumnId(group.id), + title: group.name, + description: group.slug, + href: `/groups/${group.id}`, + items: + domains?.filter((d) => d.group_id === group.id) ?? [], + })) ?? [] + + const ungrouped: DomainListItem[] = + domains?.filter((d) => d.group_id === null) ?? [] + + return [ + ...groupColumns, + { + id: UNGROUPED_COLUMN_ID, + title: 'Без группы', + description: 'Домены без назначенной группы', + items: ungrouped, + }, + ] + }, [groups, domains]) + const createMutation = useMutation({ mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body), onSuccess: () => { @@ -62,73 +141,150 @@ function GroupsPage() { }, }) + const moveDomainMutation = useMutation({ + mutationFn: ({ domainId, groupId }: { domainId: number; groupId: number | null }) => + api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: domainKeys.all }) + toast.success('Группа домена обновлена') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен') + }, + }) + + function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) { + const groupId = parseGroupColumnId(toColumnId) + if (toColumnId !== UNGROUPED_COLUMN_ID && groupId === null) return + moveDomainMutation.mutate({ domainId: Number(itemId), groupId }) + } + const handleSubmit = form.handleSubmit((values) => { createMutation.mutate(values) }) return ( -
+
- - - Создать группу - Добавить новую группу доменов - - -
- - - Название - - - - Slug - - - - -
-
-
- ({ - id: g.id, - primary: g.name, - secondary: `(${g.slug})`, - })) ?? [] - } - emptyTitle="Группы не найдены" - emptyDescription="Создайте первую группу в форме выше" - renderActions={(item) => ( - + + + + + - Удалить - - )} - /> + + + + Название + Slug + Действия + + + + {groups?.map((group) => ( + + {group.name} + {group.slug} + +
+ + +
+
+
+ ))} +
+
+
+ +
) } diff --git a/apps/web/src/routes/_auth/groups/$groupId.tsx b/apps/web/src/routes/_auth/groups/$groupId.tsx new file mode 100644 index 0000000..67aa468 --- /dev/null +++ b/apps/web/src/routes/_auth/groups/$groupId.tsx @@ -0,0 +1,101 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { + domainsListQueryOptions, + groupDetailQueryOptions, +} from '@/queries' +import { PageHeader } from '@/components/page-header' +import { StatusBadge } from '@/components/status-badge' +import { DataTableCard } from '@/components/data-table-card' +import { Button } from '@cfdm/ui/components/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' + +export const Route = createFileRoute('/_auth/groups/$groupId')({ + loader: ({ context: { queryClient }, params }) => { + const id = Number(params.groupId) + return Promise.all([ + queryClient.ensureQueryData(groupDetailQueryOptions(id)), + queryClient.ensureQueryData(domainsListQueryOptions(id)), + ]) + }, + component: GroupDetailPage, +}) + +function GroupDetailPage() { + const { groupId } = Route.useParams() + const id = Number(groupId) + const { data: group } = useQuery(groupDetailQueryOptions(id)) + const { data: domains } = useQuery(domainsListQueryOptions(id)) + + return ( +
+ } + > + На канбан + + } + /> + + + + + Зона + Статус + Сервисы + Действия + + + + {domains?.map((domain) => ( + + {domain.zone_name} + + + + {domain.service_count} + + + + + ))} + +
+
+
+ ) +} diff --git a/apps/web/src/routes/_auth/services.tsx b/apps/web/src/routes/_auth/services.tsx index d5610b4..def6a65 100644 --- a/apps/web/src/routes/_auth/services.tsx +++ b/apps/web/src/routes/_auth/services.tsx @@ -1,13 +1,29 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' -import { servicesQueryOptions, serviceKeys } from '@/queries' +import { + domainKeys, + domainsListQueryOptions, + serviceBindingKeys, + serviceBindingsQueryOptions, + serviceKeys, + servicesQueryOptions, +} from '@/queries' import { api } from '@/lib/api-client' -import { createServiceSchema, type CreateServiceInput } from '@/lib/schemas' +import { + createServiceBindingSchema, + createServiceSchema, + type CreateServiceBindingInput, + type CreateServiceInput, + type ServiceBinding, +} from '@/lib/schemas' import { PageHeader } from '@/components/page-header' -import { ResourceList } from '@/components/resource-list' +import { DataTableCard } from '@/components/data-table-card' +import { KanbanBoard } from '@/components/kanban-board' +import { ServiceBindingCard } from '@/components/service-binding-card' import { Button } from '@cfdm/ui/components/button' import { Input } from '@cfdm/ui/components/input' import { @@ -17,32 +33,99 @@ import { CardHeader, CardTitle, } from '@cfdm/ui/components/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' import { Field, FieldGroup, FieldLabel, } from '@cfdm/ui/components/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@cfdm/ui/components/sheet' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@cfdm/ui/components/tabs' import { Spinner } from '@cfdm/ui/components/spinner' export const Route = createFileRoute('/_auth/services')({ - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(servicesQueryOptions()), + loader: ({ context: { queryClient } }) => + Promise.all([ + queryClient.ensureQueryData(servicesQueryOptions()), + queryClient.ensureQueryData(serviceBindingsQueryOptions()), + queryClient.ensureQueryData(domainsListQueryOptions()), + ]), component: ServicesPage, }) +function serviceColumnId(serviceId: number) { + return `service-${serviceId}` +} + +function parseServiceColumnId(columnId: string): number | null { + const match = columnId.match(/^service-(\d+)$/) + return match ? Number(match[1]) : null +} + function ServicesPage() { + const [sheetOpen, setSheetOpen] = useState(false) const queryClient = useQueryClient() const { data: services } = useQuery(servicesQueryOptions()) + const { data: bindings } = useQuery(serviceBindingsQueryOptions()) + const { data: domains } = useQuery(domainsListQueryOptions()) - const form = useForm({ + const catalogForm = useForm({ resolver: zodResolver(createServiceSchema), defaultValues: { name: '', slug: '' }, }) - const createMutation = useMutation({ + const bindingForm = useForm({ + resolver: zodResolver(createServiceBindingSchema), + defaultValues: { + domain_id: '', + service_id: '', + hostname: '@', + target_ip: '', + }, + }) + + const columns = useMemo(() => { + return ( + services?.map((service) => ({ + id: serviceColumnId(service.id), + title: service.name, + description: service.slug, + items: bindings?.filter((b) => b.service_id === service.id) ?? [], + })) ?? [] + ) + }, [services, bindings]) + + const createServiceMutation = useMutation({ mutationFn: (body: CreateServiceInput) => api.post('/api/v1/services', body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: serviceKeys.all }) - form.reset() + catalogForm.reset() toast.success('Сервис создан') }, onError: (err) => { @@ -50,63 +133,249 @@ function ServicesPage() { }, }) - const handleSubmit = form.handleSubmit((values) => { - createMutation.mutate(values) + const createBindingMutation = useMutation({ + mutationFn: (body: { + domain_id: number + service_id: number + hostname?: string + target_ip?: string + }) => api.post('/api/v1/service-bindings', body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }) + queryClient.invalidateQueries({ queryKey: domainKeys.all }) + bindingForm.reset({ domain_id: '', service_id: '', hostname: '@', target_ip: '' }) + setSheetOpen(false) + toast.success('Привязка создана') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось создать привязку') + }, + }) + + const updateBindingMutation = useMutation({ + mutationFn: ({ + id, + body, + }: { + id: number + body: { service_id?: number; hostname?: string; target_ip?: string } + }) => api.patch(`/api/v1/service-bindings/${id}`, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }) + queryClient.invalidateQueries({ queryKey: domainKeys.all }) + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось обновить привязку') + }, + }) + + function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) { + const serviceId = parseServiceColumnId(toColumnId) + if (serviceId === null) return + updateBindingMutation.mutate({ + id: Number(itemId), + body: { service_id: serviceId }, + }) + } + + function handleIpChange(id: number, targetIp: string) { + updateBindingMutation.mutate({ id, body: { target_ip: targetIp } }) + } + + function handleHostnameChange(id: number, hostname: string) { + updateBindingMutation.mutate({ id, body: { hostname } }) + } + + const renderBindingCard = (binding: ServiceBinding) => ( + + ) + + const handleCatalogSubmit = catalogForm.handleSubmit((values) => { + createServiceMutation.mutate(values) + }) + + const handleBindingSubmit = bindingForm.handleSubmit((values) => { + createBindingMutation.mutate({ + domain_id: Number(values.domain_id), + service_id: Number(values.service_id), + hostname: values.hostname || '@', + target_ip: values.target_ip || undefined, + }) }) return ( -
+
setSheetOpen(true)}>Добавить привязку + } /> - - - Создать сервис - Добавить новый сервис в справочник - - -
- - - Название - - - - Slug - - - + +
+
+
+ + + + + Название + Slug + + + + {services?.map((service) => ( + + {service.name} + {service.slug} + + ))} + +
+
+ + + + + + + Новая привязка + + Свяжите домен с сервисом и укажите IP для A-записи + + +
+ + Домен + + + + Сервис + + + + Hostname + + + + IPv4 + + + + - +
- - - ({ - id: s.id, - primary: s.name, - secondary: `(${s.slug})`, - })) ?? [] - } - emptyTitle="Сервисы не найдены" - emptyDescription="Создайте первый сервис в форме выше" - /> +
+
) } diff --git a/backend/migrations/002_service_bindings.sql b/backend/migrations/002_service_bindings.sql new file mode 100644 index 0000000..a5e21f8 --- /dev/null +++ b/backend/migrations/002_service_bindings.sql @@ -0,0 +1,23 @@ +PRAGMA foreign_keys = OFF; + +CREATE TABLE service_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + hostname TEXT NOT NULL DEFAULT '@', + dns_record_id INTEGER REFERENCES dns_records(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(domain_id, service_id, hostname) +); + +CREATE INDEX idx_service_bindings_domain_id ON service_bindings(domain_id); +CREATE INDEX idx_service_bindings_service_id ON service_bindings(service_id); + +INSERT INTO service_bindings (domain_id, service_id, hostname, created_at, updated_at) +SELECT domain_id, service_id, '@', datetime('now'), datetime('now') +FROM domain_services; + +DROP TABLE domain_services; + +PRAGMA foreign_keys = ON; diff --git a/backend/src/api/handlers/domains.rs b/backend/src/api/handlers/domains.rs index 16c687a..be71bb5 100644 --- a/backend/src/api/handlers/domains.rs +++ b/backend/src/api/handlers/domains.rs @@ -29,7 +29,7 @@ pub struct SetServicesBody { pub async fn list( State(state): State, Query(q): Query, -) -> AppResult>> { +) -> AppResult>> { Ok(axum::Json(domain_service::list_domains(&state.pool, q.group_id).await?)) } diff --git a/backend/src/api/handlers/groups.rs b/backend/src/api/handlers/groups.rs index b96907b..e799fac 100644 --- a/backend/src/api/handlers/groups.rs +++ b/backend/src/api/handlers/groups.rs @@ -17,8 +17,8 @@ pub async fn list(State(state): State) -> AppResult, Path(id): Path, -) -> AppResult> { - Ok(axum::Json(crate::repositories::groups::get(&state.pool, id).await?)) +) -> AppResult> { + Ok(axum::Json(crate::repositories::groups::get_with_stats(&state.pool, id).await?)) } pub async fn create( diff --git a/backend/src/api/handlers/mod.rs b/backend/src/api/handlers/mod.rs index 27f3570..a9b9cd8 100644 --- a/backend/src/api/handlers/mod.rs +++ b/backend/src/api/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod dns; pub mod domains; pub mod groups; pub mod health; +pub mod service_bindings; pub mod services; pub mod subdomains; pub mod sync; diff --git a/backend/src/api/handlers/service_bindings.rs b/backend/src/api/handlers/service_bindings.rs new file mode 100644 index 0000000..d12f0b2 --- /dev/null +++ b/backend/src/api/handlers/service_bindings.rs @@ -0,0 +1,53 @@ +use crate::error::AppResult; +use crate::services::binding_service::{self, CreateBindingRequest, UpdateBindingRequest}; +use crate::state::AppState; +use axum::extract::{Path, State}; + +pub async fn list(State(state): State) -> AppResult>> { + Ok(axum::Json(binding_service::list_all(&state.pool).await?)) +} + +pub async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(axum::Json( + crate::repositories::service_bindings::get_view(&state.pool, id).await?, + )) +} + +pub async fn create( + State(state): State, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json( + binding_service::create(&state.pool, &state.cf, body).await?, + )) +} + +pub async fn update( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json( + binding_service::update(&state.pool, &state.cf, id, body).await?, + )) +} + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> AppResult> { + binding_service::delete(&state.pool, id).await?; + Ok(axum::Json(serde_json::json!({ "deleted": true }))) +} + +pub async fn list_by_domain( + State(state): State, + Path(domain_id): Path, +) -> AppResult>> { + Ok(axum::Json( + binding_service::list_by_domain(&state.pool, domain_id).await?, + )) +} diff --git a/backend/src/api/router.rs b/backend/src/api/router.rs index 1881698..1e3594c 100644 --- a/backend/src/api/router.rs +++ b/backend/src/api/router.rs @@ -1,4 +1,4 @@ -use super::handlers::{auth, certificates, dns, domains, groups, health, services, subdomains, sync}; +use super::handlers::{auth, certificates, dns, domains, groups, health, service_bindings, services, subdomains, sync}; use crate::state::AppState; use axum::{ middleware, @@ -33,6 +33,16 @@ pub fn create_router(state: AppState) -> Router { .patch(services::update) .delete(services::delete), ) + .route( + "/service-bindings", + get(service_bindings::list).post(service_bindings::create), + ) + .route( + "/service-bindings/{id}", + get(service_bindings::get_one) + .patch(service_bindings::update) + .delete(service_bindings::delete), + ) .route("/domains", get(domains::list).post(domains::create)) .route( "/domains/{id}", @@ -42,6 +52,10 @@ pub fn create_router(state: AppState) -> Router { ) .route("/domains/{id}/import", post(domains::import_zone)) .route("/domains/{id}/services", put(domains::set_services)) + .route( + "/domains/{id}/service-bindings", + get(service_bindings::list_by_domain), + ) .route("/domains/{id}/dns", get(dns::list).post(dns::create)) .route("/domains/{id}/dns/bulk", post(dns::bulk)) .route( diff --git a/backend/src/domain/entities.rs b/backend/src/domain/entities.rs index c9ca0f3..6d931a0 100644 --- a/backend/src/domain/entities.rs +++ b/backend/src/domain/entities.rs @@ -72,6 +72,59 @@ pub struct Certificate { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ServiceBinding { + pub id: i64, + pub domain_id: i64, + pub service_id: i64, + pub hostname: String, + pub dns_record_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ServiceBindingView { + pub id: i64, + pub domain_id: i64, + pub service_id: i64, + pub hostname: String, + pub dns_record_id: Option, + pub zone_name: String, + pub group_id: Option, + pub group_name: Option, + pub service_name: String, + pub service_slug: String, + pub target_ip: Option, + pub sync_status: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct GroupWithStats { + pub id: i64, + pub name: String, + pub slug: String, + pub created_at: String, + pub updated_at: String, + pub domain_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DomainListItem { + pub id: i64, + pub group_id: Option, + pub zone_name: String, + pub cf_zone_id: String, + pub status: String, + pub last_synced_at: Option, + pub created_at: String, + pub updated_at: String, + pub group_name: Option, + pub service_count: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct SyncJob { pub id: String, diff --git a/backend/src/repositories/domains.rs b/backend/src/repositories/domains.rs index 62be7da..764d7f3 100644 --- a/backend/src/repositories/domains.rs +++ b/backend/src/repositories/domains.rs @@ -1,4 +1,4 @@ -use crate::domain::Domain; +use crate::domain::{Domain, DomainListItem}; use crate::error::{AppError, AppResult}; use sqlx::SqlitePool; @@ -17,6 +17,37 @@ pub async fn list(pool: &SqlitePool, group_id: Option) -> AppResult) -> AppResult> { + if let Some(gid) = group_id { + let sql = format!("{LIST_ENRICHED_SELECT} WHERE d.group_id = ? ORDER BY d.zone_name"); + Ok(sqlx::query_as::<_, DomainListItem>(&sql) + .bind(gid) + .fetch_all(pool) + .await?) + } else { + let sql = format!("{LIST_ENRICHED_SELECT} ORDER BY d.zone_name"); + Ok(sqlx::query_as::<_, DomainListItem>(&sql) + .fetch_all(pool) + .await?) + } +} + pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { sqlx::query_as::<_, Domain>("SELECT * FROM domains WHERE id = ?") .bind(id) diff --git a/backend/src/repositories/groups.rs b/backend/src/repositories/groups.rs index 6a98dc9..99020ef 100644 --- a/backend/src/repositories/groups.rs +++ b/backend/src/repositories/groups.rs @@ -1,4 +1,4 @@ -use crate::domain::Group; +use crate::domain::{Group, GroupWithStats}; use crate::error::{AppError, AppResult}; use sqlx::SqlitePool; @@ -17,6 +17,26 @@ pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { .ok_or_else(|| AppError::NotFound(format!("group {id}"))) } +pub async fn get_with_stats(pool: &SqlitePool, id: i64) -> AppResult { + sqlx::query_as::<_, GroupWithStats>( + r#" + SELECT + g.id, + g.name, + g.slug, + g.created_at, + g.updated_at, + (SELECT COUNT(*) FROM domains d WHERE d.group_id = g.id) AS domain_count + FROM groups g + WHERE g.id = ? + "#, + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("group {id}"))) +} + pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult { let id = sqlx::query_scalar::<_, i64>( "INSERT INTO groups (name, slug) VALUES (?, ?) RETURNING id", diff --git a/backend/src/repositories/mod.rs b/backend/src/repositories/mod.rs index e3f3c21..90078bc 100644 --- a/backend/src/repositories/mod.rs +++ b/backend/src/repositories/mod.rs @@ -2,6 +2,7 @@ pub mod certificates; pub mod dns_records; pub mod domains; pub mod groups; +pub mod service_bindings; pub mod services; pub mod subdomains; pub mod sync_jobs; diff --git a/backend/src/repositories/service_bindings.rs b/backend/src/repositories/service_bindings.rs new file mode 100644 index 0000000..ef6b730 --- /dev/null +++ b/backend/src/repositories/service_bindings.rs @@ -0,0 +1,135 @@ +use crate::domain::{ServiceBinding, ServiceBindingView}; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +const VIEW_SELECT: &str = r#" +SELECT + sb.id, + sb.domain_id, + sb.service_id, + sb.hostname, + sb.dns_record_id, + d.zone_name, + d.group_id, + g.name AS group_name, + s.name AS service_name, + s.slug AS service_slug, + dr.content AS target_ip, + dr.sync_status, + sb.created_at, + sb.updated_at +FROM service_bindings sb +JOIN domains d ON d.id = sb.domain_id +LEFT JOIN groups g ON g.id = d.group_id +JOIN services s ON s.id = sb.service_id +LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id +"#; + +pub async fn list_all(pool: &SqlitePool) -> AppResult> { + let sql = format!("{VIEW_SELECT} ORDER BY d.zone_name, s.name"); + Ok(sqlx::query_as::<_, ServiceBindingView>(&sql) + .fetch_all(pool) + .await?) +} + +pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult> { + let sql = format!("{VIEW_SELECT} WHERE sb.domain_id = ? ORDER BY s.name"); + Ok(sqlx::query_as::<_, ServiceBindingView>(&sql) + .bind(domain_id) + .fetch_all(pool) + .await?) +} + +pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { + sqlx::query_as::<_, ServiceBinding>("SELECT * FROM service_bindings WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("service binding {id}"))) +} + +pub async fn get_view(pool: &SqlitePool, id: i64) -> AppResult { + let sql = format!("{VIEW_SELECT} WHERE sb.id = ?"); + sqlx::query_as::<_, ServiceBindingView>(&sql) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("service binding {id}"))) +} + +pub async fn insert( + pool: &SqlitePool, + domain_id: i64, + service_id: i64, + hostname: &str, + dns_record_id: Option, +) -> AppResult { + let id = sqlx::query_scalar::<_, i64>( + r#" + INSERT INTO service_bindings (domain_id, service_id, hostname, dns_record_id) + VALUES (?, ?, ?, ?) + RETURNING id + "#, + ) + .bind(domain_id) + .bind(service_id) + .bind(hostname) + .bind(dns_record_id) + .fetch_one(pool) + .await?; + get(pool, id).await +} + +pub async fn update_fields( + pool: &SqlitePool, + id: i64, + service_id: i64, + hostname: &str, + dns_record_id: Option, +) -> AppResult { + let affected = sqlx::query( + r#" + UPDATE service_bindings + SET service_id = ?, hostname = ?, dns_record_id = ?, updated_at = datetime('now') + WHERE id = ? + "#, + ) + .bind(service_id) + .bind(hostname) + .bind(dns_record_id) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("service binding {id}"))); + } + get(pool, id).await +} + +pub async fn set_dns_record_id(pool: &SqlitePool, id: i64, dns_record_id: Option) -> AppResult<()> { + let affected = sqlx::query( + "UPDATE service_bindings SET dns_record_id = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(dns_record_id) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("service binding {id}"))); + } + Ok(()) +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + let affected = sqlx::query("DELETE FROM service_bindings WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("service binding {id}"))); + } + Ok(()) +} diff --git a/backend/src/repositories/services.rs b/backend/src/repositories/services.rs index 89be800..6ca5f8c 100644 --- a/backend/src/repositories/services.rs +++ b/backend/src/repositories/services.rs @@ -54,33 +54,3 @@ pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { } Ok(()) } - -pub async fn set_domain_services( - pool: &SqlitePool, - domain_id: i64, - service_ids: &[i64], -) -> AppResult<()> { - let mut tx = pool.begin().await?; - sqlx::query("DELETE FROM domain_services WHERE domain_id = ?") - .bind(domain_id) - .execute(&mut *tx) - .await?; - for sid in service_ids { - sqlx::query("INSERT INTO domain_services (domain_id, service_id) VALUES (?, ?)") - .bind(domain_id) - .bind(sid) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(()) -} - -pub async fn list_domain_service_ids(pool: &SqlitePool, domain_id: i64) -> AppResult> { - Ok(sqlx::query_scalar::<_, i64>( - "SELECT service_id FROM domain_services WHERE domain_id = ?", - ) - .bind(domain_id) - .fetch_all(pool) - .await?) -} diff --git a/backend/src/services/binding_service.rs b/backend/src/services/binding_service.rs new file mode 100644 index 0000000..131e5dc --- /dev/null +++ b/backend/src/services/binding_service.rs @@ -0,0 +1,175 @@ +use crate::cloudflare::CloudflareClient; +use crate::domain::ServiceBindingView; +use crate::error::AppResult; +use crate::repositories::{domains, service_bindings, services as service_repo}; +use crate::services::dns_service::{self, CreateDnsRequest, UpdateDnsRequest}; +use serde::Deserialize; +use sqlx::SqlitePool; + +#[derive(Debug, Deserialize)] +pub struct CreateBindingRequest { + pub domain_id: i64, + pub service_id: i64, + pub hostname: Option, + pub target_ip: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateBindingRequest { + pub service_id: Option, + pub hostname: Option, + pub target_ip: Option, +} + +fn normalize_hostname(hostname: Option) -> String { + hostname + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "@".to_string()) +} + +async fn sync_target_ip( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, + binding_id: i64, + hostname: &str, + dns_record_id: Option, + target_ip: &str, +) -> AppResult { + if let Some(record_id) = dns_record_id { + dns_service::update( + pool, + cf, + domain_id, + record_id, + UpdateDnsRequest { + record_type: Some("A".into()), + name: Some(hostname.to_string()), + content: Some(target_ip.to_string()), + ttl: None, + proxied: Some(false), + priority: None, + }, + ) + .await?; + Ok(record_id) + } else { + let record = dns_service::create( + pool, + cf, + domain_id, + CreateDnsRequest { + record_type: "A".into(), + name: hostname.to_string(), + content: target_ip.to_string(), + ttl: Some(1), + proxied: Some(false), + priority: None, + }, + ) + .await?; + service_bindings::set_dns_record_id(pool, binding_id, Some(record.id)).await?; + Ok(record.id) + } +} + +pub async fn list_all(pool: &SqlitePool) -> AppResult> { + service_bindings::list_all(pool).await +} + +pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult> { + domains::get(pool, domain_id).await?; + service_bindings::list_by_domain(pool, domain_id).await +} + +pub async fn create( + pool: &SqlitePool, + cf: &CloudflareClient, + req: CreateBindingRequest, +) -> AppResult { + domains::get(pool, req.domain_id).await?; + service_repo::get(pool, req.service_id).await?; + + let hostname = normalize_hostname(req.hostname); + let binding = service_bindings::insert(pool, req.domain_id, req.service_id, &hostname, None).await?; + + if let Some(ip) = req.target_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + sync_target_ip(pool, cf, req.domain_id, binding.id, &hostname, None, ip).await?; + } + + service_bindings::get_view(pool, binding.id).await +} + +pub async fn update( + pool: &SqlitePool, + cf: &CloudflareClient, + id: i64, + req: UpdateBindingRequest, +) -> AppResult { + let existing = service_bindings::get(pool, id).await?; + let service_id = if let Some(sid) = req.service_id { + service_repo::get(pool, sid).await?; + sid + } else { + existing.service_id + }; + let hostname = req + .hostname + .map(|h| normalize_hostname(Some(h))) + .unwrap_or_else(|| existing.hostname.clone()); + + let dns_record_id = existing.dns_record_id; + service_bindings::update_fields(pool, id, service_id, &hostname, dns_record_id).await?; + + if let Some(ip) = req.target_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + sync_target_ip( + pool, + cf, + existing.domain_id, + id, + &hostname, + dns_record_id, + ip, + ) + .await?; + } + + service_bindings::get_view(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + service_bindings::get(pool, id).await?; + service_bindings::delete(pool, id).await +} + +pub async fn set_domain_services( + pool: &SqlitePool, + domain_id: i64, + service_ids: Vec, +) -> AppResult> { + domains::get(pool, domain_id).await?; + for sid in &service_ids { + service_repo::get(pool, *sid).await?; + } + + let existing = service_bindings::list_by_domain(pool, domain_id).await?; + for binding in &existing { + if !service_ids.contains(&binding.service_id) { + service_bindings::delete(pool, binding.id).await?; + } + } + + for sid in &service_ids { + let already = existing.iter().any(|b| b.service_id == *sid); + if !already { + service_bindings::insert(pool, domain_id, *sid, "@", None).await?; + } + } + + Ok(service_bindings::list_by_domain(pool, domain_id) + .await? + .into_iter() + .map(|b| b.service_id) + .collect()) +} diff --git a/backend/src/services/domain_service.rs b/backend/src/services/domain_service.rs index fe43b02..8678f23 100644 --- a/backend/src/services/domain_service.rs +++ b/backend/src/services/domain_service.rs @@ -1,11 +1,11 @@ use crate::cloudflare::CloudflareClient; -use crate::domain::Domain; +use crate::domain::{Domain, DomainListItem}; use crate::error::{AppError, AppResult}; -use crate::repositories::{domains, services as service_repo}; +use crate::repositories::domains; use sqlx::SqlitePool; -pub async fn list_domains(pool: &SqlitePool, group_id: Option) -> AppResult> { - domains::list(pool, group_id).await +pub async fn list_domains(pool: &SqlitePool, group_id: Option) -> AppResult> { + domains::list_enriched(pool, group_id).await } pub async fn get_domain(pool: &SqlitePool, id: i64) -> AppResult { @@ -57,12 +57,7 @@ pub async fn set_domain_services( domain_id: i64, service_ids: Vec, ) -> AppResult> { - domains::get(pool, domain_id).await?; - for sid in &service_ids { - service_repo::get(pool, *sid).await?; - } - service_repo::set_domain_services(pool, domain_id, &service_ids).await?; - service_repo::list_domain_service_ids(pool, domain_id).await + crate::services::binding_service::set_domain_services(pool, domain_id, service_ids).await } pub async fn import_zone_records( diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index a77072a..141e247 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod binding_service; pub mod certificate_service; pub mod dns_service; pub mod domain_service; diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 25b1ab1..bd0a3a2 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -44,16 +44,12 @@ function Button({ className, variant = "default", size = "default", - render, - nativeButton, ...props }: ButtonPrimitive.Props & VariantProps) { return ( ) diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx new file mode 100644 index 0000000..fd8ee87 --- /dev/null +++ b/packages/ui/src/components/input-group.tsx @@ -0,0 +1,156 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { Input } from "@cfdm/ui/components/input" +import { Textarea } from "@cfdm/ui/components/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset" + }) { + return ( +