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
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:
@@ -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 |
|
||||
|
||||
@@ -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-примитивов
|
||||
|
||||
@@ -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).
|
||||
@@ -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/**`).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: 'Конфликт',
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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?))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?,
|
||||
))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod binding_service;
|
||||
pub mod certificate_service;
|
||||
pub mod dns_service;
|
||||
pub mod domain_service;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Generated
+56
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user