Files
EvoBGP/apps/web/src/lib/api-client.ts
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 <cursoragent@cursor.com>
2026-07-02 17:59:59 +07:00

201 lines
6.5 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 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 {}
}