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