diff --git a/.gitignore b/.gitignore index 32c1b74..ecebd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Node node_modules/ dist/ +*.tsbuildinfo # Environment .env diff --git a/backend/scripts/smoke_e2e.py b/backend/scripts/smoke_e2e.py new file mode 100644 index 0000000..90cb3d2 --- /dev/null +++ b/backend/scripts/smoke_e2e.py @@ -0,0 +1,127 @@ +"""Manual end-to-end smoke: real LFX + real API, frontend-shaped calls. + +Usage (from backend/): + TEST_DATABASE_URL=postgresql+psycopg://... uv run python scripts/smoke_e2e.py +Exits non-zero on any unexpected status/body. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +BACKEND = Path(__file__).resolve().parent.parent +VENV_BIN = BACKEND / ".venv" / "bin" +FIXTURES = BACKEND / "tests" / "fixtures" / "flows" + +LFX_PORT = 6004 +API_PORT = 8000 +LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs" + + +def wait_healthy(url: str, timeout: int = 180) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=3) as r: + if r.status == 200: + return + except OSError: + time.sleep(1) + raise RuntimeError(f"never healthy: {url}") + + +def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]: + import httpx + + with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c: + r = c.request(method, path, json=body) + try: + return r.status_code, r.json() + except ValueError: + return r.status_code, r.text + + +def check(name: str, cond: bool, detail: str = "") -> None: + print(f"[{'OK' if cond else 'FAIL'}] {name} {detail}") + if not cond: + raise SystemExit(f"smoke failed: {name} {detail}") + + +def main() -> None: + flow_dir = Path("/tmp/smoke-e2e-flows") + flow_dir.mkdir(exist_ok=True) + (flow_dir / "seed.json").write_text( + (FIXTURES / "simple_rule.json").read_text() + ) + env = { + **os.environ, + "LFX_API_KEY": LFX_KEY, + "DATABASE_URL": os.environ["TEST_DATABASE_URL"], + "LFX_URL": f"http://127.0.0.1:{LFX_PORT}", + "LFX_API_KEY": LFX_KEY, + "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", + } + lfx = subprocess.Popen( + [str(VENV_BIN / "lfx"), "serve", "--host", "127.0.0.1", + "--port", str(LFX_PORT), "--flow-dir", str(flow_dir), str(flow_dir)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, + ) + uvicorn = subprocess.Popen( + [str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", + "--port", str(API_PORT)], + cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=env, + ) + try: + wait_healthy(f"http://127.0.0.1:{LFX_PORT}/health") + wait_healthy(f"http://127.0.0.1:{API_PORT}/health") + + s, flows = api("GET", "/api/flows") + check("list flows", s == 200 and isinstance(flows, list)) + + s, flow = api("POST", "/api/flows", {"name": "smoke-dach"}) + check("create flow", s == 201, json.dumps(flow)[:120]) + fid = flow["id"] + + fx = json.loads((FIXTURES / "simple_rule.json").read_text()) + s, ver = api( + "POST", f"/api/flows/{fid}/versions", + {"langflow_json": fx, "author": "smoke"}, + ) + check("create version", s == 201, f"v{ver.get('version_no')}") + + s, dep = api("POST", f"/api/flows/{fid}/versions/1/deploy") + check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160]) + + s, run = api( + "POST", f"/api/flows/{fid}/run", + {"input": {"betrag": 120000, "schwelle": 200000}}, + ) + check("run standard", s == 200 and run.get("status") == "completed", + json.dumps(run.get("output"))[:120]) + + s, run2 = api( + "POST", f"/api/flows/{fid}/run", + {"input": {"betrag": 999000, "schwelle": 200000}}, + ) + check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", "")) + + s, tasks = api("GET", "/api/approvals") + check("approvals empty (no callback flow)", s == 200 and tasks == []) + + print("smoke E2E: all green") + finally: + uvicorn.terminate() + lfx.terminate() + + +if __name__ == "__main__": + if "TEST_DATABASE_URL" not in os.environ: + sys.exit("TEST_DATABASE_URL must be set") + main() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 721cd8a..a8ebbdb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,39 @@ -function App() { +import { useState } from "react"; +import AdminTab from "./components/AdminTab"; +import ApprovalsTab from "./components/ApprovalsTab"; +import FlowsTab from "./components/FlowsTab"; + +type Tab = "flows" | "approvals" | "admin"; + +const tabStyle = (active: boolean): React.CSSProperties => ({ + fontWeight: active ? "bold" : "normal", + textDecoration: active ? "underline" : "none", +}); + +export default function App() { + const [tab, setTab] = useState("flows"); + return ( -
+

Kirche Einfach

KI-gestützte Verwaltungsplattform für Kirche

