MAESTRO: Create typed API client with in-memory JWT auth, fetch wrappers, and WebSocket helper
This commit is contained in:
parent
4cd0b8a1c8
commit
43d2aafbbe
4 changed files with 1099 additions and 1 deletions
|
|
@ -41,6 +41,7 @@ Set up the PromptLooper repository, Docker infrastructure, and basic project ske
|
||||||
- [x] Initialize the frontend: run npm create vite@latest with React + TypeScript template. Install Tailwind CSS and configure it. Install react-router-dom for routing. Create the basic App.tsx with routes for Setup, Login, Dashboard, Projects, Experiment, Live, Compare, and Admin pages (all as placeholder components). Verify it builds cleanly.
|
- [x] Initialize the frontend: run npm create vite@latest with React + TypeScript template. Install Tailwind CSS and configure it. Install react-router-dom for routing. Create the basic App.tsx with routes for Setup, Login, Dashboard, Projects, Experiment, Live, Compare, and Admin pages (all as placeholder components). Verify it builds cleanly.
|
||||||
> Frontend was already scaffolded with Vite + React + TypeScript + Tailwind + react-router-dom from the Dockerfile task. Added 8 placeholder page components (SetupPage, LoginPage, DashboardPage, ProjectsPage, ExperimentPage, LivePage, ComparePage, AdminPage) in frontend/src/pages/. Updated App.tsx with react-router-dom Routes and main.tsx with BrowserRouter. Unknown routes redirect to dashboard. Installed vitest + @testing-library/react for testing. 9 routing tests in App.test.tsx all passing. Build completes cleanly. All 132 backend tests still pass.
|
> Frontend was already scaffolded with Vite + React + TypeScript + Tailwind + react-router-dom from the Dockerfile task. Added 8 placeholder page components (SetupPage, LoginPage, DashboardPage, ProjectsPage, ExperimentPage, LivePage, ComparePage, AdminPage) in frontend/src/pages/. Updated App.tsx with react-router-dom Routes and main.tsx with BrowserRouter. Unknown routes redirect to dashboard. Installed vitest + @testing-library/react for testing. 9 routing tests in App.test.tsx all passing. Build completes cleanly. All 132 backend tests still pass.
|
||||||
|
|
||||||
- [ ] Create frontend/src/api/client.ts with a typed API client using fetch. Include JWT token management (stored in memory, not localStorage), request/response interceptors for auth headers, and typed wrapper functions for each API endpoint group. Include WebSocket connection helper.
|
- [x] Create frontend/src/api/client.ts with a typed API client using fetch. Include JWT token management (stored in memory, not localStorage), request/response interceptors for auth headers, and typed wrapper functions for each API endpoint group. Include WebSocket connection helper.
|
||||||
|
> Created frontend/src/api/client.ts with: TypeScript interfaces mirroring all backend Pydantic schemas, in-memory JWT token management (setToken/getToken/clearToken — never localStorage), automatic Authorization header injection on all requests, Content-Type header for POST/PUT bodies, ApiError class for non-ok responses, typed wrapper functions for all 8 endpoint groups (auth, projects, experiments, runs, endpoints, export, webhooks, admin) plus health check, and connectWebSocket() helper that derives ws/wss from current protocol and handles JSON message parsing. 39 tests in src/api/client.test.ts covering token management, header injection, all endpoint groups, error handling, and WebSocket lifecycle. All 48 frontend tests pass. All 132 backend tests still pass.
|
||||||
|
|
||||||
- [ ] Verify the full stack runs: docker compose up should start all services. The API should respond to /health. The frontend should load and show the setup screen (since no admin exists). The database migration should have run. Document any manual steps needed in the README.
|
- [ ] Verify the full stack runs: docker compose up should start all services. The API should respond to /health. The frontend should load and show the setup screen (since no admin exists). The database migration should have run. Document any manual steps needed in the README.
|
||||||
|
|
|
||||||
552
frontend/src/api/client.test.ts
Normal file
552
frontend/src/api/client.test.ts
Normal file
|
|
@ -0,0 +1,552 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
setToken,
|
||||||
|
getToken,
|
||||||
|
clearToken,
|
||||||
|
ApiError,
|
||||||
|
auth,
|
||||||
|
projects,
|
||||||
|
experiments,
|
||||||
|
runs,
|
||||||
|
endpoints,
|
||||||
|
exportApi,
|
||||||
|
webhooks,
|
||||||
|
admin,
|
||||||
|
health,
|
||||||
|
connectWebSocket,
|
||||||
|
} from "./client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock fetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
clearToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText: status === 200 ? "OK" : "Error",
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
text: () => Promise.resolve(JSON.stringify(body)),
|
||||||
|
headers: new Headers(),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noContentResponse(): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
statusText: "No Content",
|
||||||
|
json: () => Promise.reject(new Error("no body")),
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
headers: new Headers(),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("token management", () => {
|
||||||
|
it("starts with null token", () => {
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets and gets token", () => {
|
||||||
|
setToken("abc123");
|
||||||
|
expect(getToken()).toBe("abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears token", () => {
|
||||||
|
setToken("abc123");
|
||||||
|
clearToken();
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth header injection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("auth header injection", () => {
|
||||||
|
it("sends Authorization header when token is set", async () => {
|
||||||
|
setToken("my-jwt");
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
|
||||||
|
|
||||||
|
await health.check();
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect((init.headers as Record<string, string>)["Authorization"]).toBe(
|
||||||
|
"Bearer my-jwt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Authorization header when no token", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
|
||||||
|
|
||||||
|
await health.check();
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(
|
||||||
|
(init.headers as Record<string, string>)["Authorization"],
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ApiError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("ApiError", () => {
|
||||||
|
it("throws ApiError on non-ok response", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ detail: "not found" }, 404),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(projects.get("some-id")).rejects.toThrow(ApiError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ detail: "bad" }, 400),
|
||||||
|
);
|
||||||
|
await projects.get("some-id");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(ApiError);
|
||||||
|
expect((e as ApiError).status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content-Type header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("content-type", () => {
|
||||||
|
it("sets Content-Type for POST with body", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ access_token: "tok", token_type: "bearer" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await auth.setup({ username: "admin", password: "password123" });
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect((init.headers as Record<string, string>)["Content-Type"]).toBe(
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Content-Type for GET requests", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||||
|
|
||||||
|
await projects.list();
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(
|
||||||
|
(init.headers as Record<string, string>)["Content-Type"],
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("health", () => {
|
||||||
|
it("calls /health", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ status: "ok", database: true, redis: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await health.check();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith("/health", expect.anything());
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("auth", () => {
|
||||||
|
it("setup POSTs to /api/auth/setup", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ access_token: "tok", token_type: "bearer" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await auth.setup({
|
||||||
|
username: "admin",
|
||||||
|
password: "password123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/auth/setup",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(result.access_token).toBe("tok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login sets token automatically", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ access_token: "jwt-123", token_type: "bearer" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await auth.login({ username: "admin", password: "pass" });
|
||||||
|
|
||||||
|
expect(getToken()).toBe("jwt-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("me GETs /api/auth/me", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
id: "u1",
|
||||||
|
username: "admin",
|
||||||
|
is_admin: true,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await auth.me();
|
||||||
|
expect(user.username).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logout clears token", () => {
|
||||||
|
setToken("tok");
|
||||||
|
auth.logout();
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Projects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("projects", () => {
|
||||||
|
it("list GETs /api/projects/", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||||
|
await projects.list();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/projects/",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create POSTs to /api/projects/", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ id: "p1", name: "Test" }),
|
||||||
|
);
|
||||||
|
await projects.create({ name: "Test" });
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ name: "Test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get fetches by id", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "p1" }));
|
||||||
|
await projects.get("p1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/projects/p1",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update PUTs by id", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "p1" }));
|
||||||
|
await projects.update("p1", { name: "New" });
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe("/api/projects/p1");
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete DELETEs by id", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||||
|
await projects.delete("p1");
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe("/api/projects/p1");
|
||||||
|
expect(init.method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Experiments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("experiments", () => {
|
||||||
|
it("list GETs /api/experiments/", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||||
|
await experiments.list();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/experiments/",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("startSweep POSTs to sweep endpoint", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||||
|
await experiments.startSweep("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/experiments/e1/sweep",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pause POSTs to pause endpoint", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||||
|
await experiments.pause("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/experiments/e1/pause",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resume POSTs to resume endpoint", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||||
|
await experiments.resume("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/experiments/e1/resume",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop POSTs to stop endpoint", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||||
|
await experiments.stop("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/experiments/e1/stop",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Runs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("runs", () => {
|
||||||
|
it("list GETs runs for experiment", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||||
|
await runs.list("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/runs/experiments/e1/runs",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get fetches run detail", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ id: "r1", stage_results: [], scores: [] }),
|
||||||
|
);
|
||||||
|
await runs.get("r1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/runs/r1",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("score POSTs to run score endpoint", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "s1" }));
|
||||||
|
await runs.score("r1", { scorer_name: "human", value: 0.9 });
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe("/api/runs/r1/score");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaderboard GETs leaderboard", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||||
|
await runs.leaderboard("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/runs/experiments/e1/leaderboard",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("endpoints", () => {
|
||||||
|
it("list GETs /api/endpoints/", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||||
|
await endpoints.list();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/endpoints/",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test POSTs to test endpoint", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ models: [] }));
|
||||||
|
await endpoints.test("ep1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/endpoints/ep1/test",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("exportApi", () => {
|
||||||
|
it("best GETs best config", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||||
|
await exportApi.best("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/export/experiments/e1/best",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("env GETs env export", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse("KEY=val"));
|
||||||
|
await exportApi.env("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/export/experiments/e1/env",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("report GETs report", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse("# Report"));
|
||||||
|
await exportApi.report("e1");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/export/experiments/e1/report",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Webhooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("webhooks", () => {
|
||||||
|
it("list GETs /api/webhooks/", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||||
|
await webhooks.list();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/webhooks/",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create POSTs webhook", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "w1" }));
|
||||||
|
await webhooks.create({ event_type: "run.complete", url: "http://x" });
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete DELETEs webhook", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||||
|
await webhooks.delete("w1");
|
||||||
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe("/api/webhooks/w1");
|
||||||
|
expect(init.method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("admin", () => {
|
||||||
|
it("getSettings GETs /api/admin/settings", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||||
|
await admin.getSettings();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/admin/settings",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateSettings PUTs /api/admin/settings", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||||
|
await admin.updateSettings({ guest_access: true });
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getStats GETs /api/admin/stats", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||||
|
await admin.getStats();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/admin/stats",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebSocket helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("connectWebSocket", () => {
|
||||||
|
it("creates WebSocket with correct URL and handles messages", () => {
|
||||||
|
const sendSpy = vi.fn();
|
||||||
|
const closeSpy = vi.fn();
|
||||||
|
let capturedInstance: {
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null;
|
||||||
|
onclose: (() => void) | null;
|
||||||
|
readyState: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use a class constructor so `new WebSocket(...)` works
|
||||||
|
class MockWebSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
readyState = 1;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
send = sendSpy;
|
||||||
|
close = closeSpy;
|
||||||
|
constructor(public url: string) {
|
||||||
|
capturedInstance = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { protocol: "http:", host: "localhost:5173" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMessage = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const conn = connectWebSocket(onMessage, onClose);
|
||||||
|
|
||||||
|
expect(capturedInstance!.url).toBe("ws://localhost:5173/ws");
|
||||||
|
|
||||||
|
// Simulate incoming message
|
||||||
|
capturedInstance!.onmessage!({ data: JSON.stringify({ type: "update" }) });
|
||||||
|
expect(onMessage).toHaveBeenCalledWith({ type: "update" });
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
conn.send({ type: "ping" });
|
||||||
|
expect(sendSpy).toHaveBeenCalledWith('{"type":"ping"}');
|
||||||
|
|
||||||
|
// Simulate close
|
||||||
|
capturedInstance!.onclose!();
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Close from client
|
||||||
|
conn.close();
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
545
frontend/src/api/client.ts
Normal file
545
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
/**
|
||||||
|
* PromptLooper typed API client.
|
||||||
|
*
|
||||||
|
* - JWT token stored in memory (never localStorage) for security.
|
||||||
|
* - Automatic Authorization header injection.
|
||||||
|
* - Typed wrapper functions for every API endpoint group.
|
||||||
|
* - WebSocket connection helper for real-time updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types — mirrors backend Pydantic schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ProjectCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdate {
|
||||||
|
name?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
owner_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectListResponse {
|
||||||
|
items: ProjectResponse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExperimentCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
sample_data?: Record<string, unknown> | null;
|
||||||
|
pipeline_stages?: Record<string, unknown> | null;
|
||||||
|
scoring_config?: Record<string, unknown> | null;
|
||||||
|
parameter_space?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExperimentUpdate {
|
||||||
|
name?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
sample_data?: Record<string, unknown> | null;
|
||||||
|
pipeline_stages?: Record<string, unknown> | null;
|
||||||
|
scoring_config?: Record<string, unknown> | null;
|
||||||
|
parameter_space?: Record<string, unknown> | null;
|
||||||
|
status?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExperimentResponse {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
sample_data: Record<string, unknown> | null;
|
||||||
|
pipeline_stages: Record<string, unknown> | null;
|
||||||
|
scoring_config: Record<string, unknown> | null;
|
||||||
|
parameter_space: Record<string, unknown> | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExperimentListResponse {
|
||||||
|
items: ExperimentResponse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunResponse {
|
||||||
|
id: string;
|
||||||
|
experiment_id: string;
|
||||||
|
config_hash: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
duration_ms: number | null;
|
||||||
|
tokens_in: number | null;
|
||||||
|
tokens_out: number | null;
|
||||||
|
cost_estimate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunListResponse {
|
||||||
|
items: RunResponse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageResultResponse {
|
||||||
|
id: string;
|
||||||
|
run_id: string;
|
||||||
|
stage_index: number;
|
||||||
|
prompt_sent: string;
|
||||||
|
response_raw: string;
|
||||||
|
model_used: string;
|
||||||
|
parameters: Record<string, unknown> | null;
|
||||||
|
tokens_in: number | null;
|
||||||
|
tokens_out: number | null;
|
||||||
|
latency_ms: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreResponse {
|
||||||
|
id: string;
|
||||||
|
run_id: string;
|
||||||
|
scorer_name: string;
|
||||||
|
value: number;
|
||||||
|
scorer_metadata: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunDetailResponse extends RunResponse {
|
||||||
|
stage_results: StageResultResponse[];
|
||||||
|
scores: ScoreResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreInput {
|
||||||
|
scorer_name: string;
|
||||||
|
value: number;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointCreate {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
api_key?: string | null;
|
||||||
|
default_model?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointUpdate {
|
||||||
|
name?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
api_key?: string | null;
|
||||||
|
default_model?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
default_model: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointListResponse {
|
||||||
|
items: EndpointResponse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookCreate {
|
||||||
|
event_type: string;
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string> | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookUpdate {
|
||||||
|
event_type?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
headers?: Record<string, string> | null;
|
||||||
|
is_active?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookResponse {
|
||||||
|
id: string;
|
||||||
|
event_type: string;
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string> | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookListResponse {
|
||||||
|
items: WebhookResponse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
status: string;
|
||||||
|
database: boolean;
|
||||||
|
redis: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportRunRow {
|
||||||
|
run_id: string;
|
||||||
|
experiment_id: string;
|
||||||
|
config_hash: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
duration_ms: number | null;
|
||||||
|
tokens_in: number | null;
|
||||||
|
tokens_out: number | null;
|
||||||
|
cost_estimate: number | null;
|
||||||
|
scores: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResponse {
|
||||||
|
experiment_id: string;
|
||||||
|
experiment_name: string;
|
||||||
|
rows: ExportRunRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API Error
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public statusText: string,
|
||||||
|
public body: unknown,
|
||||||
|
) {
|
||||||
|
super(`API ${status}: ${statusText}`);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token management (in-memory only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _accessToken: string | null = null;
|
||||||
|
|
||||||
|
export function setToken(token: string | null): void {
|
||||||
|
_accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return _accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken(): void {
|
||||||
|
_accessToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Base fetch wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BASE_URL = ""; // Uses Vite proxy in dev; same origin in prod
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(options.headers as Record<string, string> | undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inject auth header
|
||||||
|
if (_accessToken) {
|
||||||
|
headers["Authorization"] = `Bearer ${_accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default content-type for requests with bodies
|
||||||
|
if (options.body && !headers["Content-Type"]) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
body = await response.text();
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, response.statusText, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get<T>(path: string): Promise<T> {
|
||||||
|
return request<T>(path, { method: "GET" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: "PUT",
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function del<T>(path: string): Promise<T> {
|
||||||
|
return request<T>(path, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const health = {
|
||||||
|
check: () => get<HealthResponse>("/health"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
setup: (data: SetupRequest) =>
|
||||||
|
post<TokenResponse>("/api/auth/setup", data),
|
||||||
|
|
||||||
|
login: async (data: LoginRequest): Promise<TokenResponse> => {
|
||||||
|
const resp = await post<TokenResponse>("/api/auth/login", data);
|
||||||
|
setToken(resp.access_token);
|
||||||
|
return resp;
|
||||||
|
},
|
||||||
|
|
||||||
|
me: () => get<UserResponse>("/api/auth/me"),
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
clearToken();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Projects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const projects = {
|
||||||
|
list: () => get<ProjectListResponse>("/api/projects/"),
|
||||||
|
|
||||||
|
create: (data: ProjectCreate) =>
|
||||||
|
post<ProjectResponse>("/api/projects/", data),
|
||||||
|
|
||||||
|
get: (id: string) => get<ProjectResponse>(`/api/projects/${id}`),
|
||||||
|
|
||||||
|
update: (id: string, data: ProjectUpdate) =>
|
||||||
|
put<ProjectResponse>(`/api/projects/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) => del<void>(`/api/projects/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Experiments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const experiments = {
|
||||||
|
list: () => get<ExperimentListResponse>("/api/experiments/"),
|
||||||
|
|
||||||
|
create: (data: ExperimentCreate) =>
|
||||||
|
post<ExperimentResponse>("/api/experiments/", data),
|
||||||
|
|
||||||
|
get: (id: string) => get<ExperimentResponse>(`/api/experiments/${id}`),
|
||||||
|
|
||||||
|
update: (id: string, data: ExperimentUpdate) =>
|
||||||
|
put<ExperimentResponse>(`/api/experiments/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) => del<void>(`/api/experiments/${id}`),
|
||||||
|
|
||||||
|
startSweep: (id: string) =>
|
||||||
|
post<void>(`/api/experiments/${id}/sweep`),
|
||||||
|
|
||||||
|
pause: (id: string) =>
|
||||||
|
post<void>(`/api/experiments/${id}/pause`),
|
||||||
|
|
||||||
|
resume: (id: string) =>
|
||||||
|
post<void>(`/api/experiments/${id}/resume`),
|
||||||
|
|
||||||
|
stop: (id: string) =>
|
||||||
|
post<void>(`/api/experiments/${id}/stop`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Runs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const runs = {
|
||||||
|
list: (experimentId: string) =>
|
||||||
|
get<RunListResponse>(`/api/runs/experiments/${experimentId}/runs`),
|
||||||
|
|
||||||
|
get: (runId: string) =>
|
||||||
|
get<RunDetailResponse>(`/api/runs/${runId}`),
|
||||||
|
|
||||||
|
create: (data: Record<string, unknown>) =>
|
||||||
|
post<RunResponse>("/api/runs/", data),
|
||||||
|
|
||||||
|
score: (runId: string, data: ScoreInput) =>
|
||||||
|
post<ScoreResponse>(`/api/runs/${runId}/score`, data),
|
||||||
|
|
||||||
|
leaderboard: (experimentId: string) =>
|
||||||
|
get<RunListResponse>(
|
||||||
|
`/api/runs/experiments/${experimentId}/leaderboard`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoints (LLM targets)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const endpoints = {
|
||||||
|
list: () => get<EndpointListResponse>("/api/endpoints/"),
|
||||||
|
|
||||||
|
create: (data: EndpointCreate) =>
|
||||||
|
post<EndpointResponse>("/api/endpoints/", data),
|
||||||
|
|
||||||
|
update: (id: string, data: EndpointUpdate) =>
|
||||||
|
put<EndpointResponse>(`/api/endpoints/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) => del<void>(`/api/endpoints/${id}`),
|
||||||
|
|
||||||
|
test: (id: string) =>
|
||||||
|
post<Record<string, unknown>>(`/api/endpoints/${id}/test`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const exportApi = {
|
||||||
|
best: (experimentId: string) =>
|
||||||
|
get<Record<string, unknown>>(
|
||||||
|
`/api/export/experiments/${experimentId}/best`,
|
||||||
|
),
|
||||||
|
|
||||||
|
env: (experimentId: string) =>
|
||||||
|
get<string>(`/api/export/experiments/${experimentId}/env`),
|
||||||
|
|
||||||
|
yaml: (experimentId: string) =>
|
||||||
|
get<string>(`/api/export/experiments/${experimentId}/yaml`),
|
||||||
|
|
||||||
|
report: (experimentId: string) =>
|
||||||
|
get<string>(`/api/export/experiments/${experimentId}/report`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Webhooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const webhooks = {
|
||||||
|
list: () => get<WebhookListResponse>("/api/webhooks/"),
|
||||||
|
|
||||||
|
create: (data: WebhookCreate) =>
|
||||||
|
post<WebhookResponse>("/api/webhooks/", data),
|
||||||
|
|
||||||
|
delete: (id: string) => del<void>(`/api/webhooks/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const admin = {
|
||||||
|
getSettings: () =>
|
||||||
|
get<Record<string, unknown>>("/api/admin/settings"),
|
||||||
|
|
||||||
|
updateSettings: (data: Record<string, unknown>) =>
|
||||||
|
put<Record<string, unknown>>("/api/admin/settings", data),
|
||||||
|
|
||||||
|
getStats: () => get<Record<string, unknown>>("/api/admin/stats"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebSocket helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WsMessageHandler = (data: unknown) => void;
|
||||||
|
|
||||||
|
export interface WsConnection {
|
||||||
|
send: (data: unknown) => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the real-time WebSocket endpoint.
|
||||||
|
*
|
||||||
|
* @param onMessage Called for each incoming message.
|
||||||
|
* @param onClose Optional callback when connection closes.
|
||||||
|
* @returns Object with `send()` and `close()` methods.
|
||||||
|
*/
|
||||||
|
export function connectWebSocket(
|
||||||
|
onMessage: WsMessageHandler,
|
||||||
|
onClose?: () => void,
|
||||||
|
): WsConnection {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data: unknown = JSON.parse(event.data as string);
|
||||||
|
onMessage(data);
|
||||||
|
} catch {
|
||||||
|
onMessage(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
send: (data: unknown) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
ws.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue