Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6e319a275 | ||
|
|
869b13cb57 |
@@ -167,7 +167,7 @@ export function DashboardModulesGrid({
|
|||||||
description="Поиск, сортировка и быстрый переход к настройке"
|
description="Поиск, сортировка и быстрый переход к настройке"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const ACTIONS: QuickActionItem[] = [
|
|||||||
id: 'new-module',
|
id: 'new-module',
|
||||||
title: 'Создать модуль',
|
title: 'Создать модуль',
|
||||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||||
to: '/modules/new',
|
to: '/modules',
|
||||||
|
search: { create: true },
|
||||||
icon: <Plus aria-hidden />,
|
icon: <Plus aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function LookupAddStep({
|
|||||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
|
||||||
Перейти к модулям
|
Перейти к модулям
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
|
||||||
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { CommunitySelect } from '@/components/modules/community-select'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { dohProfileShortLabel } from '@/lib/modules/helpers'
|
||||||
|
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||||
|
import { useCreateModuleMutation } from '@/queries/modules'
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
DohProfile,
|
||||||
|
DohResolverPolicy,
|
||||||
|
ModuleCreate,
|
||||||
|
ModuleRow,
|
||||||
|
ModuleType,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
dohProfiles: DohProfile[]
|
||||||
|
onCreated?: (mod: ModuleRow) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @see https://reui.io/preview/base/form-7 */
|
||||||
|
/** @see https://reui.io/preview/base/sheet-8 */
|
||||||
|
|
||||||
|
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
|
||||||
|
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||||
|
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||||
|
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||||
|
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
||||||
|
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
||||||
|
{ value: 'failover', label: dohPolicyRu('failover') },
|
||||||
|
{ value: 'union', label: dohPolicyRu('union') },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ModuleCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
communities,
|
||||||
|
dohProfiles,
|
||||||
|
onCreated,
|
||||||
|
}: ModuleCreateDialogProps) {
|
||||||
|
const createMutation = useCreateModuleMutation()
|
||||||
|
|
||||||
|
const [type, setType] = useState<ModuleType>('IP_RANGES')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [priority, setPriority] = useState('0')
|
||||||
|
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
||||||
|
const [cronExpr, setCronExpr] = useState('')
|
||||||
|
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
||||||
|
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||||
|
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
const isDomains = type === 'DOMAINS'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setType('IP_RANGES')
|
||||||
|
setName('')
|
||||||
|
setEnabled(true)
|
||||||
|
setPriority('0')
|
||||||
|
setRefreshIntervalSec('')
|
||||||
|
setCronExpr('')
|
||||||
|
setDefaultCommunityId(null)
|
||||||
|
setDohResolverPolicy('primary_only')
|
||||||
|
setDohProfileIds([])
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function toggleDohProfile(id: string, checked: boolean) {
|
||||||
|
setDohProfileIds((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
if (prev.includes(id)) return prev
|
||||||
|
return [...prev, id]
|
||||||
|
}
|
||||||
|
return prev.filter((x) => x !== id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error('Укажите название модуля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityNum = Number(priority)
|
||||||
|
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||||
|
toast.error('Приоритет должен быть целым числом')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh: number | undefined
|
||||||
|
if (refreshIntervalSec.trim() !== '') {
|
||||||
|
const n = Number(refreshIntervalSec)
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||||
|
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh = n
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: ModuleCreate = {
|
||||||
|
type,
|
||||||
|
name: trimmedName,
|
||||||
|
enabled,
|
||||||
|
priority: priorityNum,
|
||||||
|
}
|
||||||
|
if (refresh !== undefined) {
|
||||||
|
body.refresh_interval_sec = refresh
|
||||||
|
}
|
||||||
|
const cron = cronExpr.trim()
|
||||||
|
if (cron) {
|
||||||
|
body.cron_expr = cron
|
||||||
|
}
|
||||||
|
if (defaultCommunityId) {
|
||||||
|
body.default_community_id = defaultCommunityId
|
||||||
|
}
|
||||||
|
if (isDomains) {
|
||||||
|
body.doh_resolver_policy = dohResolverPolicy
|
||||||
|
body.doh_profile_ids = dohProfileIds
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createMutation.mutateAsync(body)
|
||||||
|
onOpenChange(false)
|
||||||
|
onCreated?.(created)
|
||||||
|
} catch {
|
||||||
|
// toast in mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormDrawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Новый модуль"
|
||||||
|
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
|
||||||
|
className="sm:max-w-md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||||
|
Создать
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectField
|
||||||
|
id="mod-create-type"
|
||||||
|
label="Тип"
|
||||||
|
items={MODULE_TYPE_ITEMS}
|
||||||
|
value={type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setType(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-name">Название</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Имя модуля"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label htmlFor="mod-create-enabled">Включён</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Выключенный модуль не участвует в обновлении и применении.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
id="mod-create-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(v) => setEnabled(v === true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-priority"
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-interval"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="пусто = по умолчанию"
|
||||||
|
value={refreshIntervalSec}
|
||||||
|
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
||||||
|
<Input
|
||||||
|
id="mod-create-cron"
|
||||||
|
placeholder="0 * * * *"
|
||||||
|
value={cronExpr}
|
||||||
|
onChange={(e) => setCronExpr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommunitySelect
|
||||||
|
id="mod-create-community"
|
||||||
|
label="Community по умолчанию"
|
||||||
|
value={defaultCommunityId}
|
||||||
|
onValueChange={setDefaultCommunityId}
|
||||||
|
communities={communities}
|
||||||
|
nullable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isDomains ? (
|
||||||
|
<>
|
||||||
|
<SelectField
|
||||||
|
id="mod-create-doh-policy"
|
||||||
|
label="Политика DoH"
|
||||||
|
items={DOH_POLICY_ITEMS}
|
||||||
|
value={dohResolverPolicy}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setDohResolverPolicy(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>DoH профили</Label>
|
||||||
|
{dohProfiles.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||||
|
{dohProfiles.map((p) => {
|
||||||
|
const checked = dohProfileIds.includes(p.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
htmlFor={`mod-create-doh-${p.id}`}
|
||||||
|
className="flex cursor-pointer items-start gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-create-doh-${p.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{dohProfileShortLabel(p.id, dohProfiles)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
||||||
|
{p.url}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FormDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ArrowLeft, ShieldOff } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Switch } from '@evobgp/ui/components/switch'
|
|
||||||
import {
|
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from '@evobgp/ui/components/toggle-group'
|
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
|
||||||
import {
|
|
||||||
NumberField,
|
|
||||||
NumberFieldDecrement,
|
|
||||||
NumberFieldGroup,
|
|
||||||
NumberFieldIncrement,
|
|
||||||
NumberFieldInput,
|
|
||||||
} from '@/components/reui/number-field'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { CommunitySelect } from '@/components/modules/community-select'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
|
||||||
import { SelectMenu } from '@/components/select-field'
|
|
||||||
import { SettingRow } from '@/components/settings/setting-row'
|
|
||||||
import { SettingsCard } from '@/components/settings/settings-card'
|
|
||||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
|
||||||
import { sessionCanWriteModules } from '@/lib/auth'
|
|
||||||
import { dohProfileShortLabel } from '@/lib/modules/helpers'
|
|
||||||
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
|
||||||
import {
|
|
||||||
directoriesCommunitiesQueryOptions,
|
|
||||||
directoriesDohQueryOptions,
|
|
||||||
} from '@/queries/directories'
|
|
||||||
import { useCreateModuleMutation } from '@/queries/modules'
|
|
||||||
import type { DohResolverPolicy, ModuleCreate, ModuleType } from '@/types/api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create module — settings-3 / settings-16 Frame rows.
|
|
||||||
* @see https://reui.io/preview/base/settings-3
|
|
||||||
* @see https://reui.io/preview/base/settings-16
|
|
||||||
* @see https://reui.io/docs/components/base/frame
|
|
||||||
* @see https://reui.io/docs/components/base/number-field
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MODULE_TYPES: ModuleType[] = ['IP_RANGES', 'AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS']
|
|
||||||
|
|
||||||
const MODULE_TYPE_HINT: Record<ModuleType, string> = {
|
|
||||||
IP_RANGES: 'Статические префиксы. Тип после создания не меняется.',
|
|
||||||
AS_PREFIXES: 'Префиксы по номерам AS (RIPEstat). Тип после создания не меняется.',
|
|
||||||
CDN_CIDRS: 'CIDR-списки с URL-источников. Тип после создания не меняется.',
|
|
||||||
DOMAINS: 'FQDN → префиксы через DoH. Тип после создания не меняется.',
|
|
||||||
}
|
|
||||||
|
|
||||||
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
|
||||||
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
|
||||||
{ value: 'failover', label: dohPolicyRu('failover') },
|
|
||||||
{ value: 'union', label: dohPolicyRu('union') },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ModuleCreateForm() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const sessionQ = useQuery(authSessionQueryOptions())
|
|
||||||
const canWrite = sessionCanWriteModules(sessionQ.data)
|
|
||||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
|
||||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
|
||||||
const createMutation = useCreateModuleMutation()
|
|
||||||
|
|
||||||
const [type, setType] = useState<ModuleType>('IP_RANGES')
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [enabled, setEnabled] = useState(true)
|
|
||||||
const [priority, setPriority] = useState(0)
|
|
||||||
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
|
||||||
const [cronExpr, setCronExpr] = useState('')
|
|
||||||
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
|
||||||
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
|
||||||
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
|
||||||
|
|
||||||
const isDomains = type === 'DOMAINS'
|
|
||||||
const communities = communitiesQ.data?.items ?? []
|
|
||||||
const dohProfiles = dohQ.data?.items ?? []
|
|
||||||
|
|
||||||
function toggleDohProfile(id: string, checked: boolean) {
|
|
||||||
setDohProfileIds((prev) => {
|
|
||||||
if (checked) {
|
|
||||||
if (prev.includes(id)) return prev
|
|
||||||
return [...prev, id]
|
|
||||||
}
|
|
||||||
return prev.filter((x) => x !== id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const trimmedName = name.trim()
|
|
||||||
if (!trimmedName) {
|
|
||||||
toast.error('Укажите название модуля')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(priority)) {
|
|
||||||
toast.error('Приоритет должен быть целым числом')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let refresh: number | undefined
|
|
||||||
if (refreshIntervalSec.trim() !== '') {
|
|
||||||
const n = Number(refreshIntervalSec)
|
|
||||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
|
||||||
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refresh = n
|
|
||||||
}
|
|
||||||
|
|
||||||
const body: ModuleCreate = {
|
|
||||||
type,
|
|
||||||
name: trimmedName,
|
|
||||||
enabled,
|
|
||||||
priority,
|
|
||||||
}
|
|
||||||
if (refresh !== undefined) {
|
|
||||||
body.refresh_interval_sec = refresh
|
|
||||||
}
|
|
||||||
const cron = cronExpr.trim()
|
|
||||||
if (cron) {
|
|
||||||
body.cron_expr = cron
|
|
||||||
}
|
|
||||||
if (defaultCommunityId) {
|
|
||||||
body.default_community_id = defaultCommunityId
|
|
||||||
}
|
|
||||||
if (isDomains) {
|
|
||||||
body.doh_resolver_policy = dohResolverPolicy
|
|
||||||
body.doh_profile_ids = dohProfileIds
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await createMutation.mutateAsync(body)
|
|
||||||
await navigate({ to: '/modules/$moduleId', params: { moduleId: created.id } })
|
|
||||||
} catch {
|
|
||||||
// toast in mutation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const back = (
|
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
|
||||||
<ArrowLeft />
|
|
||||||
К списку
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (sessionQ.isPending) {
|
|
||||||
return (
|
|
||||||
<PageShell className="mx-auto w-full max-w-3xl">
|
|
||||||
<PageHeader title="Новый модуль" description="Создание маршрутного списка" actions={back} />
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canWrite) {
|
|
||||||
return (
|
|
||||||
<PageShell>
|
|
||||||
<PageHeader
|
|
||||||
title="Новый модуль"
|
|
||||||
description="Создание маршрутного списка"
|
|
||||||
actions={back}
|
|
||||||
/>
|
|
||||||
<Alert variant="warning">
|
|
||||||
<ShieldOff aria-hidden />
|
|
||||||
<AlertTitle>Недостаточно прав</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Нужно право bgp:modules:write, чтобы создавать модули.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell className="mx-auto w-full max-w-3xl">
|
|
||||||
<PageHeader
|
|
||||||
title="Новый модуль"
|
|
||||||
description="Тип задаётся один раз при создании. Записи (AS, домены, CIDR) добавляются на карточке модуля."
|
|
||||||
actions={back}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title="Параметры"
|
|
||||||
description="Основные поля. Тип после сохранения изменить нельзя."
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" type="button" render={<Link to="/modules" />}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<LoadingButton
|
|
||||||
type="button"
|
|
||||||
loading={createMutation.isPending}
|
|
||||||
onClick={() => void submit()}
|
|
||||||
>
|
|
||||||
Создать
|
|
||||||
</LoadingButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SettingsFieldGroup
|
|
||||||
legend="Создание модуля"
|
|
||||||
description="Тип, название и расписание нового маршрутного списка."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Тип"
|
|
||||||
description={MODULE_TYPE_HINT[type]}
|
|
||||||
stacked
|
|
||||||
labelFor="mod-create-type"
|
|
||||||
>
|
|
||||||
<ToggleGroup
|
|
||||||
multiple={false}
|
|
||||||
value={[type]}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value.length > 0) setType(value[0] as ModuleType)
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
aria-label="Тип модуля"
|
|
||||||
className="flex w-full min-w-0 flex-wrap"
|
|
||||||
>
|
|
||||||
{MODULE_TYPES.map((value) => (
|
|
||||||
<ToggleGroupItem key={value} value={value} className="gap-1.5">
|
|
||||||
{moduleTypeRu(value)}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
|
||||||
</ToggleGroup>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Название"
|
|
||||||
description="Короткое имя в списках и на карточке."
|
|
||||||
labelFor="mod-create-name"
|
|
||||||
stacked
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="mod-create-name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Например: static-ru"
|
|
||||||
className="w-full min-w-0"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Включён"
|
|
||||||
description="Выключенный модуль не участвует в обновлении и применении."
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={setEnabled}
|
|
||||||
aria-label="Модуль включён"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Приоритет"
|
|
||||||
description="Меньше значение — выше приоритет при агрегации."
|
|
||||||
labelFor="mod-create-priority"
|
|
||||||
>
|
|
||||||
<NumberField
|
|
||||||
id="mod-create-priority"
|
|
||||||
value={priority}
|
|
||||||
onValueChange={(next) => {
|
|
||||||
if (typeof next === 'number' && Number.isFinite(next)) {
|
|
||||||
setPriority(Math.trunc(next))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
step={1}
|
|
||||||
className="w-full max-w-40"
|
|
||||||
>
|
|
||||||
<NumberFieldGroup>
|
|
||||||
<NumberFieldDecrement />
|
|
||||||
<NumberFieldInput />
|
|
||||||
<NumberFieldIncrement />
|
|
||||||
</NumberFieldGroup>
|
|
||||||
</NumberField>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Интервал обновления"
|
|
||||||
description="Секунды. Пусто — без периодического ingest по интервалу."
|
|
||||||
labelFor="mod-create-interval"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="mod-create-interval"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="пусто"
|
|
||||||
value={refreshIntervalSec}
|
|
||||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
|
||||||
className="w-full max-w-40"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Cron"
|
|
||||||
description="Опциональное расписание планировщика, например 0 * * * *."
|
|
||||||
labelFor="mod-create-cron"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="mod-create-cron"
|
|
||||||
placeholder="0 * * * *"
|
|
||||||
value={cronExpr}
|
|
||||||
onChange={(e) => setCronExpr(e.target.value)}
|
|
||||||
className="w-full min-w-0"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Community по умолчанию"
|
|
||||||
description="Подставляется в записи без своей community."
|
|
||||||
labelFor="mod-create-community"
|
|
||||||
last={!isDomains}
|
|
||||||
>
|
|
||||||
<CommunitySelect
|
|
||||||
id="mod-create-community"
|
|
||||||
value={defaultCommunityId}
|
|
||||||
onValueChange={setDefaultCommunityId}
|
|
||||||
communities={communities}
|
|
||||||
nullable
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
{isDomains ? (
|
|
||||||
<>
|
|
||||||
<SettingRow
|
|
||||||
title="Политика DoH"
|
|
||||||
description="Как выбирать профили при нескольких URL."
|
|
||||||
labelFor="mod-create-doh-policy"
|
|
||||||
>
|
|
||||||
<SelectMenu
|
|
||||||
id="mod-create-doh-policy"
|
|
||||||
items={DOH_POLICY_ITEMS}
|
|
||||||
value={dohResolverPolicy}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) setDohResolverPolicy(v)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="DoH профили"
|
|
||||||
description="Упорядоченный список. Пусто — резолв без профилей модуля."
|
|
||||||
stacked
|
|
||||||
last
|
|
||||||
>
|
|
||||||
{dohProfiles.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex w-full min-w-0 flex-col gap-2">
|
|
||||||
{dohProfiles.map((p) => {
|
|
||||||
const checked = dohProfileIds.includes(p.id)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={p.id}
|
|
||||||
htmlFor={`mod-create-doh-${p.id}`}
|
|
||||||
className="flex cursor-pointer items-start gap-3"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`mod-create-doh-${p.id}`}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<span className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{dohProfileShortLabel(p.id, dohProfiles)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
|
||||||
{p.url}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingRow>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</SettingsFieldGroup>
|
|
||||||
</SettingsCard>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridPrimaryCell
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
accent="mono"
|
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { Boxes, Plus } from 'lucide-react'
|
import { Boxes, Plus } from 'lucide-react'
|
||||||
|
|
||||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
/** empty-state-3 pattern for first module. */
|
/** empty-state-3 pattern for first module. */
|
||||||
export function ProjectsEmptyState({ canCreate = true }: { canCreate?: boolean }) {
|
export function ProjectsEmptyState({
|
||||||
|
canCreate = true,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
canCreate?: boolean
|
||||||
|
onCreate?: () => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Создайте первый модуль"
|
title="Создайте первый модуль"
|
||||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||||
action={
|
action={
|
||||||
canCreate ? (
|
canCreate && onCreate ? (
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
<Button size="sm" onClick={onCreate}>
|
||||||
<Plus />
|
<Plus />
|
||||||
Новый модуль
|
Новый модуль
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evobgp/ui/components/select'
|
} from '@evobgp/ui/components/select'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { isRefreshJobKind, jobKindRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleCalendarView } from './schedule-calendar-view'
|
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||||
@@ -34,7 +34,7 @@ function jobTimestamp(job: JobRow): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||||
if (filter === 'refresh') return job.kind === 'module_refresh'
|
if (filter === 'refresh') return isRefreshJobKind(job.kind)
|
||||||
if (filter === 'failed')
|
if (filter === 'failed')
|
||||||
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FrameDataGrid } from '@/components/reui-kit'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import { isRefreshJobKind } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||||
@@ -11,7 +12,7 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
|||||||
type JobTab = 'all' | 'refresh' | 'failed'
|
type JobTab = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||||
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
if (tab === 'refresh') return items.filter((j) => isRefreshJobKind(j.kind))
|
||||||
if (tab === 'failed')
|
if (tab === 'failed')
|
||||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
return items
|
return items
|
||||||
@@ -20,7 +21,7 @@ function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
|||||||
function tabCounts(items: JobRow[]) {
|
function tabCounts(items: JobRow[]) {
|
||||||
return {
|
return {
|
||||||
all: items.length,
|
all: items.length,
|
||||||
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
refresh: items.filter((j) => isRefreshJobKind(j.kind)).length,
|
||||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
.length,
|
.length,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ScheduleJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
|
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} />,
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
|
|||||||
|
|
||||||
const JOB_KIND_RU: Record<string, string> = {
|
const JOB_KIND_RU: Record<string, string> = {
|
||||||
module_refresh: 'Обновление модуля',
|
module_refresh: 'Обновление модуля',
|
||||||
|
tenant_refresh: 'Обновление тенанта',
|
||||||
|
peer_reconcile: 'Согласование пиров',
|
||||||
|
deploy_apply: 'Применение на спикеры',
|
||||||
apply: 'Применение конфигурации',
|
apply: 'Применение конфигурации',
|
||||||
|
revision_rollback: 'Откат ревизии',
|
||||||
rollback: 'Откат ревизии',
|
rollback: 'Откат ревизии',
|
||||||
bird_reload: 'Перезагрузка BIRD',
|
bird_reload: 'Перезагрузка BIRD',
|
||||||
|
postgres_metrics_refresh: 'Метрики PostgreSQL',
|
||||||
|
postgres_slow_query_aggregate: 'Медленные запросы PostgreSQL',
|
||||||
|
postgres_table_bloat_estimate: 'Bloat таблиц PostgreSQL',
|
||||||
|
postgres_index_usage_analyze: 'Использование индексов PostgreSQL',
|
||||||
|
postgres_autovacuum_lag_detect: 'Отставание autovacuum',
|
||||||
|
postgres_vacuum: 'VACUUM PostgreSQL',
|
||||||
|
postgres_vacuum_analyze: 'VACUUM ANALYZE PostgreSQL',
|
||||||
|
postgres_analyze: 'ANALYZE PostgreSQL',
|
||||||
|
postgres_reindex: 'REINDEX PostgreSQL',
|
||||||
|
postgres_cleanup: 'Очистка PostgreSQL',
|
||||||
|
maintenance_policy_run: 'Политика обслуживания',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRefreshJobKind(kind: string): boolean {
|
||||||
|
return kind === 'module_refresh' || kind === 'tenant_refresh'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobKindRu(kind: string): string {
|
export function jobKindRu(kind: string): string {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Plus, RefreshCw } from 'lucide-react'
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
@@ -6,22 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
|
|||||||
|
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
import { FrameDataGrid } from '@/components/reui-kit'
|
||||||
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
|
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
|
||||||
|
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
|
||||||
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { sessionCanWriteModules } from '@/lib/auth'
|
import { sessionCanWriteModules } from '@/lib/auth'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import {
|
||||||
|
directoriesCommunitiesQueryOptions,
|
||||||
|
directoriesDohQueryOptions,
|
||||||
|
} from '@/queries/directories'
|
||||||
import { modulesListQueryOptions } from '@/queries/modules'
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
|
|
||||||
|
function parseCreateFlag(value: unknown): boolean {
|
||||||
|
return value === true || value === '1' || value === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/')({
|
export const Route = createFileRoute('/_auth/modules/')({
|
||||||
component: ModulesListComponent,
|
component: ModulesListComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
|
||||||
|
if (parseCreateFlag(search.create)) return { create: true }
|
||||||
|
return {}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function ModulesListComponent() {
|
function ModulesListComponent() {
|
||||||
|
const { create } = Route.useSearch()
|
||||||
|
const navigate = Route.useNavigate()
|
||||||
const query = useQuery(modulesListQueryOptions())
|
const query = useQuery(modulesListQueryOptions())
|
||||||
const sessionQ = useQuery(authSessionQueryOptions())
|
const sessionQ = useQuery(authSessionQueryOptions())
|
||||||
const canWrite = sessionCanWriteModules(sessionQ.data)
|
const canWrite = sessionCanWriteModules(sessionQ.data)
|
||||||
|
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||||
|
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||||
|
|
||||||
|
const createOpen = canWrite && create === true
|
||||||
|
|
||||||
|
function setCreateOpen(open: boolean) {
|
||||||
|
void navigate({ search: open ? { create: true } : {}, replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -47,7 +70,7 @@ function ModulesListComponent() {
|
|||||||
title="Все модули"
|
title="Все модули"
|
||||||
actions={
|
actions={
|
||||||
canWrite ? (
|
canWrite ? (
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus />
|
<Plus />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
@@ -60,7 +83,12 @@ function ModulesListComponent() {
|
|||||||
isError={query.isError}
|
isError={query.isError}
|
||||||
error={query.error}
|
error={query.error}
|
||||||
empty={query.data?.items?.length === 0}
|
empty={query.data?.items?.length === 0}
|
||||||
emptyContent={<ProjectsEmptyState canCreate={canWrite} />}
|
emptyContent={
|
||||||
|
<ProjectsEmptyState
|
||||||
|
canCreate={canWrite}
|
||||||
|
onCreate={() => setCreateOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||||
onRetry={() => query.refetch()}
|
onRetry={() => query.refetch()}
|
||||||
>
|
>
|
||||||
@@ -72,6 +100,16 @@ function ModulesListComponent() {
|
|||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</FrameDataGrid>
|
</FrameDataGrid>
|
||||||
|
|
||||||
|
<ModuleCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
communities={communitiesQ.data?.items ?? []}
|
||||||
|
dohProfiles={dohQ.data?.items ?? []}
|
||||||
|
onCreated={(mod) => {
|
||||||
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { ModuleCreateForm } from '@/components/modules/module-create-form'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/new')({
|
export const Route = createFileRoute('/_auth/modules/new')({
|
||||||
component: ModuleCreateForm,
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/modules', search: { create: true } })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user