+ + {tab === "flows" && } + {tab === "approvals" && } + {tab === "admin" && }
); } - -export default App; diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..1dbc676 --- /dev/null +++ b/frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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("/api/flows"), + listVersions: (flowId: string) => + req(`/api/flows/${flowId}/versions`), + deployVersion: (flowId: string, versionNo: number) => + req(`/api/flows/${flowId}/versions/${versionNo}/deploy`, { + method: "POST", + }), + runFlow: (flowId: string, input: unknown, tenantId: string) => + req(`/api/flows/${flowId}/run`, { + method: "POST", + body: JSON.stringify({ input, tenant_id: tenantId }), + }), + listRuns: (flowId: string) => req(`/api/flows/${flowId}/runs`), + listApprovals: () => req("/api/approvals"), + decide: ( + taskId: string, + decision: "approve" | "reject", + approver: string, + note: string, + ) => + req(`/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"), +}; diff --git a/frontend/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx new file mode 100644 index 0000000..23908ac --- /dev/null +++ b/frontend/src/components/AdminTab.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useState } from "react"; +import { + ApiError, + LANGFLOW_URL, + api, + type FlowDefinition, + type FlowVersion, +} from "../api"; + +export default function AdminTab() { + const [flows, setFlows] = useState([]); + const [versions, setVersions] = useState>({}); + const [langflowId, setLangflowId] = useState(""); + const [importName, setImportName] = useState(""); + const [error, setError] = useState(""); + const [info, setInfo] = useState(""); + + const load = useCallback(async () => { + try { + const list = await api.listFlows(); + setFlows(list); + const all: Record = {}; + for (const f of list) { + all[f.id] = await api.listVersions(f.id); + } + setVersions(all); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function onImport() { + setError(""); + setInfo(""); + try { + const result = await api.importFlow( + langflowId, + importName.trim() ? importName.trim() : undefined, + ); + setInfo( + `Importiert: ${result.definition.name} (Version ${result.version.version_no})`, + ); + setLangflowId(""); + setImportName(""); + await load(); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + } + + async function onDeploy(flowId: string, versionNo: number) { + setError(""); + setInfo(""); + try { + const version = await api.deployVersion(flowId, versionNo); + setInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`); + await load(); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + } + + return ( +
+

Administration

+ {error &&

{error}

} + {info &&

{info}

} +

+ Flows werden visuell in Langflow entworfen:{" "} + + Langflow öffnen + +

+

Import aus Langflow

+
+ setLangflowId(e.target.value)} + placeholder="Langflow Flow-ID (UUID)" + style={{ flex: 1, fontFamily: "monospace" }} + /> + setImportName(e.target.value)} + placeholder="Name (optional)" + /> + +
+

Flows + Versionen

+ {flows.length === 0 &&

Keine Flows vorhanden.

} +
    + {flows.map((f) => ( +
  • + {f.name} +
      + {(versions[f.id] ?? []).map((v) => ( +
    • + v{v.version_no} ·{" "} + {v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "} + {v.author || "–"}{" "} + +
    • + ))} +
    +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/ApprovalsTab.tsx b/frontend/src/components/ApprovalsTab.tsx new file mode 100644 index 0000000..8c9c57e --- /dev/null +++ b/frontend/src/components/ApprovalsTab.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError, api, type ApprovalTask } from "../api"; + +export default function ApprovalsTab() { + const [tasks, setTasks] = useState([]); + const [approver, setApprover] = useState(""); + const [note, setNote] = useState(""); + const [error, setError] = useState(""); + const [busyId, setBusyId] = useState(""); + + const load = useCallback(async () => { + try { + setTasks(await api.listApprovals()); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function decide(taskId: string, decision: "approve" | "reject") { + setBusyId(taskId); + setError(""); + try { + const result = await api.decide(taskId, decision, approver, note); + setTasks((prev) => + prev.map((t) => (t.id === taskId ? result.task : t)), + ); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } finally { + setBusyId(""); + } + } + + const open = tasks.filter((t) => t.status === "open"); + const done = tasks.filter((t) => t.status !== "open"); + + return ( +
+

Freigaben

+ {error &&

{error}

} +
+ setApprover(e.target.value)} + placeholder="Name (Bearbeiter)" + /> + setNote(e.target.value)} + placeholder="Notiz" + style={{ flex: 1 }} + /> + +
+

Offen ({open.length})

+ {open.length === 0 &&

Keine offenen Freigaben.

} +
    + {open.map((t) => ( +
  • + {t.title || t.id} · {t.tenant_id} +
    + {JSON.stringify(t.payload)} +
    + {" "} + +
  • + ))} +
+

Entschieden ({done.length})

+
    + {done.map((t) => ( +
  • + {t.status} · {t.title || t.id} ·{" "} + {t.approver ?? "–"} + {t.decision_note ? ` · „${t.decision_note}“` : ""} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/FlowsTab.tsx b/frontend/src/components/FlowsTab.tsx new file mode 100644 index 0000000..98cfdbe --- /dev/null +++ b/frontend/src/components/FlowsTab.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError, api, type FlowDefinition, type Run } from "../api"; + +const inputStyle: React.CSSProperties = { + width: "100%", + fontFamily: "monospace", + fontSize: 13, +}; + +export default function FlowsTab() { + const [flows, setFlows] = useState([]); + const [flowId, setFlowId] = useState(""); + const [inputJson, setInputJson] = useState( + '{\n "betrag": 120000,\n "schwelle": 200000\n}', + ); + const [tenant, setTenant] = useState("default"); + const [runs, setRuns] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const loadFlows = useCallback(async () => { + try { + const list = await api.listFlows(); + setFlows(list); + const first = list[0]; + if (first && !flowId) setFlowId(first.id); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, [flowId]); + + const loadRuns = useCallback(async (id: string) => { + if (!id) return; + try { + setRuns(await api.listRuns(id)); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, []); + + useEffect(() => { + void loadFlows(); + }, [loadFlows]); + + useEffect(() => { + void loadRuns(flowId); + }, [flowId, loadRuns]); + + async function onRun() { + setBusy(true); + setError(""); + try { + const payload: unknown = JSON.parse(inputJson); + const run = await api.runFlow(flowId, payload, tenant); + setRuns((prev) => [run, ...prev]); + } catch (e) { + if (e instanceof SyntaxError) setError("Ungültiges JSON in der Eingabe."); + else setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+

Vorgang starten

+ {error &&

{error}

} +
+ + setTenant(e.target.value)} + placeholder="Mandant" + style={{ width: 160 }} + /> + +
+