Files
liqa/client/src/api/client.ts
T
ars9 a50dce2755 feat(scenarios): add timeoutSeconds to scenarios and steps
- add timeoutSeconds field to scenario and step entities
- add timeoutSeconds to create/update DTOs for scenario and step
- enforce timeout via scheduler: abort run if step exceeds limit
- expose timeoutSeconds in MCP create/update scenario and step tools
- add client-side type, API, i18n, and form support for timeoutSeconds
- add integration tests for timeout persistence via REST and MCP
2026-04-17 13:46:30 +03:00

332 lines
12 KiB
TypeScript

import type {
PaginatedResponse,
Credential,
Environment,
ScenarioCredential,
Session,
Scenario,
ScenarioRun,
ScenarioRunDetail,
ScenarioRunStep,
ScenarioStep,
Snippet,
} from './types';
import { emitApiErrorToast } from '../lib/toast-events';
// API calls are versioned under /api/v1 by default.
// Set VITE_API_URL (for example, http://localhost:13000/api/v1) to use a different origin.
const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(
/\/$/,
'',
);
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, 'alias' | 'title' | 'code'> & { description?: string },
): Promise<Snippet> {
return request('/snippets', {
method: 'POST',
body: JSON.stringify(payload),
});
},
update(
id: string,
patch: Partial<Pick<Snippet, 'alias' | 'title' | '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'], description?: string): Promise<Environment> {
return request('/environments', {
method: 'POST',
body: JSON.stringify({ name, description, data }),
});
},
update(id: string, patch: Partial<Pick<Environment, 'name' | 'description' | '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, orderBy = 'id', orderDir: 'ASC' | 'DESC' = 'DESC'): Promise<PaginatedResponse<Session>> {
return request(`/sessions?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
},
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,
orderBy = 'updatedAt',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<PaginatedResponse<Scenario>> {
return request(`/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
},
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
return request(`/scenarios/${id}`);
},
create(name: string, description?: string, environmentId?: string): Promise<Scenario> {
return request('/scenarios', {
method: 'POST',
body: JSON.stringify({ name, description, environmentId }),
});
},
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>): 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, environmentId: string, saveSession?: boolean): Promise<ScenarioRun> {
return request(`/scenarios/${id}/run`, {
method: 'POST',
body: JSON.stringify({ environmentId, saveSession }),
});
},
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,
options: { includeEnvironment: boolean; credentialIds: string[] } = {
includeEnvironment: false,
credentialIds: [],
},
): Promise<string> {
const params = new URLSearchParams();
params.set('includeEnvironment', String(options.includeEnvironment));
for (const cid of options.credentialIds) {
params.append('credentialIds', cid);
}
return request(`/scenarios/${id}/export?${params.toString()}`);
},
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,
orderBy = 'createdAt',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<
PaginatedResponse<
ScenarioRun & { scenario: { id: string; name: string } }
>
> {
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
if (status) q.set('status', status);
return request(`/scenarios/runs?${q}`);
},
list(
scenarioId: string,
page = 1,
limit = 20,
orderBy = 'createdAt',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
},
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;
timeoutSeconds?: number | null;
}
export interface UpdateStepPayload {
title?: string;
order?: number;
execCode?: string;
timeoutSeconds?: number | null;
}
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' });
},
};