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

This commit is contained in:
Denozordec
2026-06-25 15:47:29 +07:00
parent 3632bd25e4
commit 9569c22e4d
31 changed files with 1125 additions and 547 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 44284,
"pid": 42864,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
"startedAt": 1782094135868
"startedAt": 1782359415038
}
+10 -3
View File
@@ -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.
+26 -3
View File
@@ -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
+7 -4
View File
@@ -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
+17
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
pnpm --filter web lint
+61
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+60
View File
@@ -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>
)
}
+23 -19
View File
@@ -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>
)
}
+10 -8
View File
@@ -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>
+4 -3
View File
@@ -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}
+42
View File
@@ -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>
)
}
+29 -9
View File
@@ -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}
+112 -77
View File
@@ -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>
)
}
+122 -72
View File
@@ -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>
)
}
+2
View File
@@ -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
View File
@@ -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]",
+10
View File
@@ -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]: {}