refactor: finish declutter - test_flow removal, NOT NULL schema, parametrized tests, smoke harness, docs

This commit is contained in:
Jonas Miederer 2026-10-01 00:11:43 +02:00
parent 696db34c90
commit 92bb3bacba
23 changed files with 552 additions and 1261 deletions

View file

@ -1,78 +0,0 @@
"""Fetch landing-page photos (one-time + re-runnable).
Downloads royalty-free originals (see SOURCES.md next to the photos),
resizes to max 1600px wide and stores them as WebP q80 in
frontend/public/photos/ (self-hosted: no hotlinking, no tracking).
Run in the api container (has network + Pillow):
docker compose exec api /opt/venv/bin/python \\
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
"""
from __future__ import annotations
import sys
import urllib.request
from io import BytesIO
from pathlib import Path
try:
from PIL import Image
except ImportError:
sys.exit("Pillow required (present in the api image)")
ROOT = Path(__file__).resolve().parent.parent.parent
OUT = ROOT / "frontend" / "public" / "photos"
def pexels(photo_id: int) -> str:
return (
f"https://images.pexels.com/photos/{photo_id}/"
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600"
)
PHOTOS = {
# target file -> (direct file URL, source page for SOURCES.md)
"hero-church.webp": (
pexels(31630127),
"https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/",
),
"church-pews.webp": (
pexels(7566207),
"https://www.pexels.com/photo/church-pews-7566207/",
),
"paperwork-desk.webp": (
pexels(18687845),
"https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/",
),
"pilot-meeting.webp": (
pexels(5668858),
"https://www.pexels.com/photo/two-people-shaking-hands-5668858/",
),
}
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"})
with urllib.request.urlopen(req, timeout=120) as response:
return response.read()
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
for filename, (url, _page) in PHOTOS.items():
raw = fetch(url)
with Image.open(BytesIO(raw)) as img:
img = img.convert("RGB")
if img.width > 1600:
img = img.resize(
(1600, round(img.height * 1600 / img.width)), Image.LANCZOS
)
target = OUT / Path(filename).with_suffix(".webp").name
img.save(target, "WEBP", quality=80, method=6)
print(f"wrote {target} ({target.stat().st_size // 1024} KB)")
if __name__ == "__main__":
main()

View file

