Files
EvoBGP/web/src/lib/api/client.ts
T
Denozordec 94a4c6acd4
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 25s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m7s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m5s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m0s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m25s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m30s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m30s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m27s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m13s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m32s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m23s
feat: add BIRD status endpoint and integrate birdc checks into job processing. Enhance UI to display BIRD runtime status and job metadata, improving user feedback on BIRD operations.
2026-04-06 00:25:43 +07:00

125 lines
3.9 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 { browser } from '$app/environment';
import type { JobRow } from './types.js';
export const TOKEN_STORAGE_KEY = 'evobgp_api_token';
export type Problem = {
type?: string;
title?: string;
status?: number;
detail?: string;
};
function getToken(): string | null {
if (!browser) return null;
return localStorage.getItem(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 (!browser) 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}`);
}