Enhance frontend documentation and UI components; update frontend-shadcn.mdc to include UI patterns reference; improve navigation structure in AppSidebar by categorizing items; refactor DataTableCard to support empty states with EmptyState component; implement ConfirmDialog for delete actions in DnsRecordsTable and GroupsPage; add search functionality in CertificatesPage and GroupsPage for better user experience; update ServiceEditSheet to utilize tabs for organization.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -8,11 +8,11 @@ alwaysApply: false
|
|||||||
|
|
||||||
**Источник истины — MCP shadcn + официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить MCP и CLI.
|
**Источник истины — MCP shadcn + официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить MCP и CLI.
|
||||||
|
|
||||||
Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc).
|
Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc). UI patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
|
||||||
|
|
||||||
| Документ | URL |
|
| Документ | URL |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
| **Components** | https://ui.shadcn.com/docs/components |
|
| **Components (primary catalog)** | https://ui.shadcn.com/docs/components |
|
||||||
| **Blocks** | https://ui.shadcn.com/blocks |
|
| **Blocks** | https://ui.shadcn.com/blocks |
|
||||||
| **Installation** | https://ui.shadcn.com/docs/installation |
|
| **Installation** | https://ui.shadcn.com/docs/installation |
|
||||||
| **Monorepo** | https://ui.shadcn.com/docs/monorepo |
|
| **Monorepo** | https://ui.shadcn.com/docs/monorepo |
|
||||||
@@ -30,6 +30,28 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl
|
|||||||
|
|
||||||
**Новая страница** → сначала [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>`.
|
||||||
|
|
||||||
|
## Docs workflow
|
||||||
|
|
||||||
|
1. MCP `plugin-shadcn-shadcn` — search → examples → add command
|
||||||
|
2. `pnpm dlx shadcn@latest docs <component>` — fetch URLs, сверить API
|
||||||
|
3. Skill `.agents/skills/shadcn/SKILL.md` — critical rules
|
||||||
|
4. Context7 — только TanStack / Recharts
|
||||||
|
|
||||||
|
## Shared components (обязательно)
|
||||||
|
|
||||||
|
| Component | Файл |
|
||||||
|
|-----------|------|
|
||||||
|
| `PageShell` | `page-shell.tsx` |
|
||||||
|
| `PageHeader` | `page-header.tsx` |
|
||||||
|
| `EmptyState` | `empty-state.tsx` |
|
||||||
|
| `QueryState` | `query-state.tsx` |
|
||||||
|
| `ConfirmDialog` | `confirm-dialog.tsx` |
|
||||||
|
| `DataTableCard` | `data-table-card.tsx` |
|
||||||
|
| `SectionCards` | `section-cards.tsx` |
|
||||||
|
| `StatusBadge` | `status-badge.tsx` |
|
||||||
|
|
||||||
|
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
|
||||||
|
|
||||||
## Шаг 1 — CLI (обязательно)
|
## Шаг 1 — CLI (обязательно)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -50,7 +72,7 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y # тема — ТОЛЬКО
|
|||||||
|
|
||||||
1. Установленный `@cfdm/ui/components/*`
|
1. Установленный `@cfdm/ui/components/*`
|
||||||
2. Block из registry (адаптация под TanStack Router)
|
2. Block из registry (адаптация под TanStack Router)
|
||||||
3. Shared проекта: `PageHeader`, `StatusBadge`, `ResourceList`, `DataTableCard`
|
3. Shared проекта: `PageShell`, `PageHeader`, `EmptyState`, `QueryState`, `ConfirmDialog`, `DataTableCard`, `SectionCards`, `StatusBadge`
|
||||||
4. Domain-обёртка — последний уровень кастомизации
|
4. Domain-обёртка — последний уровень кастомизации
|
||||||
|
|
||||||
### Запрещено
|
### Запрещено
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
description: Единые UI-паттерны web — shared components, docs workflow, матрица стандартизации
|
||||||
|
globs: apps/web/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend UI Patterns
|
||||||
|
|
||||||
|
См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks).
|
||||||
|
|
||||||
|
## Docs workflow (обязательно)
|
||||||
|
|
||||||
|
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
|
||||||
|
5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives)
|
||||||
|
6. MCP `get_audit_checklist` — перед merge
|
||||||
|
|
||||||
|
## Иерархия компонентов
|
||||||
|
|
||||||
|
```
|
||||||
|
@cfdm/ui/components/* ← только CLI (packages/ui)
|
||||||
|
apps/web/src/components/ ← shared + domain + layout
|
||||||
|
page-shell.tsx ← обёртка страницы
|
||||||
|
page-header.tsx
|
||||||
|
empty-state.tsx
|
||||||
|
query-state.tsx
|
||||||
|
confirm-dialog.tsx
|
||||||
|
data-table-card.tsx
|
||||||
|
section-cards.tsx
|
||||||
|
status-badge.tsx
|
||||||
|
layout/ ← app-shell, site-header
|
||||||
|
domain-* ← бизнес-компоненты
|
||||||
|
```
|
||||||
|
|
||||||
|
## Матрица стандартизации
|
||||||
|
|
||||||
|
| Элемент | Shared | Primitive |
|
||||||
|
|---------|--------|-----------|
|
||||||
|
| Page wrapper | `PageShell` | — |
|
||||||
|
| Page title | `PageHeader` | — |
|
||||||
|
| Stat metrics | `SectionCards` | `Card` |
|
||||||
|
| Data list | `DataTableCard` | `Table`, `InputGroup` |
|
||||||
|
| Empty | `EmptyState` | `Empty` |
|
||||||
|
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
||||||
|
| Status | `StatusBadge` | `Badge` |
|
||||||
|
| Create/Edit | `*-sheet.tsx` | `Sheet`, `Field` |
|
||||||
|
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
||||||
|
| List row | — | `Item variant="outline" size="sm"` |
|
||||||
|
| Nav | `AppSidebar` | `Sidebar` |
|
||||||
|
| Breadcrumbs | `SiteHeader` | `Breadcrumb` |
|
||||||
|
| Dates | `lib/format.ts` | — |
|
||||||
|
|
||||||
|
## Overlay selection
|
||||||
|
|
||||||
|
| Сценарий | Компонент |
|
||||||
|
|----------|-----------|
|
||||||
|
| Create/edit форма | `Sheet` |
|
||||||
|
| Destructive confirm | `AlertDialog` via `ConfirmDialog` |
|
||||||
|
| Modal preview | `Dialog` |
|
||||||
|
|
||||||
|
## Block registry
|
||||||
|
|
||||||
|
| Зона | Block |
|
||||||
|
|------|-------|
|
||||||
|
| Shell | sidebar-07 |
|
||||||
|
| Dashboard | dashboard-01 |
|
||||||
|
| Login | login-03 |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
```
|
||||||
|
PageShell: gap-4 md:gap-6, px-4 lg:px-6 py-4 md:py-6
|
||||||
|
Card grid: gap-4
|
||||||
|
FieldGroup: gap-4
|
||||||
|
Item list: gap-2
|
||||||
|
Toolbar: gap-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner.
|
||||||
|
|
||||||
|
## Button hierarchy (max 1 primary per screen)
|
||||||
|
|
||||||
|
1. `default` — главный CTA
|
||||||
|
2. `outline` — вторичные действия
|
||||||
|
3. `ghost` / `link` — навигация, cancel
|
||||||
|
4. `destructive` — только с `ConfirmDialog`
|
||||||
@@ -17,6 +17,9 @@ CLI и docs — **после** MCP, по команде из `get_add_command_fo
|
|||||||
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. Адаптировать пример под 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
|
||||||
|
|
||||||
## Когда вызывать MCP
|
## Когда вызывать MCP
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ UI строится **исключительно** по [shadcn/ui](https://ui.s
|
|||||||
|
|
||||||
## Разработка frontend
|
## Разработка frontend
|
||||||
|
|
||||||
**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`).
|
**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`).
|
||||||
|
|
||||||
Кратко: docs → CLI из `apps/web` → Block → композиция → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
|
Кратко: docs → CLI из `apps/web` → Block → композиция → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,54 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from '@cfdm/ui/components/sidebar'
|
} from '@cfdm/ui/components/sidebar'
|
||||||
|
|
||||||
const navItems = [
|
const infrastructureNav = [
|
||||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon },
|
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon },
|
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||||
{ to: '/groups', label: 'Группы', icon: FolderTreeIcon },
|
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon },
|
|
||||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const operationsNav = [
|
||||||
|
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||||
|
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function NavSection({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
items: readonly {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: typeof GlobeIcon
|
||||||
|
exact: boolean
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.to}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip={item.label}
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
activeOptions={{ exact: item.exact }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
@@ -48,22 +88,8 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<NavSection label="Инфраструктура" items={infrastructureNav} />
|
||||||
<SidebarGroupLabel>Навигация</SidebarGroupLabel>
|
<NavSection label="Операции" items={operationsNav} />
|
||||||
<SidebarMenu>
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<SidebarMenuItem key={item.to}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
tooltip={item.label}
|
|
||||||
render={<Link to={item.to} activeOptions={{ exact: item.to === '/' }} />}
|
|
||||||
>
|
|
||||||
<item.icon />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactElement } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@cfdm/ui/components/alert-dialog'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
trigger: ReactElement
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = 'Удалить',
|
||||||
|
cancelLabel = 'Отмена',
|
||||||
|
onConfirm,
|
||||||
|
disabled,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger disabled={disabled} render={trigger} />
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -10,8 +12,11 @@ interface DataTableCardProps {
|
|||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
toolbar?: React.ReactNode
|
||||||
emptyTitle?: string
|
emptyTitle?: string
|
||||||
emptyDescription?: string
|
emptyDescription?: string
|
||||||
|
emptyIcon?: LucideIcon
|
||||||
|
emptyAction?: React.ReactNode
|
||||||
isEmpty?: boolean
|
isEmpty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +24,11 @@ export function DataTableCard({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
|
toolbar,
|
||||||
emptyTitle,
|
emptyTitle,
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
|
emptyIcon: EmptyIcon = InboxIcon,
|
||||||
|
emptyAction,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
}: DataTableCardProps) {
|
}: DataTableCardProps) {
|
||||||
return (
|
return (
|
||||||
@@ -29,12 +37,17 @@ export function DataTableCard({
|
|||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
{description && <CardDescription>{description}</CardDescription>}
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{toolbar && !isEmpty && (
|
||||||
|
<div className="flex items-center gap-2 px-6 pb-4">{toolbar}</div>
|
||||||
|
)}
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{isEmpty && emptyTitle ? (
|
{isEmpty && emptyTitle ? (
|
||||||
<div className="flex flex-col gap-1 p-6 text-sm text-muted-foreground">
|
<EmptyState
|
||||||
<p className="font-medium text-foreground">{emptyTitle}</p>
|
icon={EmptyIcon}
|
||||||
{emptyDescription && <p>{emptyDescription}</p>}
|
title={emptyTitle}
|
||||||
</div>
|
description={emptyDescription}
|
||||||
|
action={emptyAction}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
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'
|
||||||
@@ -36,14 +37,16 @@ function DnsRecordCells({
|
|||||||
<StatusBadge status={record.sync_status} />
|
<StatusBadge status={record.sync_status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<ConfirmDialog
|
||||||
variant="destructive"
|
trigger={
|
||||||
size="sm"
|
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
||||||
onClick={() => onDelete(record.id)}
|
Удалить
|
||||||
disabled={isDeleting}
|
</Button>
|
||||||
>
|
}
|
||||||
Удалить
|
title="Удалить DNS-запись?"
|
||||||
</Button>
|
description={`Запись ${record.name} (${record.record_type}) будет удалена из зоны.`}
|
||||||
|
onConfirm={() => onDelete(record.id)}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
render={<Link to="/services" />}
|
render={<Link to="/services" search={{ domainId: undefined }} />}
|
||||||
>
|
>
|
||||||
Сервисы
|
Сервисы
|
||||||
</Button>
|
</Button>
|
||||||
@@ -118,7 +118,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
{entries.length > 0 && (
|
{entries.length > 0 && (
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button variant="link" className="h-auto p-0" render={<Link to="/services" />}>
|
<Button variant="link" className="h-auto p-0" render={<Link to="/services" search={{ domainId: undefined }} />}>
|
||||||
Управление привязками
|
Управление привязками
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -148,7 +148,12 @@ export function DomainsDataTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0 tabular-nums"
|
className="h-auto p-0 tabular-nums"
|
||||||
render={<Link to="/services" />}
|
render={
|
||||||
|
<Link
|
||||||
|
to="/services"
|
||||||
|
search={{ domainId: row.original.id }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{row.original.service_count}
|
{row.original.service_count}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -199,6 +204,7 @@ export function DomainsDataTable({
|
|||||||
<Link
|
<Link
|
||||||
to="/domains/$domainId/dns"
|
to="/domains/$domainId/dns"
|
||||||
params={{ domainId: String(row.original.id) }}
|
params={{ domainId: String(row.original.id) }}
|
||||||
|
search={{ host: undefined }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '@cfdm/ui/components/empty'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<Empty className={className ?? 'border-0'}>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<Icon />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>{title}</EmptyTitle>
|
||||||
|
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||||
|
</EmptyHeader>
|
||||||
|
{action && <EmptyContent>{action}</EmptyContent>}
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,13 +5,18 @@ import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
|||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': 'calc(var(--spacing) * 72)',
|
||||||
|
'--header-height': 'calc(var(--spacing) * 12)',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
<div className="flex flex-1 flex-col">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -10,31 +11,39 @@ import {
|
|||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import { Separator } from '@cfdm/ui/components/separator'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
|
|
||||||
|
export interface RouteBreadcrumbLoaderData {
|
||||||
|
breadcrumb?: string
|
||||||
|
}
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
const routeTitles: Record<string, string> = {
|
||||||
'/': 'Панель управления',
|
'/': 'Панель управления',
|
||||||
'/domains': 'Домены',
|
'/domains': 'Домены',
|
||||||
'/groups': 'Группы',
|
'/groups': 'Группы доменов',
|
||||||
'/services': 'Сервисы',
|
'/services': 'Сервисы',
|
||||||
'/certificates': 'Сертификаты',
|
'/certificates': 'Сертификаты',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBreadcrumbs(pathname: string) {
|
function getBreadcrumbs(
|
||||||
|
pathname: string,
|
||||||
|
dynamicLabels: Record<string, string>,
|
||||||
|
) {
|
||||||
if (pathname === '/') {
|
if (pathname === '/') {
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Группы', href: '/groups' },
|
{ label: 'Группы доменов', href: '/groups' },
|
||||||
{ label: 'Группа', href: pathname },
|
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
||||||
const domainId = pathname.split('/')[2]
|
const domainId = pathname.split('/')[2]
|
||||||
|
const domainPath = `/domains/${domainId}`
|
||||||
return [
|
return [
|
||||||
{ label: 'Домены', href: '/domains' },
|
{ label: 'Домены', href: '/domains' },
|
||||||
{ label: 'Домен', href: `/domains/${domainId}` },
|
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||||
{ label: 'DNS', href: pathname },
|
{ label: 'DNS', href: pathname },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -42,7 +51,7 @@ function getBreadcrumbs(pathname: string) {
|
|||||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
if (pathname.match(/^\/domains\/\d+$/)) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Домены', href: '/domains' },
|
{ label: 'Домены', href: '/domains' },
|
||||||
{ label: 'Обзор домена', href: pathname },
|
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +63,24 @@ function getBreadcrumbs(pathname: string) {
|
|||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useDynamicBreadcrumbLabels() {
|
||||||
|
const matches = useMatches()
|
||||||
|
return useMemo(() => {
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
for (const match of matches) {
|
||||||
|
const data = match.loaderData as RouteBreadcrumbLoaderData | undefined
|
||||||
|
if (data?.breadcrumb && match.pathname) {
|
||||||
|
labels[match.pathname] = data.breadcrumb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}, [matches])
|
||||||
|
}
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const crumbs = getBreadcrumbs(pathname)
|
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||||
|
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||||
@@ -72,8 +96,12 @@ export function SiteHeader() {
|
|||||||
const isLast = index === crumbs.length - 1
|
const isLast = index === crumbs.length - 1
|
||||||
return (
|
return (
|
||||||
<span key={crumb.href} className="contents">
|
<span key={crumb.href} className="contents">
|
||||||
{index > 0 && <BreadcrumbSeparator className="hidden md:block" />}
|
{index > 0 && (
|
||||||
<BreadcrumbItem className={index === 0 ? 'hidden md:block' : undefined}>
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
|
)}
|
||||||
|
<BreadcrumbItem
|
||||||
|
className={index === 0 ? 'hidden md:block' : undefined}
|
||||||
|
>
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
interface PageHeaderBack {
|
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
params?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
back?: PageHeaderBack
|
|
||||||
actions?: React.ReactNode
|
actions?: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
@@ -19,35 +10,31 @@ interface PageHeaderProps {
|
|||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
back,
|
|
||||||
actions,
|
actions,
|
||||||
className,
|
className,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-between gap-4', className)}>
|
<div
|
||||||
<div>
|
className={cn(
|
||||||
{back && (
|
'flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between',
|
||||||
<Button
|
className,
|
||||||
variant="link"
|
)}
|
||||||
className="h-auto p-0 text-muted-foreground"
|
>
|
||||||
render={<Link to={back.to} params={back.params} />}
|
<div className="flex flex-col gap-1">
|
||||||
>
|
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||||
{back.label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<h1
|
|
||||||
className={cn(
|
|
||||||
'text-2xl font-bold tracking-tight',
|
|
||||||
back && 'mt-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-muted-foreground">{description}</p>
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{actions}
|
{actions && (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
interface PageShellProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageShell({ children, className }: PageShellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'@container/main flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:py-6 lg:px-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { CircleAlertIcon } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
|
interface QueryStateProps {
|
||||||
|
isLoading?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
onRetry?: () => void
|
||||||
|
skeleton?: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Skeleton className="h-8 w-1/3" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryState({
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
skeleton,
|
||||||
|
children,
|
||||||
|
}: QueryStateProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return skeleton ?? <DefaultSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlertIcon />
|
||||||
|
<AlertTitle>Не удалось загрузить данные</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{error?.message ?? 'Произошла ошибка при загрузке.'}
|
||||||
|
</AlertDescription>
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
FolderTreeIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@cfdm/ui/components/card'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemGroup,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
|
|
||||||
|
interface SectionCardsProps {
|
||||||
|
domainCount: number
|
||||||
|
certCount: number
|
||||||
|
groupCount: number
|
||||||
|
serviceCount: number
|
||||||
|
certSummary?: [string, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionCards({
|
||||||
|
domainCount,
|
||||||
|
certCount,
|
||||||
|
groupCount,
|
||||||
|
serviceCount,
|
||||||
|
certSummary,
|
||||||
|
}: SectionCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Домены</CardDescription>
|
||||||
|
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||||
|
{domainCount}
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
render={<Link to="/domains" />}
|
||||||
|
>
|
||||||
|
Управление доменами
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Группы доменов</CardDescription>
|
||||||
|
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||||
|
{groupCount}
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<FolderTreeIcon className="size-4 text-muted-foreground" />
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
render={<Link to="/groups" />}
|
||||||
|
>
|
||||||
|
Канбан групп
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Сервисы</CardDescription>
|
||||||
|
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||||
|
{serviceCount}
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<ServerIcon className="size-4 text-muted-foreground" />
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
render={<Link to="/services" search={{ domainId: undefined }} />}
|
||||||
|
>
|
||||||
|
Управление сервисами
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Сертификаты</CardDescription>
|
||||||
|
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||||
|
{certCount}
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<ShieldCheckIcon className="size-4 text-muted-foreground" />
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
render={<Link to="/certificates" />}
|
||||||
|
>
|
||||||
|
Мониторинг сертификатов
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{certSummary && certSummary.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Статусы сертификатов</CardTitle>
|
||||||
|
<CardDescription>Сводка по последней проверке</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ItemGroup className="gap-2">
|
||||||
|
{certSummary.map(([status, count]) => (
|
||||||
|
<Item key={status} variant="outline" size="sm">
|
||||||
|
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
<ItemTitle className="font-medium tabular-nums">{count}</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||||
import type {
|
import type {
|
||||||
@@ -32,6 +33,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@cfdm/ui/components/sheet'
|
} from '@cfdm/ui/components/sheet'
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@cfdm/ui/components/tabs'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -199,123 +206,138 @@ export function ServiceEditSheet({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex flex-col gap-4 px-4">
|
<div className="flex flex-col gap-4 px-4">
|
||||||
<FieldGroup className="flex flex-col gap-4">
|
<Tabs defaultValue="general">
|
||||||
<Field>
|
<TabsList className="w-full">
|
||||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
<TabsTrigger value="general">Основное</TabsTrigger>
|
||||||
<Input
|
<TabsTrigger value="bindings">Привязки</TabsTrigger>
|
||||||
id="edit-service-name"
|
</TabsList>
|
||||||
value={name}
|
<TabsContent value="general" className="flex flex-col gap-4 pt-4">
|
||||||
placeholder={isCreate ? 'VPN Panel' : undefined}
|
<FieldGroup className="flex flex-col gap-4">
|
||||||
onChange={(e) => setName(e.target.value)}
|
<Field>
|
||||||
/>
|
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||||
</Field>
|
<Input
|
||||||
<Field>
|
id="edit-service-name"
|
||||||
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
|
value={name}
|
||||||
<Input
|
placeholder={isCreate ? 'VPN Panel' : undefined}
|
||||||
id="edit-service-slug"
|
onChange={(e) => setName(e.target.value)}
|
||||||
value={slug}
|
/>
|
||||||
placeholder={isCreate ? 'vpn-panel' : undefined}
|
</Field>
|
||||||
onChange={(e) => setSlug(e.target.value)}
|
<Field>
|
||||||
/>
|
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
|
||||||
</Field>
|
<Input
|
||||||
<Field>
|
id="edit-service-slug"
|
||||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
value={slug}
|
||||||
<Select
|
placeholder={isCreate ? 'vpn-panel' : undefined}
|
||||||
items={groupItems}
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
value={serviceGroupId}
|
/>
|
||||||
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
</Field>
|
||||||
>
|
<Field>
|
||||||
<SelectTrigger id="edit-service-group" className="w-full">
|
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||||
<SelectValue placeholder="Без группы" />
|
<Select
|
||||||
</SelectTrigger>
|
items={groupItems}
|
||||||
<SelectContent>
|
value={serviceGroupId}
|
||||||
{groupItems.map((item) => (
|
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
||||||
<SelectItem key={item.value} value={item.value}>
|
>
|
||||||
{item.label}
|
<SelectTrigger id="edit-service-group" className="w-full">
|
||||||
</SelectItem>
|
<SelectValue placeholder="Без группы" />
|
||||||
))}
|
</SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent>
|
||||||
</Select>
|
{groupItems.map((item) => (
|
||||||
</Field>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
<Field>
|
{item.label}
|
||||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
</SelectItem>
|
||||||
<TaggedInput
|
))}
|
||||||
id="edit-service-ips"
|
</SelectContent>
|
||||||
value={ips}
|
</Select>
|
||||||
onChange={setIps}
|
</Field>
|
||||||
placeholder="192.168.1.1"
|
<Field>
|
||||||
validate={isValidIpv4}
|
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||||
/>
|
<TaggedInput
|
||||||
</Field>
|
id="edit-service-ips"
|
||||||
</FieldGroup>
|
value={ips}
|
||||||
|
onChange={setIps}
|
||||||
<div className="flex flex-col gap-2">
|
placeholder="192.168.1.1"
|
||||||
<div className="flex items-center justify-between gap-2">
|
validate={isValidIpv4}
|
||||||
<FieldLabel>Привязки доменов</FieldLabel>
|
/>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
</Field>
|
||||||
<PlusIcon data-icon="inline-start" />
|
</FieldGroup>
|
||||||
Добавить
|
</TabsContent>
|
||||||
</Button>
|
<TabsContent value="bindings" className="flex flex-col gap-4 pt-4">
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
{bindings.length === 0 ? (
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<FieldLabel>Привязки доменов</FieldLabel>
|
||||||
Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
|
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||||
автоматически.
|
<PlusIcon data-icon="inline-start" />
|
||||||
</p>
|
Добавить
|
||||||
) : (
|
</Button>
|
||||||
<ItemGroup>
|
</div>
|
||||||
{bindings.map((binding, index) => (
|
{bindings.length === 0 ? (
|
||||||
<Item key={`binding-${index}`} variant="outline">
|
<p className="text-sm text-muted-foreground">
|
||||||
<ItemContent className="flex flex-col gap-3">
|
Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
|
||||||
<Field>
|
автоматически.
|
||||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
</p>
|
||||||
<TaggedInput
|
) : (
|
||||||
id={`binding-fqdn-${index}`}
|
<ItemGroup>
|
||||||
value={binding.fqdn ? [binding.fqdn] : []}
|
{bindings.map((binding, index) => (
|
||||||
onChange={(tags) => handleFqdnChange(index, tags)}
|
<Item key={`binding-${index}`} variant="outline">
|
||||||
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
|
<ItemContent className="flex flex-col gap-3">
|
||||||
maxItems={1}
|
<Field>
|
||||||
/>
|
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
||||||
</Field>
|
<TaggedInput
|
||||||
<Field>
|
id={`binding-fqdn-${index}`}
|
||||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
value={binding.fqdn ? [binding.fqdn] : []}
|
||||||
<ServiceBindingIpInput
|
onChange={(tags) => handleFqdnChange(index, tags)}
|
||||||
id={`binding-ip-${index}`}
|
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
|
||||||
value={binding.target_ips}
|
maxItems={1}
|
||||||
pool={ips}
|
/>
|
||||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
</Field>
|
||||||
/>
|
<Field>
|
||||||
</Field>
|
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
||||||
</ItemContent>
|
<ServiceBindingIpInput
|
||||||
<ItemActions>
|
id={`binding-ip-${index}`}
|
||||||
<Button
|
value={binding.target_ips}
|
||||||
type="button"
|
pool={ips}
|
||||||
variant="ghost"
|
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
||||||
size="icon-sm"
|
/>
|
||||||
aria-label="Удалить привязку"
|
</Field>
|
||||||
onClick={() => handleRemoveBinding(index)}
|
</ItemContent>
|
||||||
>
|
<ItemActions>
|
||||||
<Trash2Icon />
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
</ItemActions>
|
variant="ghost"
|
||||||
</Item>
|
size="icon-sm"
|
||||||
))}
|
aria-label="Удалить привязку"
|
||||||
</ItemGroup>
|
onClick={() => handleRemoveBinding(index)}
|
||||||
)}
|
>
|
||||||
</div>
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="flex flex-row flex-wrap gap-2">
|
<SheetFooter className="flex flex-row flex-wrap gap-2">
|
||||||
{!isCreate ? (
|
{!isCreate ? (
|
||||||
<Button
|
<ConfirmDialog
|
||||||
type="button"
|
trigger={
|
||||||
variant="destructive"
|
<Button
|
||||||
disabled={!service || isDeleting || isSaving}
|
type="button"
|
||||||
onClick={handleDelete}
|
variant="destructive"
|
||||||
>
|
disabled={!service || isDeleting || isSaving}
|
||||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
>
|
||||||
<Trash2Icon data-icon="inline-start" />
|
{isDeleting && <Spinner data-icon="inline-start" />}
|
||||||
Удалить
|
<Trash2Icon data-icon="inline-start" />
|
||||||
</Button>
|
Удалить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Удалить сервис?"
|
||||||
|
description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо."
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ServiceRow({
|
|||||||
<ItemTitle>{service.name}</ItemTitle>
|
<ItemTitle>{service.name}</ItemTitle>
|
||||||
<ItemDescription>{fqdn}</ItemDescription>
|
<ItemDescription>{fqdn}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions className="flex flex-wrap items-center gap-2">
|
<ItemActions className="gap-1">
|
||||||
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
|
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
|
||||||
{isToggling ? (
|
{isToggling ? (
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
@@ -52,10 +52,20 @@ export function ServiceRow({
|
|||||||
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
|
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Редактировать ${service.name}`}
|
||||||
|
onClick={() => onEdit(service)}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="hidden md:inline-flex"
|
||||||
onClick={() => onEdit(service)}
|
onClick={() => onEdit(service)}
|
||||||
>
|
>
|
||||||
<PencilIcon data-icon="inline-start" />
|
<PencilIcon data-icon="inline-start" />
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ interface StatusBadgeProps {
|
|||||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||||
const variant = statusVariants[status] ?? 'outline'
|
const variant = statusVariants[status] ?? 'outline'
|
||||||
return (
|
return (
|
||||||
<Badge variant={variant} className={cn(className)}>
|
<Badge variant={variant} className={cn('gap-1.5', className)}>
|
||||||
|
<span
|
||||||
|
className="size-1.5 rounded-full bg-current opacity-70"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
{labels[status] ?? status}
|
{labels[status] ?? status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { SearchIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@cfdm/ui/components/input-group'
|
||||||
|
|
||||||
|
interface TableToolbarProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableToolbar({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Поиск…',
|
||||||
|
}: TableToolbarProps) {
|
||||||
|
return (
|
||||||
|
<InputGroup className="max-w-sm">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={placeholder}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const dateFormatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
const relativeFormatter = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' })
|
||||||
|
|
||||||
|
export function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return dateFormatter.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
const diffMs = date.getTime() - Date.now()
|
||||||
|
const diffSec = Math.round(diffMs / 1000)
|
||||||
|
const absSec = Math.abs(diffSec)
|
||||||
|
|
||||||
|
if (absSec < 60) return relativeFormatter.format(diffSec, 'second')
|
||||||
|
const diffMin = Math.round(diffSec / 60)
|
||||||
|
if (Math.abs(diffMin) < 60) return relativeFormatter.format(diffMin, 'minute')
|
||||||
|
const diffHour = Math.round(diffMin / 60)
|
||||||
|
if (Math.abs(diffHour) < 24) return relativeFormatter.format(diffHour, 'hour')
|
||||||
|
const diffDay = Math.round(diffHour / 24)
|
||||||
|
if (Math.abs(diffDay) < 30) return relativeFormatter.format(diffDay, 'day')
|
||||||
|
return formatDate(iso)
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
||||||
|
import { ShieldCheckIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { EmptyState } from '@/components/empty-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 { TableToolbar } from '@/components/table-toolbar'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -48,8 +55,15 @@ export const Route = createFileRoute('/_auth/certificates')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function CertificatesPage() {
|
function CertificatesPage() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
const {
|
||||||
|
data: certs,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery(certificatesQueryOptions())
|
||||||
const { data: summary } = useQuery(certSummaryQueryOptions())
|
const { data: summary } = useQuery(certSummaryQueryOptions())
|
||||||
|
|
||||||
const checkMutation = useMutation({
|
const checkMutation = useMutation({
|
||||||
@@ -66,8 +80,21 @@ function CertificatesPage() {
|
|||||||
|
|
||||||
const chartData = summary?.map(([status, count]) => ({ status, count })) ?? []
|
const chartData = summary?.map(([status, count]) => ({ status, count })) ?? []
|
||||||
|
|
||||||
|
const filteredCerts = useMemo(() => {
|
||||||
|
const list = certs ?? []
|
||||||
|
const query = searchQuery.trim().toLowerCase()
|
||||||
|
if (!query) return list
|
||||||
|
return list.filter(
|
||||||
|
(c) =>
|
||||||
|
c.hostname.toLowerCase().includes(query) ||
|
||||||
|
c.status.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [certs, searchQuery])
|
||||||
|
|
||||||
|
const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL-сертификатов"
|
description="Мониторинг SSL-сертификатов"
|
||||||
@@ -83,51 +110,81 @@ function CertificatesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card>
|
<QueryState isLoading={isLoading} isError={isError} error={error} onRetry={refetch}>
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Обзор статусов</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Распределение сертификатов по статусам</CardDescription>
|
<CardTitle>Обзор статусов</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>Распределение сертификатов по статусам</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
|
<CardContent>
|
||||||
<BarChart data={chartData}>
|
{chartData.length > 0 ? (
|
||||||
<CartesianGrid vertical={false} />
|
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
|
||||||
<XAxis
|
<BarChart data={chartData}>
|
||||||
dataKey="status"
|
<CartesianGrid vertical={false} />
|
||||||
tickLine={false}
|
<XAxis
|
||||||
axisLine={false}
|
dataKey="status"
|
||||||
tickMargin={8}
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldCheckIcon}
|
||||||
|
title="Нет данных для графика"
|
||||||
|
description="Запустите проверку сертификатов"
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
)}
|
||||||
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
|
</CardContent>
|
||||||
</BarChart>
|
</Card>
|
||||||
</ChartContainer>
|
<DataTableCard
|
||||||
</CardContent>
|
title="Сертификаты"
|
||||||
</Card>
|
description="Все отслеживаемые хосты"
|
||||||
<DataTableCard title="Сертификаты" description="Все отслеживаемые хосты">
|
isEmpty={!filteredCerts.length}
|
||||||
<Table>
|
emptyTitle={
|
||||||
<TableHeader>
|
isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'
|
||||||
<TableRow>
|
}
|
||||||
<TableHead>Хост</TableHead>
|
emptyDescription={
|
||||||
<TableHead>Статус</TableHead>
|
isFilteredEmpty
|
||||||
<TableHead>Истекает</TableHead>
|
? 'Измените поисковый запрос'
|
||||||
<TableHead>Последняя проверка</TableHead>
|
: 'Сертификаты появятся после проверки доменов'
|
||||||
</TableRow>
|
}
|
||||||
</TableHeader>
|
emptyIcon={ShieldCheckIcon}
|
||||||
<TableBody>
|
toolbar={
|
||||||
{certs?.map((c) => (
|
<TableToolbar
|
||||||
<TableRow key={c.id}>
|
value={searchQuery}
|
||||||
<TableCell className="font-medium">{c.hostname}</TableCell>
|
onChange={setSearchQuery}
|
||||||
<TableCell>
|
placeholder="Поиск по хосту или статусу…"
|
||||||
<StatusBadge status={c.status} />
|
/>
|
||||||
</TableCell>
|
}
|
||||||
<TableCell>{c.expires_at ?? '—'}</TableCell>
|
>
|
||||||
<TableCell>{c.last_checked_at ?? '—'}</TableCell>
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Хост</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead>Истекает</TableHead>
|
||||||
|
<TableHead>Последняя проверка</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filteredCerts.map((c) => (
|
||||||
</DataTableCard>
|
<TableRow key={c.id}>
|
||||||
</div>
|
<TableCell className="font-medium">{c.hostname}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={c.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(c.expires_at)}</TableCell>
|
||||||
|
<TableCell>{formatDate(c.last_checked_at)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DataTableCard>
|
||||||
|
</QueryState>
|
||||||
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { NetworkIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
|
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
|
||||||
import { api } from '@/lib/api-client'
|
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 { DataTableCard } from '@/components/data-table-card'
|
import { DataTableCard } from '@/components/data-table-card'
|
||||||
|
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'
|
||||||
@@ -40,19 +43,23 @@ const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
|||||||
const dnsTypeItems = DNS_TYPES.map((type) => ({ label: type, value: type }))
|
const dnsTypeItems = DNS_TYPES.map((type) => ({ label: type, value: type }))
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
|
export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
|
||||||
loader: ({ context: { queryClient }, params }) => {
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
host: typeof search.host === 'string' ? search.host : undefined,
|
||||||
|
}),
|
||||||
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
const id = Number(params.domainId)
|
const id = Number(params.domainId)
|
||||||
return Promise.all([
|
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
|
||||||
queryClient.ensureQueryData(domainDetailQueryOptions(id)),
|
await queryClient.ensureQueryData(dnsListQueryOptions(id))
|
||||||
queryClient.ensureQueryData(dnsListQueryOptions(id)),
|
return { breadcrumb: domain.zone_name }
|
||||||
])
|
|
||||||
},
|
},
|
||||||
component: DnsPage,
|
component: DnsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function DnsPage() {
|
function DnsPage() {
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const { domainId } = Route.useParams()
|
const { domainId } = Route.useParams()
|
||||||
|
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 { data: domain } = useQuery(domainDetailQueryOptions(id))
|
||||||
@@ -117,14 +124,30 @@ function DnsPage() {
|
|||||||
createMutation.mutate(values)
|
createMutation.mutate(values)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasRecords = useMemo(() => (records?.length ?? 0) > 0, [records])
|
const filteredRecords = useMemo(() => {
|
||||||
|
const list = records ?? []
|
||||||
|
const query = (host ?? searchQuery).trim().toLowerCase()
|
||||||
|
if (!query) return list
|
||||||
|
return list.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(query) ||
|
||||||
|
r.content.toLowerCase().includes(query) ||
|
||||||
|
r.record_type.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [records, host, searchQuery])
|
||||||
|
|
||||||
|
const hasRecords = useMemo(() => filteredRecords.length > 0, [filteredRecords])
|
||||||
|
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||||
description="Управление DNS-записями зоны"
|
description={
|
||||||
back={{ to: '/domains', label: '← К списку доменов' }}
|
host
|
||||||
|
? `Фильтр по хосту: ${host}`
|
||||||
|
: 'Управление DNS-записями зоны'
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||||
@@ -146,14 +169,30 @@ function DnsPage() {
|
|||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="DNS-записи"
|
title="DNS-записи"
|
||||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||||
emptyTitle="DNS-записи не найдены"
|
emptyTitle={
|
||||||
emptyDescription="Создайте запись или синхронизируйте зону с Cloudflare"
|
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||||
|
}
|
||||||
|
emptyDescription={
|
||||||
|
isFilteredEmpty
|
||||||
|
? 'Измените поисковый запрос'
|
||||||
|
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||||
|
}
|
||||||
|
emptyIcon={NetworkIcon}
|
||||||
isEmpty={!hasRecords}
|
isEmpty={!hasRecords}
|
||||||
|
toolbar={
|
||||||
|
!host ? (
|
||||||
|
<TableToolbar
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Поиск по имени или значению…"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{hasRecords && (
|
{hasRecords && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<DnsRecordsTable
|
<DnsRecordsTable
|
||||||
records={records ?? []}
|
records={filteredRecords}
|
||||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||||
isDeleting={deleteMutation.isPending}
|
isDeleting={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
@@ -238,6 +277,6 @@ function DnsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { GlobeIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
domainDetailQueryOptions,
|
domainDetailQueryOptions,
|
||||||
@@ -9,7 +10,10 @@ import {
|
|||||||
subdomainsListQueryOptions,
|
subdomainsListQueryOptions,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DomainBindingsCard } from '@/components/domain-bindings-card'
|
import { DomainBindingsCard } from '@/components/domain-bindings-card'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
@@ -21,14 +25,9 @@ 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,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
ItemGroup,
|
ItemGroup,
|
||||||
ItemSeparator,
|
ItemSeparator,
|
||||||
@@ -37,13 +36,14 @@ import {
|
|||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||||
loader: ({ context: { queryClient }, params }) => {
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
const id = Number(params.domainId)
|
const id = Number(params.domainId)
|
||||||
return Promise.all([
|
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
|
||||||
queryClient.ensureQueryData(domainDetailQueryOptions(id)),
|
await Promise.all([
|
||||||
queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
|
queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
|
||||||
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
|
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
|
||||||
])
|
])
|
||||||
|
return { breadcrumb: domain.zone_name }
|
||||||
},
|
},
|
||||||
component: DomainOverviewPage,
|
component: DomainOverviewPage,
|
||||||
})
|
})
|
||||||
@@ -70,11 +70,10 @@ function DomainOverviewPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={domain?.zone_name ?? ''}
|
title={domain?.zone_name ?? ''}
|
||||||
description="Обзор домена и поддоменов"
|
description="Обзор домена и поддоменов"
|
||||||
back={{ to: '/domains', label: '← К списку доменов' }}
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -93,6 +92,7 @@ function DomainOverviewPage() {
|
|||||||
<Link
|
<Link
|
||||||
to="/domains/$domainId/dns"
|
to="/domains/$domainId/dns"
|
||||||
params={{ domainId }}
|
params={{ domainId }}
|
||||||
|
search={{ host: undefined }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -133,7 +133,7 @@ function DomainOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-muted-foreground">Последняя синхронизация</span>
|
<span className="text-muted-foreground">Последняя синхронизация</span>
|
||||||
<span className="font-medium">{domain?.last_synced_at ?? '—'}</span>
|
<span className="font-medium">{formatDate(domain?.last_synced_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -153,23 +153,35 @@ function DomainOverviewPage() {
|
|||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
|
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/domains/$domainId/dns"
|
||||||
|
params={{ domainId }}
|
||||||
|
search={{ host: s.fqdn }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
DNS
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
{index < subdomains.length - 1 && <ItemSeparator />}
|
{index < subdomains.length - 1 && <ItemSeparator />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
) : (
|
) : (
|
||||||
<Empty className="border border-dashed p-4">
|
<EmptyState
|
||||||
<EmptyHeader>
|
icon={GlobeIcon}
|
||||||
<EmptyTitle>Поддомены не найдены</EmptyTitle>
|
title="Поддомены не найдены"
|
||||||
<EmptyDescription>
|
description="Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare"
|
||||||
Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare
|
/>
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,11 @@ import {
|
|||||||
serviceBindingsQueryOptions,
|
serviceBindingsQueryOptions,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { DataTableCard } from '@/components/data-table-card'
|
||||||
import { DomainsDataTable } from '@/components/domains-data-table'
|
import { DomainsDataTable } from '@/components/domains-data-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 {
|
|
||||||
Card,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@cfdm/ui/components/card'
|
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
@@ -137,10 +132,10 @@ function DomainsPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Домены"
|
title="Домены"
|
||||||
description="Импорт и управление зонами Cloudflare"
|
description={`${tableData.length} зон · ${tableData.filter((d) => d.ips.length > 0).length} с IP · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" render={<Link to="/groups" />}>
|
<Button variant="outline" render={<Link to="/groups" />}>
|
||||||
@@ -151,17 +146,6 @@ function DomainsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Быстрый обзор</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{tableData.length} зон ·{' '}
|
|
||||||
{tableData.filter((d) => d.ips.length > 0).length} с IP-адресами ·{' '}
|
|
||||||
{tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="Список доменов"
|
title="Список доменов"
|
||||||
description="Импортированные зоны Cloudflare"
|
description="Импортированные зоны Cloudflare"
|
||||||
@@ -222,6 +206,6 @@ function DomainsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo } 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 { FolderTreeIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
domainKeys,
|
domainKeys,
|
||||||
@@ -14,6 +15,9 @@ import {
|
|||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas'
|
import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { TableToolbar } from '@/components/table-toolbar'
|
||||||
import { KanbanBoard } from '@/components/kanban-board'
|
import { KanbanBoard } from '@/components/kanban-board'
|
||||||
import { DomainGroupCard } from '@/components/domain-group-card'
|
import { DomainGroupCard } from '@/components/domain-group-card'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { DataTableCard } from '@/components/data-table-card'
|
||||||
@@ -70,6 +74,7 @@ function parseGroupColumnId(columnId: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GroupsPage() {
|
function GroupsPage() {
|
||||||
|
const [catalogSearch, setCatalogSearch] = useState('')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: groups } = useQuery(groupsQueryOptions())
|
const { data: groups } = useQuery(groupsQueryOptions())
|
||||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||||
@@ -163,10 +168,24 @@ function GroupsPage() {
|
|||||||
createMutation.mutate(values)
|
createMutation.mutate(values)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
const list = groups ?? []
|
||||||
|
const query = catalogSearch.trim().toLowerCase()
|
||||||
|
if (!query) return list
|
||||||
|
return list.filter(
|
||||||
|
(g) =>
|
||||||
|
g.name.toLowerCase().includes(query) ||
|
||||||
|
g.slug.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [groups, catalogSearch])
|
||||||
|
|
||||||
|
const isCatalogFilteredEmpty =
|
||||||
|
(groups?.length ?? 0) > 0 && filteredGroups.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Группы"
|
title="Группы доменов"
|
||||||
description="Канбан-доска доменов по группам и справочник групп"
|
description="Канбан-доска доменов по группам и справочник групп"
|
||||||
/>
|
/>
|
||||||
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
|
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
|
||||||
@@ -237,9 +256,23 @@ function GroupsPage() {
|
|||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="Справочник групп"
|
title="Справочник групп"
|
||||||
description="Все группы доменов"
|
description="Все группы доменов"
|
||||||
isEmpty={!groups?.length}
|
isEmpty={!filteredGroups.length}
|
||||||
emptyTitle="Группы не найдены"
|
emptyTitle={
|
||||||
emptyDescription="Создайте первую группу в форме выше"
|
isCatalogFilteredEmpty ? 'Ничего не найдено' : 'Группы не найдены'
|
||||||
|
}
|
||||||
|
emptyDescription={
|
||||||
|
isCatalogFilteredEmpty
|
||||||
|
? 'Измените поисковый запрос'
|
||||||
|
: 'Создайте первую группу в форме выше'
|
||||||
|
}
|
||||||
|
emptyIcon={FolderTreeIcon}
|
||||||
|
toolbar={
|
||||||
|
<TableToolbar
|
||||||
|
value={catalogSearch}
|
||||||
|
onChange={setCatalogSearch}
|
||||||
|
placeholder="Поиск по названию или slug…"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -250,7 +283,7 @@ function GroupsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{groups?.map((group) => (
|
{filteredGroups.map((group) => (
|
||||||
<TableRow key={group.id}>
|
<TableRow key={group.id}>
|
||||||
<TableCell className="font-medium">{group.name}</TableCell>
|
<TableCell className="font-medium">{group.name}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{group.slug}</TableCell>
|
<TableCell className="text-muted-foreground">{group.slug}</TableCell>
|
||||||
@@ -268,14 +301,20 @@ function GroupsPage() {
|
|||||||
>
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<ConfirmDialog
|
||||||
variant="destructive"
|
trigger={
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => deleteMutation.mutate(group.id)}
|
variant="destructive"
|
||||||
disabled={deleteMutation.isPending}
|
size="sm"
|
||||||
>
|
disabled={deleteMutation.isPending}
|
||||||
Удалить
|
>
|
||||||
</Button>
|
Удалить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Удалить группу?"
|
||||||
|
description={`Группа «${group.name}» будет удалена. Домены останутся без группы.`}
|
||||||
|
onConfirm={() => deleteMutation.mutate(group.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -285,6 +324,6 @@ function GroupsPage() {
|
|||||||
</DataTableCard>
|
</DataTableCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { GlobeIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
domainsListQueryOptions,
|
domainsListQueryOptions,
|
||||||
groupDetailQueryOptions,
|
groupDetailQueryOptions,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
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 { TableToolbar } from '@/components/table-toolbar'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -18,24 +22,33 @@ import {
|
|||||||
} from '@cfdm/ui/components/table'
|
} from '@cfdm/ui/components/table'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/groups/$groupId')({
|
export const Route = createFileRoute('/_auth/groups/$groupId')({
|
||||||
loader: ({ context: { queryClient }, params }) => {
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
const id = Number(params.groupId)
|
const id = Number(params.groupId)
|
||||||
return Promise.all([
|
const group = await queryClient.ensureQueryData(groupDetailQueryOptions(id))
|
||||||
queryClient.ensureQueryData(groupDetailQueryOptions(id)),
|
await queryClient.ensureQueryData(domainsListQueryOptions(id))
|
||||||
queryClient.ensureQueryData(domainsListQueryOptions(id)),
|
return { breadcrumb: group.name }
|
||||||
])
|
|
||||||
},
|
},
|
||||||
component: GroupDetailPage,
|
component: GroupDetailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function GroupDetailPage() {
|
function GroupDetailPage() {
|
||||||
|
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 { data: group } = useQuery(groupDetailQueryOptions(id))
|
||||||
const { data: domains } = useQuery(domainsListQueryOptions(id))
|
const { data: domains } = useQuery(domainsListQueryOptions(id))
|
||||||
|
|
||||||
|
const filteredDomains = useMemo(() => {
|
||||||
|
const list = domains ?? []
|
||||||
|
const query = searchQuery.trim().toLowerCase()
|
||||||
|
if (!query) return list
|
||||||
|
return list.filter((d) => d.zone_name.toLowerCase().includes(query))
|
||||||
|
}, [domains, searchQuery])
|
||||||
|
|
||||||
|
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={group?.name ?? 'Группа'}
|
title={group?.name ?? 'Группа'}
|
||||||
description={
|
description={
|
||||||
@@ -43,7 +56,6 @@ function GroupDetailPage() {
|
|||||||
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
|
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
|
||||||
: 'Домены в группе'
|
: 'Домены в группе'
|
||||||
}
|
}
|
||||||
back={{ to: '/groups', label: '← К группам' }}
|
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -56,9 +68,23 @@ function GroupDetailPage() {
|
|||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="Домены группы"
|
title="Домены группы"
|
||||||
description="Список доменов, назначенных этой группе"
|
description="Список доменов, назначенных этой группе"
|
||||||
emptyTitle="В группе нет доменов"
|
emptyTitle={
|
||||||
emptyDescription="Перетащите домены на канбане групп или назначьте группу при импорте"
|
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||||
isEmpty={!domains?.length}
|
}
|
||||||
|
emptyDescription={
|
||||||
|
isFilteredEmpty
|
||||||
|
? 'Измените поисковый запрос'
|
||||||
|
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||||
|
}
|
||||||
|
emptyIcon={GlobeIcon}
|
||||||
|
isEmpty={!filteredDomains.length}
|
||||||
|
toolbar={
|
||||||
|
<TableToolbar
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Поиск по зоне…"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -70,13 +96,26 @@ function GroupDetailPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{domains?.map((domain) => (
|
{filteredDomains.map((domain) => (
|
||||||
<TableRow key={domain.id}>
|
<TableRow key={domain.id}>
|
||||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={domain.status} />
|
<StatusBadge status={domain.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{domain.service_count}</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">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -96,6 +135,6 @@ function GroupDetailPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</DataTableCard>
|
</DataTableCard>
|
||||||
</div>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,72 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions, groupsQueryOptions, serviceGroupsQueryOptions } from '@/queries'
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@cfdm/ui/components/card'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions } from '@/queries'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { SectionCards } from '@/components/section-cards'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||||
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
||||||
|
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||||
|
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||||
]),
|
]),
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
const {
|
||||||
const { data: summary } = useQuery(certSummaryQueryOptions())
|
data: domains,
|
||||||
|
isLoading: domainsLoading,
|
||||||
|
isError: domainsError,
|
||||||
|
error: domainsErr,
|
||||||
|
refetch: refetchDomains,
|
||||||
|
} = useQuery(domainsListQueryOptions())
|
||||||
|
const {
|
||||||
|
data: summary,
|
||||||
|
isLoading: summaryLoading,
|
||||||
|
isError: summaryError,
|
||||||
|
error: summaryErr,
|
||||||
|
refetch: refetchSummary,
|
||||||
|
} = useQuery(certSummaryQueryOptions())
|
||||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||||
|
const { data: groups } = useQuery(groupsQueryOptions())
|
||||||
|
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
||||||
|
|
||||||
|
const serviceCount =
|
||||||
|
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
||||||
|
(serviceData?.ungrouped.length ?? 0)
|
||||||
|
|
||||||
|
const isLoading = domainsLoading || summaryLoading
|
||||||
|
const isError = domainsError || summaryError
|
||||||
|
const error = domainsErr ?? summaryErr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Панель управления"
|
title="Панель управления"
|
||||||
description="Обзор доменов и сертификатов Cloudflare"
|
description="Обзор доменов, групп, сервисов и сертификатов Cloudflare"
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<QueryState
|
||||||
<Card>
|
isLoading={isLoading}
|
||||||
<CardHeader>
|
isError={isError}
|
||||||
<CardDescription>Домены</CardDescription>
|
error={error}
|
||||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
onRetry={() => {
|
||||||
{domains?.length ?? 0}
|
void refetchDomains()
|
||||||
</CardTitle>
|
void refetchSummary()
|
||||||
</CardHeader>
|
}}
|
||||||
<CardFooter>
|
>
|
||||||
<Button
|
<SectionCards
|
||||||
variant="link"
|
domainCount={domains?.length ?? 0}
|
||||||
className="h-auto p-0"
|
certCount={certs?.length ?? 0}
|
||||||
render={<Link to="/domains" />}
|
groupCount={groups?.length ?? 0}
|
||||||
>
|
serviceCount={serviceCount}
|
||||||
Управление доменами
|
certSummary={summary}
|
||||||
</Button>
|
/>
|
||||||
</CardFooter>
|
</QueryState>
|
||||||
</Card>
|
</PageShell>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>Сертификаты</CardDescription>
|
|
||||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
|
||||||
{certs?.length ?? 0}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0"
|
|
||||||
render={<Link to="/certificates" />}
|
|
||||||
>
|
|
||||||
Мониторинг сертификатов
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>Статусы сертификатов</CardDescription>
|
|
||||||
<CardTitle className="text-base font-medium">Сводка</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="flex flex-col gap-1 text-sm">
|
|
||||||
{summary?.map(([status, count]) => (
|
|
||||||
<li key={status} className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">{status}</span>
|
|
||||||
<span className="font-medium tabular-nums">{count}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { ServerIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
domainKeys,
|
domainKeys,
|
||||||
@@ -20,6 +21,9 @@ import type {
|
|||||||
UpdateServiceConfigInput,
|
UpdateServiceConfigInput,
|
||||||
} from '@/lib/schemas'
|
} from '@/lib/schemas'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||||
import { ServiceGroupCard } from '@/components/service-group-card'
|
import { ServiceGroupCard } from '@/components/service-group-card'
|
||||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||||
@@ -31,17 +35,16 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@cfdm/ui/components/card'
|
} from '@cfdm/ui/components/card'
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyContent,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@cfdm/ui/components/empty'
|
|
||||||
import { ItemGroup } from '@cfdm/ui/components/item'
|
import { ItemGroup } from '@cfdm/ui/components/item'
|
||||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services')({
|
export const Route = createFileRoute('/_auth/services')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
domainId:
|
||||||
|
search.domainId != null && search.domainId !== ''
|
||||||
|
? Number(search.domainId)
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||||
@@ -88,7 +91,13 @@ function setGroupEnabled(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
||||||
|
if (!domainId) return true
|
||||||
|
return service.domains?.some((d) => d.domain_id === domainId) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
function ServicesPage() {
|
function ServicesPage() {
|
||||||
|
const { domainId } = Route.useSearch()
|
||||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||||
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
||||||
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
|
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
|
||||||
@@ -98,9 +107,19 @@ function ServicesPage() {
|
|||||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||||
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
|
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data, isLoading } = useQuery(serviceGroupsQueryOptions())
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery(serviceGroupsQueryOptions())
|
||||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||||
|
|
||||||
|
const filteredDomain = domainId
|
||||||
|
? domains?.find((d) => d.id === domainId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
function invalidateAll() {
|
function invalidateAll() {
|
||||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
||||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||||
@@ -286,15 +305,34 @@ function ServicesPage() {
|
|||||||
toggleGroupMutation.mutate({ id: groupId, enabled })
|
toggleGroupMutation.mutate({ id: groupId, enabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = data?.groups ?? []
|
const groups = useMemo(() => {
|
||||||
const ungrouped = data?.ungrouped ?? []
|
const list = data?.groups ?? []
|
||||||
|
if (!domainId) return list
|
||||||
|
return list
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.services.length > 0)
|
||||||
|
}, [data?.groups, domainId])
|
||||||
|
|
||||||
|
const ungrouped = useMemo(() => {
|
||||||
|
const list = data?.ungrouped ?? []
|
||||||
|
if (!domainId) return list
|
||||||
|
return list.filter((s) => serviceMatchesDomain(s, domainId))
|
||||||
|
}, [data?.ungrouped, domainId])
|
||||||
|
|
||||||
const isEmpty = groups.length === 0 && ungrouped.length === 0
|
const isEmpty = groups.length === 0 && ungrouped.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сервисы"
|
title="Сервисы"
|
||||||
description="Домен группы (FQDN) и FQDN сервисов синхронизируются в Cloudflare при включении"
|
description={
|
||||||
|
filteredDomain
|
||||||
|
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||||
|
: 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении'
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||||
@@ -305,28 +343,37 @@ function ServicesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
<QueryState
|
||||||
<div className="flex items-center justify-center py-12">
|
isLoading={isLoading}
|
||||||
<Spinner className="size-8" />
|
isError={isError}
|
||||||
</div>
|
error={error}
|
||||||
) : isEmpty ? (
|
onRetry={refetch}
|
||||||
<Empty>
|
skeleton={
|
||||||
<EmptyHeader>
|
<div className="flex flex-col gap-4">
|
||||||
<EmptyTitle>Сервисы не найдены</EmptyTitle>
|
<Skeleton className="h-32 w-full" />
|
||||||
<EmptyDescription>
|
<Skeleton className="h-32 w-full" />
|
||||||
Создайте группу и сервис — например VPN Panel или Home Assistant.
|
</div>
|
||||||
</EmptyDescription>
|
}
|
||||||
</EmptyHeader>
|
>
|
||||||
<EmptyContent>
|
{isEmpty ? (
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<EmptyState
|
||||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
icon={ServerIcon}
|
||||||
Добавить группу
|
title="Сервисы не найдены"
|
||||||
</Button>
|
description={
|
||||||
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
filteredDomain
|
||||||
</div>
|
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||||
</EmptyContent>
|
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
|
||||||
</Empty>
|
}
|
||||||
) : (
|
action={
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||||
|
Добавить группу
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<ServiceGroupCard
|
<ServiceGroupCard
|
||||||
@@ -362,7 +409,8 @@ function ServicesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</QueryState>
|
||||||
|
|
||||||
<ServiceEditSheet
|
<ServiceEditSheet
|
||||||
mode="edit"
|
mode="edit"
|
||||||
@@ -409,6 +457,6 @@ function ServicesPage() {
|
|||||||
}}
|
}}
|
||||||
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
|
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex items-center gap-0.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "outline" : "ghost"}
|
||||||
|
size={size}
|
||||||
|
className={cn(className)}
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
text = "Previous",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pl-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
text = "Next",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pr-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user