feat: minimal frontend (run view, approvals, admin) with E2E smoke

- Tabs: Vorgaenge (run flows + history), Freigaben (decide tasks),
  Administration (Langflow link, import, deploy)
- Typed API client over vite /api proxy; tsc + vite build clean
- scripts/smoke_e2e.py: real LFX + API end-to-end, all green
- 34 backend tests green
This commit is contained in:
Jonas Miederer 2026-09-06 23:13:05 +00:00
parent 6c195bc2e7
commit 0a2b73f3b4
8 changed files with 618 additions and 4 deletions

138
frontend/src/api.ts Normal file
View file

@ -0,0 +1,138 @@
/* Typed client for the kirche-einfach API (served via vite /api proxy). */
export interface FlowDefinition {
id: string;
name: string;
description: string;
status: string;
continuation_flow_id: string | null;
created_at: string;
}
export interface FlowVersion {
id: string;
flow_id: string;
version_no: number;
langflow_json: unknown;
lfx_flow_id: string | null;
deployed_at: string | null;
author: string;
created_at: string;
}
export interface RunOutput {
result?: string;
component?: string;
type?: string;
error?: string;
[key: string]: unknown;
}
export interface Run {
id: string;
version_id: string;
tenant_id: string;
status: string;
input: unknown;
output: RunOutput | null;
session_id: string | null;
created_at: string;
finished_at: string | null;
}
export interface ApprovalTask {
id: string;
run_id: string;
version_id: string;
tenant_id: string;
title: string;
payload: unknown;
continuation_flow_id: string | null;
approver: string | null;
status: string;
decision_note: string;
created_at: string;
decided_at: string | null;
}
export interface Decision {
task: ApprovalTask;
continuation_run: Run | null;
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!res.ok) {
let detail = res.statusText;
try {
const body: unknown = await res.json();
if (
typeof body === "object" &&
body !== null &&
"detail" in body &&
typeof (body as { detail: unknown }).detail === "string"
) {
detail = (body as { detail: string }).detail;
}
} catch {
/* keep statusText */
}
throw new ApiError(res.status, detail);
}
return (await res.json()) as T;
}
export const LANGFLOW_URL =
(import.meta.env.VITE_LANGFLOW_URL as string | undefined) ??
"http://localhost:7860";
export const api = {
listFlows: () => req<FlowDefinition[]>("/api/flows"),
listVersions: (flowId: string) =>
req<FlowVersion[]>(`/api/flows/${flowId}/versions`),
deployVersion: (flowId: string, versionNo: number) =>
req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
method: "POST",
}),
runFlow: (flowId: string, input: unknown, tenantId: string) =>
req<Run>(`/api/flows/${flowId}/run`, {
method: "POST",
body: JSON.stringify({ input, tenant_id: tenantId }),
}),
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
decide: (
taskId: string,
decision: "approve" | "reject",
approver: string,
note: string,
) =>
req<Decision>(`/api/approvals/${taskId}/decision`, {
method: "POST",
body: JSON.stringify({ decision, approver, note }),
}),
importFlow: (langflowFlowId: string, name?: string) =>
req<{ definition: FlowDefinition; version: FlowVersion }>(
"/api/flows/import",
{
method: "POST",
body: JSON.stringify({
langflow_flow_id: langflowFlowId,
...(name ? { name } : {}),
}),
},
),
listLangflow: () =>
req<{ id: string; name: string }[]>("/api/flows/langflow/available"),
};