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.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 16h45m4s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled

This commit is contained in:
Denozordec
2026-06-15 16:41:10 +07:00
parent b1467575c5
commit 5f7b3ac388
50 changed files with 3364 additions and 367 deletions
+2
View File
@@ -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 |
+8 -6
View File
@@ -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 <component>` — API и примеры
3. `cd apps/web && pnpm dlx shadcn@latest search "<query>"` — если компонент неочевиден
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 <component>` — API и примеры
4. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/<query>"` — если MCP не дал результат
5. Только потом писать код
**Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add <block-id>`.
@@ -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-примитивов
+43
View File
@@ -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).
+2
View File
@@ -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/**`).
+3
View File
@@ -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",
+16 -1
View File
@@ -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 (
<Card>
@@ -23,7 +29,16 @@ export function DataTableCard({
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className="p-0">{children}</CardContent>
<CardContent className="p-0">
{isEmpty && emptyTitle ? (
<div className="flex flex-col gap-1 p-6 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{emptyTitle}</p>
{emptyDescription && <p>{emptyDescription}</p>}
</div>
) : (
children
)}
</CardContent>
</Card>
)
}
@@ -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 (
<>
<TableCell className="max-w-xs truncate font-mono text-sm">{record.content}</TableCell>
<TableCell className="tabular-nums">{record.ttl}</TableCell>
<TableCell>
<StatusBadge status={record.sync_status} />
</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(record.id)}
disabled={isDeleting}
>
Удалить
</Button>
</TableCell>
</>
)
}
function SingleRecordRow({
record,
onDelete,
isDeleting,
isFirstGroup,
}: {
record: DnsRecord
onDelete: (recordId: number) => void
isDeleting?: boolean
isFirstGroup: boolean
}) {
return (
<TableRow className={cn(!isFirstGroup && 'border-t-4 border-muted')}>
<TableCell>{record.record_type}</TableCell>
<TableCell className="font-medium">{record.name}</TableCell>
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
</TableRow>
)
}
function MultiValueGroupRows({
group,
onDelete,
isDeleting,
isFirstGroup,
}: {
group: DnsRecordGroup
onDelete: (recordId: number) => void
isDeleting?: boolean
isFirstGroup: boolean
}) {
const [first, ...rest] = group.records
return (
<>
<TableRow
className={cn(
'bg-muted/25',
!isFirstGroup && 'border-t-4 border-muted',
)}
>
<TableCell rowSpan={group.records.length} className="align-top">
<Badge variant="outline">{group.recordType}</Badge>
</TableCell>
<TableCell rowSpan={group.records.length} className="align-top font-medium">
<div className="flex flex-col gap-1">
<span>{group.name}</span>
<Badge variant="secondary" className="w-fit">
{group.records.length} адресов
</Badge>
</div>
</TableCell>
<DnsRecordCells record={first} onDelete={onDelete} isDeleting={isDeleting} />
</TableRow>
{rest.map((record) => (
<TableRow key={record.id} className="bg-muted/25">
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
</TableRow>
))}
</>
)
}
export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTableProps) {
const groups = groupDnsRecords(records)
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Тип</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Значение</TableHead>
<TableHead className="w-16">TTL</TableHead>
<TableHead>Синхронизация</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group, index) =>
group.isMultiValue ? (
<MultiValueGroupRows
key={group.key}
group={group}
onDelete={onDelete}
isDeleting={isDeleting}
isFirstGroup={index === 0}
/>
) : (
<SingleRecordRow
key={group.records[0].id}
record={group.records[0]}
onDelete={onDelete}
isDeleting={isDeleting}
isFirstGroup={index === 0}
/>
),
)}
</TableBody>
</Table>
</div>
)
}
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Привязки сервисов</CardTitle>
<CardDescription>IP-адреса, назначенные сервисам в этой зоне</CardDescription>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<Empty className="border border-dashed p-4">
<EmptyHeader>
<EmptyTitle>Нет привязок</EmptyTitle>
<EmptyDescription>
Создайте привязку на странице сервисов IP появится здесь после синхронизации DNS
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<ItemGroup className="gap-0">
{entries.map(([hostname, hostnameBindings], index) => {
const ips = uniqueIps(hostnameBindings)
const services = [...new Set(hostnameBindings.map((b) => b.service_name))]
return (
<div key={hostname}>
<Item variant="outline">
<ItemContent className="gap-2">
<ItemTitle className="font-mono">{hostname}</ItemTitle>
<ItemDescription>
<div className="flex flex-wrap gap-1">
{ips.length > 0 ? (
ips.map((ip) => (
<Badge key={ip} variant="secondary" className="font-mono">
{ip}
</Badge>
))
) : (
<span className="text-muted-foreground">IP не задан</span>
)}
</div>
</ItemDescription>
<div className="flex flex-wrap items-center gap-2">
{services.map((name) => (
<Badge key={name}>{name}</Badge>
))}
{[
...new Set(
hostnameBindings
.map((b) => b.sync_status)
.filter((s): s is string => Boolean(s)),
),
].map((status) => (
<StatusBadge key={status} status={status} />
))}
</div>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
render={<Link to="/services" />}
>
Сервисы
</Button>
</ItemActions>
</Item>
{index < entries.length - 1 && <ItemSeparator />}
</div>
)
})}
</ItemGroup>
)}
</CardContent>
{entries.length > 0 && (
<CardFooter>
<Button variant="link" className="h-auto p-0" render={<Link to="/services" />}>
Управление привязками
</Button>
</CardFooter>
)}
</Card>
)
}
@@ -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 (
<Card
ref={setNodeRef}
size="sm"
style={style}
className={cn(
'cursor-grab bg-card active:cursor-grabbing',
isDragging && 'opacity-60 shadow-lg',
)}
{...listeners}
{...attributes}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GlobeIcon />
{domain.zone_name}
</CardTitle>
<CardAction>
<StatusBadge status={domain.status} />
</CardAction>
</CardHeader>
{(serviceLabels.length > 0 || domain.service_count > 0) && (
<CardContent>
<div className="flex flex-wrap gap-1">
{serviceLabels.length > 0
? serviceLabels.map((label) => (
<Badge key={label} variant="outline">
{label}
</Badge>
))
: (
<Badge variant="outline">
{domain.service_count} сервис(ов)
</Badge>
)}
</div>
</CardContent>
)}
<CardFooter>
<Button
variant="outline"
size="sm"
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Обзор
</Button>
</CardFooter>
</Card>
)
}
@@ -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 (
<Badge variant="secondary" className="font-mono">
{ip}
</Badge>
)
}
export function DomainIpBadges({ ips }: DomainIpBadgesProps) {
if (ips.length === 0) {
return <span className="text-muted-foreground"></span>
}
if (ips.length === 1) {
return <IpBadge ip={ips[0]} />
}
const [first, ...rest] = ips
return (
<TooltipProvider>
<div className="flex flex-wrap items-center gap-1">
<IpBadge ip={first} />
<Tooltip>
<TooltipTrigger
render={
<Badge variant="outline" className="cursor-default font-mono tabular-nums" />
}
>
+{rest.length}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs p-2">
<ItemGroup className="gap-0">
{ips.map((ip, index) => (
<div key={ip}>
<Item size="xs" variant="muted" className="border-0 px-2 py-1">
<ItemContent>
<ItemTitle className="font-mono font-normal">{ip}</ItemTitle>
</ItemContent>
</Item>
{index < ips.length - 1 && <ItemSeparator className="my-0" />}
</div>
))}
</ItemGroup>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)
}
@@ -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<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const columns = useMemo<ColumnDef<DomainTableRow>[]>(
() => [
{
accessorKey: 'zone_name',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
Зона
<ArrowUpDownIcon />
</Button>
),
cell: ({ row }) => (
<Button
variant="link"
className="h-auto p-0 font-medium"
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(row.original.id) }}
/>
}
>
{row.original.zone_name}
</Button>
),
},
{
id: 'group',
accessorFn: (row) => row.group_name ?? 'Без группы',
header: 'Группа',
cell: ({ row }) => {
const domain = row.original
if (domain.group_id && domain.group_name) {
return (
<Button
variant="link"
className="h-auto p-0"
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
{domain.group_name}
</Button>
)
}
return <Badge variant="outline">Без группы</Badge>
},
},
{
id: 'ips',
accessorFn: (row) => row.ips.join(' '),
header: 'IP-адреса',
enableSorting: false,
cell: ({ row }) => <DomainIpBadges ips={row.original.ips} />,
},
{
accessorKey: 'service_count',
header: 'Сервисы',
cell: ({ row }) => (
<Button
variant="link"
className="h-auto p-0 tabular-nums"
render={<Link to="/services" />}
>
{row.original.service_count}
</Button>
),
},
{
accessorKey: 'status',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: 'last_synced_at',
header: 'Синхронизация',
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.last_synced_at ?? '—'}
</span>
),
},
{
id: 'actions',
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon" className="size-8" />
}
>
<MoreHorizontalIcon />
<span className="sr-only">Действия</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(row.original.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId: String(row.original.id) }}
/>
}
>
DNS
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
)
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 (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center">
<InputGroup className="max-w-sm">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
placeholder="Поиск по зоне…"
value={(table.getColumn('zone_name')?.getFilterValue() as string) ?? ''}
onChange={(event) =>
table.getColumn('zone_name')?.setFilterValue(event.target.value)
}
/>
</InputGroup>
<Select
items={filterGroupItems}
value={groupFilterValue || 'all'}
onValueChange={onGroupFilterChange}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue placeholder="Все группы" />
</SelectTrigger>
<SelectContent>
{filterGroupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 text-sm text-muted-foreground sm:ml-auto">
<GlobeIcon className="size-4" />
<span className="tabular-nums">
{table.getFilteredRowModel().rows.length} зон
</span>
</div>
</div>
<div className="overflow-hidden px-4 pb-4">
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={header.id === 'actions' ? 'w-12 text-right' : undefined}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.id === 'actions' ? 'text-right' : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-48 p-0">
<Empty className="border-0">
<EmptyHeader>
<EmptyTitle>Домены не найдены</EmptyTitle>
<EmptyDescription>
Импортируйте зону из Cloudflare или измените фильтры
</EmptyDescription>
</EmptyHeader>
</Empty>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-2 px-4 pb-4">
<span className="text-sm text-muted-foreground tabular-nums">
Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()}
</span>
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Назад
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Вперёд
</Button>
</div>
)}
</div>
)
}
+96
View File
@@ -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<T> {
id: string
title: string
description?: string
href?: string
items: T[]
}
interface KanbanBoardProps<T> {
columns: KanbanColumnDef<T>[]
getItemId: (item: T) => string
renderCard: (item: T) => ReactNode
renderOverlay?: (item: T) => ReactNode
onMove: (itemId: string, fromColumnId: string, toColumnId: string) => void
}
function findColumnForItem<T>(
columns: KanbanColumnDef<T>[],
itemId: string,
getItemId: (item: T) => string,
): string | undefined {
return columns.find((col) => col.items.some((item) => getItemId(item) === itemId))?.id
}
export function KanbanBoard<T>({
columns,
getItemId,
renderCard,
renderOverlay,
onMove,
}: KanbanBoardProps<T>) {
const [activeId, setActiveId] = useState<string | null>(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 (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<ScrollArea className="w-full">
<div className="flex w-max gap-4 pb-4">
{columns.map((column) => (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
description={column.description}
href={column.href}
count={column.items.length}
>
{column.items.map((item) => (
<div key={getItemId(item)}>{renderCard(item)}</div>
))}
</KanbanColumn>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<DragOverlay dropAnimation={null}>
{activeItem && (renderOverlay ? renderOverlay(activeItem) : renderCard(activeItem))}
</DragOverlay>
</DndContext>
)
}
+81
View File
@@ -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 (
<div
ref={setNodeRef}
className={cn(
'flex w-80 shrink-0 flex-col gap-3 rounded-xl bg-muted/50 p-4 ring-1 ring-foreground/10 transition-shadow',
isOver && 'ring-2 ring-primary',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
{href ? (
<Button
variant="link"
className="h-auto justify-start p-0 text-base font-medium"
render={
<Link
to="/groups/$groupId"
params={{ groupId: href.replace('/groups/', '') }}
/>
}
>
{title}
</Button>
) : (
<span className="text-base font-medium">{title}</span>
)}
{description && (
<span className="text-sm text-muted-foreground">{description}</span>
)}
</div>
<Badge variant="secondary">{count}</Badge>
</div>
<div className="flex min-h-32 flex-col gap-2">
{children}
{isEmpty && (
<Empty className="min-h-28 border">
<EmptyHeader>
<EmptyTitle className="text-xs">Пусто</EmptyTitle>
<EmptyDescription className="text-xs">
Перетащите домен сюда
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</div>
)
}
@@ -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 [
@@ -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 (
<Card
ref={setNodeRef}
size="sm"
style={style}
className={cn(
'cursor-grab bg-card active:cursor-grabbing',
isDragging && 'opacity-60 shadow-lg',
)}
{...listeners}
{...attributes}
>
<CardHeader>
<CardTitle>{binding.zone_name}</CardTitle>
<CardAction>
{binding.group_name ? (
<Badge variant="secondary">{binding.group_name}</Badge>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</CardAction>
</CardHeader>
<CardContent>
<FieldGroup className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`hostname-${binding.id}`}>Hostname</FieldLabel>
<Input
id={`hostname-${binding.id}`}
value={hostname}
placeholder="@"
onPointerDown={(e) => e.stopPropagation()}
onChange={(e) => setHostname(e.target.value)}
onBlur={() => {
if (hostname !== binding.hostname) {
onHostnameChange(binding.id, hostname)
}
}}
/>
</Field>
<Field>
<FieldLabel htmlFor={`ip-${binding.id}`}>IPv4</FieldLabel>
<Input
id={`ip-${binding.id}`}
value={ip}
placeholder="192.168.1.1"
onPointerDown={(e) => e.stopPropagation()}
onChange={(e) => setIp(e.target.value)}
onBlur={() => {
if (ip !== (binding.target_ip ?? '')) {
onIpChange(binding.id, ip)
}
}}
/>
</Field>
</FieldGroup>
<div className="flex flex-wrap items-center gap-2">
<Badge>{binding.service_name}</Badge>
{binding.sync_status && <StatusBadge status={binding.sync_status} />}
</div>
</CardContent>
<CardFooter>
<Button
variant="outline"
size="sm"
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(binding.domain_id) }}
/>
}
>
Домен
</Button>
</CardFooter>
</Card>
)
}
+2
View File
@@ -5,6 +5,7 @@ import { cn } from '@cfdm/ui/lib/utils'
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
const statusVariants: Record<string, BadgeVariant> = {
active: 'default',
synced: 'default',
ok: 'default',
pending_push: 'secondary',
@@ -16,6 +17,7 @@ const statusVariants: Record<string, BadgeVariant> = {
}
const labels: Record<string, string> = {
active: 'Активен',
synced: 'Синхронизировано',
pending_push: 'Ожидает отправки',
conflict: 'Конфликт',
+46
View File
@@ -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<string, DnsRecord[]>()
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<T>(values: T[]): boolean {
if (values.length <= 1) return true
return values.every((v) => v === values[0])
}
+32
View File
@@ -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<string, ServiceBinding[]> {
const map = new Map<string, ServiceBinding[]>()
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<number, string[]> {
const map = new Map<number, string[]>()
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
}
+42
View File
@@ -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<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema>
export type Domain = z.infer<typeof domainSchema>
export type DomainListItem = z.infer<typeof domainListItemSchema>
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema>
@@ -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<typeof createGroupSchema>
export type CreateServiceInput = z.infer<typeof createServiceSchema>
export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>
export type CreateDomainInput = z.infer<typeof createDomainSchema>
export type LoginInput = z.infer<typeof loginSchema>
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
+44 -2
View File
@@ -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<unknown>(`/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<unknown[]>('/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<unknown[]>(`/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<unknown[]>(`/api/v1/domains${qs}`)
return z.array(domainSchema).parse(data)
return z.array(domainListItemSchema).parse(data)
},
})
+36 -5
View File
@@ -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,
@@ -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 (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
title={`${domain?.zone_name ?? ''} — DNS`}
description="Управление DNS-записями зоны"
back={{ to: '/domains', label: '← К списку доменов' }}
actions={
<Button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
>
{syncMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
</Button>
<>
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
<Button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
>
{syncMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
</Button>
</>
}
/>
<Card>
<CardHeader>
<CardTitle>Новая запись</CardTitle>
<CardDescription>Добавить DNS-запись в зону</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate}>
<FieldGroup className="grid gap-4 md:grid-cols-6">
<DataTableCard
title="DNS-записи"
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
emptyTitle="DNS-записи не найдены"
emptyDescription="Создайте запись или синхронизируйте зону с Cloudflare"
isEmpty={!hasRecords}
>
{hasRecords && (
<div className="p-4">
<DnsRecordsTable
records={records ?? []}
onDelete={(recordId) => deleteMutation.mutate(recordId)}
isDeleting={deleteMutation.isPending}
/>
</div>
)}
</DataTableCard>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Новая DNS-запись</SheetTitle>
<SheetDescription>
Добавить запись в зону {domain?.zone_name ?? ''}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="record_type">Тип</FieldLabel>
<Controller
control={form.control}
name="record_type"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select
items={dnsTypeItems}
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger id="record_type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DNS_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
{dnsTypeItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
@@ -168,11 +198,11 @@ function DnsPage() {
</Field>
<Field>
<FieldLabel htmlFor="name">Имя</FieldLabel>
<Input id="name" {...form.register('name')} />
<Input id="name" placeholder="@" {...form.register('name')} />
</Field>
<Field className="md:col-span-2">
<Field>
<FieldLabel htmlFor="content">Значение</FieldLabel>
<Input id="content" {...form.register('content')} />
<Input id="content" placeholder="192.168.1.1" {...form.register('content')} />
</Field>
<Field>
<FieldLabel htmlFor="ttl">TTL</FieldLabel>
@@ -182,7 +212,7 @@ function DnsPage() {
{...form.register('ttl', { valueAsNumber: true })}
/>
</Field>
<Field className="flex flex-col justify-end gap-2">
<Field className="flex flex-row items-center justify-between gap-4">
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
<Controller
control={form.control}
@@ -196,55 +226,18 @@ function DnsPage() {
)}
/>
</Field>
<Field className="flex items-end">
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</Field>
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={createMutation.isPending} className="w-full">
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</SheetFooter>
</form>
</CardContent>
</Card>
<DataTableCard title="DNS-записи" description="Записи в зоне">
<Table>
<TableHeader>
<TableRow>
<TableHead>Тип</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Значение</TableHead>
<TableHead>TTL</TableHead>
<TableHead>Синхронизация</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{records?.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.record_type}</TableCell>
<TableCell>{r.name}</TableCell>
<TableCell className="max-w-xs truncate">{r.content}</TableCell>
<TableCell>{r.ttl}</TableCell>
<TableCell>
<StatusBadge status={r.sync_status} />
</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(r.id)}
disabled={deleteMutation.isPending}
>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</SheetContent>
</Sheet>
</div>
)
}
@@ -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() {
</>
}
/>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Сводка</CardTitle>
<CardDescription>Основные параметры зоны</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Статус</span>
{domain ? <StatusBadge status={domain.status} /> : '—'}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Группа</span>
{domain?.group_id ? (
<Button
variant="link"
className="h-auto p-0"
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
Открыть группу
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Последняя синхронизация</span>
<span className="font-medium">{domain?.last_synced_at ?? '—'}</span>
</div>
</CardContent>
</Card>
<DomainBindingsCard bindings={bindings ?? []} />
</div>
<Card>
<CardHeader>
<CardTitle>Поддомены</CardTitle>
@@ -89,13 +146,18 @@ function DomainOverviewPage() {
</CardHeader>
<CardContent>
{subdomains?.length ? (
<ul className="flex flex-col gap-1">
{subdomains.map((s) => (
<li key={s.id} className="text-sm">
{s.fqdn}
</li>
<ItemGroup className="gap-0">
{subdomains.map((s, index) => (
<div key={s.id}>
<Item variant="outline">
<ItemContent>
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
</ItemContent>
</Item>
{index < subdomains.length - 1 && <ItemSeparator />}
</div>
))}
</ul>
</ItemGroup>
) : (
<Empty className="border border-dashed p-4">
<EmptyHeader>
+112 -89
View File
@@ -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 (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
title="Домены"
description="Импорт и управление зонами Cloudflare"
actions={
<>
<Button variant="outline" render={<Link to="/groups" />}>
Канбан групп
</Button>
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
</>
}
/>
<Card>
<CardHeader>
<CardTitle>Добавить домен</CardTitle>
<CardDescription>Импортировать зону из Cloudflare</CardDescription>
<Card className="border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-base">Быстрый обзор</CardTitle>
<CardDescription>
{tableData.length} зон ·{' '}
{tableData.filter((d) => d.ips.length > 0).length} с IP-адресами ·{' '}
{tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="max-w-xs flex-1">
</Card>
<DataTableCard
title="Список доменов"
description="Импортированные зоны Cloudflare"
>
<DomainsDataTable
data={tableData}
groupFilterItems={groupItems}
groupFilterValue={filterGroupId || 'all'}
onGroupFilterChange={handleFilterGroupChange}
/>
</DataTableCard>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Импорт домена</SheetTitle>
<SheetDescription>
Добавить зону из аккаунта Cloudflare в менеджер
</SheetDescription>
</SheetHeader>
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="zone_name">Имя зоны</FieldLabel>
<Input
id="zone_name"
@@ -119,14 +193,14 @@ function DomainsPage() {
aria-invalid={!!form.formState.errors.zone_name}
/>
</Field>
<Field className="w-48">
<FieldLabel htmlFor="group_id">Группа</FieldLabel>
<Field>
<FieldLabel htmlFor="import_group_id">Группа</FieldLabel>
<Select
items={groupItems}
value={groupId || 'none'}
onValueChange={handleGroupChange}
value={importGroupId || 'none'}
onValueChange={handleImportGroupChange}
>
<SelectTrigger id="group_id" className="w-full">
<SelectTrigger id="import_group_id" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
@@ -138,67 +212,16 @@ function DomainsPage() {
</SelectContent>
</Select>
</Field>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={createMutation.isPending} className="w-full">
{createMutation.isPending && <Spinner data-icon="inline-start" />}
{createMutation.isPending ? 'Импорт…' : 'Импортировать'}
</Button>
</FieldGroup>
</SheetFooter>
</form>
</CardContent>
</Card>
<DataTableCard title="Список доменов" description="Импортированные зоны">
<Table>
<TableHeader>
<TableRow>
<TableHead>Зона</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Последняя синхронизация</TableHead>
<TableHead>Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domains?.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-medium">{d.zone_name}</TableCell>
<TableCell>
<StatusBadge status={d.status} />
</TableCell>
<TableCell>{d.last_synced_at ?? '—'}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="link"
className="h-auto p-0"
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(d.id) }}
/>
}
>
Обзор
</Button>
<Button
variant="link"
className="h-auto p-0"
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId: String(d.id) }}
/>
}
>
DNS
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</SheetContent>
</Sheet>
</div>
)
}
+218 -62
View File
@@ -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<CreateGroupInput>({
resolver: zodResolver(createGroupSchema),
defaultValues: { name: '', slug: '' },
})
const serviceLabelsByDomain = useMemo(() => {
const map = new Map<number, string[]>()
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 (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
title="Группы"
description="Группировка доменов для удобного управления"
description="Канбан-доска доменов по группам и справочник групп"
/>
<Card>
<CardHeader>
<CardTitle>Создать группу</CardTitle>
<CardDescription>Добавить новую группу доменов</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="name">Название</FieldLabel>
<Input
id="name"
placeholder="Название"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="slug">Slug</FieldLabel>
<Input
id="slug"
placeholder="slug"
{...form.register('slug')}
aria-invalid={!!form.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
<TabsList>
<TabsTrigger value="kanban">Канбан</TabsTrigger>
<TabsTrigger value="catalog">Справочник</TabsTrigger>
</TabsList>
<TabsContent value="kanban">
<Card>
<CardHeader>
<CardTitle>Доска групп</CardTitle>
<CardDescription>
Перетащите домен в колонку группы. Нажмите на название колонки, чтобы открыть список доменов.
</CardDescription>
</CardHeader>
<CardContent>
<KanbanBoard
columns={columns}
getItemId={(domain) => String(domain.id)}
renderCard={(domain) => (
<DomainGroupCard
domain={domain}
serviceLabels={serviceLabelsByDomain.get(domain.id)}
/>
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<ResourceList
items={
groups?.map((g) => ({
id: g.id,
primary: g.name,
secondary: `(${g.slug})`,
})) ?? []
}
emptyTitle="Группы не найдены"
emptyDescription="Создайте первую группу в форме выше"
renderActions={(item) => (
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(item.id as number)}
disabled={deleteMutation.isPending}
onMove={handleMove}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="catalog" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Создать группу</CardTitle>
<CardDescription>Добавить новую группу доменов</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="name">Название</FieldLabel>
<Input
id="name"
placeholder="Название"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="slug">Slug</FieldLabel>
<Input
id="slug"
placeholder="slug"
{...form.register('slug')}
aria-invalid={!!form.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<DataTableCard
title="Справочник групп"
description="Все группы доменов"
isEmpty={!groups?.length}
emptyTitle="Группы не найдены"
emptyDescription="Создайте первую группу в форме выше"
>
Удалить
</Button>
)}
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell className="text-muted-foreground">{group.slug}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(group.id) }}
/>
}
>
Открыть
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(group.id)}
disabled={deleteMutation.isPending}
>
Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</TabsContent>
</Tabs>
</div>
)
}
@@ -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 (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
title={group?.name ?? 'Группа'}
description={
group
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
: 'Домены в группе'
}
back={{ to: '/groups', label: '← К группам' }}
actions={
<Button
variant="outline"
render={<Link to="/groups" />}
>
На канбан
</Button>
}
/>
<DataTableCard
title="Домены группы"
description="Список доменов, назначенных этой группе"
emptyTitle="В группе нет доменов"
emptyDescription="Перетащите домены на канбане групп или назначьте группу при импорте"
isEmpty={!domains?.length}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Зона</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Сервисы</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domains?.map((domain) => (
<TableRow key={domain.id}>
<TableCell className="font-medium">{domain.zone_name}</TableCell>
<TableCell>
<StatusBadge status={domain.status} />
</TableCell>
<TableCell>{domain.service_count}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Обзор
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</div>
)
}
+323 -54
View File
@@ -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<CreateServiceInput>({
const catalogForm = useForm<CreateServiceInput>({
resolver: zodResolver(createServiceSchema),
defaultValues: { name: '', slug: '' },
})
const createMutation = useMutation({
const bindingForm = useForm<CreateServiceBindingInput>({
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) => (
<ServiceBindingCard
binding={binding}
onIpChange={handleIpChange}
onHostnameChange={handleHostnameChange}
/>
)
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 (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
title="Сервисы"
description="Справочник сервисов для привязки к доменам"
description="Канбан привязок доменов к сервисам с настройкой IP через DNS"
actions={
<Button onClick={() => setSheetOpen(true)}>Добавить привязку</Button>
}
/>
<Card>
<CardHeader>
<CardTitle>Создать сервис</CardTitle>
<CardDescription>Добавить новый сервис в справочник</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="name">Название</FieldLabel>
<Input
id="name"
placeholder="Название"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="slug">Slug</FieldLabel>
<Input
id="slug"
placeholder="slug"
{...form.register('slug')}
aria-invalid={!!form.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
<TabsList>
<TabsTrigger value="kanban">Канбан</TabsTrigger>
<TabsTrigger value="catalog">Справочник</TabsTrigger>
</TabsList>
<TabsContent value="kanban">
<Card>
<CardHeader>
<CardTitle>Доска сервисов</CardTitle>
<CardDescription>
Перетащите привязку между колонками или отредактируйте IP прямо на карточке
</CardDescription>
</CardHeader>
<CardContent>
<KanbanBoard
columns={columns}
getItemId={(binding) => String(binding.id)}
renderCard={renderBindingCard}
renderOverlay={renderBindingCard}
onMove={handleMove}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="catalog" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Создать сервис</CardTitle>
<CardDescription>Добавить новый тип сервиса в справочник</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCatalogSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="svc-name">Название</FieldLabel>
<Input
id="svc-name"
placeholder="Название"
{...catalogForm.register('name')}
aria-invalid={!!catalogForm.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="svc-slug">Slug</FieldLabel>
<Input
id="svc-slug"
placeholder="slug"
{...catalogForm.register('slug')}
aria-invalid={!!catalogForm.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createServiceMutation.isPending}>
{createServiceMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createServiceMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<DataTableCard
title="Справочник сервисов"
description="Типы сервисов для привязки к доменам"
isEmpty={!services?.length}
emptyTitle="Сервисы не найдены"
emptyDescription="Создайте первый сервис в форме выше"
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Slug</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services?.map((service) => (
<TableRow key={service.id}>
<TableCell className="font-medium">{service.name}</TableCell>
<TableCell className="text-muted-foreground">{service.slug}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</TabsContent>
</Tabs>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Новая привязка</SheetTitle>
<SheetDescription>
Свяжите домен с сервисом и укажите IP для A-записи
</SheetDescription>
</SheetHeader>
<form onSubmit={handleBindingSubmit} className="flex flex-col gap-4 px-4">
<Field>
<FieldLabel>Домен</FieldLabel>
<Select
value={bindingForm.watch('domain_id')}
onValueChange={(value) => bindingForm.setValue('domain_id', value ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Выберите домен" />
</SelectTrigger>
<SelectContent>
{domains?.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.zone_name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Сервис</FieldLabel>
<Select
value={bindingForm.watch('service_id')}
onValueChange={(value) => bindingForm.setValue('service_id', value ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Выберите сервис" />
</SelectTrigger>
<SelectContent>
{services?.map((service) => (
<SelectItem key={service.id} value={String(service.id)}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="binding-hostname">Hostname</FieldLabel>
<Input
id="binding-hostname"
placeholder="@"
{...bindingForm.register('hostname')}
/>
</Field>
<Field>
<FieldLabel htmlFor="binding-ip">IPv4</FieldLabel>
<Input
id="binding-ip"
placeholder="192.168.1.1"
{...bindingForm.register('target_ip')}
/>
</Field>
<SheetFooter>
<Button type="submit" disabled={createBindingMutation.isPending}>
{createBindingMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
Создать
</Button>
</FieldGroup>
</SheetFooter>
</form>
</CardContent>
</Card>
<ResourceList
items={
services?.map((s) => ({
id: s.id,
primary: s.name,
secondary: `(${s.slug})`,
})) ?? []
}
emptyTitle="Сервисы не найдены"
emptyDescription="Создайте первый сервис в форме выше"
/>
</SheetContent>
</Sheet>
</div>
)
}
@@ -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;
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct SetServicesBody {
pub async fn list(
State(state): State<AppState>,
Query(q): Query<DomainListQuery>,
) -> AppResult<axum::Json<Vec<crate::domain::Domain>>> {
) -> AppResult<axum::Json<Vec<crate::domain::DomainListItem>>> {
Ok(axum::Json(domain_service::list_domains(&state.pool, q.group_id).await?))
}
+2 -2
View File
@@ -17,8 +17,8 @@ pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<cra
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::Group>> {
Ok(axum::Json(crate::repositories::groups::get(&state.pool, id).await?))
) -> AppResult<axum::Json<crate::domain::GroupWithStats>> {
Ok(axum::Json(crate::repositories::groups::get_with_stats(&state.pool, id).await?))
}
pub async fn create(
+1
View File
@@ -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;
@@ -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<AppState>) -> AppResult<axum::Json<Vec<crate::domain::ServiceBindingView>>> {
Ok(axum::Json(binding_service::list_all(&state.pool).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::ServiceBindingView>> {
Ok(axum::Json(
crate::repositories::service_bindings::get_view(&state.pool, id).await?,
))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<CreateBindingRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceBindingView>> {
Ok(axum::Json(
binding_service::create(&state.pool, &state.cf, body).await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<UpdateBindingRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceBindingView>> {
Ok(axum::Json(
binding_service::update(&state.pool, &state.cf, id, body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
binding_service::delete(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn list_by_domain(
State(state): State<AppState>,
Path(domain_id): Path<i64>,
) -> AppResult<axum::Json<Vec<crate::domain::ServiceBindingView>>> {
Ok(axum::Json(
binding_service::list_by_domain(&state.pool, domain_id).await?,
))
}
+15 -1
View File
@@ -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(
+53
View File
@@ -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<i64>,
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<i64>,
pub zone_name: String,
pub group_id: Option<i64>,
pub group_name: Option<String>,
pub service_name: String,
pub service_slug: String,
pub target_ip: Option<String>,
pub sync_status: Option<String>,
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<i64>,
pub zone_name: String,
pub cf_zone_id: String,
pub status: String,
pub last_synced_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub group_name: Option<String>,
pub service_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SyncJob {
pub id: String,
+32 -1
View File
@@ -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<i64>) -> AppResult<Vec<Dom
}
}
const LIST_ENRICHED_SELECT: &str = r#"
SELECT
d.id,
d.group_id,
d.zone_name,
d.cf_zone_id,
d.status,
d.last_synced_at,
d.created_at,
d.updated_at,
g.name AS group_name,
(SELECT COUNT(*) FROM service_bindings sb WHERE sb.domain_id = d.id) AS service_count
FROM domains d
LEFT JOIN groups g ON g.id = d.group_id
"#;
pub async fn list_enriched(pool: &SqlitePool, group_id: Option<i64>) -> AppResult<Vec<DomainListItem>> {
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<Domain> {
sqlx::query_as::<_, Domain>("SELECT * FROM domains WHERE id = ?")
.bind(id)
+21 -1
View File
@@ -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<Group> {
.ok_or_else(|| AppError::NotFound(format!("group {id}")))
}
pub async fn get_with_stats(pool: &SqlitePool, id: i64) -> AppResult<GroupWithStats> {
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<Group> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO groups (name, slug) VALUES (?, ?) RETURNING id",
+1
View File
@@ -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;
@@ -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<Vec<ServiceBindingView>> {
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<Vec<ServiceBindingView>> {
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<ServiceBinding> {
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<ServiceBindingView> {
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<i64>,
) -> AppResult<ServiceBinding> {
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<i64>,
) -> AppResult<ServiceBinding> {
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<i64>) -> 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(())
}
-30
View File
@@ -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<Vec<i64>> {
Ok(sqlx::query_scalar::<_, i64>(
"SELECT service_id FROM domain_services WHERE domain_id = ?",
)
.bind(domain_id)
.fetch_all(pool)
.await?)
}
+175
View File
@@ -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<String>,
pub target_ip: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateBindingRequest {
pub service_id: Option<i64>,
pub hostname: Option<String>,
pub target_ip: Option<String>,
}
fn normalize_hostname(hostname: Option<String>) -> 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<i64>,
target_ip: &str,
) -> AppResult<i64> {
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<Vec<ServiceBindingView>> {
service_bindings::list_all(pool).await
}
pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult<Vec<ServiceBindingView>> {
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<ServiceBindingView> {
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<ServiceBindingView> {
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<i64>,
) -> AppResult<Vec<i64>> {
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())
}
+5 -10
View File
@@ -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<i64>) -> AppResult<Vec<Domain>> {
domains::list(pool, group_id).await
pub async fn list_domains(pool: &SqlitePool, group_id: Option<i64>) -> AppResult<Vec<DomainListItem>> {
domains::list_enriched(pool, group_id).await
}
pub async fn get_domain(pool: &SqlitePool, id: i64) -> AppResult<Domain> {
@@ -57,12 +57,7 @@ pub async fn set_domain_services(
domain_id: i64,
service_ids: Vec<i64>,
) -> AppResult<Vec<i64>> {
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(
+1
View File
@@ -1,4 +1,5 @@
pub mod auth;
pub mod binding_service;
pub mod certificate_service;
pub mod dns_service;
pub mod domain_service;
-4
View File
@@ -44,16 +44,12 @@ function Button({
className,
variant = "default",
size = "default",
render,
nativeButton,
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
render={render}
nativeButton={nativeButton ?? !render}
{...props}
/>
)
+156
View File
@@ -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 (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[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<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
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<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+201
View File
@@ -0,0 +1,201 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { Separator } from "@cfdm/ui/components/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
@@ -0,0 +1,52 @@
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@cfdm/ui/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
-2
View File
@@ -1,5 +1,3 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@cfdm/ui/lib/utils"
+80
View File
@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@cfdm/ui/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+56
View File
@@ -17,6 +17,15 @@ importers:
'@cfdm/ui':
specifier: workspace:*
version: link:../../packages/ui
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.7)
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.79.0(react@19.2.7))
@@ -273,6 +282,28 @@ packages:
'@types/react':
optional: true
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -2211,6 +2242,31 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1