Files
EvoBGP/apps/web/src/routes/_auth/schedule.tsx
T
DenozordecandCursor c144b49acf
CI / changes (push) Successful in 17s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 17s
CI / release (push) Failing after 2m22s
feat!(web): migrate UI from SvelteKit to React + shadcn/ui + ReUI
Web UI полностью переведён с SvelteKit на новый стек: React 19,
TanStack Router/Query/Table/Virtual, shadcn/ui (base-nova) и ReUI
enterprise-компоненты (data-grid, filters, autocomplete). Новый код
разложен по слоям: packages/ui (shadcn-примитивы), apps/web
(роуты, shared-обёртки, ReUI-адаптации).

BREAKING CHANGE: меняется структура и инструментинг фронтенда.

- apps/web/ — новый Vite + React-проект (@evobgp/web), file-based
  роуты TanStack Router; экраны dashboard, modules, monitoring,
  network, operations, schedule, settings, tenant-settings, access,
  directories.
- packages/ui/ — shadcn/ui-примитивы (@evobgp/ui) с общими стилями
  globals.css и cn-утилитой; CLI shadcn запускается из apps/web.
- apps/web/src/components/reui/ — enterprise-паттерны ReUI.
- pnpm workspace (pnpm-workspace.yaml, pnpm-lock.yaml, tsconfig.base.json)
  заменяет npm-проект в web/.
- web/ переименован в web-legacy-svelte/ (архив-референс для миграции);
  импорты оттуда запрещены правилом WEB-22.
- CI (.gitea/workflows/ci.yaml): job web переведён на Node 22 + pnpm 10
  (typecheck/lint/build через pnpm --filter @evobgp/web); пути триггеров
  обновлены под apps/web|packages/ui.
- deploy/docker/evobgp-web/Dockerfile: сборка из корня репозитория,
  pnpm install --frozen-lockfile, выход dist из apps/web/dist.
- .cursor/rules/web-shadcn.mdc, context7-stack.mdc, engineering.mdc,
  AGENTS.md — обновлены под React-стек (WEB-01..WEB-22, DOC-SYNC-06/07).

Проверки WEB-19 локально: typecheck, lint, build — exit 0.

Co-authored-by: Cursor <[email protected]>
2026-07-02 17:59:59 +07:00

264 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { LoadingButton } from '@/components/loading-button'
import { operationsJobsQueryOptions } from '@/queries/operations'
import { modulesListQueryOptions } from '@/queries/modules'
import { apiMutate } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/schedule')({
component: ScheduleComponent,
})
function ScheduleComponent() {
const modulesQ = useQuery(modulesListQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const qc = useQueryClient()
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({})
const modules = modulesQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const loading = modulesQ.isLoading || jobsQ.isLoading
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' },
{
label: 'С ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
variant: failed > 0 ? 'warning' : 'default',
},
]
const refreshMutation = useMutation({
mutationFn: async (id: string) => apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST'),
onMutate: (id) => setRefreshing((s) => ({ ...s, [id]: true })),
onSuccess: (data, id) => {
if (data === undefined) toast.message('Обновление не требуется (тип IP_RANGES)')
else toast.success('Задача поставлена в очередь')
void qc.invalidateQueries({ queryKey: ['operations'] })
void qc.invalidateQueries({ queryKey: ['modules'] })
setRefreshing((s) => ({ ...s, [id]: false }))
},
onError: (e, id) => {
toast.error(e instanceof Error ? e.message : 'Не удалось запустить')
setRefreshing((s) => ({ ...s, [id]: false }))
},
})
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Расписание и задачи"
description="Интервалы обновления модулей и ручной запуск"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void modulesQ.refetch()
void jobsQ.refetch()
}}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Как работает расписание</AlertTitle>
<AlertDescription>
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
<code className="text-xs">cron_expr</code>. Ручной запуск {' '}
<code className="text-xs">POST /v1/modules/&#123;id&#125;/refresh</code>.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Card>
<CardHeader>
<CardTitle className="text-base">Модули</CardTitle>
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={modules}
isLoading={modulesQ.isLoading}
isError={modulesQ.isError}
error={modulesQ.error}
empty={modules.length === 0}
emptyTitle="Нет модулей"
onRetry={() => modulesQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Модуль</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Расписание</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-32 text-right" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="default">Вкл</Badge>
) : (
<Badge variant="secondary">Выкл</Badge>
)}
</TableCell>
<TableCell className="text-right">
<LoadingButton
size="sm"
variant="secondary"
loading={!!refreshing[m.id]}
onClick={() => refreshMutation.mutate(m.id)}
>
<RefreshCw />
Обновить
</LoadingButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Задачи</CardTitle>
<CardDescription>Последние задачи из API</CardDescription>
</CardHeader>
<CardContent className="p-0">
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
</CardContent>
</Card>
</div>
)
}
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
)
return (
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-0">
<JobsTable items={jobs} loading={loading} />
</TabsContent>
<TabsContent value="refresh" className="mt-0">
<JobsTable items={refresh} loading={loading} />
</TabsContent>
<TabsContent value="failed" className="mt-0">
<JobsTable items={failed} loading={loading} />
</TabsContent>
</Tabs>
)
}
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка…</div>
if (items.length === 0)
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">{j.kind}</TableCell>
<TableCell>
<Badge
variant={
j.status === 'succeeded'
? 'default'
: j.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{j.status}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-destructive">
{j.error ?? ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}