feat!(web): migrate UI from SvelteKit to React + shadcn/ui + ReUI
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

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]>
This commit is contained in:
Denozordec
2026-07-02 17:59:59 +07:00
co-authored by Cursor
parent db75126bea
commit c144b49acf
417 changed files with 25450 additions and 124 deletions
+200
View File
@@ -0,0 +1,200 @@
import type {
AsEntriesResponse,
CdnSourcesResponse,
DomainEntriesResponse,
IpRangeEntriesResponse,
JobRow,
ModuleType,
RevisionPrefix,
RevisionPrefixesResponse,
} from '@/types/api'
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
export type Problem = {
type?: string
title?: string
status?: number
detail?: string
}
function getToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
}
export function setToken(token: string | null): void {
if (typeof window === 'undefined') return
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
}
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
const h = new Headers(init?.headers)
if (!h.has('Accept')) h.set('Accept', 'application/json')
const t = getToken()
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
if (extraHeaders) {
for (const [k, v] of Object.entries(extraHeaders)) {
if (!h.has(k)) h.set(k, v)
}
}
return h
}
/**
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
*/
function newIdempotencyKey(): string {
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
if (c?.randomUUID) return c.randomUUID()
if (c?.getRandomValues) {
const buf = new Uint8Array(16)
c.getRandomValues(buf)
buf[6] = (buf[6]! & 0x0f) | 0x40
buf[8] = (buf[8]! & 0x3f) | 0x80
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly problem?: Problem,
) {
super(message)
}
}
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
return fetch(path, { ...init, headers: mergeHeaders(init) })
}
/** GET / DELETE без тела */
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init)
return parseResponse<T>(res)
}
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
export async function apiMutate<T = void>(
path: string,
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown,
opts?: { idempotent?: boolean },
): Promise<T> {
const headers: Record<string, string> = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (opts?.idempotent !== false) {
headers['Idempotency-Key'] = newIdempotencyKey()
}
const res = await fetch(path, {
method,
headers: mergeHeaders({ headers }, headers),
body: body !== undefined ? JSON.stringify(body) : undefined,
})
return parseResponse<T>(res)
}
async function parseResponse<T>(res: Response): Promise<T> {
if (res.status === 204 || res.status === 205) return undefined as T
const text = await res.text()
if (!res.ok) {
let problem: Problem | undefined
let detail = `HTTP ${res.status}`
try {
problem = JSON.parse(text) as Problem
detail = problem.detail ?? problem.title ?? detail
} catch {
if (text) detail = text
}
throw new ApiError(res.status, detail, problem)
}
if (!text) return undefined as T
return JSON.parse(text) as T
}
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
export async function waitForJob(
jobId: string,
opts?: { pollMs?: number; timeoutMs?: number },
): Promise<JobRow> {
const pollMs = opts?.pollMs ?? 400
const timeoutMs = opts?.timeoutMs ?? 120000
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
if (terminalJobStatuses.has(j.status)) return j
await new Promise((r) => setTimeout(r, pollMs))
}
throw new Error(`Таймаут ожидания задачи ${jobId}`)
}
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
const items: T[] = []
let cursor: string | null = null
while (true) {
const [basePath, rawQuery = ''] = path.split('?')
const query = new URLSearchParams(rawQuery)
if (!query.has('limit')) query.set('limit', String(limit))
if (cursor) query.set('cursor', cursor)
else query.delete('cursor')
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
`${basePath}?${query.toString()}`,
)
items.push(...(page.items ?? []))
if (!page.has_more || !page.next_cursor) break
cursor = page.next_cursor
}
return items
}
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
return items
}
export async function fetchRevisionPrefixesResponse(
revisionId: string,
cursor?: string | null,
limit = 500,
): Promise<RevisionPrefixesResponse> {
const query = new URLSearchParams({ limit: String(limit) })
if (cursor) query.set('cursor', cursor)
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
}
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
if (moduleType === 'DOMAINS') {
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/domain-entries`,
)
return { domains: entries }
}
if (moduleType === 'AS_PREFIXES') {
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/as-entries`,
)
return { asns: entries }
}
if (moduleType === 'CDN_CIDRS') {
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
`/v1/modules/${moduleId}/cdn-sources`,
)
return { cdnSources: entries }
}
if (moduleType === 'IP_RANGES') {
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/ip-range-entries`,
)
return { ipRanges: entries }
}
return {}
}