refactor: finish declutter - test_flow removal, NOT NULL schema, parametrized tests, smoke harness, docs
This commit is contained in:
parent
696db34c90
commit
92bb3bacba
23 changed files with 552 additions and 1261 deletions
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue