- emit API/network failures through a shared toast event bridge - remove per-page inline error notifications to avoid duplicate error UI - dedupe repeated toast messages to keep feedback readable
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import type {
|
|
PaginatedResponse,
|
|
Credential,
|
|
Environment,
|
|
ScenarioCredential,
|
|
Session,
|
|
Scenario,
|
|
ScenarioRun,
|
|
ScenarioRunDetail,
|
|
ScenarioRunStep,
|
|
ScenarioStep,
|
|
Snippet,
|
|
} from './types';
|
|
import { emitApiErrorToast } from '../lib/toast-events';
|
|
|
|
// In dev, Vite proxies /environments /sessions /scenarios to localhost:3000.
|
|
// In production (or when VITE_API_URL is set) we hit the configured origin directly.
|
|
const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
|
...init,
|
|
});
|
|
} catch (err) {
|
|
const message = (err as Error).message || 'Network request failed';
|
|
emitApiErrorToast(message);
|
|
throw new Error(message);
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => res.statusText);
|
|
const message = `${res.status} ${text}`;
|
|
emitApiErrorToast(message);
|
|
throw new Error(message);
|
|
}
|
|
const contentLength = res.headers.get('content-length');
|
|
if (res.status === 204 || contentLength === '0') return undefined as T;
|
|
const contentType = res.headers.get('content-type')?.toLowerCase() ?? '';
|
|
const text = await res.text();
|
|
if (!text) return undefined as T;
|
|
if (contentType.includes('application/json')) {
|
|
return JSON.parse(text) as T;
|
|
}
|
|
return text as T;
|
|
}
|
|
|
|
// ── Credentials ───────────────────────────────────────────────────────────────
|
|
|
|
export const credentials = {
|
|
list(page = 1, limit = 50): Promise<PaginatedResponse<Credential>> {
|
|
return request(`/credentials?page=${page}&limit=${limit}`);
|
|
},
|
|
get(id: string): Promise<Credential> {
|
|
return request(`/credentials/${id}`);
|
|
},
|
|
create(name: string, data?: string): Promise<Credential> {
|
|
return request('/credentials', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, data }),
|
|
});
|
|
},
|
|
update(id: string, patch: Partial<Pick<Credential, 'name' | 'data'>>): Promise<Credential> {
|
|
return request(`/credentials/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
remove(id: string): Promise<void> {
|
|
return request(`/credentials/${id}`, { method: 'DELETE' });
|
|
},
|
|
exportCredential(id: string): Promise<unknown> {
|
|
return request(`/credentials/${id}/export`);
|
|
},
|
|
importCredential(payload: unknown): Promise<Credential> {
|
|
return request('/credentials/import', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
};
|
|
|
|
// ── Snippets ──────────────────────────────────────────────────────────────────
|
|
|
|
export const snippets = {
|
|
list(page = 1, limit = 50): Promise<PaginatedResponse<Snippet>> {
|
|
return request(`/snippets?page=${page}&limit=${limit}`);
|
|
},
|
|
get(id: string): Promise<Snippet> {
|
|
return request(`/snippets/${id}`);
|
|
},
|
|
create(payload: Pick<Snippet, 'name' | 'code'> & { description?: string }): Promise<Snippet> {
|
|
return request('/snippets', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
update(
|
|
id: string,
|
|
patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>,
|
|
): Promise<Snippet> {
|
|
return request(`/snippets/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
remove(id: string): Promise<void> {
|
|
return request(`/snippets/${id}`, { method: 'DELETE' });
|
|
},
|
|
exportSnippet(id: string): Promise<unknown> {
|
|
return request(`/snippets/${id}/export`);
|
|
},
|
|
importSnippet(payload: unknown): Promise<Snippet> {
|
|
return request('/snippets/import', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
};
|
|
|
|
// ── Environments ──────────────────────────────────────────────────────────────
|
|
|
|
export const environments = {
|
|
list(page = 1, limit = 50): Promise<PaginatedResponse<Environment>> {
|
|
return request(`/environments?page=${page}&limit=${limit}`);
|
|
},
|
|
get(id: string): Promise<Environment> {
|
|
return request(`/environments/${id}`);
|
|
},
|
|
create(name: string, data: Environment['data']): Promise<Environment> {
|
|
return request('/environments', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, data }),
|
|
});
|
|
},
|
|
update(id: string, patch: Partial<Pick<Environment, 'name' | 'data'>>): Promise<Environment> {
|
|
return request(`/environments/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
remove(id: string): Promise<void> {
|
|
return request(`/environments/${id}`, { method: 'DELETE' });
|
|
},
|
|
exportEnvironment(id: string): Promise<unknown> {
|
|
return request(`/environments/${id}/export`);
|
|
},
|
|
importEnvironment(payload: unknown): Promise<Environment> {
|
|
return request('/environments/import', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
};
|
|
|
|
// ── Sessions ──────────────────────────────────────────────────────────────────
|
|
|
|
export const sessions = {
|
|
list(page = 1, limit = 50): Promise<PaginatedResponse<Session>> {
|
|
return request(`/sessions?page=${page}&limit=${limit}`);
|
|
},
|
|
get(id: string): Promise<Session> {
|
|
return request(`/sessions/${id}`);
|
|
},
|
|
remove(id: string): Promise<void> {
|
|
return request(`/sessions/${id}`, { method: 'DELETE' });
|
|
},
|
|
};
|
|
|
|
// ── Scenarios ─────────────────────────────────────────────────────────────────
|
|
|
|
export const scenarios = {
|
|
list(page = 1, limit = 50): Promise<PaginatedResponse<Scenario>> {
|
|
return request(`/scenarios?page=${page}&limit=${limit}&orderBy=updatedAt&orderDir=DESC`);
|
|
},
|
|
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
|
|
return request(`/scenarios/${id}`);
|
|
},
|
|
create(name: string): Promise<Scenario> {
|
|
return request('/scenarios', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
},
|
|
update(id: string, patch: Partial<Pick<Scenario, 'name'>>): Promise<Scenario> {
|
|
return request(`/scenarios/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
remove(id: string): Promise<void> {
|
|
return request(`/scenarios/${id}`, { method: 'DELETE' });
|
|
},
|
|
run(id: string): Promise<ScenarioRun> {
|
|
return request(`/scenarios/${id}/run`, { method: 'POST' });
|
|
},
|
|
getRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
|
|
return request(`/scenarios/${scenarioId}/run/${runId}`);
|
|
},
|
|
waitForRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
|
|
return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' });
|
|
},
|
|
listRuns(
|
|
scenarioId: string,
|
|
page = 1,
|
|
limit = 20,
|
|
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
|
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
|
},
|
|
exportScenario(id: string): Promise<string> {
|
|
return request(`/scenarios/${id}/export`);
|
|
},
|
|
importScenario(payload: unknown): Promise<Scenario> {
|
|
return request('/scenarios/import', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
};
|
|
|
|
// ── Runs ─────────────────────────────────────────────────────────────────────
|
|
|
|
export const runs = {
|
|
listAll(
|
|
page = 1,
|
|
limit = 20,
|
|
status?: string,
|
|
): Promise<
|
|
PaginatedResponse<
|
|
ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }
|
|
>
|
|
> {
|
|
const q = new URLSearchParams({ page: String(page), limit: String(limit) });
|
|
if (status) q.set('status', status);
|
|
return request(`/scenarios/runs?${q}`);
|
|
},
|
|
list(
|
|
scenarioId: string,
|
|
page = 1,
|
|
limit = 20,
|
|
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
|
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
|
},
|
|
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
|
|
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
|
|
return request(`/scenarios/${scenarioId}/run/${runId}${qs}`);
|
|
},
|
|
};
|
|
|
|
// ── Scenario Credentials ──────────────────────────────────────────────────────
|
|
|
|
export const scenarioCredentials = {
|
|
list(scenarioId: string): Promise<ScenarioCredential[]> {
|
|
return request(`/scenarios/${scenarioId}/credentials`);
|
|
},
|
|
add(scenarioId: string, credentialId: string, alias: string): Promise<ScenarioCredential> {
|
|
return request(`/scenarios/${scenarioId}/credentials`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ credentialId, alias }),
|
|
});
|
|
},
|
|
remove(scenarioId: string, scCredId: string): Promise<void> {
|
|
return request(`/scenarios/${scenarioId}/credentials/${scCredId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
},
|
|
};
|
|
|
|
// ── Scenario Steps ────────────────────────────────────────────────────────────
|
|
export interface CreateStepPayload {
|
|
title?: string;
|
|
execCode?: string;
|
|
validateCode?: string;
|
|
}
|
|
|
|
export interface UpdateStepPayload {
|
|
title?: string;
|
|
order?: number;
|
|
execCode?: string;
|
|
validateCode?: string;
|
|
}
|
|
|
|
export const steps = {
|
|
get(scenarioId: string, stepId: string): Promise<ScenarioStep> {
|
|
return request(`/scenarios/${scenarioId}/steps/${stepId}`);
|
|
},
|
|
create(scenarioId: string, payload: CreateStepPayload): Promise<ScenarioStep> {
|
|
return request(`/scenarios/${scenarioId}/steps`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
update(scenarioId: string, stepId: string, payload: UpdateStepPayload): Promise<ScenarioStep> {
|
|
return request(`/scenarios/${scenarioId}/steps/${stepId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
remove(scenarioId: string, stepId: string): Promise<void> {
|
|
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' });
|
|
},
|
|
};
|