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",
|
||||
"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. **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))
|
||||
2. Открыть **Components** или **Blocks** — найти готовое решение
|
||||
3. `cd apps/web && pnpm dlx shadcn@latest docs <component>` — API и примеры
|
||||
4. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/<query>"` — если MCP не дал результат
|
||||
5. Только потом писать код
|
||||
3. `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API с [Components](https://ui.shadcn.com/docs/components)
|
||||
4. Сверить MCP examples ↔ docs API — только потом писать код
|
||||
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>`.
|
||||
|
||||
@@ -49,6 +50,12 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl
|
||||
| `DataTableCard` | `data-table-card.tsx` |
|
||||
| `SectionCards` | `section-cards.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.
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ alwaysApply: false
|
||||
|
||||
## Docs workflow (обязательно)
|
||||
|
||||
0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты
|
||||
1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection
|
||||
2. MCP `plugin-shadcn-shadcn` — search → examples → add command
|
||||
3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs <component>` → fetch URLs
|
||||
4. Код по examples + docs API
|
||||
3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs <component>` → сверить API с [Components](https://ui.shadcn.com/docs/components)
|
||||
4. Код по examples + docs API (только после совпадения MCP ↔ docs)
|
||||
5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives)
|
||||
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
|
||||
section-cards.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
|
||||
domain-* ← бизнес-компоненты
|
||||
```
|
||||
@@ -45,7 +53,10 @@ apps/web/src/components/ ← shared + domain + layout
|
||||
| Empty | `EmptyState` | `Empty` |
|
||||
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
||||
| 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` |
|
||||
| List row | — | `Item variant="outline" size="sm"` |
|
||||
| Nav | `AppSidebar` | `Sidebar` |
|
||||
@@ -80,6 +91,18 @@ Toolbar: gap-2
|
||||
|
||||
**Запрещено:** `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)
|
||||
|
||||
1. `default` — главный CTA
|
||||
|
||||
@@ -11,15 +11,18 @@ CLI и docs — **после** MCP, по команде из `get_add_command_fo
|
||||
|
||||
## Порядок (строго)
|
||||
|
||||
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки (один вызов перед правками)
|
||||
1. **`get_project_registries`** — какие registry доступны в проекте
|
||||
2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`)
|
||||
3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX
|
||||
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`
|
||||
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
|
||||
6. Адаптировать пример под TanStack Router / Query → `apps/web/src/`
|
||||
7. **CLI docs:** `cd apps/web && pnpm dlx shadcn@latest docs <component>` — fetch URLs, сверить API
|
||||
8. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–7 для примитивов)
|
||||
9. **`get_audit_checklist`** — перед merge PR
|
||||
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. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения
|
||||
8. Адаптировать пример под TanStack Router / Query → `apps/web/src/`
|
||||
9. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–8 для примитивов)
|
||||
10. **`get_audit_checklist`** — перед merge PR
|
||||
11. **Codegraph** `codegraph_status` — Pending sync пустой после правок
|
||||
|
||||
## Когда вызывать MCP
|
||||
|
||||
|
||||
@@ -16,6 +16,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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
|
||||
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 { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
const rawHtmlElements = ['table', 'select', 'hr']
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
@@ -18,5 +20,64 @@ export default defineConfig([
|
||||
languageOptions: {
|
||||
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 { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TableCard } from '@/components/table-card'
|
||||
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
|
||||
import type { DnsRecord } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
@@ -119,7 +120,7 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
||||
const groups = groupDnsRecords(records)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<TableCard>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -153,6 +154,6 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Link2Icon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { groupBindingsByHostname } from '@/lib/domain-ips'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
@@ -12,12 +14,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -47,14 +43,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 ? (
|
||||
<Empty className="border border-dashed p-4">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Нет привязок</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Создайте привязку на странице сервисов или в таблице поддоменов
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет привязок"
|
||||
description="Создайте привязку на странице сервисов или в таблице поддоменов"
|
||||
className="border border-dashed p-4"
|
||||
/>
|
||||
) : (
|
||||
<ItemGroup className="gap-0">
|
||||
{entries.map(([hostname, hostnameBindings], index) => {
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateGroupInput, Group } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createGroupSchema, type CreateGroupInput, type Group } from '@/lib/schemas'
|
||||
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 {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
interface DomainGroupEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
@@ -36,76 +27,74 @@ export function DomainGroupEditSheet({
|
||||
onCreate,
|
||||
onSave,
|
||||
}: DomainGroupEditSheetProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const form = useForm<CreateGroupInput>({
|
||||
resolver: zodResolver(createGroupSchema),
|
||||
defaultValues: { name: '', slug: '' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && group) {
|
||||
setName(group.name)
|
||||
setSlug(group.slug)
|
||||
form.reset({ name: group.name, slug: group.slug })
|
||||
} else {
|
||||
setName('')
|
||||
setSlug('')
|
||||
form.reset({ name: '', slug: '' })
|
||||
}
|
||||
}, [open, mode, group])
|
||||
}, [open, mode, group, form])
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const trimmedName = name.trim()
|
||||
const trimmedSlug = slug.trim()
|
||||
if (!trimmedName || !trimmedSlug) return
|
||||
const body: CreateGroupInput = {
|
||||
name: trimmedName,
|
||||
slug: trimmedSlug,
|
||||
}
|
||||
function handleSubmit(values: CreateGroupInput) {
|
||||
if (mode === 'create') {
|
||||
onCreate?.(body)
|
||||
onCreate?.(values)
|
||||
} else if (group) {
|
||||
onSave?.(group.id, body)
|
||||
onSave?.(group.id, values)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Группы используются для организации доменов на доске.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="domain-group-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="domain-group-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Production"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="domain-group-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="domain-group-slug"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder="production"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
|
||||
description="Группы используются для организации доменов на доске."
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
contentClassName="gap-6"
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple
|
||||
label="Название"
|
||||
htmlFor="domain-group-name"
|
||||
error={form.formState.errors.name}
|
||||
>
|
||||
<Input
|
||||
id="domain-group-name"
|
||||
placeholder="Production"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Slug"
|
||||
htmlFor="domain-group-slug"
|
||||
error={form.formState.errors.slug}
|
||||
>
|
||||
<Input
|
||||
id="domain-group-slug"
|
||||
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 { CSS } from '@dnd-kit/utilities'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
@@ -67,7 +68,7 @@ function DomainServiceList({
|
||||
)
|
||||
}
|
||||
|
||||
export function DomainRow({
|
||||
export const DomainRow = memo(function DomainRow({
|
||||
domain,
|
||||
serviceLabels = [],
|
||||
dragDisabled = false,
|
||||
@@ -174,4 +175,4 @@ export function DomainRow({
|
||||
</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 { setToken } from '@/lib/auth'
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -14,14 +15,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
type LoginFormProps = React.ComponentProps<'div'>
|
||||
|
||||
@@ -59,8 +55,11 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">Имя пользователя</FieldLabel>
|
||||
<FormFieldSimple
|
||||
label="Имя пользователя"
|
||||
htmlFor="username"
|
||||
error={form.formState.errors.username}
|
||||
>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="admin"
|
||||
@@ -68,9 +67,12 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
{...form.register('username')}
|
||||
aria-invalid={!!form.formState.errors.username}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Пароль"
|
||||
htmlFor="password"
|
||||
error={form.formState.errors.password}
|
||||
>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -78,7 +80,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
{...form.register('password')}
|
||||
aria-invalid={!!form.formState.errors.password}
|
||||
/>
|
||||
</Field>
|
||||
</FormFieldSimple>
|
||||
{rootError && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
@@ -86,12 +88,14 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
<AlertDescription>{rootError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Spinner data-icon="inline-start" />}
|
||||
{isLoading ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</Field>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
loadingLabel="Вход…"
|
||||
>
|
||||
Войти
|
||||
</LoadingButton>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</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,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -432,30 +432,32 @@ export function ServiceEditSheet({
|
||||
{!isCreate ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!service || isDeleting || isSaving}
|
||||
isLoading={isDeleting}
|
||||
loadingLabel="Удаление…"
|
||||
>
|
||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
</LoadingButton>
|
||||
}
|
||||
title="Удалить сервис?"
|
||||
description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо."
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
<LoadingButton
|
||||
type="button"
|
||||
className="ml-auto"
|
||||
disabled={!canSubmit || isSaving || isDeleting}
|
||||
disabled={!canSubmit || isDeleting}
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isCreate ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateServiceGroupInput, ServiceGroup } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
createServiceGroupSchema,
|
||||
type CreateServiceGroupInput,
|
||||
type ServiceGroup,
|
||||
} 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 {
|
||||
Select,
|
||||
@@ -14,15 +19,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 = [
|
||||
{ value: 'vpn', label: 'VPN' },
|
||||
@@ -32,6 +28,8 @@ const groupTypes = [
|
||||
{ value: 'custom', label: 'Другое' },
|
||||
] as const
|
||||
|
||||
type ServiceGroupFormValues = z.input<typeof createServiceGroupSchema>
|
||||
|
||||
interface ServiceGroupEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
group: ServiceGroup | null
|
||||
@@ -51,31 +49,30 @@ export function ServiceGroupEditSheet({
|
||||
onCreate,
|
||||
onSave,
|
||||
}: ServiceGroupEditSheetProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<CreateServiceGroupInput['type']>('custom')
|
||||
const [domain, setDomain] = useState('')
|
||||
const form = useForm<ServiceGroupFormValues>({
|
||||
resolver: zodResolver(createServiceGroupSchema),
|
||||
defaultValues: { name: '', type: 'custom', domain: null },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && group) {
|
||||
setName(group.name)
|
||||
setType(group.type)
|
||||
setDomain(group.domain ?? '')
|
||||
form.reset({
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
domain: group.domain ?? null,
|
||||
})
|
||||
} else {
|
||||
setName('')
|
||||
setType('custom')
|
||||
setDomain('')
|
||||
form.reset({ name: '', type: 'custom', domain: null })
|
||||
}
|
||||
}, [open, mode, group])
|
||||
}, [open, mode, group, form])
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) return
|
||||
function handleSubmit(values: ServiceGroupFormValues) {
|
||||
const body: CreateServiceGroupInput = {
|
||||
name: trimmedName,
|
||||
type,
|
||||
domain: domain.trim() || null,
|
||||
name: values.name,
|
||||
type: values.type ?? 'custom',
|
||||
icon: values.icon,
|
||||
domain: values.domain?.trim() || null,
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.(body)
|
||||
@@ -85,33 +82,47 @@ export function ServiceGroupEditSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="group-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="VPN"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-type">Тип</FieldLabel>
|
||||
<FormSheet<ServiceGroupFormValues>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
contentClassName="gap-6"
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple
|
||||
label="Название"
|
||||
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
|
||||
value={type}
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
setType(value as CreateServiceGroupInput['type'])
|
||||
field.onChange(value as CreateServiceGroupInput['type'])
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="group-type">
|
||||
@@ -125,25 +136,20 @@ export function ServiceGroupEditSheet({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-domain">Домен группы (FQDN, необязательно)</FieldLabel>
|
||||
<Input
|
||||
id="group-domain"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="domain.new.ivx.su"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Домен группы (FQDN, необязательно)"
|
||||
htmlFor="group-domain"
|
||||
>
|
||||
<Input
|
||||
id="group-domain"
|
||||
placeholder="domain.new.ivx.su"
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
@@ -41,7 +42,7 @@ export interface ServiceRowProps {
|
||||
showCheckbox?: boolean
|
||||
}
|
||||
|
||||
export function ServiceRow({
|
||||
export const ServiceRow = memo(function ServiceRow({
|
||||
service,
|
||||
onToggle,
|
||||
onEdit,
|
||||
@@ -196,4 +197,4 @@ export function ServiceRow({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,14 +3,10 @@ import type { CertMonitoring } from '@cfdm/shared'
|
||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
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 {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { FieldDescription, FieldGroup } from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,7 +22,6 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export interface SubdomainEditValues {
|
||||
name: string
|
||||
@@ -121,20 +116,19 @@ export function SubdomainEditSheet({
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subdomain_name">Имя</FieldLabel>
|
||||
<FormFieldSimple label="Имя" htmlFor="subdomain_name">
|
||||
<Input
|
||||
id="subdomain_name"
|
||||
placeholder="www"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="font-mono"
|
||||
aria-invalid={!name.trim() && name.length > 0}
|
||||
/>
|
||||
</Field>
|
||||
</FormFieldSimple>
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subdomain_service">Сервис</FieldLabel>
|
||||
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
|
||||
<Select
|
||||
items={serviceItems}
|
||||
value={serviceId}
|
||||
@@ -151,11 +145,11 @@ export function SubdomainEditSheet({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subdomain_cert_monitoring">
|
||||
Мониторинг SSL
|
||||
</FieldLabel>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Мониторинг SSL"
|
||||
htmlFor="subdomain_cert_monitoring"
|
||||
>
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={certMonitoring}
|
||||
@@ -180,19 +174,20 @@ export function SubdomainEditSheet({
|
||||
?.description
|
||||
}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FormFieldSimple>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving
|
||||
? 'Сохранение…'
|
||||
: mode === 'create'
|
||||
? 'Создать'
|
||||
: 'Сохранить'}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!name.trim()}
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { MoreHorizontalIcon } from 'lucide-react'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TableCard } from '@/components/table-card'
|
||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
||||
import { formatDate } from '@/lib/format'
|
||||
@@ -46,7 +47,7 @@ export function SubdomainsTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<TableCard>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -61,7 +62,7 @@ export function SubdomainsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
<Badge variant={row.subdomain.enabled ? 'default' : 'outline'}>
|
||||
@@ -115,7 +116,7 @@ export function SubdomainsTable({
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCard>
|
||||
|
||||
<ConfirmDialog
|
||||
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,
|
||||
TableRow,
|
||||
} 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 = {
|
||||
count: {
|
||||
@@ -99,18 +100,22 @@ function CertificatesPage() {
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
|
||||
actions={
|
||||
<Button
|
||||
<LoadingButton
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending}
|
||||
isLoading={checkMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
>
|
||||
{checkMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{checkMutation.isPending ? 'Проверка…' : 'Запустить проверку'}
|
||||
</Button>
|
||||
Запустить проверку
|
||||
</LoadingButton>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<CardHeader>
|
||||
<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}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
|
||||
@@ -10,16 +10,17 @@ import { api } from '@/lib/api-client'
|
||||
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
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 { DnsRecordsTable } from '@/components/dns-records-table'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,16 +28,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||
|
||||
@@ -62,8 +54,24 @@ function DnsPage() {
|
||||
const { host } = Route.useSearch()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domain } = useQuery(domainDetailQueryOptions(id))
|
||||
const { data: records } = useQuery(dnsListQueryOptions(id))
|
||||
const {
|
||||
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>({
|
||||
resolver: zodResolver(createDnsRecordSchema),
|
||||
@@ -120,9 +128,9 @@ function DnsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = form.handleSubmit((values) => {
|
||||
const handleCreate = (values: CreateDnsRecordInput) => {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
}
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const list = records ?? []
|
||||
@@ -136,7 +144,7 @@ function DnsPage() {
|
||||
)
|
||||
}, [records, host, searchQuery])
|
||||
|
||||
const hasRecords = useMemo(() => filteredRecords.length > 0, [filteredRecords])
|
||||
const hasRecords = filteredRecords.length > 0
|
||||
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
||||
|
||||
return (
|
||||
@@ -153,130 +161,166 @@ function DnsPage() {
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
<Button
|
||||
<LoadingButton
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
|
||||
</Button>
|
||||
Синхронизировать с Cloudflare
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTableCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||
}
|
||||
emptyIcon={NetworkIcon}
|
||||
isEmpty={!hasRecords}
|
||||
toolbar={
|
||||
!host ? (
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по имени или значению…"
|
||||
/>
|
||||
) : undefined
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchDomain()
|
||||
void refetchRecords()
|
||||
}}
|
||||
skeleton={<TableSkeleton rows={6} columns={6} />}
|
||||
>
|
||||
<DataTableCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||
}
|
||||
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 && (
|
||||
<div className="p-4">
|
||||
<DnsRecordsTable
|
||||
records={filteredRecords}
|
||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
<FieldGroup>
|
||||
<FormFieldSimple label="Тип" htmlFor="record_type">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="record_type"
|
||||
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>
|
||||
)}
|
||||
</DataTableCard>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новая DNS-запись</SheetTitle>
|
||||
<SheetDescription>
|
||||
Добавить запись в зону {domain?.zone_name ?? ''}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="record_type">Тип</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="record_type"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
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>
|
||||
)}
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Имя"
|
||||
htmlFor="name"
|
||||
error={form.formState.errors.name}
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="@"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Значение"
|
||||
htmlFor="content"
|
||||
error={form.formState.errors.content}
|
||||
>
|
||||
<Input
|
||||
id="content"
|
||||
placeholder="192.168.1.1"
|
||||
{...form.register('content')}
|
||||
aria-invalid={!!form.formState.errors.content}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="TTL"
|
||||
htmlFor="ttl"
|
||||
error={form.formState.errors.ttl}
|
||||
>
|
||||
<Input
|
||||
id="ttl"
|
||||
type="number"
|
||||
{...form.register('ttl', { valueAsNumber: true })}
|
||||
aria-invalid={!!form.formState.errors.ttl}
|
||||
/>
|
||||
</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>
|
||||
<Input id="name" placeholder="@" {...form.register('name')} />
|
||||
</Field>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
@@ -54,12 +54,12 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
function DomainPageSkeleton() {
|
||||
return (
|
||||
<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">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<TableSkeleton withCard rows={3} columns={2} />
|
||||
<TableSkeleton withCard rows={3} columns={2} />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<TableSkeleton rows={5} columns={5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -166,16 +166,14 @@ function DomainOverviewPage() {
|
||||
description="Обзор домена и поддоменов"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать'}
|
||||
</Button>
|
||||
Синхронизировать
|
||||
</LoadingButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { GlobeIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
|
||||
@@ -14,15 +15,16 @@ import {
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
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 { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,15 +32,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -56,7 +49,13 @@ function DomainsPage() {
|
||||
const listGroupId =
|
||||
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
|
||||
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 groupItems = useMemo(
|
||||
@@ -102,12 +101,12 @@ function DomainsPage() {
|
||||
deleteMutation.mutate(domain.id)
|
||||
}
|
||||
|
||||
const handleCreate = form.handleSubmit((values) => {
|
||||
const handleCreate = (values: CreateDomainInput) => {
|
||||
createMutation.mutate({
|
||||
zone_name: values.zone_name.trim(),
|
||||
group_id: importGroupId ? Number(importGroupId) : undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleImportGroupChange = (value: string | null) => {
|
||||
const next = value === 'none' || !value ? '' : value
|
||||
@@ -135,6 +134,10 @@ function DomainsPage() {
|
||||
[filteredDomains],
|
||||
)
|
||||
|
||||
const hasActiveFilter = filterGroupId !== ''
|
||||
const isZeroResults = hasActiveFilter && tableData.length === 0 && (domains?.length ?? 0) > 0
|
||||
const isTrulyEmpty = tableData.length === 0 && !hasActiveFilter
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -150,68 +153,100 @@ function DomainsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTableCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<TableSkeleton />}
|
||||
>
|
||||
<DomainsDataTable
|
||||
data={tableData}
|
||||
groupFilterItems={groupItems}
|
||||
groupFilterValue={filterGroupId || 'all'}
|
||||
onGroupFilterChange={handleFilterGroupChange}
|
||||
onDelete={handleDeleteDomain}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</DataTableCard>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Импорт домена</SheetTitle>
|
||||
<SheetDescription>
|
||||
Добавить зону из аккаунта Cloudflare в менеджер
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="zone_name">Имя зоны</FieldLabel>
|
||||
<Input
|
||||
id="zone_name"
|
||||
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 ? 'Импорт…' : 'Импортировать'}
|
||||
<DataTableCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
isEmpty={isTrulyEmpty || isZeroResults}
|
||||
emptyTitle={
|
||||
isZeroResults ? 'Ничего не найдено' : 'Домены не импортированы'
|
||||
}
|
||||
emptyDescription={
|
||||
isZeroResults
|
||||
? 'По выбранному фильтру группы нет подходящих зон'
|
||||
: 'Импортируйте зону из аккаунта Cloudflare'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
emptyAction={
|
||||
isZeroResults ? (
|
||||
<Button variant="outline" onClick={() => setFilterGroupId('')}>
|
||||
Сбросить фильтр
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : (
|
||||
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -35,8 +37,24 @@ function GroupDetailPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const { groupId } = Route.useParams()
|
||||
const id = Number(groupId)
|
||||
const { data: group } = useQuery(groupDetailQueryOptions(id))
|
||||
const { data: domains } = useQuery(domainsListQueryOptions(id))
|
||||
const {
|
||||
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 list = domains ?? []
|
||||
@@ -46,6 +64,7 @@ function GroupDetailPage() {
|
||||
}, [domains, searchQuery])
|
||||
|
||||
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
||||
const hasRecords = filteredDomains.length > 0
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -59,82 +78,113 @@ function GroupDetailPage() {
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
На группам
|
||||
К группам
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataTableCard
|
||||
title="Домены группы"
|
||||
description="Список доменов, назначенных этой группе"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
isEmpty={!filteredDomains.length}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по зоне…"
|
||||
/>
|
||||
}
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchGroup()
|
||||
void refetchDomains()
|
||||
}}
|
||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Сервисы</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDomains.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<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"
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ domainId: domain.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.service_count}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
<DataTableCard
|
||||
title="Домены группы"
|
||||
description="Список доменов, назначенных этой группе"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
isEmpty={!hasRecords}
|
||||
emptyAction={
|
||||
isFilteredEmpty ? (
|
||||
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||
Сбросить фильтр
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
Открыть канбан групп
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по зоне…"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasRecords && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-10">
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Сервисы</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDomains.map((domain) => (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/section-cards-skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -54,6 +55,7 @@ function DashboardPage() {
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
skeleton={<SectionCardsSkeleton />}
|
||||
onRetry={() => {
|
||||
void refetchDomains()
|
||||
void refetchSummary()
|
||||
|
||||
+3
-1
@@ -6,9 +6,11 @@
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"lint": "turbo run lint"
|
||||
"lint": "turbo run lint",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
|
||||
Generated
+10
@@ -9,6 +9,9 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
turbo:
|
||||
specifier: ^2.5.4
|
||||
version: 2.9.18
|
||||
@@ -2908,6 +2911,11 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -6313,6 +6321,8 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
Reference in New Issue
Block a user