kirche-einfach/frontend/src/components/AdminTab.tsx

264 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import {
Anchor,
Button,
Card,
Group,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import {
IconExternalLink,
IconRefresh,
IconUpload,
} from "@tabler/icons-react";
import { z } from "zod";
import {
LANGFLOW_URL,
api,
type FlowDefinition,
type FlowVersion,
} from "../api";
import { formatApiError } from "../utils";
import FlowHelp from "./FlowHelp";
import { ErrorAlert } from "./ErrorAlert";
import StatusBadge from "./StatusBadge";
const importSchema = z.object({
langflowId: z.string().min(1, "Flow-ID erforderlich"),
importName: z.string(),
});
export default function AdminTab() {
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const [versions, setVersions] = useState<Record<string, FlowVersion[]>>({});
const importForm = useForm({
initialValues: { langflowId: "", importName: "" },
validate: (values) => {
const parsed = importSchema.safeParse(values);
if (parsed.success) return {};
return {
langflowId:
parsed.error.flatten().fieldErrors.langflowId?.[0] ?? null,
};
},
});
const [error, setError] = useState("");
function notifyInfo(message: string) {
notifications.show({ message, color: "green" });
}
useEffect(() => {
void (async () => {
try {
const list = await api.listFlows();
setFlows(list);
const entries = await Promise.all(
list.map(async (f) => [f.id, await api.listVersions(f.id)] as const),
);
setVersions(Object.fromEntries(entries));
} catch (e) {
setError(formatApiError(e));
}
})();
}, []);
async function load() {
try {
const list = await api.listFlows();
setFlows(list);
const entries = await Promise.all(
list.map(async (f) => [f.id, await api.listVersions(f.id)] as const),
);
setVersions(Object.fromEntries(entries));
} catch (e) {
setError(formatApiError(e));
}
}
async function onImport() {
setError("");
const validation = importForm.validate();
if (validation.hasErrors) return;
const { langflowId, importName } = importForm.values;
try {
const result = await api.importFlow(
langflowId,
importName.trim() ? importName.trim() : undefined,
);
notifyInfo(
`Importiert: ${result.definition.name} (Version ${result.version.version_no})`,
);
importForm.reset();
await load();
} catch (e) {
setError(formatApiError(e));
}
}
async function onDeploy(flowId: string, versionNo: number) {
setError("");
try {
const version = await api.deployVersion(flowId, versionNo);
notifyInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`);
await load();
} catch (e) {
setError(formatApiError(e));
}
}
async function onSync() {
setError("");
try {
const r = await api.syncFlows();
const parts = [
`Langflow: ${r.pulled.length}`,
`+${r.added.length}`,
`~${r.updated.length}`,
`-${r.deleted.length}`,
].join(" ");
const errs = Object.entries(r.errors)
.map(([f, e]) => `${f}: ${e}`)
.join("; ");
notifyInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`);
await load();
} catch (e) {
setError(formatApiError(e));
}
}
return (
<div>
<Title order={3} mb="xs">
Flows & Versionen
</Title>
<ErrorAlert message={error} />
<Card withBorder padding="md" mb="md">
<Text mb="xs">
Flows werden visuell in Langflow entworfen:{" "}
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
Langflow öffnen <IconExternalLink size={14} />
</Anchor>
</Text>
<Group align="flex-end">
<TextInput
label="Langflow Flow-ID (UUID)"
style={{ flex: 1 }}
styles={{ input: { fontFamily: "monospace" } }}
{...importForm.getInputProps("langflowId")}
/>
<TextInput
label="Name (optional)"
{...importForm.getInputProps("importName")}
/>
<Button
leftSection={<IconUpload size={16} />}
onClick={() => void onImport()}
disabled={!importForm.values.langflowId.trim()}
>
Importieren + Deployen
</Button>
</Group>
</Card>
<FlowHelp />
<Card withBorder padding="md" mb="md">
<Group>
<Button
variant="light"
leftSection={<IconRefresh size={16} />}
onClick={() => void onSync()}
>
Aus Langflow synchronisieren
</Button>
</Group>
<Text size="sm" c="dimmed" mt="xs">
Holt alle Flows aus Langflow nach backend/flows und deployed sie
danach automatisch.
</Text>
</Card>
{flows.length === 0 ? (
<Text c="dimmed">Keine Flows vorhanden.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Flow</Table.Th>
<Table.Th>Version</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Autor</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{flows.map((f) => {
const list = versions[f.id] ?? [];
const latest = list[list.length - 1];
if (!latest) {
return (
<Table.Tr key={f.id}>
<Table.Td>
<strong>{f.name}</strong>{" "}
<Text size="xs" c="dimmed" component="span">
{f.source_path ? `(${f.source_path})` : "(API)"}
</Text>
</Table.Td>
<Table.Td>–</Table.Td>
<Table.Td>
<StatusBadge status="pending" />
</Table.Td>
<Table.Td>–</Table.Td>
<Table.Td />
</Table.Tr>
);
}
const live = list
.filter((v) => v.deployed_at && v.id !== latest.id)
.at(-1);
return (
<Table.Tr key={latest.id}>
<Table.Td>
<strong>{f.name}</strong>{" "}
<Text size="xs" c="dimmed" component="span">
{f.source_path ? `(${f.source_path})` : "(API)"}
</Text>
</Table.Td>
<Table.Td>
v{latest.version_no}
{live && (
<Text size="xs" c="dimmed" component="span">
{" "}
(live: v{live.version_no})
</Text>
)}
</Table.Td>
<Table.Td>
{latest.deployed_at ? (
<StatusBadge status="deployed" />
) : (
<StatusBadge status="pending" />
)}
</Table.Td>
<Table.Td>{latest.author || "–"}</Table.Td>
<Table.Td>
<Button
size="compact-sm"
variant="light"
onClick={() => void onDeploy(f.id, latest.version_no)}
>
Deployen
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
</div>
);
}