chore: Update package.json and pnpm-lock.yaml to add husky for Git hooks; enhance Docker workflow with pnpm setup and linting steps; refactor various components to utilize new form components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m5s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m26s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 8s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m5s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m26s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 8s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 44284,
|
"pid": 42864,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
|
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
|
||||||
"startedAt": 1782094135868
|
"startedAt": 1782359415038
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl
|
|||||||
|
|
||||||
## Шаг 0 — перед любым UI-кодом
|
## Шаг 0 — перед любым UI-кодом
|
||||||
|
|
||||||
|
0. **Codegraph** `codegraph_explore` — найти существующие реализации
|
||||||
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))
|
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** — найти готовое решение
|
2. Открыть **Components** или **Blocks** — найти готовое решение
|
||||||
3. `cd apps/web && pnpm dlx shadcn@latest docs <component>` — API и примеры
|
3. `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API с [Components](https://ui.shadcn.com/docs/components)
|
||||||
4. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/<query>"` — если MCP не дал результат
|
4. Сверить MCP examples ↔ docs API — только потом писать код
|
||||||
5. Только потом писать код
|
5. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/<query>"` — если MCP не дал результат
|
||||||
|
|
||||||
**Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add <block-id>`.
|
**Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add <block-id>`.
|
||||||
|
|
||||||
@@ -49,6 +50,12 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl
|
|||||||
| `DataTableCard` | `data-table-card.tsx` |
|
| `DataTableCard` | `data-table-card.tsx` |
|
||||||
| `SectionCards` | `section-cards.tsx` |
|
| `SectionCards` | `section-cards.tsx` |
|
||||||
| `StatusBadge` | `status-badge.tsx` |
|
| `StatusBadge` | `status-badge.tsx` |
|
||||||
|
| `FormSheet` | `form-sheet.tsx` |
|
||||||
|
| `FormField` | `form-field.tsx` |
|
||||||
|
| `TableCard` | `table-card.tsx` |
|
||||||
|
| `LoadingButton` | `loading-button.tsx` |
|
||||||
|
| `SectionCardsSkeleton` | `section-cards-skeleton.tsx` |
|
||||||
|
| `TableSkeleton` | `table-skeleton.tsx` |
|
||||||
|
|
||||||
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
|
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Docs workflow (обязательно)
|
## Docs workflow (обязательно)
|
||||||
|
|
||||||
|
0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты
|
||||||
1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection
|
1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection
|
||||||
2. MCP `plugin-shadcn-shadcn` — search → examples → add command
|
2. MCP `plugin-shadcn-shadcn` — search → examples → add command
|
||||||
3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs <component>` → fetch URLs
|
3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs <component>` → сверить API с [Components](https://ui.shadcn.com/docs/components)
|
||||||
4. Код по examples + docs API
|
4. Код по examples + docs API (только после совпадения MCP ↔ docs)
|
||||||
5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives)
|
5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives)
|
||||||
6. MCP `get_audit_checklist` — перед merge
|
6. MCP `get_audit_checklist` — перед merge
|
||||||
|
7. Codegraph `codegraph_status` — Pending sync пустой
|
||||||
|
|
||||||
## Иерархия компонентов
|
## Иерархия компонентов
|
||||||
|
|
||||||
@@ -30,6 +32,12 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
data-table-card.tsx
|
data-table-card.tsx
|
||||||
section-cards.tsx
|
section-cards.tsx
|
||||||
status-badge.tsx
|
status-badge.tsx
|
||||||
|
form-sheet.tsx ← Sheet + RHF FormProvider
|
||||||
|
form-field.tsx ← Field + Controller + aria-invalid
|
||||||
|
table-card.tsx ← Card + Table wrapper
|
||||||
|
loading-button.tsx ← Button + Spinner + label swap
|
||||||
|
section-cards-skeleton.tsx
|
||||||
|
table-skeleton.tsx
|
||||||
layout/ ← app-shell, site-header
|
layout/ ← app-shell, site-header
|
||||||
domain-* ← бизнес-компоненты
|
domain-* ← бизнес-компоненты
|
||||||
```
|
```
|
||||||
@@ -45,7 +53,10 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
| Empty | `EmptyState` | `Empty` |
|
| Empty | `EmptyState` | `Empty` |
|
||||||
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
||||||
| Status | `StatusBadge` | `Badge` |
|
| Status | `StatusBadge` | `Badge` |
|
||||||
| Create/Edit | `*-sheet.tsx` | `Sheet`, `Field` |
|
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
|
||||||
|
| Form field | `FormField` | `Field`, `Input`, `Select` |
|
||||||
|
| Submit button | `LoadingButton` | `Button`, `Spinner` |
|
||||||
|
| Table wrapper | `TableCard` | `Table`, `Card` |
|
||||||
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
||||||
| List row | — | `Item variant="outline" size="sm"` |
|
| List row | — | `Item variant="outline" size="sm"` |
|
||||||
| Nav | `AppSidebar` | `Sidebar` |
|
| Nav | `AppSidebar` | `Sidebar` |
|
||||||
@@ -80,6 +91,18 @@ Toolbar: gap-2
|
|||||||
|
|
||||||
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner.
|
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner.
|
||||||
|
|
||||||
|
## UX/UI 2026 (состояния данных)
|
||||||
|
|
||||||
|
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
|
||||||
|
|
||||||
|
- **Loading** — `Skeleton` с размерами финального контента (`QueryState skeleton={…}`), не Spinner на странице
|
||||||
|
- **Empty** — `EmptyState` с CTA (кнопка создания)
|
||||||
|
- **Zero-results** — отдельный empty с «Сбросить фильтр» (не «Создать»)
|
||||||
|
- **Error** — `QueryState` + `onRetry` + иконка + текст
|
||||||
|
- **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел
|
||||||
|
- **Density** — таблицы `h-10 text-sm`; max 1 primary CTA на экран
|
||||||
|
- **A11y** — `aria-invalid` на полях, `aria-label`/`sr-only` на icon-only кнопках, цвет не единственный сигнал статуса
|
||||||
|
|
||||||
## Button hierarchy (max 1 primary per screen)
|
## Button hierarchy (max 1 primary per screen)
|
||||||
|
|
||||||
1. `default` — главный CTA
|
1. `default` — главный CTA
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ CLI и docs — **после** MCP, по команде из `get_add_command_fo
|
|||||||
|
|
||||||
## Порядок (строго)
|
## Порядок (строго)
|
||||||
|
|
||||||
|
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки (один вызов перед правками)
|
||||||
1. **`get_project_registries`** — какие registry доступны в проекте
|
1. **`get_project_registries`** — какие registry доступны в проекте
|
||||||
2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`)
|
2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`)
|
||||||
3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX
|
3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX
|
||||||
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`
|
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`
|
||||||
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
|
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
|
||||||
6. Адаптировать пример под TanStack Router / Query → `apps/web/src/`
|
6. **CLI docs (обязательно):** `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API/props с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
||||||
7. **CLI docs:** `cd apps/web && pnpm dlx shadcn@latest docs <component>` — fetch URLs, сверить API
|
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения
|
||||||
8. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–7 для примитивов)
|
8. Адаптировать пример под TanStack Router / Query → `apps/web/src/`
|
||||||
9. **`get_audit_checklist`** — перед merge PR
|
9. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–8 для примитивов)
|
||||||
|
10. **`get_audit_checklist`** — перед merge PR
|
||||||
|
11. **Codegraph** `codegraph_status` — Pending sync пустой после правок
|
||||||
|
|
||||||
## Когда вызывать MCP
|
## Когда вызывать MCP
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.12.1
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint web
|
||||||
|
run: pnpm --filter web lint
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
pnpm --filter web lint
|
||||||
@@ -5,6 +5,8 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
const rawHtmlElements = ['table', 'select', 'hr']
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
@@ -18,5 +20,64 @@ export default defineConfig([
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['@/components/ui/*'],
|
||||||
|
message: 'Импортируйте примитивы через @cfdm/ui/components/*',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
...rawHtmlElements.map((name) => ({
|
||||||
|
selector: `JSXOpeningElement[name.name="${name}"]`,
|
||||||
|
message: `Используйте shadcn-компонент вместо <${name}>`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
'JSXAttribute[name.name="className"] Literal[value=/\\bspace-[xy]-/]',
|
||||||
|
message: 'Используйте flex + gap-* вместо space-y-* / space-x-*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
'JSXAttribute[name.name="className"] Literal[value=/\\bbg-(emerald|red|green|blue|yellow|orange)-/]',
|
||||||
|
message: 'Используйте semantic tokens (bg-primary, bg-muted и т.д.)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
'JSXAttribute[name.name="className"] Literal[value=/\\btext-(blue|red|green)-/]',
|
||||||
|
message: 'Используйте semantic tokens (text-foreground, text-muted-foreground)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/routes/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/components/tagged-input.tsx'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'**/services-board/*-row.tsx',
|
||||||
|
'**/groups-board/*-row.tsx',
|
||||||
|
'**/layout/app-shell.tsx',
|
||||||
|
'**/service-binding-card.tsx',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': 'off',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { TableCard } from '@/components/table-card'
|
||||||
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
|
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
|
||||||
import type { DnsRecord } from '@/lib/schemas'
|
import type { DnsRecord } from '@/lib/schemas'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
@@ -119,7 +120,7 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
|||||||
const groups = groupDnsRecords(records)
|
const groups = groupDnsRecords(records)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<TableCard>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -153,6 +154,6 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</TableCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Link2Icon } from 'lucide-react'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { groupBindingsByHostname } from '@/lib/domain-ips'
|
import { groupBindingsByHostname } from '@/lib/domain-ips'
|
||||||
import type { ServiceBinding } from '@/lib/schemas'
|
import type { ServiceBinding } from '@/lib/schemas'
|
||||||
@@ -12,12 +14,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@cfdm/ui/components/card'
|
} from '@cfdm/ui/components/card'
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@cfdm/ui/components/empty'
|
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemActions,
|
ItemActions,
|
||||||
@@ -47,14 +43,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<Empty className="border border-dashed p-4">
|
<EmptyState
|
||||||
<EmptyHeader>
|
icon={Link2Icon}
|
||||||
<EmptyTitle>Нет привязок</EmptyTitle>
|
title="Нет привязок"
|
||||||
<EmptyDescription>
|
description="Создайте привязку на странице сервисов или в таблице поддоменов"
|
||||||
Создайте привязку на странице сервисов или в таблице поддоменов
|
className="border border-dashed p-4"
|
||||||
</EmptyDescription>
|
/>
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
) : (
|
) : (
|
||||||
<ItemGroup className="gap-0">
|
<ItemGroup className="gap-0">
|
||||||
{entries.map(([hostname, hostnameBindings], index) => {
|
{entries.map(([hostname, hostnameBindings], index) => {
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import type { CreateGroupInput, Group } from '@/lib/schemas'
|
import { useForm } from 'react-hook-form'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import {
|
import { createGroupSchema, type CreateGroupInput, type Group } from '@/lib/schemas'
|
||||||
Field,
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
FieldGroup,
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
FieldLabel,
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
} from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@cfdm/ui/components/sheet'
|
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
|
||||||
|
|
||||||
interface DomainGroupEditSheetProps {
|
interface DomainGroupEditSheetProps {
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
@@ -36,76 +27,74 @@ export function DomainGroupEditSheet({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onSave,
|
onSave,
|
||||||
}: DomainGroupEditSheetProps) {
|
}: DomainGroupEditSheetProps) {
|
||||||
const [name, setName] = useState('')
|
const form = useForm<CreateGroupInput>({
|
||||||
const [slug, setSlug] = useState('')
|
resolver: zodResolver(createGroupSchema),
|
||||||
|
defaultValues: { name: '', slug: '' },
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (mode === 'edit' && group) {
|
if (mode === 'edit' && group) {
|
||||||
setName(group.name)
|
form.reset({ name: group.name, slug: group.slug })
|
||||||
setSlug(group.slug)
|
|
||||||
} else {
|
} else {
|
||||||
setName('')
|
form.reset({ name: '', slug: '' })
|
||||||
setSlug('')
|
|
||||||
}
|
}
|
||||||
}, [open, mode, group])
|
}, [open, mode, group, form])
|
||||||
|
|
||||||
function handleSubmit(event: React.FormEvent) {
|
function handleSubmit(values: CreateGroupInput) {
|
||||||
event.preventDefault()
|
|
||||||
const trimmedName = name.trim()
|
|
||||||
const trimmedSlug = slug.trim()
|
|
||||||
if (!trimmedName || !trimmedSlug) return
|
|
||||||
const body: CreateGroupInput = {
|
|
||||||
name: trimmedName,
|
|
||||||
slug: trimmedSlug,
|
|
||||||
}
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.(body)
|
onCreate?.(values)
|
||||||
} else if (group) {
|
} else if (group) {
|
||||||
onSave?.(group.id, body)
|
onSave?.(group.id, values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<FormSheet
|
||||||
<SheetContent>
|
open={open}
|
||||||
<SheetHeader>
|
onOpenChange={onOpenChange}
|
||||||
<SheetTitle>
|
title={mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
|
||||||
{mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
|
description="Группы используются для организации доменов на доске."
|
||||||
</SheetTitle>
|
form={form}
|
||||||
<SheetDescription>
|
onSubmit={handleSubmit}
|
||||||
Группы используются для организации доменов на доске.
|
contentClassName="gap-6"
|
||||||
</SheetDescription>
|
footer={
|
||||||
</SheetHeader>
|
<LoadingButton
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
type="submit"
|
||||||
<FieldGroup>
|
className="w-full"
|
||||||
<Field>
|
isLoading={isSaving}
|
||||||
<FieldLabel htmlFor="domain-group-name">Название</FieldLabel>
|
loadingLabel="Сохранение…"
|
||||||
<Input
|
>
|
||||||
id="domain-group-name"
|
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||||
value={name}
|
</LoadingButton>
|
||||||
onChange={(event) => setName(event.target.value)}
|
}
|
||||||
placeholder="Production"
|
>
|
||||||
/>
|
<FieldGroup>
|
||||||
</Field>
|
<FormFieldSimple
|
||||||
<Field>
|
label="Название"
|
||||||
<FieldLabel htmlFor="domain-group-slug">Slug</FieldLabel>
|
htmlFor="domain-group-name"
|
||||||
<Input
|
error={form.formState.errors.name}
|
||||||
id="domain-group-slug"
|
>
|
||||||
value={slug}
|
<Input
|
||||||
onChange={(event) => setSlug(event.target.value)}
|
id="domain-group-name"
|
||||||
placeholder="production"
|
placeholder="Production"
|
||||||
/>
|
{...form.register('name')}
|
||||||
</Field>
|
aria-invalid={!!form.formState.errors.name}
|
||||||
</FieldGroup>
|
/>
|
||||||
<SheetFooter>
|
</FormFieldSimple>
|
||||||
<Button type="submit" disabled={isSaving} className="w-full">
|
<FormFieldSimple
|
||||||
{isSaving && <Spinner data-icon="inline-start" />}
|
label="Slug"
|
||||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
htmlFor="domain-group-slug"
|
||||||
</Button>
|
error={form.formState.errors.slug}
|
||||||
</SheetFooter>
|
>
|
||||||
</form>
|
<Input
|
||||||
</SheetContent>
|
id="domain-group-slug"
|
||||||
</Sheet>
|
placeholder="production"
|
||||||
|
{...form.register('slug')}
|
||||||
|
aria-invalid={!!form.formState.errors.slug}
|
||||||
|
/>
|
||||||
|
</FormFieldSimple>
|
||||||
|
</FieldGroup>
|
||||||
|
</FormSheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { Control, FieldPath, FieldValues } from 'react-hook-form'
|
||||||
|
import { Controller } from 'react-hook-form'
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from '@cfdm/ui/components/field'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
interface FormFieldProps<T extends FieldValues> {
|
||||||
|
name: FieldPath<T>
|
||||||
|
control: Control<T>
|
||||||
|
label: string
|
||||||
|
htmlFor?: string
|
||||||
|
className?: string
|
||||||
|
children: (props: {
|
||||||
|
id: string
|
||||||
|
'aria-invalid': boolean
|
||||||
|
value: unknown
|
||||||
|
onChange: (...args: unknown[]) => void
|
||||||
|
onBlur: () => void
|
||||||
|
ref: React.Ref<unknown>
|
||||||
|
}) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField<T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: FormFieldProps<T>) {
|
||||||
|
const fieldId = htmlFor ?? String(name)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field
|
||||||
|
data-invalid={!!fieldState.error}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<FieldLabel htmlFor={fieldId}>{label}</FieldLabel>
|
||||||
|
{children({
|
||||||
|
id: fieldId,
|
||||||
|
'aria-invalid': !!fieldState.error,
|
||||||
|
value: field.value,
|
||||||
|
onChange: field.onChange,
|
||||||
|
onBlur: field.onBlur,
|
||||||
|
ref: field.ref,
|
||||||
|
})}
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormFieldSimpleProps {
|
||||||
|
label: string
|
||||||
|
htmlFor: string
|
||||||
|
error?: { message?: string }
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldSimple({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: FormFieldSimpleProps) {
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error} className={cn(className)}>
|
||||||
|
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||||
|
{children}
|
||||||
|
<FieldError errors={[error]} />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { FieldValues, UseFormReturn } from 'react-hook-form'
|
||||||
|
import { FormProvider } from 'react-hook-form'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@cfdm/ui/components/sheet'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
interface FormSheetProps<T extends FieldValues> {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
form: UseFormReturn<T>
|
||||||
|
onSubmit: (values: T) => void | Promise<void>
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSheet<T extends FieldValues>({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
}: FormSheetProps<T>) {
|
||||||
|
const handleSubmit = form.handleSubmit(onSubmit)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className={cn(className)}>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{title}</SheetTitle>
|
||||||
|
{description && <SheetDescription>{description}</SheetDescription>}
|
||||||
|
</SheetHeader>
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={cn('flex flex-1 flex-col gap-4 px-4', contentClassName)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{footer && <SheetFooter>{footer}</SheetFooter>}
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { useDraggable } from '@dnd-kit/core'
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
@@ -67,7 +68,7 @@ function DomainServiceList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DomainRow({
|
export const DomainRow = memo(function DomainRow({
|
||||||
domain,
|
domain,
|
||||||
serviceLabels = [],
|
serviceLabels = [],
|
||||||
dragDisabled = false,
|
dragDisabled = false,
|
||||||
@@ -174,4 +175,4 @@ export function DomainRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
interface LoadingButtonProps extends ComponentProps<typeof Button> {
|
||||||
|
isLoading?: boolean
|
||||||
|
loadingLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingButton({
|
||||||
|
isLoading = false,
|
||||||
|
loadingLabel,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: LoadingButtonProps) {
|
||||||
|
const label =
|
||||||
|
isLoading && loadingLabel != null ? loadingLabel : children
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
disabled={disabled ?? isLoading}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && <Spinner data-icon="inline-start" />}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { cn } from '@cfdm/ui/lib/utils'
|
|||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { setToken } from '@/lib/auth'
|
import { setToken } from '@/lib/auth'
|
||||||
import { loginSchema, type LoginInput } from '@/lib/schemas'
|
import { loginSchema, type LoginInput } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -14,14 +15,9 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@cfdm/ui/components/card'
|
} from '@cfdm/ui/components/card'
|
||||||
import {
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from '@cfdm/ui/components/field'
|
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
|
||||||
|
|
||||||
type LoginFormProps = React.ComponentProps<'div'>
|
type LoginFormProps = React.ComponentProps<'div'>
|
||||||
|
|
||||||
@@ -59,8 +55,11 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field>
|
<FormFieldSimple
|
||||||
<FieldLabel htmlFor="username">Имя пользователя</FieldLabel>
|
label="Имя пользователя"
|
||||||
|
htmlFor="username"
|
||||||
|
error={form.formState.errors.username}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
placeholder="admin"
|
placeholder="admin"
|
||||||
@@ -68,9 +67,12 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
{...form.register('username')}
|
{...form.register('username')}
|
||||||
aria-invalid={!!form.formState.errors.username}
|
aria-invalid={!!form.formState.errors.username}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormFieldSimple>
|
||||||
<Field>
|
<FormFieldSimple
|
||||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
label="Пароль"
|
||||||
|
htmlFor="password"
|
||||||
|
error={form.formState.errors.password}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -78,7 +80,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
{...form.register('password')}
|
{...form.register('password')}
|
||||||
aria-invalid={!!form.formState.errors.password}
|
aria-invalid={!!form.formState.errors.password}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormFieldSimple>
|
||||||
{rootError && (
|
{rootError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CircleAlertIcon />
|
<CircleAlertIcon />
|
||||||
@@ -86,12 +88,14 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
<AlertDescription>{rootError}</AlertDescription>
|
<AlertDescription>{rootError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Field>
|
<LoadingButton
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
type="submit"
|
||||||
{isLoading && <Spinner data-icon="inline-start" />}
|
className="w-full"
|
||||||
{isLoading ? 'Вход…' : 'Войти'}
|
isLoading={isLoading}
|
||||||
</Button>
|
loadingLabel="Вход…"
|
||||||
</Field>
|
>
|
||||||
|
Войти
|
||||||
|
</LoadingButton>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from '@cfdm/ui/components/card'
|
||||||
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
|
export function SectionCardsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</CardHeader>
|
||||||
|
<div className="flex flex-col gap-2 px-6 pb-6">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@cfdm/ui/components/sheet'
|
} from '@cfdm/ui/components/sheet'
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -432,30 +432,32 @@ export function ServiceEditSheet({
|
|||||||
{!isCreate ? (
|
{!isCreate ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<LoadingButton
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={!service || isDeleting || isSaving}
|
disabled={!service || isDeleting || isSaving}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
loadingLabel="Удаление…"
|
||||||
>
|
>
|
||||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
|
||||||
<Trash2Icon data-icon="inline-start" />
|
<Trash2Icon data-icon="inline-start" />
|
||||||
Удалить
|
Удалить
|
||||||
</Button>
|
</LoadingButton>
|
||||||
}
|
}
|
||||||
title="Удалить сервис?"
|
title="Удалить сервис?"
|
||||||
description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо."
|
description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо."
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<LoadingButton
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
disabled={!canSubmit || isSaving || isDeleting}
|
disabled={!canSubmit || isDeleting}
|
||||||
|
isLoading={isSaving}
|
||||||
|
loadingLabel="Сохранение…"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{isSaving && <Spinner data-icon="inline-start" />}
|
|
||||||
{isCreate ? 'Создать' : 'Сохранить'}
|
{isCreate ? 'Создать' : 'Сохранить'}
|
||||||
</Button>
|
</LoadingButton>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import type { CreateServiceGroupInput, ServiceGroup } from '@/lib/schemas'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import {
|
import {
|
||||||
Field,
|
createServiceGroupSchema,
|
||||||
FieldGroup,
|
type CreateServiceGroupInput,
|
||||||
FieldLabel,
|
type ServiceGroup,
|
||||||
} from '@cfdm/ui/components/field'
|
} from '@/lib/schemas'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -14,15 +19,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@cfdm/ui/components/sheet'
|
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
|
||||||
|
|
||||||
const groupTypes = [
|
const groupTypes = [
|
||||||
{ value: 'vpn', label: 'VPN' },
|
{ value: 'vpn', label: 'VPN' },
|
||||||
@@ -32,6 +28,8 @@ const groupTypes = [
|
|||||||
{ value: 'custom', label: 'Другое' },
|
{ value: 'custom', label: 'Другое' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
type ServiceGroupFormValues = z.input<typeof createServiceGroupSchema>
|
||||||
|
|
||||||
interface ServiceGroupEditSheetProps {
|
interface ServiceGroupEditSheetProps {
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
group: ServiceGroup | null
|
group: ServiceGroup | null
|
||||||
@@ -51,31 +49,30 @@ export function ServiceGroupEditSheet({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onSave,
|
onSave,
|
||||||
}: ServiceGroupEditSheetProps) {
|
}: ServiceGroupEditSheetProps) {
|
||||||
const [name, setName] = useState('')
|
const form = useForm<ServiceGroupFormValues>({
|
||||||
const [type, setType] = useState<CreateServiceGroupInput['type']>('custom')
|
resolver: zodResolver(createServiceGroupSchema),
|
||||||
const [domain, setDomain] = useState('')
|
defaultValues: { name: '', type: 'custom', domain: null },
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (mode === 'edit' && group) {
|
if (mode === 'edit' && group) {
|
||||||
setName(group.name)
|
form.reset({
|
||||||
setType(group.type)
|
name: group.name,
|
||||||
setDomain(group.domain ?? '')
|
type: group.type,
|
||||||
|
domain: group.domain ?? null,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setName('')
|
form.reset({ name: '', type: 'custom', domain: null })
|
||||||
setType('custom')
|
|
||||||
setDomain('')
|
|
||||||
}
|
}
|
||||||
}, [open, mode, group])
|
}, [open, mode, group, form])
|
||||||
|
|
||||||
function handleSubmit(event: React.FormEvent) {
|
function handleSubmit(values: ServiceGroupFormValues) {
|
||||||
event.preventDefault()
|
|
||||||
const trimmedName = name.trim()
|
|
||||||
if (!trimmedName) return
|
|
||||||
const body: CreateServiceGroupInput = {
|
const body: CreateServiceGroupInput = {
|
||||||
name: trimmedName,
|
name: values.name,
|
||||||
type,
|
type: values.type ?? 'custom',
|
||||||
domain: domain.trim() || null,
|
icon: values.icon,
|
||||||
|
domain: values.domain?.trim() || null,
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.(body)
|
onCreate?.(body)
|
||||||
@@ -85,33 +82,47 @@ export function ServiceGroupEditSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<FormSheet<ServiceGroupFormValues>
|
||||||
<SheetContent>
|
open={open}
|
||||||
<SheetHeader>
|
onOpenChange={onOpenChange}
|
||||||
<SheetTitle>
|
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||||
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
|
||||||
</SheetTitle>
|
form={form}
|
||||||
<SheetDescription>
|
onSubmit={handleSubmit}
|
||||||
Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов.
|
contentClassName="gap-6"
|
||||||
</SheetDescription>
|
footer={
|
||||||
</SheetHeader>
|
<LoadingButton
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
type="submit"
|
||||||
<FieldGroup>
|
className="w-full"
|
||||||
<Field>
|
isLoading={isSaving}
|
||||||
<FieldLabel htmlFor="group-name">Название</FieldLabel>
|
loadingLabel="Сохранение…"
|
||||||
<Input
|
>
|
||||||
id="group-name"
|
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||||
value={name}
|
</LoadingButton>
|
||||||
onChange={(event) => setName(event.target.value)}
|
}
|
||||||
placeholder="VPN"
|
>
|
||||||
/>
|
<FieldGroup>
|
||||||
</Field>
|
<FormFieldSimple
|
||||||
<Field>
|
label="Название"
|
||||||
<FieldLabel htmlFor="group-type">Тип</FieldLabel>
|
htmlFor="group-name"
|
||||||
|
error={form.formState.errors.name}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="group-name"
|
||||||
|
placeholder="VPN"
|
||||||
|
{...form.register('name')}
|
||||||
|
aria-invalid={!!form.formState.errors.name}
|
||||||
|
/>
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Тип" htmlFor="group-type">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={field.value}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setType(value as CreateServiceGroupInput['type'])
|
field.onChange(value as CreateServiceGroupInput['type'])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="group-type">
|
<SelectTrigger id="group-type">
|
||||||
@@ -125,25 +136,20 @@ export function ServiceGroupEditSheet({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
)}
|
||||||
<Field>
|
/>
|
||||||
<FieldLabel htmlFor="group-domain">Домен группы (FQDN, необязательно)</FieldLabel>
|
</FormFieldSimple>
|
||||||
<Input
|
<FormFieldSimple
|
||||||
id="group-domain"
|
label="Домен группы (FQDN, необязательно)"
|
||||||
value={domain}
|
htmlFor="group-domain"
|
||||||
onChange={(event) => setDomain(event.target.value)}
|
>
|
||||||
placeholder="domain.new.ivx.su"
|
<Input
|
||||||
/>
|
id="group-domain"
|
||||||
</Field>
|
placeholder="domain.new.ivx.su"
|
||||||
</FieldGroup>
|
{...form.register('domain')}
|
||||||
<SheetFooter>
|
/>
|
||||||
<Button type="submit" disabled={isSaving} className="w-full">
|
</FormFieldSimple>
|
||||||
{isSaving && <Spinner data-icon="inline-start" />}
|
</FieldGroup>
|
||||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
</FormSheet>
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { useSortable } from '@dnd-kit/sortable'
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||||
@@ -41,7 +42,7 @@ export interface ServiceRowProps {
|
|||||||
showCheckbox?: boolean
|
showCheckbox?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceRow({
|
export const ServiceRow = memo(function ServiceRow({
|
||||||
service,
|
service,
|
||||||
onToggle,
|
onToggle,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -196,4 +197,4 @@ export function ServiceRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ import type { CertMonitoring } from '@cfdm/shared'
|
|||||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
import { FieldDescription, FieldGroup } from '@cfdm/ui/components/field'
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from '@cfdm/ui/components/field'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -26,7 +22,6 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@cfdm/ui/components/sheet'
|
} from '@cfdm/ui/components/sheet'
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
|
||||||
|
|
||||||
export interface SubdomainEditValues {
|
export interface SubdomainEditValues {
|
||||||
name: string
|
name: string
|
||||||
@@ -121,20 +116,19 @@ export function SubdomainEditSheet({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field>
|
<FormFieldSimple label="Имя" htmlFor="subdomain_name">
|
||||||
<FieldLabel htmlFor="subdomain_name">Имя</FieldLabel>
|
|
||||||
<Input
|
<Input
|
||||||
id="subdomain_name"
|
id="subdomain_name"
|
||||||
placeholder="www"
|
placeholder="www"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
aria-invalid={!name.trim() && name.length > 0}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormFieldSimple>
|
||||||
{mode === 'edit' && (
|
{mode === 'edit' && (
|
||||||
<>
|
<>
|
||||||
<Field>
|
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
|
||||||
<FieldLabel htmlFor="subdomain_service">Сервис</FieldLabel>
|
|
||||||
<Select
|
<Select
|
||||||
items={serviceItems}
|
items={serviceItems}
|
||||||
value={serviceId}
|
value={serviceId}
|
||||||
@@ -151,11 +145,11 @@ export function SubdomainEditSheet({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</FormFieldSimple>
|
||||||
<Field>
|
<FormFieldSimple
|
||||||
<FieldLabel htmlFor="subdomain_cert_monitoring">
|
label="Мониторинг SSL"
|
||||||
Мониторинг SSL
|
htmlFor="subdomain_cert_monitoring"
|
||||||
</FieldLabel>
|
>
|
||||||
<Select
|
<Select
|
||||||
items={certMonitoringItems}
|
items={certMonitoringItems}
|
||||||
value={certMonitoring}
|
value={certMonitoring}
|
||||||
@@ -180,19 +174,20 @@ export function SubdomainEditSheet({
|
|||||||
?.description
|
?.description
|
||||||
}
|
}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</FormFieldSimple>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button type="submit" disabled={isSaving || !name.trim()} className="w-full">
|
<LoadingButton
|
||||||
{isSaving && <Spinner data-icon="inline-start" />}
|
type="submit"
|
||||||
{isSaving
|
className="w-full"
|
||||||
? 'Сохранение…'
|
disabled={!name.trim()}
|
||||||
: mode === 'create'
|
isLoading={isSaving}
|
||||||
? 'Создать'
|
loadingLabel="Сохранение…"
|
||||||
: 'Сохранить'}
|
>
|
||||||
</Button>
|
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||||
|
</LoadingButton>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { MoreHorizontalIcon } from 'lucide-react'
|
import { MoreHorizontalIcon } from 'lucide-react'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { TableCard } from '@/components/table-card'
|
||||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||||
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
||||||
import { formatDate } from '@/lib/format'
|
import { formatDate } from '@/lib/format'
|
||||||
@@ -46,7 +47,7 @@ export function SubdomainsTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<TableCard>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -61,7 +62,7 @@ export function SubdomainsTable({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<TableRow key={row.subdomain.id}>
|
<TableRow key={row.subdomain.id} className="h-10 text-sm">
|
||||||
<TableCell className="font-mono">{row.subdomain.fqdn}</TableCell>
|
<TableCell className="font-mono">{row.subdomain.fqdn}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={row.subdomain.enabled ? 'default' : 'outline'}>
|
<Badge variant={row.subdomain.enabled ? 'default' : 'outline'}>
|
||||||
@@ -115,7 +116,7 @@ export function SubdomainsTable({
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</TableCard>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { InboxIcon } from 'lucide-react'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
interface TableCardProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
isEmpty?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
emptyIcon?: LucideIcon
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
isEmpty,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
emptyIcon: EmptyIcon = InboxIcon,
|
||||||
|
emptyAction,
|
||||||
|
}: TableCardProps) {
|
||||||
|
if (isEmpty && emptyTitle) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={EmptyIcon}
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
action={emptyAction}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('overflow-hidden rounded-lg border', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
} from '@cfdm/ui/components/card'
|
||||||
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
|
interface TableSkeletonProps {
|
||||||
|
rows?: number
|
||||||
|
columns?: number
|
||||||
|
withCard?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRowsSkeleton({ rows, columns }: { rows: number; columns: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<div className="flex gap-4 border-b px-4 py-3">
|
||||||
|
{Array.from({ length: columns }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-4 flex-1" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="flex gap-4 border-b px-4 py-3 last:border-0">
|
||||||
|
{Array.from({ length: columns }).map((__, colIndex) => (
|
||||||
|
<Skeleton key={colIndex} className="h-4 flex-1" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSkeleton({
|
||||||
|
rows = 5,
|
||||||
|
columns = 4,
|
||||||
|
withCard = true,
|
||||||
|
}: TableSkeletonProps) {
|
||||||
|
const table = <TableRowsSkeleton rows={rows} columns={columns} />
|
||||||
|
|
||||||
|
if (!withCard) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border">{table}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">{table}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@cfdm/ui/components/table'
|
} from '@cfdm/ui/components/table'
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { TableSkeleton } from '@/components/table-skeleton'
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
count: {
|
count: {
|
||||||
@@ -99,18 +100,22 @@ function CertificatesPage() {
|
|||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
|
description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<LoadingButton
|
||||||
onClick={() => checkMutation.mutate()}
|
onClick={() => checkMutation.mutate()}
|
||||||
disabled={checkMutation.isPending}
|
isLoading={checkMutation.isPending}
|
||||||
|
loadingLabel="Проверка…"
|
||||||
>
|
>
|
||||||
{checkMutation.isPending && (
|
Запустить проверку
|
||||||
<Spinner data-icon="inline-start" />
|
</LoadingButton>
|
||||||
)}
|
|
||||||
{checkMutation.isPending ? 'Проверка…' : 'Запустить проверку'}
|
|
||||||
</Button>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<QueryState isLoading={isLoading} isError={isError} error={error} onRetry={refetch}>
|
<QueryState
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={refetch}
|
||||||
|
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Обзор статусов</CardTitle>
|
<CardTitle>Обзор статусов</CardTitle>
|
||||||
@@ -152,6 +157,21 @@ function CertificatesPage() {
|
|||||||
? 'Измените поисковый запрос'
|
? 'Измените поисковый запрос'
|
||||||
: 'Сертификаты появятся после проверки доменов'
|
: 'Сертификаты появятся после проверки доменов'
|
||||||
}
|
}
|
||||||
|
emptyAction={
|
||||||
|
isFilteredEmpty ? (
|
||||||
|
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||||
|
Сбросить фильтр
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<LoadingButton
|
||||||
|
onClick={() => checkMutation.mutate()}
|
||||||
|
isLoading={checkMutation.isPending}
|
||||||
|
loadingLabel="Проверка…"
|
||||||
|
>
|
||||||
|
Запустить проверку
|
||||||
|
</LoadingButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
emptyIcon={ShieldCheckIcon}
|
emptyIcon={ShieldCheckIcon}
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ import { api } from '@/lib/api-client'
|
|||||||
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { DataTableCard } from '@/components/data-table-card'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { TableSkeleton } from '@/components/table-skeleton'
|
||||||
import { TableToolbar } from '@/components/table-toolbar'
|
import { TableToolbar } from '@/components/table-toolbar'
|
||||||
import { DnsRecordsTable } from '@/components/dns-records-table'
|
import { DnsRecordsTable } from '@/components/dns-records-table'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from '@cfdm/ui/components/field'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,16 +28,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@cfdm/ui/components/sheet'
|
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
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 DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||||
|
|
||||||
@@ -62,8 +54,24 @@ function DnsPage() {
|
|||||||
const { host } = Route.useSearch()
|
const { host } = Route.useSearch()
|
||||||
const id = Number(domainId)
|
const id = Number(domainId)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: domain } = useQuery(domainDetailQueryOptions(id))
|
const {
|
||||||
const { data: records } = useQuery(dnsListQueryOptions(id))
|
data: domain,
|
||||||
|
isLoading: domainLoading,
|
||||||
|
isError: domainError,
|
||||||
|
error: domainErr,
|
||||||
|
refetch: refetchDomain,
|
||||||
|
} = useQuery(domainDetailQueryOptions(id))
|
||||||
|
const {
|
||||||
|
data: records,
|
||||||
|
isLoading: recordsLoading,
|
||||||
|
isError: recordsError,
|
||||||
|
error: recordsErr,
|
||||||
|
refetch: refetchRecords,
|
||||||
|
} = useQuery(dnsListQueryOptions(id))
|
||||||
|
|
||||||
|
const isLoading = domainLoading || recordsLoading
|
||||||
|
const isError = domainError || recordsError
|
||||||
|
const error = domainErr ?? recordsErr
|
||||||
|
|
||||||
const form = useForm<CreateDnsRecordInput>({
|
const form = useForm<CreateDnsRecordInput>({
|
||||||
resolver: zodResolver(createDnsRecordSchema),
|
resolver: zodResolver(createDnsRecordSchema),
|
||||||
@@ -120,9 +128,9 @@ function DnsPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleCreate = form.handleSubmit((values) => {
|
const handleCreate = (values: CreateDnsRecordInput) => {
|
||||||
createMutation.mutate(values)
|
createMutation.mutate(values)
|
||||||
})
|
}
|
||||||
|
|
||||||
const filteredRecords = useMemo(() => {
|
const filteredRecords = useMemo(() => {
|
||||||
const list = records ?? []
|
const list = records ?? []
|
||||||
@@ -136,7 +144,7 @@ function DnsPage() {
|
|||||||
)
|
)
|
||||||
}, [records, host, searchQuery])
|
}, [records, host, searchQuery])
|
||||||
|
|
||||||
const hasRecords = useMemo(() => filteredRecords.length > 0, [filteredRecords])
|
const hasRecords = filteredRecords.length > 0
|
||||||
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,130 +161,166 @@ function DnsPage() {
|
|||||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||||
Новая запись
|
Новая запись
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<LoadingButton
|
||||||
onClick={() => syncMutation.mutate()}
|
onClick={() => syncMutation.mutate()}
|
||||||
disabled={syncMutation.isPending}
|
isLoading={syncMutation.isPending}
|
||||||
|
loadingLabel="Синхронизация…"
|
||||||
>
|
>
|
||||||
{syncMutation.isPending && (
|
Синхронизировать с Cloudflare
|
||||||
<Spinner data-icon="inline-start" />
|
</LoadingButton>
|
||||||
)}
|
|
||||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTableCard
|
<QueryState
|
||||||
title="DNS-записи"
|
isLoading={isLoading}
|
||||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
isError={isError}
|
||||||
emptyTitle={
|
error={error}
|
||||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
onRetry={() => {
|
||||||
}
|
void refetchDomain()
|
||||||
emptyDescription={
|
void refetchRecords()
|
||||||
isFilteredEmpty
|
}}
|
||||||
? 'Измените поисковый запрос'
|
skeleton={<TableSkeleton rows={6} columns={6} />}
|
||||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
>
|
||||||
}
|
<DataTableCard
|
||||||
emptyIcon={NetworkIcon}
|
title="DNS-записи"
|
||||||
isEmpty={!hasRecords}
|
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||||
toolbar={
|
emptyTitle={
|
||||||
!host ? (
|
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||||
<TableToolbar
|
}
|
||||||
value={searchQuery}
|
emptyDescription={
|
||||||
onChange={setSearchQuery}
|
isFilteredEmpty
|
||||||
placeholder="Поиск по имени или значению…"
|
? 'Измените поисковый запрос'
|
||||||
/>
|
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||||
) : undefined
|
}
|
||||||
|
emptyIcon={NetworkIcon}
|
||||||
|
isEmpty={!hasRecords}
|
||||||
|
emptyAction={
|
||||||
|
isFilteredEmpty ? (
|
||||||
|
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||||
|
Сбросить фильтр
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setSheetOpen(true)}>Новая запись</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
toolbar={
|
||||||
|
!host ? (
|
||||||
|
<TableToolbar
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Поиск по имени или значению…"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasRecords && (
|
||||||
|
<div className="p-4">
|
||||||
|
<DnsRecordsTable
|
||||||
|
records={filteredRecords}
|
||||||
|
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DataTableCard>
|
||||||
|
</QueryState>
|
||||||
|
|
||||||
|
<FormSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
title="Новая DNS-запись"
|
||||||
|
description={`Добавить запись в зону ${domain?.zone_name ?? ''}`}
|
||||||
|
form={form}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
footer={
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
loadingLabel="Создание…"
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</LoadingButton>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasRecords && (
|
<FieldGroup>
|
||||||
<div className="p-4">
|
<FormFieldSimple label="Тип" htmlFor="record_type">
|
||||||
<DnsRecordsTable
|
<Controller
|
||||||
records={filteredRecords}
|
control={form.control}
|
||||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
name="record_type"
|
||||||
isDeleting={deleteMutation.isPending}
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
items={dnsTypeItems}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="record_type" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{dnsTypeItems.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormFieldSimple>
|
||||||
)}
|
<FormFieldSimple
|
||||||
</DataTableCard>
|
label="Имя"
|
||||||
|
htmlFor="name"
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
error={form.formState.errors.name}
|
||||||
<SheetContent>
|
>
|
||||||
<SheetHeader>
|
<Input
|
||||||
<SheetTitle>Новая DNS-запись</SheetTitle>
|
id="name"
|
||||||
<SheetDescription>
|
placeholder="@"
|
||||||
Добавить запись в зону {domain?.zone_name ?? ''}
|
{...form.register('name')}
|
||||||
</SheetDescription>
|
aria-invalid={!!form.formState.errors.name}
|
||||||
</SheetHeader>
|
/>
|
||||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
</FormFieldSimple>
|
||||||
<FieldGroup>
|
<FormFieldSimple
|
||||||
<Field>
|
label="Значение"
|
||||||
<FieldLabel htmlFor="record_type">Тип</FieldLabel>
|
htmlFor="content"
|
||||||
<Controller
|
error={form.formState.errors.content}
|
||||||
control={form.control}
|
>
|
||||||
name="record_type"
|
<Input
|
||||||
render={({ field }) => (
|
id="content"
|
||||||
<Select
|
placeholder="192.168.1.1"
|
||||||
items={dnsTypeItems}
|
{...form.register('content')}
|
||||||
value={field.value}
|
aria-invalid={!!form.formState.errors.content}
|
||||||
onValueChange={field.onChange}
|
/>
|
||||||
>
|
</FormFieldSimple>
|
||||||
<SelectTrigger id="record_type" className="w-full">
|
<FormFieldSimple
|
||||||
<SelectValue />
|
label="TTL"
|
||||||
</SelectTrigger>
|
htmlFor="ttl"
|
||||||
<SelectContent>
|
error={form.formState.errors.ttl}
|
||||||
{dnsTypeItems.map((item) => (
|
>
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<Input
|
||||||
{item.label}
|
id="ttl"
|
||||||
</SelectItem>
|
type="number"
|
||||||
))}
|
{...form.register('ttl', { valueAsNumber: true })}
|
||||||
</SelectContent>
|
aria-invalid={!!form.formState.errors.ttl}
|
||||||
</Select>
|
/>
|
||||||
)}
|
</FormFieldSimple>
|
||||||
|
<Field className="flex flex-row items-center justify-between gap-4">
|
||||||
|
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="proxied"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
id="proxied"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</Field>
|
)}
|
||||||
<Field>
|
/>
|
||||||
<FieldLabel htmlFor="name">Имя</FieldLabel>
|
</Field>
|
||||||
<Input id="name" placeholder="@" {...form.register('name')} />
|
</FieldGroup>
|
||||||
</Field>
|
</FormSheet>
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="content">Значение</FieldLabel>
|
|
||||||
<Input id="content" placeholder="192.168.1.1" {...form.register('content')} />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="ttl">TTL</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="ttl"
|
|
||||||
type="number"
|
|
||||||
{...form.register('ttl', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field className="flex flex-row items-center justify-between gap-4">
|
|
||||||
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="proxied"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Switch
|
|
||||||
id="proxied"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
<SheetFooter>
|
|
||||||
<Button type="submit" disabled={createMutation.isPending} className="w-full">
|
|
||||||
{createMutation.isPending && (
|
|
||||||
<Spinner data-icon="inline-start" />
|
|
||||||
)}
|
|
||||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@cfdm/ui/components/card'
|
} from '@cfdm/ui/components/card'
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
import { TableSkeleton } from '@/components/table-skeleton'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||||
loader: async ({ context: { queryClient }, params }) => {
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
@@ -54,12 +54,12 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
|||||||
function DomainPageSkeleton() {
|
function DomainPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Skeleton className="h-10 w-1/3" />
|
<TableSkeleton withCard={false} rows={1} columns={2} />
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Skeleton className="h-40 w-full" />
|
<TableSkeleton withCard rows={3} columns={2} />
|
||||||
<Skeleton className="h-40 w-full" />
|
<TableSkeleton withCard rows={3} columns={2} />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-64 w-full" />
|
<TableSkeleton rows={5} columns={5} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -166,16 +166,14 @@ function DomainOverviewPage() {
|
|||||||
description="Обзор домена и поддоменов"
|
description="Обзор домена и поддоменов"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button
|
<LoadingButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => syncMutation.mutate()}
|
onClick={() => syncMutation.mutate()}
|
||||||
disabled={syncMutation.isPending}
|
isLoading={syncMutation.isPending}
|
||||||
|
loadingLabel="Синхронизация…"
|
||||||
>
|
>
|
||||||
{syncMutation.isPending && (
|
Синхронизировать
|
||||||
<Spinner data-icon="inline-start" />
|
</LoadingButton>
|
||||||
)}
|
|
||||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { GlobeIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
|
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
|
||||||
@@ -14,15 +15,16 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { DataTableCard } from '@/components/data-table-card'
|
||||||
import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
|
import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { TableSkeleton } from '@/components/table-skeleton'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from '@cfdm/ui/components/field'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -30,15 +32,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@cfdm/ui/components/sheet'
|
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/')({
|
export const Route = createFileRoute('/_auth/domains/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -56,7 +49,13 @@ function DomainsPage() {
|
|||||||
const listGroupId =
|
const listGroupId =
|
||||||
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
|
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: domains } = useQuery(domainsListQueryOptions(listGroupId))
|
const {
|
||||||
|
data: domains,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery(domainsListQueryOptions(listGroupId))
|
||||||
const { data: groups } = useQuery(groupsQueryOptions())
|
const { data: groups } = useQuery(groupsQueryOptions())
|
||||||
|
|
||||||
const groupItems = useMemo(
|
const groupItems = useMemo(
|
||||||
@@ -102,12 +101,12 @@ function DomainsPage() {
|
|||||||
deleteMutation.mutate(domain.id)
|
deleteMutation.mutate(domain.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = form.handleSubmit((values) => {
|
const handleCreate = (values: CreateDomainInput) => {
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
zone_name: values.zone_name.trim(),
|
zone_name: values.zone_name.trim(),
|
||||||
group_id: importGroupId ? Number(importGroupId) : undefined,
|
group_id: importGroupId ? Number(importGroupId) : undefined,
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
const handleImportGroupChange = (value: string | null) => {
|
const handleImportGroupChange = (value: string | null) => {
|
||||||
const next = value === 'none' || !value ? '' : value
|
const next = value === 'none' || !value ? '' : value
|
||||||
@@ -135,6 +134,10 @@ function DomainsPage() {
|
|||||||
[filteredDomains],
|
[filteredDomains],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasActiveFilter = filterGroupId !== ''
|
||||||
|
const isZeroResults = hasActiveFilter && tableData.length === 0 && (domains?.length ?? 0) > 0
|
||||||
|
const isTrulyEmpty = tableData.length === 0 && !hasActiveFilter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -150,68 +153,100 @@ function DomainsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTableCard
|
<QueryState
|
||||||
title="Список доменов"
|
isLoading={isLoading}
|
||||||
description="Импортированные зоны Cloudflare"
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={refetch}
|
||||||
|
skeleton={<TableSkeleton />}
|
||||||
>
|
>
|
||||||
<DomainsDataTable
|
<DataTableCard
|
||||||
data={tableData}
|
title="Список доменов"
|
||||||
groupFilterItems={groupItems}
|
description="Импортированные зоны Cloudflare"
|
||||||
groupFilterValue={filterGroupId || 'all'}
|
isEmpty={isTrulyEmpty || isZeroResults}
|
||||||
onGroupFilterChange={handleFilterGroupChange}
|
emptyTitle={
|
||||||
onDelete={handleDeleteDomain}
|
isZeroResults ? 'Ничего не найдено' : 'Домены не импортированы'
|
||||||
isDeleting={deleteMutation.isPending}
|
}
|
||||||
/>
|
emptyDescription={
|
||||||
</DataTableCard>
|
isZeroResults
|
||||||
|
? 'По выбранному фильтру группы нет подходящих зон'
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
: 'Импортируйте зону из аккаунта Cloudflare'
|
||||||
<SheetContent>
|
}
|
||||||
<SheetHeader>
|
emptyIcon={GlobeIcon}
|
||||||
<SheetTitle>Импорт домена</SheetTitle>
|
emptyAction={
|
||||||
<SheetDescription>
|
isZeroResults ? (
|
||||||
Добавить зону из аккаунта Cloudflare в менеджер
|
<Button variant="outline" onClick={() => setFilterGroupId('')}>
|
||||||
</SheetDescription>
|
Сбросить фильтр
|
||||||
</SheetHeader>
|
|
||||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="zone_name">Имя зоны</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="zone_name"
|
|
||||||
placeholder="example.com"
|
|
||||||
{...form.register('zone_name')}
|
|
||||||
aria-invalid={!!form.formState.errors.zone_name}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="import_group_id">Группа</FieldLabel>
|
|
||||||
<Select
|
|
||||||
items={groupItems}
|
|
||||||
value={importGroupId || 'none'}
|
|
||||||
onValueChange={handleImportGroupChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="import_group_id" className="w-full">
|
|
||||||
<SelectValue placeholder="Без группы" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{groupItems.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
<SheetFooter>
|
|
||||||
<Button type="submit" disabled={createMutation.isPending} className="w-full">
|
|
||||||
{createMutation.isPending && <Spinner data-icon="inline-start" />}
|
|
||||||
{createMutation.isPending ? 'Импорт…' : 'Импортировать'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
) : (
|
||||||
</form>
|
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
|
||||||
</SheetContent>
|
)
|
||||||
</Sheet>
|
}
|
||||||
|
>
|
||||||
|
{!isTrulyEmpty && !isZeroResults && (
|
||||||
|
<DomainsDataTable
|
||||||
|
data={tableData}
|
||||||
|
groupFilterItems={groupItems}
|
||||||
|
groupFilterValue={filterGroupId || 'all'}
|
||||||
|
onGroupFilterChange={handleFilterGroupChange}
|
||||||
|
onDelete={handleDeleteDomain}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DataTableCard>
|
||||||
|
</QueryState>
|
||||||
|
|
||||||
|
<FormSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
title="Импорт домена"
|
||||||
|
description="Добавить зону из аккаунта Cloudflare в менеджер"
|
||||||
|
form={form}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
footer={
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
loadingLabel="Импорт…"
|
||||||
|
>
|
||||||
|
Импортировать
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<FormFieldSimple
|
||||||
|
label="Имя зоны"
|
||||||
|
htmlFor="zone_name"
|
||||||
|
error={form.formState.errors.zone_name}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="zone_name"
|
||||||
|
placeholder="example.com"
|
||||||
|
{...form.register('zone_name')}
|
||||||
|
aria-invalid={!!form.formState.errors.zone_name}
|
||||||
|
/>
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Группа" htmlFor="import_group_id">
|
||||||
|
<Select
|
||||||
|
items={groupItems}
|
||||||
|
value={importGroupId || 'none'}
|
||||||
|
onValueChange={handleImportGroupChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="import_group_id" className="w-full">
|
||||||
|
<SelectValue placeholder="Без группы" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{groupItems.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormFieldSimple>
|
||||||
|
</FieldGroup>
|
||||||
|
</FormSheet>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { DataTableCard } from '@/components/data-table-card'
|
||||||
|
import { TableSkeleton } from '@/components/table-skeleton'
|
||||||
import { TableToolbar } from '@/components/table-toolbar'
|
import { TableToolbar } from '@/components/table-toolbar'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -35,8 +37,24 @@ function GroupDetailPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const { groupId } = Route.useParams()
|
const { groupId } = Route.useParams()
|
||||||
const id = Number(groupId)
|
const id = Number(groupId)
|
||||||
const { data: group } = useQuery(groupDetailQueryOptions(id))
|
const {
|
||||||
const { data: domains } = useQuery(domainsListQueryOptions(id))
|
data: group,
|
||||||
|
isLoading: groupLoading,
|
||||||
|
isError: groupError,
|
||||||
|
error: groupErr,
|
||||||
|
refetch: refetchGroup,
|
||||||
|
} = useQuery(groupDetailQueryOptions(id))
|
||||||
|
const {
|
||||||
|
data: domains,
|
||||||
|
isLoading: domainsLoading,
|
||||||
|
isError: domainsError,
|
||||||
|
error: domainsErr,
|
||||||
|
refetch: refetchDomains,
|
||||||
|
} = useQuery(domainsListQueryOptions(id))
|
||||||
|
|
||||||
|
const isLoading = groupLoading || domainsLoading
|
||||||
|
const isError = groupError || domainsError
|
||||||
|
const error = groupErr ?? domainsErr
|
||||||
|
|
||||||
const filteredDomains = useMemo(() => {
|
const filteredDomains = useMemo(() => {
|
||||||
const list = domains ?? []
|
const list = domains ?? []
|
||||||
@@ -46,6 +64,7 @@ function GroupDetailPage() {
|
|||||||
}, [domains, searchQuery])
|
}, [domains, searchQuery])
|
||||||
|
|
||||||
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
||||||
|
const hasRecords = filteredDomains.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -59,82 +78,113 @@ function GroupDetailPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
nativeButton={false}
|
||||||
render={<Link to="/groups" />}
|
render={<Link to="/groups" />}
|
||||||
>
|
>
|
||||||
На группам
|
К группам
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DataTableCard
|
<QueryState
|
||||||
title="Домены группы"
|
isLoading={isLoading}
|
||||||
description="Список доменов, назначенных этой группе"
|
isError={isError}
|
||||||
emptyTitle={
|
error={error}
|
||||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
onRetry={() => {
|
||||||
}
|
void refetchGroup()
|
||||||
emptyDescription={
|
void refetchDomains()
|
||||||
isFilteredEmpty
|
}}
|
||||||
? 'Измените поисковый запрос'
|
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
|
||||||
}
|
|
||||||
emptyIcon={GlobeIcon}
|
|
||||||
isEmpty={!filteredDomains.length}
|
|
||||||
toolbar={
|
|
||||||
<TableToolbar
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={setSearchQuery}
|
|
||||||
placeholder="Поиск по зоне…"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Table>
|
<DataTableCard
|
||||||
<TableHeader>
|
title="Домены группы"
|
||||||
<TableRow>
|
description="Список доменов, назначенных этой группе"
|
||||||
<TableHead>Зона</TableHead>
|
emptyTitle={
|
||||||
<TableHead>Статус</TableHead>
|
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||||
<TableHead>Сервисы</TableHead>
|
}
|
||||||
<TableHead className="text-right">Действия</TableHead>
|
emptyDescription={
|
||||||
</TableRow>
|
isFilteredEmpty
|
||||||
</TableHeader>
|
? 'Измените поисковый запрос'
|
||||||
<TableBody>
|
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||||
{filteredDomains.map((domain) => (
|
}
|
||||||
<TableRow key={domain.id}>
|
emptyIcon={GlobeIcon}
|
||||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
isEmpty={!hasRecords}
|
||||||
<TableCell>
|
emptyAction={
|
||||||
<StatusBadge status={domain.status} />
|
isFilteredEmpty ? (
|
||||||
</TableCell>
|
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||||
<TableCell>
|
Сбросить фильтр
|
||||||
<Button
|
</Button>
|
||||||
variant="link"
|
) : (
|
||||||
className="h-auto p-0 tabular-nums"
|
<Button
|
||||||
render={
|
variant="outline"
|
||||||
<Link
|
nativeButton={false}
|
||||||
to="/services"
|
render={<Link to="/groups" />}
|
||||||
search={{ domainId: domain.id }}
|
>
|
||||||
/>
|
Открыть канбан групп
|
||||||
}
|
</Button>
|
||||||
>
|
)
|
||||||
{domain.service_count}
|
}
|
||||||
</Button>
|
toolbar={
|
||||||
</TableCell>
|
<TableToolbar
|
||||||
<TableCell className="text-right">
|
value={searchQuery}
|
||||||
<Button
|
onChange={setSearchQuery}
|
||||||
variant="outline"
|
placeholder="Поиск по зоне…"
|
||||||
size="sm"
|
/>
|
||||||
render={
|
}
|
||||||
<Link
|
>
|
||||||
to="/domains/$domainId"
|
{hasRecords && (
|
||||||
params={{ domainId: String(domain.id) }}
|
<Table>
|
||||||
/>
|
<TableHeader>
|
||||||
}
|
<TableRow className="h-10">
|
||||||
>
|
<TableHead>Зона</TableHead>
|
||||||
Обзор
|
<TableHead>Статус</TableHead>
|
||||||
</Button>
|
<TableHead>Сервисы</TableHead>
|
||||||
</TableCell>
|
<TableHead className="text-right">Действия</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filteredDomains.map((domain) => (
|
||||||
</DataTableCard>
|
<TableRow key={domain.id} className="h-10 text-sm">
|
||||||
|
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={domain.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 tabular-nums"
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/services"
|
||||||
|
search={{ domainId: domain.id }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{domain.service_count}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/domains/$domainId"
|
||||||
|
params={{ domainId: String(domain.id) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Обзор
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</DataTableCard>
|
||||||
|
</QueryState>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
|
import { SectionCardsSkeleton } from '@/components/section-cards-skeleton'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -54,6 +55,7 @@ function DashboardPage() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
|
skeleton={<SectionCardsSkeleton />}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
void refetchDomains()
|
void refetchDomains()
|
||||||
void refetchSummary()
|
void refetchSummary()
|
||||||
|
|||||||
+3
-1
@@ -6,9 +6,11 @@
|
|||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"lint": "turbo run lint"
|
"lint": "turbo run lint",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"husky": "^9.1.7",
|
||||||
"turbo": "^2.5.4"
|
"turbo": "^2.5.4"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
|
|||||||
Generated
+10
@@ -9,6 +9,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
husky:
|
||||||
|
specifier: ^9.1.7
|
||||||
|
version: 9.1.7
|
||||||
turbo:
|
turbo:
|
||||||
specifier: ^2.5.4
|
specifier: ^2.5.4
|
||||||
version: 2.9.18
|
version: 2.9.18
|
||||||
@@ -2908,6 +2911,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
@@ -6313,6 +6321,8 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user