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
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:
@@ -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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user