@ -1,5 +1,19 @@
/* Typed client for the kirche-einfach API (served via vite /api proxy). */
/** All statuses the backend can report (single color language in StatusBadge). */
export type Status =
| "completed"
| "approved"
| "genehmigt"
| "deployed"
| "running"
| "open"
| "pending"
| "pending_approval"
| "failed"
| "rejected"
| "abgelehnt";
export interface FlowDefinition {
id: string;
name: string;
@ -33,7 +47,7 @@ export interface Run {
id: string;
version_id: string;
tenant_id: string;
status: string;
status: Status;
input: unknown;
output: RunOutput | null;
session_id: string | null;
@ -50,7 +64,7 @@ export interface ApprovalTask {
payload: unknown;
continuation_flow_id: string | null;
approver: string | null;
status: string;
status: Status;
decision_note: string;
created_at: string;
decided_at: string | null;
@ -88,7 +102,7 @@ export interface AccessRequest {
name: string;
email: string;
reason: string;
status: string;
status: Status;
created_at: string;
decided_at: string | null;
decided_by: string | null;

View file

@ -1,12 +1,5 @@
import { useState } from "react";
import {
Anchor,
Button,
Card,
List,
Text,
Title,
} from "@mantine/core";
import { Anchor, Button, Card, List, Text, Title } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
import { LANGFLOW_URL } from "../api";
@ -23,70 +16,46 @@ export default function FlowHelp() {
{open ? "Anleitung verbergen" : "Anleitung: Neuer Flow in Langflow"}
</Button>
{open && (
<div>
<Title order={4} mt="sm" mb="xs">
So kommt ein neuer Flow in die Webapp
</Title>
<List size="sm" spacing="xs">
<List.Item>
<Text component="span" fw={600}>
1. In Langflow entwerfen und speichern:{" "}
</Text>
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
Langflow öffnen <IconExternalLink size={14} />
</Anchor>
. Die bestehenden Flows sind dort bereits geladen. Name vergeben, speichern.
</List.Item>
<List.Item>
<Text component="span" fw={600}>
2. Sync-Button unten klicken:{" "}
</Text>
holt alle Flows aus Langflow nach{" "}
<div>
<Title order={4} mt="sm" mb="xs">
So kommt ein neuer Flow in die Webapp
</Title>
<List size="sm" spacing="xs">
<List.Item>
<Text component="span" fw={600}>
1. In Langflow entwerfen und speichern:{" "}
</Text>
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
Langflow öffnen <IconExternalLink size={14} />
</Anchor>
. Die bestehenden Flows sind dort bereits geladen.
</List.Item>
<List.Item>
<Text component="span" fw={600}>
2. Sync-Button unten klicken:{" "}
</Text>
holt alle Flows nach{" "}
<Text component="span" ff="monospace">
backend/flows/*.json
</Text>{" "}
und deployed sie automatisch.
</List.Item>
<List.Item>
<Text component="span" fw={600}>
3. Prüfen:{" "}
</Text>
danach erscheint der Flow auf der Seite Workflows. Flows ohne
Formular-Knoten zeigen ein Freitext-Feld.
</List.Item>
</List>
<Text size="sm" c="dimmed" mt="sm">
Regeln, Formular-Felder und Sync-Sicherheit: siehe{" "}
<Text component="span" ff="monospace">
backend/flows/*.json
docs/flow-authoring.md
</Text>{" "}
und deployed sie danach automatisch (neue Version nur bei geändertem Inhalt,
sonst übersprungen).
</List.Item>
<List.Item>
<Text component="span" fw={600}>
3. Prüfen:{" "}
</Text>
Tabelle unten zeigt Version, Autor und Status. Danach erscheint der Flow
automatisch auf der Seite Workflows mit seinem Formular. Flows ohne
Formular-Knoten zeigen ein Freitext-Feld.
</List.Item>
</List>
<Title order={5} mt="md" mb="xs">
Regeln
</Title>
<List size="sm" spacing="xs">
<List.Item>
Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json).
Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt
bestehen und muss bei Bedarf gelöscht werden.
</List.Item>
<List.Item>
Eine gelöschte Datei löscht den Flow inklusive seiner Läufe
(Datei bleibt in Git erhalten).
</List.Item>
<List.Item>
Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON).
Der automatische Beobachter im Hintergrund syncet nur Dateien,
Langflow holt nur dieser Button.
</List.Item>
</List>
<Title order={5} mt="md" mb="xs">
Formular-Felder
</Title>
<Text size="sm">
Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: jeder
Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename = Label).
Die Verbindung ChatInput nach form_values bitte behalten — darüber kommen
die ausgefüllten Werte an. Unbekannte Feldtypen fallen automatisch auf ein
Textfeld zurück.
</Text>
</div>
im Repo.
</Text>
</div>
)}
</Card>
);

View file

@ -4,12 +4,19 @@ import { FormattedDate } from "./FormattedDate";
import { JsonView } from "./JsonView";
import type { Run } from "../api";
export function RunsTable({ runs }: { runs: Run[] }) {
export function RunsTable({
runs,
flowName,
}: {
runs: Run[];
flowName?: (run: Run) => string;
}) {
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
{flowName && <Table.Th>Flow</Table.Th>}
<Table.Th>Ergebnis</Table.Th>
<Table.Th>Erstellt</Table.Th>
</Table.Tr>
@ -20,6 +27,7 @@ export function RunsTable({ runs }: { runs: Run[] }) {
<Table.Td>
<StatusBadge status={r.status} />
</Table.Td>
{flowName && <Table.Td>{flowName(r)}</Table.Td>}
<Table.Td>
<JsonView value={r.output?.result ?? r.output?.error ?? "–"} />
</Table.Td>

View file

@ -1,8 +1,9 @@
import { Badge } from "@mantine/core";
import type { Status } from "../api";
/* Single color language for every status in the app. */
const COLORS: Record<string, string> = {
const COLORS: Record<Status, string> = {
completed: "green",
approved: "green",
genehmigt: "green",
@ -16,6 +17,6 @@ const COLORS: Record<string, string> = {
abgelehnt: "red",
};
export default function StatusBadge({ status }: { status: string }) {
return <Badge color={COLORS[status] ?? "gray"}>{status}</Badge>;
export default function StatusBadge({ status }: { status: Status }) {
return <Badge color={COLORS[status]}>{status}</Badge>;
}

View file

@ -39,6 +39,7 @@ import {
import { useAuth } from "../auth";
import {
FeatureCard,
PHOTO_CLASS,
PhotoImage,
SectionCard,
SectionTitle,
@ -404,7 +405,7 @@ export default function StartPage() {
src="/screenshots/workflows-light.png"
alt="Portal von Kirche Einfach mit Vorgangsformular"
radius="md"
className="photo"
className={PHOTO_CLASS}
loading="lazy"
bd="1px solid var(--mantine-color-default-border)"
/>

View file

@ -1,13 +1,11 @@
import { Table, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import { Text, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api } from "../api";
import { formatApiError } from "../utils";
import ApprovalsTab from "../components/ApprovalsTab";
import { ErrorAlert } from "../components/ErrorAlert";
import { JsonView } from "../components/JsonView";
import StatusBadge from "../components/StatusBadge";
import { useEffect, useState } from "react";
import { RunsTable } from "../components/RunsTable";
export default function TasksPage() {
const [error, setError] = useState("");
@ -19,6 +17,7 @@ export default function TasksPage() {
useEffect(() => {
if (runsQuery.error) setError(formatApiError(runsQuery.error));
}, [runsQuery.error]);
const byId = new Map(runs.map(({ flow_name, run }) => [run.id, flow_name]));
return (
<div>
@ -34,34 +33,10 @@ export default function TasksPage() {
Noch keine Läufe.
</Text>
) : (
<Table striped highlightOnHover mb="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Flow</Table.Th>
<Table.Th>Ergebnis</Table.Th>
<Table.Th>Erstellt</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map(({ flow_name, run }) => (
<Table.Tr key={run.id}>
<Table.Td>
<StatusBadge status={run.status} />
</Table.Td>
<Table.Td>{flow_name}</Table.Td>
<Table.Td>
<JsonView
value={run.output?.result ?? run.output?.error ?? "–"}
/>
</Table.Td>
<Table.Td>
{dayjs(run.created_at).format("DD.MM.YYYY HH:mm")}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<RunsTable
runs={runs.map(({ run }) => run)}
flowName={(r) => byId.get(r.id) ?? "–"}
/>
)}
<ApprovalsTab />
</div>

View file

@ -1,9 +1,7 @@
/* Shared frontend helpers: error formatting, config (was scattered). */
/* Shared frontend helpers: error formatting (was scattered). */
import { ApiError } from "./api";
export function formatApiError(e: unknown): string {
if (e instanceof ApiError) return `${e.status}: ${e.message}`;
return String(e);
}
export const LANGFLOW_URL_FALLBACK = "http://localhost:7860";