Onyscribe Admin: Initialer Stand

Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

0
app/__init__.py Normal file
View File

0
app/agent/__init__.py Normal file
View File

129
app/agent/backends.py Normal file
View File

@@ -0,0 +1,129 @@
"""LLM-Backends für den Agenten. Interne Nachrichten im Anthropic-Block-Format:
assistant: [{"type":"text"|"tool_use",...}] · user: str | [{"type":"tool_result","tool_use_id","content"}]
"""
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass, field
@dataclass
class Turn:
blocks: list[dict]
usage: dict = field(default_factory=dict)
class MessagesBackend:
"""Anthropic-Messages-Protokoll – Cortecs bietet es unter https://api.cortecs.ai/v1/messages an."""
name = "messages"
def __init__(self, api_key: str, base_url: str, model: str):
import anthropic
root = re.sub(r"/v1/?$", "", base_url.rstrip("/")) # SDK hängt /v1/messages selbst an
self.client = anthropic.Anthropic(api_key=api_key, auth_token=api_key, base_url=root, max_retries=2, timeout=180)
self.model = model
def step(self, system: str, messages: list[dict], tools: list[dict]) -> Turn:
r = self.client.messages.create(
model=self.model, max_tokens=8000, system=system, messages=messages,
tools=[{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools])
blocks = []
for b in r.content:
if b.type == "text":
blocks.append({"type": "text", "text": b.text})
elif b.type == "tool_use":
blocks.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input})
return Turn(blocks, {"in": r.usage.input_tokens, "out": r.usage.output_tokens})
class ChatBackend:
"""OpenAI-Chat-Completions (Fallback, falls ein Modell nur diesen Weg mit Tools kann)."""
name = "chat"
def __init__(self, api_key: str, base_url: str, model: str):
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=base_url, max_retries=2, timeout=180)
self.model = model
@staticmethod
def _convert(system: str, messages: list[dict]) -> list[dict]:
out = [{"role": "system", "content": system}]
for m in messages:
c = m["content"]
if isinstance(c, str):
out.append({"role": m["role"], "content": c})
elif m["role"] == "assistant":
text = "".join(b["text"] for b in c if b["type"] == "text")
calls = [{"id": b["id"], "type": "function", "function": {"name": b["name"], "arguments": json.dumps(b["input"])}}
for b in c if b["type"] == "tool_use"]
msg = {"role": "assistant", "content": text or None}
if calls:
msg["tool_calls"] = calls
out.append(msg)
else:
for b in c:
out.append({"role": "tool", "tool_call_id": b["tool_use_id"], "content": b["content"]})
return out
def step(self, system, messages, tools) -> Turn:
r = self.client.chat.completions.create(
model=self.model, max_tokens=8000, temperature=0.2, messages=self._convert(system, messages),
tools=[{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["schema"]}} for t in tools])
msg = r.choices[0].message
blocks = []
if msg.content:
blocks.append({"type": "text", "text": msg.content})
for tc in msg.tool_calls or []:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
blocks.append({"type": "tool_use", "id": tc.id, "name": tc.function.name, "input": args})
u = r.usage
return Turn(blocks, {"in": getattr(u, "prompt_tokens", 0), "out": getattr(u, "completion_tokens", 0)})
HEX = re.compile(r"#[0-9a-fA-F]{6}\b")
def _shade(h, f):
h = h.lstrip("#"); c = [max(0, min(255, int(int(h[i:i + 2], 16) * f))) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
def _tint(h, a):
h = h.lstrip("#"); c = [int(int(h[i:i + 2], 16) + (255 - int(h[i:i + 2], 16)) * a) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
class MockBackend:
"""Demo ohne Cortecs-Key: spielt über dieselben Tools zwei einfache Aufgaben durch."""
name = "mock"
model = "mock"
def step(self, system, messages, tools) -> Turn:
used = sum(1 for m in messages if m["role"] == "assistant")
user = next(m["content"] for m in messages if m["role"] == "user" and isinstance(m["content"], str))
low = user.lower()
tid = lambda: "mock_" + uuid.uuid4().hex[:8] # noqa: E731
call = lambda name, **a: {"type": "tool_use", "id": tid(), "name": name, "input": a} # noqa: E731
hexes = HEX.findall(user)
if hexes and any(w in low for w in ("farbe", "akzent", "color", "design")):
a = hexes[0].upper()
plan = [[call("set_config", key=f"colors.{k}", value=v) for k, v in
{"accent": a, "accent_dark": _shade(a, .72), "accent_mid": _shade(a, 1.12),
"accent_light": _tint(a, .88), "accent_xlight": _tint(a, .94)}.items()],
[call("validate")]]
final = f"Akzentfarbe auf {a} gesetzt und die abgeleiteten Töne angepasst."
elif "prompt" in low or "regel" in low:
rule = re.sub(r"^(bitte\s+)?(füge\s+)?(dem\s+)?prompt\s*(hinzu)?[:,]?\s*", "", user.strip(), flags=re.I)
plan = [[call("read_core_default", name="prompt.txt")], [call("__append_prompt__", rule=rule)], [call("validate")]]
final = "Zusatzregel im Prompt ergänzt."
else:
return Turn([{"type": "text", "text": "Demo-Modus – es wurde kein Cortecs-Key gefunden (geprüft: Einstellungen → Geteilte Secrets, Env/.env CORTECS_API_KEY, Key des Tenants). "
"Key setzen und die Seite neu laden. Ohne Key verstehe ich nur „Setze die Akzentfarbe auf #RRGGBB“ und „Füge dem Prompt hinzu: …“."}])
if used < len(plan):
return Turn(plan[used])
return Turn([{"type": "text", "text": final}])

175
app/agent/loop.py Normal file
View File

@@ -0,0 +1,175 @@
"""Agent-Loop: Modell ↔ Werkzeuge auf dem Entwurfs-Workspace → Vorschlag zur Freigabe."""
from __future__ import annotations
import json
import uuid
from collections import OrderedDict
from copy import deepcopy
from typing import Iterator
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.agent import backends
from app.agent.tools import TOOLS, ToolRunner
from app.agent.workspace import Workspace
from app.models import ChatMessage, Setting, Tenant
from app.services import llm, store
MAX_TURNS = 14
# Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:<run_id>"). Nur im Speicher, höchstens 24.
WORKSPACES: "OrderedDict[str, tuple[dict, dict]]" = OrderedDict()
def remember(run_id: str, ws: Workspace) -> None:
WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files))
WORKSPACES.move_to_end(run_id)
while len(WORKSPACES) > 24:
WORKSPACES.popitem(last=False)
DEFAULT_MODEL = "claude-4-6-sonnet"
SYSTEM = """Du bist der Konfigurations-Agent der Onyscribe-Admin-Plattform. Onyscribe erzeugt aus Gesprächsaufzeichnungen Protokolle
(Transkription → LLM-Protokoll → PDF/Word). Jeder Kunde ist ein „Tenant“; alles, was ihn vom Standard unterscheidet, sind DATEN:
tenant.json (Name, Farben, Kontaktdaten, Flags, STT), prompt.txt, protocol.html/protocol.css (PDF-Layout), optional overrides.py
(Hook-Code) und Zusatzdateien. Der Core-Code der Plattform ist NICHT veränderbar und nicht sichtbar – du arbeitest ausschließlich an Tenant-Daten.
Arbeitsweise:
- Du arbeitest auf einem ENTWURF im Speicher. Nichts ist live; am Ende prüft ein Mensch deinen Vorschlag und gibt ihn frei.
- Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese.
- Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht.
- Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest.
- Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes.
- Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an.
- Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden.
- Antworte am Ende kurz auf Deutsch (du-Form): was du geändert hast und worauf der Mensch beim Prüfen achten soll. Keine Markdown-Überschriften."""
def _setting(db: Session, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value else default
def make_backend(db: Session, tenant_id: str | None = None):
model = _setting(db, "agent_model", DEFAULT_MODEL)
key = llm.api_key(db, tenant_id)
if not key:
return backends.MockBackend()
api = _setting(db, "agent_api", "messages")
cls = backends.MessagesBackend if api == "messages" else backends.ChatBackend
return cls(key, llm.base_url(db), model)
def _context(tenant: Tenant, ws: Workspace) -> str:
return (f"Tenant: {tenant.id} („{ws.cfg.get('name', '')}“). Dateien: "
+ ", ".join(f['path'] for f in ws.list_files()) + ". Der Entwurf kann bereits unveröffentlichte Änderungen enthalten.")
def _history(db: Session, tenant_id: str, limit: int = 8) -> list[dict]:
rows = db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == tenant_id).order_by(ChatMessage.id.desc()).limit(limit)).all()
out = []
for m in reversed(rows):
if m.content:
out.append({"role": m.role, "content": m.content})
# Anthropic verlangt Rollenwechsel beginnend mit user
while out and out[0]["role"] != "user":
out.pop(0)
merged = []
for m in out:
if merged and merged[-1]["role"] == m["role"]:
merged[-1]["content"] += "\n" + m["content"]
else:
merged.append(dict(m))
return merged
def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterator[dict]:
"""Generator von Ereignissen: step | text | proposal | usage | error. Schreibt NICHT in den Entwurf."""
backend = backend or make_backend(db, tenant.id)
ws = Workspace(db, tenant)
run_id = uuid.uuid4().hex[:12]
remember(run_id, ws)
last_sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
runner = ToolRunner(ws)
system = SYSTEM + "\n\n" + _context(tenant, ws)
messages = _history(db, tenant.id)
messages.append({"role": "user", "content": user_message})
usage = {"in": 0, "out": 0}
final_text = ""
yield {"type": "mode", "backend": backend.name, "model": getattr(backend, "model", ""), "run": run_id}
try:
for _ in range(MAX_TURNS):
turn = backend.step(system, messages, TOOLS)
usage["in"] += turn.usage.get("in", 0)
usage["out"] += turn.usage.get("out", 0)
messages.append({"role": "assistant", "content": turn.blocks})
calls = [b for b in turn.blocks if b["type"] == "tool_use"]
texts = [b["text"] for b in turn.blocks if b["type"] == "text" and b["text"].strip()]
if texts:
final_text = "\n".join(texts)
if calls:
yield {"type": "thought", "text": final_text}
if not calls:
break
results = []
for c in calls:
name, args = c["name"], c["input"]
if name == "__append_prompt__": # nur Mock: Standard-Prompt kopieren + Regel anhängen
base = ws.read("prompt.txt") if "prompt.txt" in ws.files else (
__import__("app.settings", fromlist=["PLATFORM_DIR"]).PLATFORM_DIR / "core" / "defaults" / "prompt.txt").read_text(encoding="utf-8")
ws.write("prompt.txt", base.rstrip() + f"\n\nZusätzliche Regel:\n{args['rule'].strip()}\n")
arg, res = "prompt.txt", "Zusatzregel angehängt"
name = "edit_file"
else:
arg, res = runner.run(name, args)
short = res if len(res) < 220 else res[:220] + " …"
if name == "validate" and not res.startswith("FEHLER"):
try:
v = json.loads(res)
short = "✔ bestanden" + (f" ({len(v['warnings'])} Hinweis(e))" if v.get("warnings") else "") if v.get("ok") else "✘ " + "; ".join(v.get("errors", []))[:200]
except (ValueError, KeyError):
pass
yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")}
sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen
last_sig = sig
remember(run_id, ws)
yield {"type": "changed", "run": run_id}
results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res})
messages.append({"role": "user", "content": results})
else:
final_text = (final_text + "\n\n(Abgebrochen: maximale Schrittzahl erreicht.)").strip()
except llm.LLMNotConfigured as exc:
yield {"type": "error", "text": str(exc)}
return
except Exception as exc: # noqa: BLE001 – Provider-/Netzfehler sauber an die UI melden
yield {"type": "error", "text": f"{type(exc).__name__}: {str(exc)[:400]}"}
return
prop = ws.proposal(final_text.split("\n")[0][:140] or "Änderungsvorschlag")
if prop is None and any(True for m in messages if m["role"] == "assistant" and isinstance(m["content"], list)
and any(b.get("name") in ("write_file", "edit_file", "set_config", "delete_file") for b in m["content"] if b["type"] == "tool_use")):
final_text = (final_text + "\n\n(Keine Änderung gegenüber dem aktuellen Entwurf.)").strip()
yield {"type": "text", "text": final_text or "(keine Antwort)"}
yield {"type": "proposal", "proposal": prop}
yield {"type": "usage", "usage": usage}
def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None:
"""Freigabe: Vorschlag in den Entwurf übernehmen (nur die betroffenen Pfade)."""
if proposal.get("config") is not None:
store.set_config(db, tenant, proposal["config"])
for path, sha in (proposal.get("files") or {}).items():
store.set_file(db, tenant, path, store.get_blob(db, sha))
for path in proposal.get("deleted") or []:
store.delete_file(db, tenant, path)
def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]:
"""Inhalt, der nach Freigabe im Entwurf stünde (für die Diff-Anzeige)."""
cfg, files = store.content(db, tenant, "draft")
if proposal.get("config") is not None:
cfg = proposal["config"]
files = {**files, **(proposal.get("files") or {})}
for p in proposal.get("deleted") or []:
files.pop(p, None)
return cfg, files

24
app/agent/testrun.py Normal file
View File

@@ -0,0 +1,24 @@
"""Prompt-Test: finalen Prompt vom Core bauen lassen (inkl. Hooks), LLM aufrufen."""
from __future__ import annotations
from app.services import llm, preview
from app.services.samples import SAMPLE_TRANSCRIPT, ROTARY_TRANSCRIPT
def user_content(transcript: str, terminart="", projekt="", kunde="", title="Beispiel") -> str:
return (f"Termin-Art: {terminart or '(aus Gespräch erkennen)'}\nProjektname: {projekt or '(keiner)'}\n"
f"Kunde / Unternehmen: {kunde or '(aus Gespräch erkennen)'}\nAnsprechpartner Kunde: (aus Gespräch erkennen)\n"
f"Meeting-Titel: {title}\n\nGesprächsprotokoll:\n{transcript}")
def default_transcript(tenant_id: str) -> str:
return ROTARY_TRANSCRIPT if tenant_id.startswith("rotary") else SAMPLE_TRANSCRIPT
def prompt_test(db, tenant, cfg, files, transcript: str | None, terminart="", projekt="", kunde="") -> dict:
transcript = (transcript or "").strip() or default_transcript(tenant.id)
r = preview.run_content(db, tenant, cfg, files, "prompt",
args={"title": "Prompt-Test", "terminart": terminart, "projekt": projekt, "kunde": kunde})
out = llm.summarize(db, r["prompt"], user_content(transcript, terminart, projekt, kunde, "Prompt-Test"), tenant_id=tenant.id)
out["prompt_chars"] = r["chars"]
return out

110
app/agent/tools.py Normal file
View File

@@ -0,0 +1,110 @@
"""Werkzeuge des Agenten (neutrale Definition + Ausführung auf dem Workspace)."""
from __future__ import annotations
import io
import json
from app.agent.workspace import Workspace, is_personal
from app.services import llm, preview
from app.settings import PLATFORM_DIR
TOOLS = [
{"name": "list_files", "description": "Listet alle Dateien des Tenants (inkl. tenant.json) mit Größe und Änderungsstatus.",
"schema": {"type": "object", "properties": {}}},
{"name": "read_file", "description": "Liest eine Textdatei des Tenants (prompt.txt, protocol.html, protocol.css, overrides.py, tenant.json …). "
"Fehlt eine Datei, gilt der Core-Standard – nutze dann read_core_default.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "read_core_default", "description": "Liest eine Standarddatei des Cores als Vorlage: prompt.txt, protocol.html, protocol.css oder hooks.py (Hook-Dokumentation).",
"schema": {"type": "object", "properties": {"name": {"type": "string", "enum": ["prompt.txt", "protocol.html", "protocol.css", "hooks.py"]}}, "required": ["name"]}},
{"name": "write_file", "description": "Schreibt/ersetzt eine Textdatei des Tenants vollständig (z. B. um eine eigene prompt.txt oder protocol.css anzulegen). Nur im Entwurf – ein Mensch gibt frei.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Ersetzt in einer Datei genau eine Stelle: old_str muss exakt 1× vorkommen. Bevorzuge das gegenüber write_file bei kleinen Änderungen.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_str": {"type": "string"}, "new_str": {"type": "string"}}, "required": ["path", "old_str", "new_str"]}},
{"name": "delete_file", "description": "Löscht eine Tenant-Datei (z. B. um auf den Core-Standard zurückzufallen).",
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "set_config", "description": "Setzt einen Wert in tenant.json per Punkt-Pfad, z. B. key='colors.accent', value='#0B6E4F' oder key='flags.ui_kontakt_field', value=false.",
"schema": {"type": "object", "properties": {"key": {"type": "string"}, "value": {}}, "required": ["key", "value"]}},
{"name": "validate", "description": "Prüft den aktuellen Workspace mit dem ECHTEN Core (Web-UI + PDF rendern, Prompt laden). Gibt Fehler/Warnungen zurück. Nach Änderungen immer aufrufen.",
"schema": {"type": "object", "properties": {}}},
{"name": "render_pdf", "description": "Rendert das Beispielprotokoll als PDF und gibt Seitenzahl und extrahierten Text zurück, damit du Aufbau und Inhalt prüfen kannst.",
"schema": {"type": "object", "properties": {}}},
{"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.",
"schema": {"type": "object", "properties": {}}},
{"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.",
"schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}},
]
class ToolRunner:
def __init__(self, ws: Workspace):
self.ws, self.db, self.tenant = ws, ws.db, ws.tenant
self.llm_calls = 0
def _preview(self, kind, **kw):
return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw)
def run(self, name: str, a: dict) -> tuple[str, str]:
"""→ (Kurzbeschreibung des Arguments für die Anzeige, Ergebnistext)."""
try:
return getattr(self, f"t_{name}")(a)
except (KeyError, ValueError, PermissionError, SyntaxError, json.JSONDecodeError) as exc:
return a.get("path") or a.get("key") or "", f"FEHLER: {exc}"
except preview.PreviewError as exc:
return "", f"FEHLER (Core-Rendering): {str(exc)[-900:]}"
def t_list_files(self, a):
rows = [{"path": "tenant.json", "text": True}] + self.ws.list_files()
return "", json.dumps(rows, ensure_ascii=False)
def t_read_file(self, a):
return a["path"], self.ws.read(a["path"])
def t_read_core_default(self, a):
n = a["name"]
p = (PLATFORM_DIR / "core" / ("hooks.py" if n == "hooks.py" else ("defaults" if n == "prompt.txt" else "templates")) / n) \
if n != "hooks.py" else PLATFORM_DIR / "core" / "hooks.py"
return n, p.read_text(encoding="utf-8")[:60_000]
def t_write_file(self, a):
self.ws.write(a["path"], a["content"])
return a["path"], f"geschrieben ({len(a['content'])} Zeichen)"
def t_edit_file(self, a):
self.ws.edit(a["path"], a["old_str"], a["new_str"])
return a["path"], "ersetzt"
def t_delete_file(self, a):
self.ws.delete(a["path"])
return a["path"], "gelöscht"
def t_set_config(self, a):
self.ws.set_config_value(a["key"], a["value"])
return a["key"], f"gesetzt = {json.dumps(a['value'], ensure_ascii=False)}"
def t_validate(self, a):
r = self._preview("validate")
return "Core-Rendering", json.dumps(r, ensure_ascii=False)
def t_render_pdf(self, a):
pdf = self._preview("pdf")
from pypdf import PdfReader
rd = PdfReader(io.BytesIO(pdf))
text = "\n".join((pg.extract_text() or "") for pg in rd.pages)
return "Beispielprotokoll", f"{len(rd.pages)} Seite(n)\n---\n{text[:3500]}"
def t_show_final_prompt(self, a):
r = self._preview("prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""})
p = r["prompt"]
# Namenslisten (personenbezogen) nicht an das Agenten-Modell geben
marker = "Namensregister"
if marker in p:
p = p[:p.index(marker)] + "[Namensregister mit personenbezogenen Daten ausgeblendet]"
return f"{r['chars']} Zeichen", p[:40_000]
def t_run_prompt_test(self, a):
if self.llm_calls >= 1:
return "", "FEHLER: Pro Antwort ist nur ein Prompt-Test erlaubt."
self.llm_calls += 1
from app.agent.testrun import prompt_test
res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript"))
return "Beispieltranskript", f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens]\n{res['text'][:6000]}"

106
app/agent/workspace.py Normal file
View File

@@ -0,0 +1,106 @@
"""Entwurfs-Workspace des Agenten: Kopie des Tenant-Entwurfs im Speicher.
Der Agent ändert NUR diese Kopie. Daraus entsteht ein Vorschlag (Diff), den der Mensch freigibt.
Datenschutz: Dateien mit personenbezogenem Inhalt (Namenslisten) sind für den Agenten nicht lesbar.
"""
from __future__ import annotations
import json
from copy import deepcopy
from sqlalchemy.orm import Session
from app.models import Tenant
from app.services import store
PERSONAL_PREFIXES = ("namensliste", "mitglieder", "members", "personen")
READ_LIMIT = 60_000
PROTECTED = {"tenant.json"}
def is_personal(path: str) -> bool:
name = path.rsplit("/", 1)[-1].lower()
return name.startswith(PERSONAL_PREFIXES)
class Workspace:
def __init__(self, db: Session, tenant: Tenant):
self.db, self.tenant = db, tenant
cfg, files = store.content(db, tenant, "draft")
self.base_cfg, self.base_files = deepcopy(cfg), dict(files)
self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha}
# ── Lesen ──
def list_files(self) -> list[dict]:
out = []
for p, sha in sorted(self.files.items()):
b = self.db.get(__import__("app.models", fromlist=["Blob"]).Blob, sha)
out.append({"path": p, "bytes": b.size, "text": store.is_text_path(p), "personal_data": is_personal(p),
"changed": self.base_files.get(p) != sha})
return out
def read(self, path: str) -> str:
if path in ("tenant.json",):
return json.dumps(self.cfg, indent=2, ensure_ascii=False)
if path not in self.files:
raise KeyError(f"Datei nicht im Tenant: {path}")
if is_personal(path):
n = len(store.get_blob(self.db, self.files[path]).decode("utf-8", "replace").splitlines())
return f"[Datei enthält personenbezogene Daten und ist für den Agenten gesperrt – {n} Zeilen. Inhalt nicht verfügbar.]"
if not store.is_text_path(path):
return "[Binärdatei]"
text = store.get_blob(self.db, self.files[path]).decode("utf-8", "replace")
return text[:READ_LIMIT] + ("\n[… gekürzt]" if len(text) > READ_LIMIT else "")
# ── Schreiben ──
def write(self, path: str, content: str) -> None:
path = store.clean_path(path)
if is_personal(path):
raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht ändern.")
if path.endswith(".py"):
compile(content, path, "exec") # Syntaxfehler früh melden
self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path)
def edit(self, path: str, old: str, new: str) -> None:
cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False)
if cur.count(old) != 1:
raise ValueError(f"old_str muss genau 1× vorkommen (gefunden: {cur.count(old)}×).")
if path == "tenant.json":
self.set_config_json(cur.replace(old, new))
else:
self.write(path, cur.replace(old, new))
def delete(self, path: str) -> None:
if is_personal(path):
raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht löschen.")
self.files.pop(path, None)
def set_config_json(self, text: str) -> None:
cfg = json.loads(text)
cfg.pop("deploy", None)
cfg["id"] = self.tenant.id
self.cfg = cfg
def set_config_value(self, key_path: str, value) -> None:
cur = self.cfg
keys = key_path.split(".")
for k in keys[:-1]:
cur = cur.setdefault(k, {})
cur[keys[-1]] = value
# ── Ergebnis ──
def changed(self) -> bool:
return self.cfg != self.base_cfg or self.files != self.base_files
def proposal(self, summary: str) -> dict | None:
if not self.changed():
return None
files = {p: s for p, s in self.files.items() if self.base_files.get(p) != s}
deleted = [p for p in self.base_files if p not in self.files]
items = []
if self.cfg != self.base_cfg:
items.append({"path": "tenant.json", "kind": "geändert"})
items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files]
items += [{"path": p, "kind": "gelöscht"} for p in deleted]
return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None,
"files": files, "deleted": deleted, "items": items}

162
app/auth.py Normal file
View File

@@ -0,0 +1,162 @@
"""OIDC-Login der Admin-Plattform über login.onyva.dev (Authorization-Code + PKCE, wie im Core).
Aktiv mit AUTH_ENABLED=true. Env: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI,
OIDC_LOGOUT_REDIRECT_URI, APP_COOKIE_SECURE, sowie die Zugriffsregel (mind. eine):
ADMIN_ALLOWED_EMAILS=a@x.de,b@y.de und/oder ADMIN_REQUIRED_ROLE=admin (gegen das Claim `app_roles`)
Ohne Zugriffsregel wird jeder Login abgelehnt (Default-Deny) – die Admin-Plattform hält alle Secrets.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.settings import AUTH_ENABLED, SECRET_KEY
COOKIE, STATE_COOKIE, STATE_TTL, SESSION_TTL = "oa_session", "oa_oauth", 600, 12 * 3600
router = APIRouter()
PUBLIC = re.compile(r"^/(healthz|auth/.*|static/.*|api/tenants/[^/]+/bundle|api/core/releases)$") # bundle: Token · releases: CI-Token (POST), GET prüft die Session selbst
DEV_USER = {"sub": "dev", "email": "dev@local", "name": "Dev (Auth aus)", "app_roles": ["admin"]}
def cfg() -> dict:
g = lambda k, d="": os.getenv(k, d).strip() # noqa: E731
return {
"issuer": g("OIDC_ISSUER", "https://login.onyva.dev").rstrip("/"), "client_id": g("OIDC_CLIENT_ID"),
"client_secret": g("OIDC_CLIENT_SECRET"), "redirect": g("OIDC_REDIRECT_URI", "http://localhost:8300/auth/callback"),
"logout_redirect": g("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8300/"),
"secure": g("APP_COOKIE_SECURE", "false").lower() == "true",
"emails": {e.strip().lower() for e in g("ADMIN_ALLOWED_EMAILS").split(",") if e.strip()},
"role": g("ADMIN_REQUIRED_ROLE"),
}
def _sign(data: dict) -> str:
payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=")
return f"{payload}.{hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()}"
def _verify(token: str) -> dict | None:
try:
payload, _, sig = token.partition(".")
if not sig or not hmac.compare_digest(sig, hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()):
return None
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
return data if int(data.get("exp", 0)) > time.time() else None
except Exception: # noqa: BLE001
return None
def session_user(request: Request) -> dict | None:
if not AUTH_ENABLED:
return DEV_USER
tok = request.cookies.get(COOKIE)
return _verify(tok) if tok else None
def is_allowed(user: dict) -> bool:
c = cfg()
if not c["emails"] and not c["role"]:
return False
if user.get("email", "").lower() in c["emails"]:
return True
return bool(c["role"]) and c["role"] in (user.get("app_roles") or [])
async def middleware(request: Request, call_next):
user = session_user(request)
request.state.user = user or {}
if user is None and not PUBLIC.match(request.url.path):
if request.url.path.startswith(("/api/", "/preview/")):
return JSONResponse({"detail": "Nicht angemeldet"}, status_code=401)
return RedirectResponse("/auth/login", status_code=302)
return await call_next(request)
def actor_name(request: Request) -> str:
u = getattr(request.state, "user", None) or {}
return u.get("email") or u.get("name") or "unbekannt"
# ── Routen ──────────────────────────────────────────────────────────────────
def exchange_code(code: str, verifier: str) -> dict:
"""Token-Tausch beim IdP (in Tests ersetzbar)."""
c = cfg()
r = httpx.post(f"{c['issuer']}/oauth/token", timeout=20, data={
"grant_type": "authorization_code", "code": code, "redirect_uri": c["redirect"], "client_id": c["client_id"],
"client_secret": c["client_secret"], "code_verifier": verifier})
if r.status_code >= 400:
raise HTTPException(502, f"Token-Tausch fehlgeschlagen ({r.status_code})")
return r.json()
def _claims(id_token: str) -> dict:
try:
p = id_token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
except Exception: # noqa: BLE001
return {}
@router.get("/auth/login")
def login():
c = cfg()
if not AUTH_ENABLED:
return RedirectResponse("/", 302)
if not c["client_id"] or not c["client_secret"]:
raise HTTPException(503, "OIDC nicht konfiguriert (OIDC_CLIENT_ID / OIDC_CLIENT_SECRET).")
state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
q = urlencode({"response_type": "code", "client_id": c["client_id"], "redirect_uri": c["redirect"], "scope": "openid profile email",
"state": state, "code_challenge": challenge, "code_challenge_method": "S256"})
resp = RedirectResponse(f"{c['issuer']}/oauth/authorize?{q}", 302)
resp.set_cookie(STATE_COOKIE, _sign({"state": state, "cv": verifier, "exp": int(time.time()) + STATE_TTL}),
max_age=STATE_TTL, httponly=True, secure=c["secure"], samesite="lax")
return resp
@router.get("/auth/callback")
def callback(request: Request, code: str = "", state: str = "", error: str = ""):
c = cfg()
if error:
raise HTTPException(400, f"Login abgebrochen: {error}")
pending = _verify(request.cookies.get(STATE_COOKIE, "") or "")
if not pending or not hmac.compare_digest(str(pending.get("state", "")), state) or not code:
return RedirectResponse("/auth/login", 303) # abgelaufen/Reload → neu starten
tokens = exchange_code(code, pending["cv"])
cl = _claims(tokens.get("id_token", ""))
user = {"sub": cl.get("sub", ""), "email": cl.get("email", ""), "name": cl.get("name", ""), "app_roles": cl.get("app_roles", [])}
if not is_allowed(user):
return HTMLResponse(f"<h2 style='font-family:sans-serif'>Kein Zugriff</h2><p style='font-family:sans-serif'>{user['email'] or 'Dieses Konto'} "
"ist für die Onyscribe-Admin-Plattform nicht freigeschaltet.</p><p><a href='/auth/logout'>Abmelden</a></p>", status_code=403)
resp = RedirectResponse("/", 303)
resp.set_cookie(COOKIE, _sign({**user, "exp": int(time.time()) + SESSION_TTL}), max_age=SESSION_TTL, httponly=True,
secure=c["secure"], samesite="lax")
resp.delete_cookie(STATE_COOKIE)
return resp
@router.get("/auth/logout")
def logout():
c = cfg()
url = f"{c['issuer']}/logout?{urlencode({'post_logout_redirect_uri': c['logout_redirect']})}" if AUTH_ENABLED else "/"
resp = RedirectResponse(url, 302)
resp.delete_cookie(COOKIE)
return resp
@router.get("/auth/me")
def me(request: Request):
return {"user": getattr(request.state, "user", {}), "auth_enabled": AUTH_ENABLED}

42
app/db.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.settings import DATABASE_URL
_kwargs = {"connect_args": {"check_same_thread": False}} if DATABASE_URL.startswith("sqlite") else {"pool_pre_ping": True}
engine = create_engine(DATABASE_URL, **_kwargs)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False, autoflush=True)
class Base(DeclarativeBase):
pass
def init_db() -> None:
from app import models # noqa: F401
Base.metadata.create_all(engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@contextmanager
def session():
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()

19
app/deps.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.auth import actor_name
from app.db import get_db
from app.models import Tenant
def actor(request: Request) -> str:
return actor_name(request)
def tenant_or_404(tenant_id: str, db: Session = Depends(get_db)) -> Tenant:
t = db.get(Tenant, tenant_id)
if t is None:
raise HTTPException(404, f"Tenant „{tenant_id}“ nicht gefunden")
return t

42
app/main.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.db import init_db, session
from app import auth
from app.routers import api, pages
from sqlalchemy import func, select
from app.models import Tenant
from app.services import seed
from app.services.importer import seed_settings
from app.settings import ROOT
SEED_FILE = ROOT / "seed" / "tenants.seed.json.gz"
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
with session() as db:
# Frische Instanz → mit den importierten Tenants starten (ohne Secret-Werte)
if SEED_FILE.exists() and not db.scalar(select(func.count()).select_from(Tenant)):
print(f"[seed] {seed.load(db, SEED_FILE)} Tenants aus {SEED_FILE.name} geladen")
seed_settings(db)
yield
app = FastAPI(title="Onyscribe Admin", lifespan=lifespan)
app.middleware("http")(auth.middleware)
app.include_router(auth.router)
app.mount("/static", StaticFiles(directory=str(ROOT / "app" / "static")), name="static")
app.include_router(api.router)
app.include_router(pages.router)
@app.get("/healthz")
def healthz():
return {"ok": True}

149
app/models.py Normal file
View File

@@ -0,0 +1,149 @@
"""Datenmodell. Inhalte sind content-addressed (Blob) und versioniert (Version)."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def now() -> datetime:
return datetime.now(timezone.utc)
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(64), primary_key=True) # Slug, == TENANT-Env im Container
name: Mapped[str] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(20), default="active") # active | paused | archived
source: Mapped[str] = mapped_column(String(10), default="git") # TENANT_SOURCE im Container: git | admin
subdomain: Mapped[str] = mapped_column(String(100), default="")
custom_domain: Mapped[str] = mapped_column(String(200), default="")
domain_status: Mapped[str] = mapped_column(String(20), default="none") # none | pending | verified
cpu: Mapped[float] = mapped_column(Float, default=0.5)
memory: Mapped[str] = mapped_column(String(20), default="512M")
current_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
deployed_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
deployed_image: Mapped[str] = mapped_column(String(100), default="")
deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
draft: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"config": {...}, "files": {path: sha}}
preview_sample: Mapped[str | None] = mapped_column(Text, nullable=True)
oidc: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Soll-Zustand des OIDC-Clients + letzter Abgleich
core_pin: Mapped[str | None] = mapped_column(String(100), nullable=True) # fester Core-Tag (nimmt an Rollouts nicht teil)
render_baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {version, core, fp:{…}} Referenz für die Kompatibilitätsprüfung
notes: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
versions: Mapped[list["Version"]] = relationship(back_populates="tenant", order_by="Version.number.desc()")
class Blob(Base):
"""Dateiinhalt, adressiert über SHA-256. Später: große Binärdateien nach S3 auslagern."""
__tablename__ = "blobs"
sha: Mapped[str] = mapped_column(String(64), primary_key=True)
content: Mapped[bytes] = mapped_column(LargeBinary)
size: Mapped[int] = mapped_column(Integer)
is_text: Mapped[bool] = mapped_column(Boolean, default=True)
class Version(Base):
"""Unveränderlicher Snapshot eines Tenants."""
__tablename__ = "versions"
__table_args__ = (UniqueConstraint("tenant_id", "number"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
number: Mapped[int] = mapped_column(Integer)
message: Mapped[str] = mapped_column(String(500), default="")
author: Mapped[str] = mapped_column(String(100), default="") # "ruben" | "agent:<run>" | "import"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
config: Mapped[dict] = mapped_column(JSON) # Inhalt der tenant.json (ohne deploy-Block)
files: Mapped[dict] = mapped_column(JSON) # {pfad: sha}
tenant: Mapped[Tenant] = relationship(back_populates="versions")
class Secret(Base):
"""Verschlüsselter Secret-Wert. tenant_id NULL = geteilt für alle Tenants."""
__tablename__ = "secrets"
__table_args__ = (UniqueConstraint("tenant_id", "key"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
key: Mapped[str] = mapped_column(String(100))
value_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(30), default="manual") # manual | sops-import
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
class Deployment(Base):
__tablename__ = "deployments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(String(40), default="", index=True)
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
kind: Mapped[str] = mapped_column(String(20), default="deploy") # deploy | dry-run | offboard
version: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_tag: Mapped[str] = mapped_column(String(100), default="")
status: Mapped[str] = mapped_column(String(20), default="running") # running | success | failed
simulated: Mapped[bool] = mapped_column(Boolean, default=True)
log: Mapped[str] = mapped_column(Text, default="")
actor: Mapped[str] = mapped_column(String(100), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
actor: Mapped[str] = mapped_column(String(100), default="")
action: Mapped[str] = mapped_column(String(60))
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
detail: Mapped[str] = mapped_column(Text, default="")
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(20)) # user | assistant
content: Mapped[str] = mapped_column(Text, default="")
steps: Mapped[list | None] = mapped_column(JSON, nullable=True) # Tool-Aufrufe (Anzeige)
proposal: Mapped[dict | None] = mapped_column(JSON, nullable=True) # vorgeschlagene Änderung
proposal_status: Mapped[str] = mapped_column(String(20), default="") # "" | open | applied | rejected
usage: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"in":…, "out":…, "backend":…, "model":…}
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class CoreRelease(Base):
"""Vom Core-CI gemeldetes Release (Image-Tag). Wird geprüft, freigegeben und ausgerollt."""
__tablename__ = "core_releases"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sha: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tag: Mapped[str] = mapped_column(String(100))
ref: Mapped[str] = mapped_column(String(100), default="")
image: Mapped[str] = mapped_column(String(300), default="")
message: Mapped[str] = mapped_column(String(500), default="")
announced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
# new → checked → approved → rolling → active (rejected / failed / superseded)
status: Mapped[str] = mapped_column(String(20), default="new")
compat: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {checked_at, core_sha, tenants:{id:{status,…}}, summary:{…}}
approved_by: Mapped[str] = mapped_column(String(100), default="")
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
previous_tag: Mapped[str] = mapped_column(String(100), default="") # aktiver Tag vor dem Rollout (für Rollback)
rollout_batch: Mapped[str] = mapped_column(String(40), default="")

0
app/routers/__init__.py Normal file
View File

770
app/routers/api.py Normal file
View File

@@ -0,0 +1,770 @@
from __future__ import annotations
import json
import re
import socket
import hmac
import os
from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Request, Response, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.agent import loop as agent_loop
from app.agent.testrun import default_transcript, prompt_test
from app.agent.workspace import is_personal
from app.db import get_db, session
from app.deps import actor, tenant_or_404
from app.models import Blob, ChatMessage, CoreRelease, Deployment, Secret, Setting, Tenant, Version
from app.services import contract, crypto, defaults, deploy, guard, llm, onyva_login, preflight, preview, releases, status as status_svc, store, tenants as tsvc
router = APIRouter(prefix="/api")
def _iso(d):
return d.isoformat() if d else None
# ── Tenants ─────────────────────────────────────────────────────────────────
class NewTenant(BaseModel):
id: str
name: str
clone_from: str | None = None
@router.post("/tenants")
def create_tenant(body: NewTenant, db: Session = Depends(get_db), who: str = Depends(actor)):
try:
t = tsvc.create(db, body.id, body.name, body.clone_from or None, who)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"id": t.id}
@router.get("/tenants/{tenant_id}/state")
def state(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
cfg, files = store.content(db, t, "draft")
meta = {p: {"size": db.get(Blob, sha).size, "text": store.is_text_path(p), "personal": is_personal(p)} for p, sha in files.items()}
return {
"id": t.id, "name": t.name, "status": t.status, "source": t.source, "notes": t.notes,
"has_draft": bool(t.draft), "current_version": t.current_version, "deployed_version": t.deployed_version,
"deployed_image": t.deployed_image, "config": cfg, "files": meta,
"host": deploy.host_for(db, t), "subdomain": t.subdomain, "custom_domain": t.custom_domain,
"domain_status": t.domain_status, "cpu": t.cpu, "memory": t.memory,
"base_domain": deploy.setting(db, "base_domain", "example.de"),
"color_labels": defaults.COLOR_LABELS, "core_version": preview.core_version(),
"sample": preview.sample_for(t), "has_custom_sample": bool(t.preview_sample),
"sample_transcript": default_transcript(t.id), "llm_ready": llm.configured(db, t.id),
"core": {"tag": deploy.effective_tag(db, t), "pin": t.core_pin, "sha": contract.sha()[:10], "contract_ok": contract.supported()},
"schema_problems": contract.check_tenant_json(cfg),
}
@router.put("/tenants/{tenant_id}/meta")
def put_meta(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
if "cpu" in body:
cpu = float(body["cpu"])
if not 0.1 <= cpu <= 8:
raise HTTPException(400, "CPU zwischen 0,1 und 8")
t.cpu = cpu
if "memory" in body:
if not re.fullmatch(r"\d{1,5}[MG]", str(body["memory"])):
raise HTTPException(400, "RAM z. B. 512M oder 1G")
t.memory = str(body["memory"])
if "status" in body:
if body["status"] not in ("active", "paused", "archived"):
raise HTTPException(400, "status: active | paused | archived")
t.status = body["status"]
if "notes" in body:
t.notes = str(body["notes"])[:5000]
store.audit(db, who, "tenant.meta", t.id, ", ".join(body))
db.commit()
return {"ok": True}
@router.delete("/tenants/{tenant_id}")
def delete_tenant(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Löscht nur die Datenbank-Einträge. Infrastruktur (Container/Ingress) vorher per Offboard entfernen."""
if t.status != "archived":
raise HTTPException(400, "Nur archivierte Tenants können gelöscht werden.")
if confirm != t.id:
raise HTTPException(400, "Bestätigung: ID des Tenants angeben.")
for model in (Secret, Version, ChatMessage):
for row in db.scalars(select(model).where(model.tenant_id == t.id)):
db.delete(row)
store.audit(db, who, "tenant.delete", t.id)
db.delete(t)
db.commit()
return {"ok": True}
@router.put("/tenants/{tenant_id}/config")
def put_config(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
store.set_config(db, t, body)
db.commit()
return {"has_draft": bool(t.draft)}
@router.get("/tenants/{tenant_id}/files/{path:path}")
def get_file(path: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
try:
_, files = preview.resolve_content(db, t, source)
except preview.SourceGone:
_, files = store.content(db, t, "draft")
if path not in files:
from app.settings import PLATFORM_DIR
for base in ("defaults", "templates"):
p = PLATFORM_DIR / "core" / base / path
if p.exists() and p.is_file():
return Response(p.read_bytes(), headers={"X-Default": "1"}, media_type="text/plain; charset=utf-8")
raise HTTPException(404, "Datei nicht vorhanden")
data = store.get_blob(db, files[path])
mt = "image/svg+xml" if path.endswith(".svg") else "image/png" if path.endswith(".png") else \
"application/pdf" if path.endswith(".pdf") else "text/plain; charset=utf-8"
return Response(data, media_type=mt)
@router.put("/tenants/{tenant_id}/files/{path:path}")
def put_file(path: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
try:
if path.endswith(".py"):
compile(body.get("content", ""), path, "exec")
store.set_file(db, t, path, body.get("content", "").encode("utf-8"))
except SyntaxError as exc:
raise HTTPException(400, f"Python-Syntaxfehler in Zeile {exc.lineno}: {exc.msg}")
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"has_draft": bool(t.draft)}
@router.post("/tenants/{tenant_id}/upload")
async def upload(file: UploadFile = File(...), path: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
data = await file.read()
if len(data) > 8 * 1024 * 1024:
raise HTTPException(413, "Datei größer als 8 MB")
try:
store.set_file(db, t, path or file.filename, data)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"ok": True}
@router.delete("/tenants/{tenant_id}/files/{path:path}")
def del_file(path: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
store.delete_file(db, t, path)
db.commit()
return {"has_draft": bool(t.draft)}
@router.put("/tenants/{tenant_id}/sample")
def put_sample(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
t.preview_sample = body.get("sample") or None
db.commit()
return {"ok": True}
@router.post("/tenants/{tenant_id}/draft/discard")
def discard(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
store.discard_draft(db, t)
store.audit(db, who, "draft.discard", t.id)
db.commit()
return {"ok": True}
@router.get("/tenants/{tenant_id}/diff")
def diff(a: str = "current", b: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return store.diff_summary(db, t, a, b, with_text=True)
class PublishBody(BaseModel):
message: str = ""
@router.post("/tenants/{tenant_id}/publish")
def publish(body: PublishBody, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
v = store.publish(db, t, body.message, who)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"version": v.number}
@router.post("/tenants/{tenant_id}/validate")
def validate(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "draft"):
try:
res = dict(preview.run(db, t, source, "validate"))
except (preview.PreviewError, preview.SourceGone) as exc:
return {"ok": False, "errors": [str(exc)], "warnings": []}
cfg, _ = preview.resolve_content(db, t, source)
res["warnings"] = [*res.get("warnings", []), *(f"Schema: {p}" for p in contract.check_tenant_json(cfg))] # unbekannte Optionen/Typfehler (Tippfehler)
return res
@router.get("/tenants/{tenant_id}/versions")
def versions(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
out = []
for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number.desc())):
out.append({"number": v.number, "message": v.message, "author": v.author, "created_at": _iso(v.created_at),
"files": len(v.files), "current": v.number == t.current_version, "deployed": v.number == t.deployed_version})
return out
@router.post("/tenants/{tenant_id}/versions/{number}/restore")
def restore(number: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
store.restore_as_draft(db, t, number)
except KeyError:
raise HTTPException(404, "Version nicht gefunden")
store.audit(db, who, "version.restore_draft", t.id, f"v{number}")
db.commit()
return {"has_draft": bool(t.draft)}
# ── Prompt-Test & LLM ───────────────────────────────────────────────────────
@router.post("/tenants/{tenant_id}/prompt-test")
def prompt_test_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Echter LLM-Lauf mit dem (Entwurfs-)Prompt auf einem Beispieltranskript. Kostet Cortecs-Guthaben."""
cfg, files = store.content(db, t, body.get("source", "draft"))
try:
r = prompt_test(db, t, cfg, files, body.get("transcript"), body.get("terminart", ""), body.get("projekt", ""), body.get("kunde", ""))
except llm.LLMNotConfigured as exc:
raise HTTPException(400, str(exc))
except preview.PreviewError as exc:
raise HTTPException(422, f"Core-Rendering fehlgeschlagen: {str(exc)[-600:]}")
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"LLM-Aufruf fehlgeschlagen: {type(exc).__name__}: {str(exc)[:300]}")
store.audit(db, who, "prompt.test", t.id, f"{r['model']} {r['input_tokens']}→{r['output_tokens']} Tokens")
db.commit()
return r
@router.get("/tenants/{tenant_id}/final-prompt")
def final_prompt(source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
"""Finaler System-Prompt, wie der Core ihn an das LLM schickt (Platzhalter ersetzt, Hooks angewandt)."""
try:
return preview.run(db, t, source, "prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""})
except preview.PreviewError as exc:
raise HTTPException(422, str(exc)[-800:])
@router.get("/llm/status")
def llm_status(tenant: str = "", db: Session = Depends(get_db)):
key, source, error = llm.key_info(db, tenant or None)
return {"configured": bool(key), "source": source, "error": error, "model": deploy.setting(db, "agent_model", "claude-4-6-sonnet"),
"api": deploy.setting(db, "agent_api", "messages"), "base_url": llm.base_url(db)}
@router.get("/llm/models")
def llm_models(db: Session = Depends(get_db)):
try:
return llm.list_models(db)
except llm.LLMNotConfigured as exc:
raise HTTPException(400, str(exc))
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Modellliste nicht abrufbar: {type(exc).__name__}: {str(exc)[:200]}")
# ── Domain, Secrets, Quelle ─────────────────────────────────────────────────
@router.put("/tenants/{tenant_id}/domain")
def put_domain(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
sub = (body.get("subdomain") or "").strip().lower()
custom = (body.get("custom_domain") or "").strip().lower()
if sub and not re.fullmatch(r"[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?", sub):
raise HTTPException(400, "Subdomain: a-z, 0-9, Bindestrich")
if custom and not re.fullmatch(r"([a-z0-9-]+\.)+[a-z]{2,}", custom):
raise HTTPException(400, "Eigene Domain ungültig")
t.subdomain = sub or t.subdomain
if custom != t.custom_domain:
t.custom_domain = custom
t.domain_status = "pending" if custom else ("verified" if t.source == "git" else "none")
store.audit(db, who, "domain.set", t.id, f"{t.subdomain} / {custom or '–'}")
db.commit()
return {"host": deploy.host_for(db, t), "domain_status": t.domain_status}
@router.post("/tenants/{tenant_id}/domain/check")
def check_domain(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
host = t.custom_domain or deploy.host_for(db, t)
try:
ips = sorted({ai[4][0] for ai in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)})
except OSError:
ips = []
if ips:
t.domain_status = "verified"
msg = f"{host} löst auf {', '.join(ips)} auf. (Die Ziel-Prüfung gegen das Mittwald-Projekt folgt mit dem Live-Deploy.)"
else:
t.domain_status = "pending" if t.custom_domain else t.domain_status
msg = f"{host} löst noch nicht auf. DNS-Einträge prüfen; Änderungen können bis zu 24 h dauern."
db.commit()
return {"status": t.domain_status, "message": msg}
# ── OIDC-Client (Developer-API von login.onyva.dev) ─────────────────────────
def _oidc_view(db: Session, t: Tenant) -> dict:
cfg = onyva_login.config_of(t)
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
sec = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_SECRET"))
base = deploy.setting(db, "base_domain", "example.de")
return {**cfg, "client_id": crypto.decrypt(cid.value_enc) if cid and cid.value_enc else None, "secret_set": bool(sec and sec.value_enc),
"redirect_uris": onyva_login.desired_redirects(base, t), "api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(),
"api_base": onyva_login.base_url(db)}
@router.get("/tenants/{tenant_id}/oidc")
def oidc_get(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return _oidc_view(db, t)
@router.put("/tenants/{tenant_id}/oidc")
def oidc_put(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
new = onyva_login.validate_config({**onyva_login.config_of(t), **body})
except ValueError as exc:
raise HTTPException(400, str(exc))
new["last_sync"] = onyva_login.config_of(t)["last_sync"]
t.oidc = new
store.audit(db, who, "oidc.config", t.id, f"mode={new['mode']} auth={new['auth_mode']} members={len(new['members'])}")
db.commit()
return _oidc_view(db, t)
@router.post("/tenants/{tenant_id}/oidc/sync")
def oidc_sync(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
"""apply=false: nur lesen und Plan zeigen. apply=true: schreiben (nur mit OIDC_SYNC_LIVE/DEPLOY_LIVE)."""
apply = bool(body.get("apply"))
if apply and not guard.live_allowed(t.id):
raise HTTPException(403, guard.denial(t.id))
if apply and not onyva_login.writes_allowed():
raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt (Env OIDC_SYNC_LIVE oder DEPLOY_LIVE ≠ true). Plan (Dry-Run) ist möglich.")
lines: list[str] = []
rep = onyva_login.sync_tenant(t.id, apply=apply, log=lines.append, rotate=bool(body.get("rotate")), actor=who)
return {**rep, "log": lines}
@router.post("/tenants/{tenant_id}/oidc/import-members")
def oidc_import_members(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Liest die aktuellen Freigaben aus dem Login und übernimmt sie als Soll-Liste (nur lesender API-Zugriff)."""
client = onyva_login.client_from_env(db)
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
if client is None:
raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.")
if not (cid and cid.value_enc):
raise HTTPException(400, "Tenant hat noch keine OIDC_CLIENT_ID.")
try:
ms = client.memberships(crypto.decrypt(cid.value_enc))
except onyva_login.OnyvaError as exc:
raise HTTPException(502, str(exc))
cfg = onyva_login.config_of(t)
cfg["members"] = [{"email": m["email"], "role": m.get("role", "viewer")} for m in ms]
t.oidc = {**cfg, "last_sync": cfg["last_sync"]}
store.audit(db, who, "oidc.import_members", t.id, f"{len(ms)} Freigaben")
db.commit()
return _oidc_view(db, t)
@router.delete("/tenants/{tenant_id}/oidc/app")
def oidc_delete_app(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Löscht den OIDC-Client im Login (nicht umkehrbar, beendet alle Logins dieser App)."""
if confirm != t.id:
raise HTTPException(400, "Bestätigung: ID des Tenants angeben.")
if not onyva_login.writes_allowed():
raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt.")
if not guard.live_allowed(t.id):
raise HTTPException(403, guard.denial(t.id))
client = onyva_login.client_from_env(db)
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
if client is None or not (cid and cid.value_enc):
raise HTTPException(400, "Kein API-Key oder keine Client-ID vorhanden.")
try:
client.delete_app(crypto.decrypt(cid.value_enc))
except onyva_login.OnyvaError as exc:
raise HTTPException(502, str(exc))
for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"):
tsvc.set_secret(db, t.id, key, None, who)
store.audit(db, who, "oidc.app_deleted", t.id)
db.commit()
return _oidc_view(db, t)
@router.get("/oidc/status")
def oidc_status(db: Session = Depends(get_db)):
return {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)}
@router.post("/oidc/test")
def oidc_test(db: Session = Depends(get_db)):
client = onyva_login.client_from_env(db)
if client is None:
raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.")
try:
apps = client.list_apps()
except onyva_login.OnyvaError as exc:
raise HTTPException(502, str(exc))
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Login-API nicht erreichbar: {type(exc).__name__}: {str(exc)[:200]}")
return {"ok": True, "apps": len(apps)}
@router.get("/tenants/{tenant_id}/secrets")
def secrets_list(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return tsvc.secret_rows(db, t.id)
@router.put("/tenants/{tenant_id}/secrets/{key}")
def secret_put(key: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
value = body.get("value")
if body.get("generate"):
value = defaults.new_token()
try:
tsvc.set_secret(db, t.id, key, value, who)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"ok": True}
@router.put("/tenants/{tenant_id}/source")
def set_source(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
src = body.get("source")
if src not in ("git", "admin"):
raise HTTPException(400, "source: git | admin")
t.source = src
store.audit(db, who, "tenant.source", t.id, f"TENANT_SOURCE={src}")
db.commit()
return {"source": t.source}
@router.get("/tenants/{tenant_id}/cutover")
def cutover(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
try:
val = preview.run(db, t, "current", "validate")
except preview.PreviewError:
val = {"ok": False}
return {"checks": tsvc.cutover_checks(db, t, val), "source": t.source}
# ── Deploy ──────────────────────────────────────────────────────────────────
def _dep(d: Deployment):
return {"id": d.id, "batch": d.batch_id, "tenant": d.tenant_id, "kind": d.kind, "version": d.version,
"image": d.image_tag, "status": d.status, "simulated": d.simulated, "log": d.log, "actor": d.actor,
"created_at": _iso(d.created_at), "finished_at": _iso(d.finished_at)}
@router.get("/deploy/mode")
def deploy_mode(db: Session = Depends(get_db)):
return {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe(), "project": bool(deploy.setting(db, "mittwald_project_id")),
"core_bundle": deploy.setting(db, "core_supports_bundle", "false") == "true"}
@router.get("/tenants/{tenant_id}/preflight")
def tenant_preflight(t: Tenant = Depends(tenant_or_404)):
"""Vorflug-Check für einen Live-Deploy (liest nur, schreibt nichts)."""
return preflight.run(t.id)
@router.get("/tenants/{tenant_id}/plan")
def plan(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return deploy.plan(db, t)
def _kind(body: dict, base: str, tenant_id: str | None = None) -> str:
dry = bool(body.get("dry_run"))
if not dry and not deploy.live_enabled():
raise HTTPException(403, "Echte Deployments sind gesperrt (Env DEPLOY_LIVE != true). Nur Dry-Run möglich.")
if not dry and tenant_id and not guard.live_allowed(tenant_id):
raise HTTPException(403, guard.denial(tenant_id))
return f"{base}-dry" if base == "offboard" and dry else ("dry-run" if dry else base)
@router.post("/tenants/{tenant_id}/deploy")
def deploy_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
return {"id": deploy.start(t.id, _kind(body, "deploy", t.id), who)}
@router.post("/tenants/{tenant_id}/offboard")
def offboard_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
return {"id": deploy.start(t.id, _kind(body, "offboard", t.id), who)}
@router.post("/deploy-all")
def deploy_all(body: dict = Body(default={}), who: str = Depends(actor)):
batch, ids = deploy.start_all(_kind(body, "deploy"), who)
return {"batch": batch, "ids": ids}
@router.get("/deployments/{dep_id}")
def deployment(dep_id: int, db: Session = Depends(get_db)):
d = db.get(Deployment, dep_id)
if d is None:
raise HTTPException(404)
return _dep(d)
@router.get("/deployments")
def deployments(batch: str = "", db: Session = Depends(get_db)):
q = select(Deployment).order_by(Deployment.id.desc()).limit(60)
if batch:
q = select(Deployment).where(Deployment.batch_id == batch).order_by(Deployment.id)
return [_dep(d) for d in db.scalars(q)]
# ── Core: Vertrag, Releases, Rollout, Zustand ───────────────────────────────
@router.get("/contract")
def contract_info():
return {"available": contract.available(), "supported": contract.supported(), "manifest": contract.manifest(), "schema": contract.schema(),
"supported_versions": sorted(contract.SUPPORTED_CONTRACT)}
def _require_session(request: Request) -> None:
if not getattr(request.state, "user", None):
raise HTTPException(401, "Nicht angemeldet")
@router.post("/core/releases")
def core_release_intake(request: Request, body: dict = Body(...), authorization: str = Header(default=""), db: Session = Depends(get_db)):
"""Release-Eingang für die Core-CI (Bearer CORE_RELEASE_TOKEN). Meldet nur an – ausgerollt wird erst nach Prüfung und Freigabe."""
expected = os.getenv("CORE_RELEASE_TOKEN", "").strip()
if not expected:
raise HTTPException(503, "Release-Eingang nicht konfiguriert (Env CORE_RELEASE_TOKEN fehlt).")
if not hmac.compare_digest(authorization.removeprefix("Bearer ").strip(), expected):
raise HTTPException(401, "Ungültiges Token")
try:
r, created = releases.announce(db, str(body.get("sha", "")), body.get("tag"), str(body.get("ref", "")), str(body.get("image", "")), str(body.get("message", "")))
except releases.ReleaseError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"id": r.id, "created": created, "status": r.status, "admin_matches": releases.admin_matches(r)}
@router.get("/core/releases")
def core_releases(request: Request, db: Session = Depends(get_db)):
_require_session(request)
active = deploy.setting(db, "core_image_tag", "latest")
rows = db.scalars(select(CoreRelease).order_by(CoreRelease.id.desc()).limit(40)).all()
return {"admin_core": {**contract.build_info(), "contract": contract.contract_version(), "supported": contract.supported(),
"schema_sha": contract.manifest().get("schema_sha256"), "fields": contract.manifest().get("fields"),
"env": contract.manifest().get("env")},
"active_tag": active, "canary": releases.canary_list(db), "live": deploy.live_enabled(),
"intake": bool(os.getenv("CORE_RELEASE_TOKEN", "").strip()), "releases": [releases.view(r, active) for r in rows]}
@router.post("/core/releases/{release_id}/check")
def core_check(release_id: int, body: dict = Body(default={})):
try:
releases.check(release_id, force=bool(body.get("force")))
except releases.ReleaseError as exc:
raise HTTPException(400, str(exc))
return {"started": True}
@router.post("/core/releases/{release_id}/approve")
def core_approve(release_id: int, body: dict = Body(default={}), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
releases.approve(db, release_id, who, bool(body.get("acknowledge")))
except releases.ReleaseError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"ok": True}
@router.post("/core/releases/{release_id}/reject")
def core_reject(release_id: int, db: Session = Depends(get_db), who: str = Depends(actor)):
r = db.get(CoreRelease, release_id)
if r is None:
raise HTTPException(404)
r.status = "rejected"
store.audit(db, who, "core.reject", None, r.sha[:10])
db.commit()
return {"ok": True}
@router.post("/core/releases/{release_id}/rollout")
def core_rollout(release_id: int, body: dict = Body(default={}), who: str = Depends(actor)):
try:
batch, ids = releases.rollout(release_id, who, dry_run=bool(body.get("dry_run", True)))
except releases.ReleaseError as exc:
raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc))
return {"batch": batch, "ids": ids}
@router.post("/core/releases/{release_id}/rollback")
def core_rollback(release_id: int, who: str = Depends(actor)):
try:
batch, ids = releases.rollback_release(release_id, who)
except releases.ReleaseError as exc:
raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc))
return {"batch": batch, "ids": ids}
@router.put("/tenants/{tenant_id}/core-pin")
def tenant_pin(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
pin = (body.get("pin") or "").strip() or None
if pin and not re.fullmatch(r"[A-Za-z0-9_.-]{1,100}", pin):
raise HTTPException(400, "Tag: Buchstaben, Ziffern, . _ -")
t.core_pin = pin
store.audit(db, who, "tenant.core_pin", t.id, pin or "aufgehoben")
db.commit()
return {"pin": t.core_pin}
@router.post("/tenants/{tenant_id}/rollback")
def tenant_rollback(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
if not deploy.live_enabled():
raise HTTPException(403, "Echte Deployments sind gesperrt (DEPLOY_LIVE != true).")
if not guard.live_allowed(t.id):
raise HTTPException(403, guard.denial(t.id))
tag = releases.tenant_rollback_tag(db, t)
if not tag:
raise HTTPException(400, "Kein früherer, erfolgreich deployter Core-Tag bekannt.")
return {"id": deploy.start(t.id, "deploy", who, core_tag=tag), "tag": tag}
@router.get("/status")
def tenants_status(refresh: bool = False):
"""Was läuft wirklich? Fragt /health jedes aktiven Tenants ab (30 s Cache)."""
return status_svc.probe_all(refresh=refresh)
# ── Einstellungen ───────────────────────────────────────────────────────────
@router.get("/settings")
def get_settings(db: Session = Depends(get_db)):
return {s.key: s.value for s in db.scalars(select(Setting))}
@router.put("/settings")
def put_settings(body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)):
for k, v in body.items():
s = db.get(Setting, k)
if s is None:
s = Setting(key=k)
db.add(s)
s.value = str(v)
store.audit(db, who, "settings.update", None, ", ".join(body))
db.commit()
return {"ok": True}
@router.put("/shared-secrets/{key}")
def shared_secret(key: str, body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)):
r = db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == key))
if r is None:
r = Secret(tenant_id=None, key=key)
db.add(r)
r.value_enc = crypto.encrypt(body["value"]) if body.get("value") else None
r.source = "manual"
store.audit(db, who, "shared-secret.set", None, key)
db.commit()
return {"ok": True}
# ── Agent ───────────────────────────────────────────────────────────────────
def _msg(m: ChatMessage):
return {"id": m.id, "role": m.role, "content": m.content, "steps": m.steps or [], "proposal": m.proposal,
"proposal_status": m.proposal_status, "usage": m.usage, "created_at": _iso(m.created_at)}
@router.get("/tenants/{tenant_id}/chat")
def chat_history(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return [_msg(m) for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id).order_by(ChatMessage.id))]
def _sse(ev: dict) -> str:
return f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
@router.post("/tenants/{tenant_id}/chat")
def chat(tenant_id: str, body: dict = Body(...), who: str = Depends(actor)):
"""Agent-Lauf als Server-Sent-Events: mode, step*, thought*, text, proposal, usage, done."""
text = (body.get("message") or "").strip()
if not text:
raise HTTPException(400, "Leere Nachricht")
def gen():
with session() as db:
t = db.get(Tenant, tenant_id)
steps, final, proposal, usage, mode, error = [], "", None, None, {}, None
for ev in agent_loop.run(db, t, text):
if ev["type"] == "step":
steps.append({k: ev[k] for k in ("tool", "arg", "result", "error")})
elif ev["type"] == "text":
final = ev["text"]
elif ev["type"] == "proposal":
proposal = ev["proposal"]
elif ev["type"] == "usage":
usage = ev["usage"]
elif ev["type"] == "mode":
mode = ev
elif ev["type"] == "error":
error = ev["text"]
yield _sse(ev if ev["type"] != "proposal" else {"type": "proposal", "summary": (proposal or {}).get("summary"),
"items": (proposal or {}).get("items", [])})
db.add(ChatMessage(tenant_id=tenant_id, role="user", content=text))
m = ChatMessage(tenant_id=tenant_id, role="assistant", content=error and f"⚠ {error}" or final, steps=steps, proposal=proposal,
proposal_status="open" if proposal else "",
usage={**(usage or {}), "backend": mode.get("backend"), "model": mode.get("model")})
db.add(m)
store.audit(db, who, "agent.run", tenant_id, f"{mode.get('backend')}/{mode.get('model')} {text[:80]}")
db.commit()
yield _sse({"type": "done", "message": _msg(m)})
return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@router.get("/tenants/{tenant_id}/chat/{msg_id}/diff")
def chat_diff(msg_id: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
m = db.get(ChatMessage, msg_id)
if m is None or m.tenant_id != t.id or not m.proposal:
raise HTTPException(404)
return store.diff_content(db, store.content(db, t, "draft"), agent_loop.proposal_content(db, t, m.proposal), with_text=True)
@router.post("/tenants/{tenant_id}/chat/{msg_id}/{decision}")
def chat_decide(msg_id: int, decision: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
m = db.get(ChatMessage, msg_id)
if m is None or m.tenant_id != t.id or m.proposal_status != "open":
raise HTTPException(400, "Kein offener Vorschlag")
if decision == "apply":
agent_loop.apply_proposal(db, t, m.proposal)
m.proposal_status = "applied"
store.audit(db, "agent", "agent.proposal.apply", t.id, f"freigegeben von {who}: {(m.proposal or {}).get('summary')}")
elif decision == "reject":
m.proposal_status = "rejected"
else:
raise HTTPException(400)
db.commit()
return _msg(m)
@router.delete("/tenants/{tenant_id}/chat")
def chat_clear(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id)):
db.delete(m)
db.commit()
return {"ok": True}
# ── Bundle: genau das holt ein Tenant-Container beim Start ──────────────────
@router.get("/tenants/{tenant_id}/bundle")
def bundle(tenant_id: str, authorization: str = Header(default=""), db: Session = Depends(get_db)):
token = authorization.removeprefix("Bearer ").strip()
if not tsvc.check_bundle_token(db, tenant_id, token):
raise HTTPException(401, "Ungültiges Bundle-Token")
t = db.get(Tenant, tenant_id)
data, manifest = store.bundle_tar(db, t, "current")
return Response(data, media_type="application/gzip", headers={"X-Bundle-Version": str(t.current_version), "ETag": f'"{t.current_version}"'})

107
app/routers/pages.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import HTMLResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db import get_db
from app.deps import tenant_or_404
from app.models import AuditLog, Deployment, Secret, Setting, Tenant
from app.services import deploy, guard, onyva_login, preview, store
from app.services.importer import DEFAULT_SETTINGS
from app.templating import templates
router = APIRouter()
def _logo(db, t: Tenant, source="current") -> str | None:
_, files = store.content(db, t, source)
for name in ("logo.svg", "logo.png"):
if name in files:
return f"/api/tenants/{t.id}/files/{name}?source={source}"
return None
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, db: Session = Depends(get_db)):
rows = []
for t in db.scalars(select(Tenant).order_by(Tenant.id)):
cfg, files = store.content(db, t, "current")
missing = db.scalar(select(func.count()).select_from(Secret).where(Secret.tenant_id == t.id, Secret.value_enc.is_(None))) or 0
rows.append({
"t": t, "cfg": cfg, "accent": (cfg.get("colors") or {}).get("accent", "#327F24"),
"logo": _logo(db, t), "host": deploy.host_for(db, t), "outdated": t.deployed_version != t.current_version,
"missing_secrets": missing, "stt": (cfg.get("stt") or {}).get("provider", "mistral"),
"has_code": "overrides.py" in files, "custom_prompt": "prompt.txt" in files,
"custom_pdf": "protocol.html" in files or "protocol.css" in files,
})
stats = {
"tenants": len(rows), "admin": sum(1 for r in rows if r["t"].source == "admin"),
"drafts": sum(1 for r in rows if r["t"].draft), "outdated": sum(1 for r in rows if r["outdated"]),
"secrets": sum(1 for r in rows if r["missing_secrets"]),
}
last = db.scalars(select(Deployment).order_by(Deployment.id.desc()).limit(5)).all()
return templates.TemplateResponse(request, "dashboard.html", {
"rows": rows, "stats": stats, "last": last, "core": preview.core_version(),
"tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "dashboard"})
@router.get("/tenants/{tenant_id}", response_class=HTMLResponse)
def tenant_page(request: Request, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return templates.TemplateResponse(request, "tenant.html", {"t": t, "nav": "dashboard", "logo": _logo(db, t, "draft")})
@router.get("/tenants/{tenant_id}/bundle.tar.gz")
def bundle_download(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "current"):
data, _ = store.bundle_tar(db, t, source)
return Response(data, media_type="application/gzip",
headers={"Content-Disposition": f'attachment; filename="{t.id}-v{t.current_version}.tar.gz"'})
@router.get("/preview/{tenant_id}/{kind}")
def preview_view(kind: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
if kind not in ("web", "pdf"):
raise HTTPException(404)
try:
data = preview.run(db, t, source, "index" if kind == "web" else "pdf")
except (preview.PreviewError, preview.SourceGone) as exc:
return Response(str(exc), status_code=422, media_type="text/plain; charset=utf-8")
if kind == "pdf":
return Response(data, media_type="application/pdf", headers={"Content-Disposition": "inline"})
html = data.decode("utf-8")
# PNG-Logos referenziert der Core über /brand/logo → auf die Admin-Datei umbiegen
html = html.replace('src="/brand/logo"', f'src="/api/tenants/{t.id}/files/logo.png?source={source}"')
html = html.replace("</head>", "<base target='_blank'></head>", 1)
return HTMLResponse(html)
@router.get("/deployments", response_class=HTMLResponse)
def deployments_page(request: Request, db: Session = Depends(get_db)):
tenants = db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)).all()
return templates.TemplateResponse(request, "deployments.html", {
"tenants": tenants, "tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "deployments"})
@router.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
values = {k: deploy.setting(db, k, "") for k in DEFAULT_SETTINGS}
shared = {s.key: bool(s.value_enc) for s in db.scalars(select(Secret).where(Secret.tenant_id.is_(None)))}
from app.services import llm
return templates.TemplateResponse(request, "settings.html", {
"v": values, "shared": shared, "core": preview.core_version(), "nav": "settings",
"llm_ready": llm.configured(db), "mode": {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe()},
"oidc": {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)}})
@router.get("/audit", response_class=HTMLResponse)
def audit_page(request: Request, db: Session = Depends(get_db)):
rows = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(200)).all()
return templates.TemplateResponse(request, "audit.html", {"rows": rows, "nav": "audit"})
@router.get("/core", response_class=HTMLResponse)
def core_page(request: Request):
return templates.TemplateResponse(request, "core.html", {"nav": "core"})

0
app/services/__init__.py Normal file
View File

125
app/services/contract.py Normal file
View File

@@ -0,0 +1,125 @@
"""Vertrag mit dem Core (siehe transkript-platform/docs/CONTRACT.md): Schema, Manifest, Build-Info, Env-Spezifikation.
Gelesen wird aus dem Core, auf dem die Admin-Plattform läuft (PLATFORM_DIR: im Container das Core-Image, lokal der vendored Stand).
`core/contract.py` ist import-sicher (nur Standardbibliothek) und wird per Dateipfad geladen – der Core-Code selbst wird NIE
in diesem Prozess importiert (er hält Prozess-Singletons); gerendert wird in der Sandbox.
Ältere Cores ohne Vertrag („legacy“) funktionieren weiter: Schema fehlt → feste Admin-Formulare und feste Env-Liste.
"""
from __future__ import annotations
import importlib.util
import json
from functools import lru_cache
from pathlib import Path
from app.settings import PLATFORM_DIR
SUPPORTED_CONTRACT = {1}
# Rückfall für Cores ohne Schema (entspricht dem Stand vor dem Vertrag)
LEGACY_SHARED_ENV = {
"OIDC_ISSUER": "oidc_issuer", "AUTH_ENABLED": "auth_enabled", "APP_COOKIE_SECURE": "app_cookie_secure",
"MAX_FILE_SIZE_MB": "max_file_size_mb", "LLM_MODEL": "llm_model", "LLM_MAX_TOKENS": "llm_max_tokens",
"LLM_TEMPERATURE": "llm_temperature", "CORTECS_BASE_URL": "cortecs_base_url", "SMTP_HOST": "smtp_host",
"SMTP_PORT": "smtp_port", "SMTP_USER": "smtp_user", "SMTP_FROM": "smtp_from",
"MISTRAL_TRANSCRIBE_MODEL": "mistral_transcribe_model", "MISTRAL_DIARIZE": "mistral_diarize",
"MISTRAL_TIMEOUT": "mistral_timeout", "COST_API_BASE": "cost_api_base", "COST_TRACKING_ENABLED": "cost_tracking_enabled",
}
LEGACY_REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET"]
@lru_cache(maxsize=1)
def _module():
path = Path(PLATFORM_DIR) / "core" / "contract.py"
if not path.exists():
return None
spec = importlib.util.spec_from_file_location("onyscribe_core_contract", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def reload() -> None:
_module.cache_clear()
schema.cache_clear()
def available() -> bool:
return _module() is not None
@lru_cache(maxsize=1)
def schema() -> dict | None:
m = _module()
return m.load_schema() if m else None
def build_info() -> dict:
m = _module()
if m:
return m.build_info()
f = Path(PLATFORM_DIR) / "CORE_VERSION"
return {"sha": f.read_text().split()[0] if f.exists() else "unbekannt", "built_at": None, "source": "legacy"}
def manifest() -> dict:
m = _module()
if m:
return m.build_manifest()
return {"contract": 0, "core": build_info(), "legacy": True}
def contract_version() -> int:
return int(manifest().get("contract", 0))
def supported() -> bool:
return (not available()) or contract_version() in SUPPORTED_CONTRACT
def sha() -> str:
return build_info()["sha"]
def same_sha(a: str, b: str) -> bool:
a, b = (a or "").lower(), (b or "").lower()
return bool(a and b) and (a.startswith(b) or b.startswith(a)) and min(len(a), len(b)) >= 7
def check_tenant_json(raw: dict) -> list[str]:
m = _module()
return m.check_tenant_json(raw, schema()) if m else []
def field_paths() -> set[str]:
s = schema()
return {f["path"] for f in s["fields"]} if s else set()
def env_spec() -> list[dict]:
s = schema()
if s:
return s["env"]
return [{"name": n, "kind": "setting", "scope": "shared", "setting": k, "default": None, "label": n, "group": "Allgemein"} for n, k in LEGACY_SHARED_ENV.items()]
def required_secrets(tenant_cfg: dict | None = None) -> list[str]:
"""Pflicht-Secrets laut Schema (required, required_if wie „stt.provider!=cortecs“ gegen die Tenant-Config ausgewertet)."""
s = schema()
if not s:
return list(LEGACY_REQUIRED_SECRETS)
out = []
for e in s["env"]:
if e["kind"] != "secret":
continue
if e.get("required"):
out.append(e["name"])
elif e.get("required_if"):
path, _, rhs = e["required_if"].partition("!=")
cur = tenant_cfg or {}
for k in path.split("."):
cur = cur.get(k, {}) if isinstance(cur, dict) else {}
val = cur if isinstance(cur, str) else ""
if (val or "mistral") != rhs: # Provider-Standard ist mistral
out.append(e["name"])
return out

15
app/services/crypto.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from cryptography.fernet import Fernet
from app.settings import SECRET_KEY
_f = Fernet(SECRET_KEY.encode())
def encrypt(v: str) -> str:
return _f.encrypt(v.encode()).decode()
def decrypt(v: str) -> str:
return _f.decrypt(v.encode()).decode()

44
app/services/defaults.py Normal file
View File

@@ -0,0 +1,44 @@
import json
import secrets
# Standardfarben des Cores (core/config.py DEFAULT_COLORS) – Basis für neue Tenants
DEFAULT_COLORS = {
"accent": "#327F24", "accent_dark": "#235A19", "accent_mid": "#3E9130", "accent_light": "#EAF4E7",
"accent_xlight": "#F3FAF1", "bg": "#F1F5F0", "surface": "#FFFFFF", "surface2": "#F6FAF5",
"border": "#DCE8D9", "border2": "#BDD1B8", "text": "#444444", "text_2": "#666666", "text_3": "#9AA89A",
"red": "#DC2626", "progress_to": "#8bbf43",
}
COLOR_LABELS = {
"accent": "Akzent", "accent_dark": "Akzent dunkel", "accent_mid": "Akzent mittel", "accent_light": "Akzent hell",
"accent_xlight": "Akzent extra hell", "bg": "Seitenhintergrund", "surface": "Karten", "surface2": "Karten 2",
"border": "Rahmen", "border2": "Rahmen 2", "text": "Text", "text_2": "Text sekundär", "text_3": "Text tertiär",
"red": "Rot (Hinweis)", "progress_to": "Fortschritt (Verlauf-Ende)",
}
# Secret-Schlüssel = Env-Namen im Container (siehe infra/deploy.py)
REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY"]
OPTIONAL_SECRETS = ["MISTRAL_API_KEY"]
SECRET_HELP = {
"OIDC_CLIENT_ID": "Client-ID aus login.onyva.dev (manuell angelegt)",
"OIDC_CLIENT_SECRET": "Client-Secret aus login.onyva.dev",
"SESSION_SECRET": "Signiert Session-Cookies – kann hier erzeugt werden",
"COST_INGEST_KEY": "Schlüssel für die Kostenerfassung (Cost-API)",
"MISTRAL_API_KEY": "Nur nötig bei STT-Provider „mistral“ (bei „cortecs“ wird der geteilte Key genutzt)",
"ADMIN_BUNDLE_TOKEN": "Token, mit dem der Container sein Bundle bei der Admin-Plattform abholt",
}
def new_token() -> str:
return secrets.token_urlsafe(48)
def skeleton_config(tenant_id: str, name: str) -> dict:
return {
"id": tenant_id, "name": name, "title": f"{name} – Gesprächsprotokoll",
"description": f"Gesprächsprotokoll-Tool für {name}", "ai_detect_org": name,
"org_short": name, "org_profile": "",
"flags": {}, "stt": {"provider": "cortecs", "model": "voxtral-mini-transcribe-2602"},
"colors": dict(DEFAULT_COLORS),
"office": {"name": name, "subtitle": "", "address": "", "tel": "", "email": "", "website": ""},
}

314
app/services/deploy.py Normal file
View File

@@ -0,0 +1,314 @@
"""Deploy-Orchestrierung: Plan (wie infra/deploy.py), Ausführung über den Mittwald-Client.
Sicherheit: Ohne `DEPLOY_LIVE=true` (Env der Admin-Plattform) ist JEDE schreibende Operation gesperrt – es gibt nur Dry-Runs.
Außerdem blockiert der Core-Kompatibilitäts-Schalter (`core_supports_bundle`) Tenants mit Quelle „Admin“,
solange der Core das Bundle noch nicht laden kann.
"""
from __future__ import annotations
import os
import threading
import time
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import session
from app.models import Deployment, Secret, Setting, Tenant
from app.services import contract, crypto, guard, onyva_login, store
from app.services.mittwald import DeployError, Mittwald, mask_env
PORT = "8276"
def live_enabled() -> bool:
return os.getenv("DEPLOY_LIVE", "false").lower() == "true"
def token_present() -> bool:
return bool(os.getenv("MITTWALD_API_TOKEN", "").strip())
def setting(db, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value != "" else default
def host_for(db, t: Tenant) -> str:
if t.custom_domain and t.domain_status == "verified":
return t.custom_domain
return f"{t.subdomain or t.id}.{setting(db, 'base_domain', 'example.de')}"
def effective_tag(db, t: Tenant | None = None, override: str | None = None) -> str:
"""Welcher Core-Tag gilt? Override (Rollout) > Pin des Tenants > freigegebener Standard-Tag."""
return override or (t.core_pin if t is not None and t.core_pin else None) or setting(db, "core_image_tag", "latest")
def image_ref(db, t: Tenant | None = None, tag: str | None = None) -> str:
registry = setting(db, "registry", "").rstrip("/")
name = setting(db, "image_name", "transkript-platform")
base = f"{registry}/{name}" if registry else name
return f"{base.lower()}:{effective_tag(db, t, tag)}"
def build_env(db, t: Tenant, reveal: bool) -> dict[str, str]:
"""Container-Env, abgeleitet aus dem Env-Schema des Cores (kind/scope) – neue Core-Variablen erscheinen ohne Admin-Änderung.
reveal=False → Secrets als Platzhalter."""
host = host_for(db, t)
env = {"TENANT": t.id, "DATA_DIR": "/data", "PORT": PORT,
"OIDC_REDIRECT_URI": f"https://{host}/auth/callback", "OIDC_LOGOUT_REDIRECT_URI": f"https://{host}/"}
spec = contract.env_spec()
for e in spec: # geteilte, nicht geheime Einstellungen
if e["kind"] == "setting" and e["scope"] == "shared":
v = setting(db, e.get("setting") or e["name"].lower())
if v:
env[e["name"]] = v
shared = db.scalars(select(Secret).where(Secret.tenant_id.is_(None))).all()
own = db.scalars(select(Secret).where(Secret.tenant_id == t.id)).all()
for s_ in [*shared, *own]: # Tenant-Werte überschreiben geteilte; auch Secrets ohne Schema-Eintrag werden durchgereicht
if s_.key == "ADMIN_BUNDLE_TOKEN" and t.source != "admin":
continue
if s_.value_enc:
env[s_.key] = crypto.decrypt(s_.value_enc) if reveal else "•••• gesetzt"
elif not reveal:
env[s_.key] = "⚠ fehlt (Wert noch nicht in der Admin-DB)"
if t.source == "admin":
env["TENANT_SOURCE"] = "admin"
env["ADMIN_URL"] = setting(db, "admin_public_url", "https://<admin-host>")
return env
def missing_secrets(db, t: Tenant) -> list[str]:
"""Pflicht-Secrets laut Schema, die weder beim Tenant noch geteilt einen Wert haben."""
cfg, _ = store.content(db, t, "current")
have = {s.key for s in db.scalars(select(Secret).where((Secret.tenant_id == t.id) | Secret.tenant_id.is_(None))) if s.value_enc}
need = set(contract.required_secrets(cfg))
if t.source == "admin":
need.add("ADMIN_BUNDLE_TOKEN")
return sorted(need - have)
def plan(db, t: Tenant) -> dict:
cfg, _ = store.content(db, t, "current")
return {
"service": f"app-{t.id}", "volume": f"vol-{t.id}",
"patch": {"description": cfg.get("name") or f"Onyscribe {t.id}", "image": image_ref(db, t),
"environment": build_env(db, t, reveal=False), "ports": [f"{PORT}:{PORT}/tcp"], "volumes": [f"vol-{t.id}:/data"]},
"ingress": {"host": host_for(db, t), "target": f"app-{t.id}:{PORT}"},
"missing_secrets": missing_secrets(db, t),
"oidc": {"mode": onyva_login.config_of(t)["mode"], "key": onyva_login.key_present(), "writes": onyva_login.writes_allowed(),
"last_sync": onyva_login.config_of(t)["last_sync"]},
"mode": {"live": live_enabled(), "token": token_present(), "tenant_allowed": guard.live_allowed(t.id), "allowed": guard.describe(), "project": bool(setting(db, "mittwald_project_id")),
"core_bundle": setting(db, "core_supports_bundle", "false") == "true"},
"core": {"tag": effective_tag(db, t), "pinned": bool(t.core_pin), "admin_core": contract.sha()[:10], "contract": contract.contract_version()},
}
# ── Ausführung ──────────────────────────────────────────────────────────────
def _append(dep_id: int, line: str) -> None:
with session() as db:
db.get(Deployment, dep_id).log += f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} {line}\n"
def _finish(dep_id: int, status: str) -> None:
with session() as db:
d = db.get(Deployment, dep_id)
d.status, d.finished_at = status, datetime.now(timezone.utc)
def _client(live: bool, log, http=None) -> Mittwald:
return Mittwald(os.getenv("MITTWALD_API_TOKEN", "").strip(), os.getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), live, log, http)
def _hex_tag(tag: str) -> bool:
return bool(tag) and all(c in "0123456789abcdef" for c in tag.lower()) and len(tag) >= 7
def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None, log, *, get=None, tries: int = 18, delay: float = 5.0,
sleep=time.sleep) -> None:
"""Prüft nach dem Deploy, ob wirklich läuft, was wir ausgerollt haben (Vertrag: /health.core / .bundle).
Wirft DeployError, wenn der Container nach `tries` Versuchen nicht gesund ist oder die falsche Version meldet."""
if get is None:
import httpx
def get(url):
r = httpx.get(url, timeout=8, follow_redirects=True)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
last = "keine Antwort"
for i in range(tries):
try:
status, body = get(f"https://{host}/health")
problems = []
if status != 200 or body.get("status") != "ok":
problems.append(f"HTTP {status}")
core = body.get("core")
if expect_sha and _hex_tag(expect_sha):
if core is None:
log("⚠ /health meldet keine Core-Version (Image vor dem Vertrag) – Version nicht prüfbar")
elif not contract.same_sha(core.get("version", ""), expect_sha):
problems.append(f"Core {str(core.get('version'))[:10]} statt {expect_sha[:10]}")
if expect_bundle is not None and body.get("bundle"):
b = body["bundle"]
if b.get("source") != "admin" or b.get("version") != expect_bundle or b.get("stale"):
problems.append(f"Bundle {b.get('source')} v{b.get('version')}{' (veraltet)' if b.get('stale') else ''} statt admin v{expect_bundle}"
+ (f" – {b.get('error')}" if b.get("error") else ""))
if not problems:
log(f"Health-Check https://{host}/health ✔" + (f" · Core {core['version'][:10]}" if core else "")
+ (f" · Bundle v{body['bundle']['version']}" if body.get("bundle", {}).get("source") == "admin" else ""))
return
last = "; ".join(problems)
except Exception as exc: # noqa: BLE001
last = f"{type(exc).__name__}: {exc}"
if i < tries - 1:
sleep(delay)
raise DeployError(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}")
def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None, core_tag: str | None = None, health=None) -> bool:
"""Führt einen Deployment-Datensatz aus. kind: dry-run | deploy | offboard-dry | offboard.
core_tag überschreibt den Core-Tag (Rollout/Rollback); `health(host, expect_sha, expect_bundle, log)` ist in Tests ersetzbar."""
log = lambda m: _append(dep_id, m) # noqa: E731
dry = kind in ("dry-run", "offboard-dry")
live = live_enabled() and not dry
try:
with session() as db:
t = db.get(Tenant, tenant_id)
ver = t.current_version
project, stack = setting(db, "mittwald_project_id"), setting(db, "mittwald_stack_id")
host = host_for(db, t) if setting(db, "base_domain") else None
source, bundle_ok = t.source, setting(db, "core_supports_bundle", "false") == "true"
image, tag = image_ref(db, t, core_tag), effective_tag(db, t, core_tag)
pinned = bool(t.core_pin) and not core_tag
log(f"▶ {kind} „{tenant_id}“ · Version v{ver} · {image}{' (gepinnt)' if pinned else ''} · Modus: {'LIVE' if live else 'Dry-Run (keine Schreibzugriffe)'}")
if not contract.supported():
raise DeployError(f"Core-Vertrag {contract.contract_version()} wird von dieser Admin-Version nicht unterstützt – Admin aktualisieren.")
if source == "admin" and not bundle_ok and not dry:
raise DeployError("Quelle „Admin“ ist noch gesperrt: Der Core kann das Bundle noch nicht laden (Einstellung core_supports_bundle).")
if not dry and not live_enabled():
raise DeployError("Echte Deployments sind gesperrt (DEPLOY_LIVE != true).")
if not dry and not guard.live_allowed(tenant_id):
raise DeployError(guard.denial(tenant_id))
if live and source == "admin":
with session() as db:
url = setting(db, "admin_public_url")
if not url.startswith("https://") or "<" in url:
raise DeployError("Einstellung „Öffentliche URL der Admin-Plattform“ (https://…) fehlt – der Container könnte sein Bundle nicht holen.")
if not kind.startswith("offboard"):
# 1) OIDC-Client abgleichen (legt bei Bedarf an) – VOR dem Service, weil dessen Env die Client-Daten braucht
rep = onyva_login.sync_tenant(tenant_id, apply=live, log=log, client=oidc_client, actor="deploy")
if live and not rep["ok"]:
raise DeployError(f"OIDC-Abgleich fehlgeschlagen: {rep['error']}")
with session() as db:
t = db.get(Tenant, tenant_id)
if live:
lacking = missing_secrets(db, t)
if lacking:
raise DeployError("Pflicht-Secrets ohne Wert: " + ", ".join(lacking) + " (Tenant → Secrets bzw. Domain & Login, geteilte unter Einstellungen).")
definition = {"description": plan(db, t)["patch"]["description"], "image": image, "ports": [f"{PORT}:{PORT}/tcp"],
"volumes": [f"vol-{t.id}:/data"], "environment": build_env(db, t, reveal=live)}
mw = _client(live, log, http)
sid = mw.resolve_stack_id(project, stack)
if kind.startswith("offboard"):
mw.offboard_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", project_id=project, hostname=host)
log("Hinweis: Der OIDC-Client bleibt im Login bestehen (Tenant → Übersicht → Gefahrenbereich: „OIDC-Client im Login löschen“).")
else:
mw.deploy_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", definition=definition,
container_port=PORT, project_id=project, hostname=host)
if live and not kind.startswith("offboard") and host:
(health or verify_health)(host, tag, ver if source == "admin" else None, log)
if not dry:
with session() as db:
t = db.get(Tenant, tenant_id)
if kind == "deploy":
t.deployed_version, t.deployed_image, t.deployed_at = ver, tag, datetime.now(timezone.utc)
store.audit(db, "system", f"{kind}.live", tenant_id, f"v{ver} :{tag}")
log("✔ Fertig" if live else "Dry-Run beendet – es wurde nichts geschrieben.")
return True
except DeployError as exc:
log(f"✘ {exc}")
return False
except Exception as exc: # noqa: BLE001
log(f"✘ Unerwarteter Fehler: {type(exc).__name__}: {exc}")
return False
def start(tenant_id: str, kind: str, actor: str, batch_id: str = "", http=None, wait: bool = False, oidc_client=None,
core_tag: str | None = None, health=None) -> int:
with session() as db:
t = db.get(Tenant, tenant_id)
d = Deployment(tenant_id=tenant_id, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag),
actor=actor, batch_id=batch_id, simulated=not (live_enabled() and kind in ("deploy", "offboard")))
db.add(d)
db.flush()
dep_id = d.id
def work():
ok = execute(dep_id, tenant_id, kind, http, oidc_client, core_tag, health)
_finish(dep_id, "success" if ok else "failed")
th = threading.Thread(target=work, daemon=True)
th.start()
if wait:
th.join()
return dep_id
def run_sequence(tenant_ids: list[str], kind: str, actor: str, *, batch: str, core_tag: str | None = None, http=None, oidc_client=None,
health=None, canary: list[str] | None = None, wait: bool = False, on_done=None) -> list[int]:
"""Tenants nacheinander (zuerst die Canary-Tenants), Abbruch beim ersten Fehler – der Rest wird „cancelled“.
on_done(ok, results) wird nach dem Lauf aufgerufen (z. B. um ein Release abzuschließen)."""
if kind in ("deploy", "offboard") and live_enabled(): # Live: nur freigegebene Tenants (siehe guard.py)
skipped = [t for t in tenant_ids if not guard.live_allowed(t)]
tenant_ids = [t for t in tenant_ids if guard.live_allowed(t)]
if skipped:
with session() as db:
store.audit(db, actor, "deploy.skipped_not_allowed", None, ", ".join(skipped))
canary = [c for c in (canary or []) if c in tenant_ids]
order = canary + [t for t in tenant_ids if t not in canary]
with session() as db:
rows = []
for tid in order:
t = db.get(Tenant, tid)
d = Deployment(tenant_id=tid, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag), actor=actor,
batch_id=batch, status="queued", simulated=not (live_enabled() and kind == "deploy"))
db.add(d)
rows.append(d)
db.flush()
dep_ids = [d.id for d in rows]
def seq():
failed, results = False, {}
for tid, did in zip(order, dep_ids):
if failed:
_append(did, "übersprungen – vorheriger Tenant fehlgeschlagen (Rollout abgebrochen)")
_finish(did, "cancelled")
results[tid] = "cancelled"
continue
with session() as db:
db.get(Deployment, did).status = "running"
ok = execute(did, tid, kind, http, oidc_client, core_tag, health)
_finish(did, "success" if ok else "failed")
results[tid] = "success" if ok else "failed"
failed = not ok
if tid in canary and ok and tid == canary[-1]:
_append(did, "Canary erfolgreich – restliche Tenants folgen")
if on_done:
on_done(not failed, results)
th = threading.Thread(target=seq, daemon=True)
th.start()
if wait:
th.join()
return dep_ids
def start_all(kind: str, actor: str, http=None, wait: bool = False, oidc_client=None, core_tag: str | None = None, health=None) -> tuple[str, list[int]]:
"""Alle aktiven, nicht gepinnten Tenants nacheinander; beim ersten Fehler Abbruch."""
batch = uuid.uuid4().hex[:10]
with session() as db:
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not (core_tag and t.core_pin)]
return batch, run_sequence(ids, kind, actor, batch=batch, core_tag=core_tag, http=http, oidc_client=oidc_client, health=health, wait=wait)

35
app/services/guard.py Normal file
View File

@@ -0,0 +1,35 @@
"""Schreib-Freigabe für externe Systeme (Mittwald, Login-API) – pro Tenant.
`DEPLOY_ALLOWED_TENANTS` (Env der Admin-Plattform) legt fest, welche Tenants live angefasst werden dürfen:
(leer/nicht gesetzt) keiner – auch mit DEPLOY_LIVE=true wird nichts geschrieben
demo-admin,demo2 nur diese
* alle
Greift bei Live-Deploy, Offboard, Rollout/Rollback und beim Schreiben im Login-System (OIDC-Abgleich/Löschen).
Dry-Runs und Lesezugriffe sind nie betroffen. Damit lässt sich die Plattform schrittweise und gefahrlos parallel zum Altbetrieb einführen.
"""
from __future__ import annotations
import os
def allowed_tenants() -> set[str] | None:
"""None = alle, sonst die erlaubten IDs (leere Menge = keiner)."""
raw = os.getenv("DEPLOY_ALLOWED_TENANTS", "").strip()
if raw == "*":
return None
return {x.strip() for x in raw.split(",") if x.strip()}
def live_allowed(tenant_id: str) -> bool:
a = allowed_tenants()
return a is None or tenant_id in a
def describe() -> str:
a = allowed_tenants()
return "alle Tenants" if a is None else (", ".join(sorted(a)) if a else "keiner")
def denial(tenant_id: str) -> str:
return (f"Tenant „{tenant_id}“ ist für Live-Schreibzugriffe nicht freigegeben (Env DEPLOY_ALLOWED_TENANTS = {describe()}). "
"Dry-Runs sind weiterhin möglich.")

151
app/services/importer.py Normal file
View File

@@ -0,0 +1,151 @@
"""Importiert die Tenants der bestehenden transkript-platform (Git-Ordner) in die Datenbank.
Idempotent: ein Tenant, dessen Inhalt bereits identisch als aktuelle Version existiert,
wird übersprungen. Secrets werden standardmäßig NUR als Schlüsselnamen importiert
(Wert „extern/SOPS“); mit with_secrets=True werden sie via `sops -d` entschlüsselt
und verschlüsselt in der DB abgelegt.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import yaml
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Deployment, Secret, Setting, Tenant, Version
from app.services import crypto, store
from app.services.samples import DEFAULT_SAMPLE, ROTARY_SAMPLE
IGNORE = {"secrets.enc.yaml", "tenant.json"}
IGNORE_DIRS = {"__pycache__"}
# Bisherige Gitea-„Variables“ (docs/DEPLOYMENT.md) → geteilte Einstellungen
DEFAULT_SETTINGS = {
"base_domain": "onyscribe.de",
"mittwald_project_id": "",
"mittwald_stack_id": "",
"registry": "",
"image_name": "transkript-platform",
"oidc_issuer": "https://login.onyva.dev",
"onyva_login_api_base": "",
"auth_enabled": "true",
"app_cookie_secure": "true",
"max_file_size_mb": "300",
"cortecs_base_url": "https://api.cortecs.ai/v1",
"llm_model": "claude-4-6-sonnet",
"llm_max_tokens": "16000",
"llm_temperature": "0.2",
"smtp_host": "",
"smtp_port": "587",
"smtp_user": "",
"smtp_from": "Gesprächsprotokoll",
"mistral_transcribe_model": "voxtral-mini-latest",
"mistral_diarize": "true",
"mistral_timeout": "600",
"core_image_tag": "latest",
"core_supports_bundle": "false",
"canary_tenants": "demo",
"admin_public_url": "",
"cost_api_base": "",
"cost_tracking_enabled": "",
"agent_model": "claude-4-6-sonnet",
"agent_api": "messages",
}
def seed_settings(db: Session) -> None:
for k, v in DEFAULT_SETTINGS.items():
if db.get(Setting, k) is None:
db.add(Setting(key=k, value=v))
def _sops_keys(path: Path) -> list[str]:
if not path.exists():
return []
data = yaml.safe_load(path.read_text()) or {}
return [k for k in data if k != "sops"]
def _sops_decrypt(path: Path) -> dict[str, str]:
out = subprocess.run(["sops", "-d", "--output-type", "json", str(path)], capture_output=True, text=True, check=True)
return {k: str(v) for k, v in json.loads(out.stdout).items()}
def _upsert_secrets(db: Session, tenant_id: str | None, path: Path, with_secrets: bool) -> int:
keys = _sops_keys(path)
values = _sops_decrypt(path) if (with_secrets and keys) else {}
n = 0
for key in keys:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
if row is None:
row = Secret(tenant_id=tenant_id, key=key)
db.add(row)
if key in values and values[key]:
row.value_enc = crypto.encrypt(values[key])
row.source = "manual"
elif row.value_enc is None:
row.source = "sops-import"
n += 1
return n
def import_all(db: Session, root: Path, with_secrets: bool = False, force: bool = False) -> list[str]:
log: list[str] = []
seed_settings(db)
tenants_dir = root / "tenants"
src_rev = ""
try:
src_rev = subprocess.run(["git", "-C", str(root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
except Exception:
pass
n = _upsert_secrets(db, None, root / "secrets" / "shared.enc.yaml", with_secrets)
log.append(f"geteilte Secrets: {n} Schlüssel")
for tdir in sorted(p for p in tenants_dir.iterdir() if p.is_dir()):
tid = tdir.name
cfg_file = tdir / "tenant.json"
if not cfg_file.exists():
continue
raw = json.loads(cfg_file.read_text(encoding="utf-8"))
deploy = raw.pop("deploy", {}) or {}
files: dict[str, bytes] = {}
for p in sorted(tdir.rglob("*")):
if p.is_dir() or p.name in IGNORE or any(part in IGNORE_DIRS for part in p.relative_to(tdir).parts):
continue
files[p.relative_to(tdir).as_posix()] = p.read_bytes()
t = db.get(Tenant, tid)
if t is None:
res = deploy.get("resources") or {}
t = Tenant(
id=tid, name=raw.get("name", tid), subdomain=deploy.get("subdomain", tid),
cpu=float(res.get("cpu", 0.5)), memory=str(res.get("memory", "512M")),
domain_status="verified", source="git", deployed_image="git-baked",
preview_sample=ROTARY_SAMPLE if tid.startswith("rotary") else None,
# Bestehende Clients wurden von Hand angelegt → nur „adoptieren“: fehlende Redirect-URI ergänzen, sonst nichts anfassen
oidc={"mode": "adopt"},
)
db.add(t)
db.flush()
shas = {p: store.put_blob(db, data, p) for p, data in files.items()}
cur = store.current_version(db, t)
same = cur is not None and cur.files == shas and json.dumps(cur.config, sort_keys=True) == json.dumps(raw, sort_keys=True)
if same and not force:
log.append(f"{tid}: unverändert (v{t.current_version})")
else:
number = (t.current_version or 0) + 1
db.add(Version(tenant_id=tid, number=number, message=f"Import aus transkript-platform @{src_rev}",
author="import", config=raw, files=shas))
t.current_version = number
if t.deployed_version is None:
t.deployed_version = number
store.audit(db, "import", "tenant.import", tid, f"v{number} aus {src_rev}")
log.append(f"{tid}: importiert als v{number} ({len(files)} Dateien)")
k = _upsert_secrets(db, tid, tdir / "secrets.enc.yaml", with_secrets)
log.append(f" Secrets: {k} Schlüssel")
db.flush()
return log

121
app/services/llm.py Normal file
View File

@@ -0,0 +1,121 @@
"""Zugang zu Cortecs (OpenAI-kompatibel + Anthropic-Messages) für Agent und Prompt-Test.
Der Key kommt aus dem geteilten Secret CORTECS_API_KEY (verschlüsselt in der DB) oder der Env CORTECS_API_KEY.
"""
from __future__ import annotations
import os
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Secret, Setting
from app.services import crypto
DEFAULT_BASE = "https://api.cortecs.ai/v1"
class LLMNotConfigured(RuntimeError):
pass
def _setting(db: Session, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value else default
def key_info(db: Session, tenant_id: str | None = None) -> tuple[str, str, str | None]:
"""(key, quelle, fehler). Reihenfolge: geteiltes Secret (DB) → Env CORTECS_API_KEY → Key des Tenants (Rückfall).
Quelle: 'shared' | 'env' | 'tenant' | 'none'. fehler: z. B. nicht entschlüsselbar (SECRET_KEY geändert?)."""
error = None
rows = [("shared", db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == "CORTECS_API_KEY")))]
for source, row in rows:
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), source, None
except Exception: # noqa: BLE001 – falscher SECRET_KEY o. Ä.
error = "Geteilter CORTECS_API_KEY in der DB ist nicht entschlüsselbar (SECRET_KEY geändert?) – bitte neu setzen."
env = os.getenv("CORTECS_API_KEY", "").strip()
if env:
return env, "env", error
if tenant_id:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "CORTECS_API_KEY"))
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), "tenant", error
except Exception: # noqa: BLE001
error = error or "CORTECS_API_KEY dieses Tenants ist nicht entschlüsselbar."
return "", "none", error
def api_key(db: Session, tenant_id: str | None = None) -> str:
return key_info(db, tenant_id)[0]
def base_url(db: Session) -> str:
return _setting(db, "cortecs_base_url", DEFAULT_BASE).rstrip("/")
def configured(db: Session, tenant_id: str | None = None) -> bool:
return bool(api_key(db, tenant_id))
def require_key(db: Session, tenant_id: str | None = None) -> str:
k, _, err = key_info(db, tenant_id)
if not k:
raise LLMNotConfigured(err or "CORTECS_API_KEY fehlt – unter Einstellungen → Geteilte Secrets setzen (oder Env CORTECS_API_KEY).")
return k
def list_models(db: Session) -> list[dict]:
"""GET /v1/models – Rohdaten von Cortecs, auf die für die Auswahl nötigen Felder reduziert."""
key = require_key(db)
r = httpx.get(f"{base_url(db)}/models", headers={"Authorization": f"Bearer {key}"}, timeout=30)
r.raise_for_status()
data = r.json()
items = data.get("data") if isinstance(data, dict) else data
out = []
for m in items or []:
if isinstance(m, str):
m = {"id": m}
caps = {k: v for k, v in m.items() if isinstance(v, (bool, list, dict)) or k in ("context_size", "context_length")}
out.append({"id": m.get("id") or m.get("name"), "raw": caps,
"tools": _supports_tools(m)})
return sorted((o for o in out if o["id"]), key=lambda o: o["id"])
def _supports_tools(m: dict) -> bool | None:
"""Cortecs dokumentiert die Feldnamen nicht eindeutig → mehrere plausible Varianten prüfen; None = unbekannt."""
flat = {}
def walk(prefix, v):
if isinstance(v, dict):
for k, x in v.items():
walk(f"{prefix}.{k}".lower(), x)
elif isinstance(v, list):
flat[prefix] = [str(i).lower() for i in v]
else:
flat[prefix] = v
walk("", m)
for k, v in flat.items():
if "tool" in k or "function" in k:
return bool(v)
if isinstance(v, list) and any("tool" in i or "function" in i for i in v):
return True
return None
def summarize(db: Session, system_prompt: str, user_content: str, model: str | None = None, tenant_id: str | None = None) -> dict:
"""Ein Protokoll-Lauf wie im Core (Chat Completions), für den Prompt-Test."""
from openai import OpenAI
client = OpenAI(api_key=require_key(db, tenant_id), base_url=base_url(db), max_retries=2, timeout=300)
model = model or _setting(db, "llm_model", "claude-4-6-sonnet")
resp = client.chat.completions.create(
model=model, temperature=float(_setting(db, "llm_temperature", "0.2")),
max_tokens=int(_setting(db, "llm_max_tokens", "16000")), stream=False,
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}],
)
text = "\n".join(c.message.content for c in resp.choices if c.message and c.message.content).strip()
u = getattr(resp, "usage", None)
return {"text": text, "model": model, "input_tokens": getattr(u, "prompt_tokens", 0), "output_tokens": getattr(u, "completion_tokens", 0),
"truncated": any(getattr(c, "finish_reason", "") in ("length", "max_tokens") for c in resp.choices)}

159
app/services/mittwald.py Normal file
View File

@@ -0,0 +1,159 @@
"""Mittwald-API-Client (Port der Logik aus transkript-platform/infra/deploy.py).
Sicherheitsnetze wie dort: differenzielles PATCH (nie PUT), Namensraum `app-`, Post-Write-Guard.
Zusätzlich: `live=False` (Standard) blockiert JEDEN schreibenden Aufruf – es wird nur geloggt, was passieren würde.
Lesende Aufrufe laufen nur, wenn ein Token gesetzt ist.
"""
from __future__ import annotations
import json
from typing import Callable
import httpx
class DeployError(RuntimeError):
pass
class DryRunSkip(Exception):
pass
def mask_env(env: dict) -> dict:
import re
return {k: ("••••" if re.search(r"(SECRET|PASSWORD|KEY|TOKEN)$", k) else v) for k, v in env.items()}
class Mittwald:
def __init__(self, token: str, base: str, live: bool, log: Callable[[str], None], http: Callable | None = None):
self.token, self.base, self.live, self.log = token, base.rstrip("/"), live, log
self._http = http or self._real_http
self._project_uuid: str | None = None
@staticmethod
def _real_http(method, url, headers, body):
r = httpx.request(method, url, headers=headers, json=body, timeout=60)
try:
data = r.json() if r.content else None
except ValueError:
data = None
return r.status_code, data, r.text[:500]
def api(self, method: str, path: str, body: dict | None = None, *, write: bool, fatal: bool = True):
if write and not self.live:
self.log(f"[DRY-RUN] {method} {path}")
if body is not None:
shown = json.loads(json.dumps(body))
for svc in (shown.get("services") or {}).values():
if isinstance(svc, dict) and "environment" in svc:
svc["environment"] = mask_env(svc["environment"])
self.log(" body: " + json.dumps(shown, ensure_ascii=False)[:1500])
raise DryRunSkip()
if not self.token:
if not self.live:
self.log(f"(übersprungen: lesender Aufruf {method} {path} – kein MITTWALD_API_TOKEN)")
raise DryRunSkip()
raise DeployError("MITTWALD_API_TOKEN nicht gesetzt.")
status, data, text = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, body)
if status >= 400:
msg = f"{method} {path} → HTTP {status}: {text}"
if fatal:
raise DeployError(msg)
self.log(f"WARNUNG (nicht fatal): {msg}")
return None
return data
def try_api(self, *a, **kw):
try:
return self.api(*a, **kw)
except DryRunSkip:
return None
# ── Stack ──
def resolve_stack_id(self, project_id: str, stack_id: str) -> str:
if stack_id:
return stack_id
if not project_id:
if not self.live:
self.log("(Hinweis: Mittwald PROJECT_ID/STACK_ID sind noch nicht in den Einstellungen gesetzt – Platzhalter im Dry-Run)")
return "<STACK_ID>"
raise DeployError("Mittwald PROJECT_ID (oder STACK_ID) fehlt in den Einstellungen.")
try:
stacks = self.api("GET", f"/projects/{project_id}/stacks/", write=False)
except DryRunSkip:
return "<STACK_ID>"
items = stacks if isinstance(stacks, list) else (stacks or {}).get("items", [])
for s in items:
if str(s.get("description") or s.get("name") or "").lower() == "default":
return s["id"]
if items:
return items[0]["id"]
raise DeployError("Kein Stack im Projekt gefunden.")
def get_stack(self, stack_id: str) -> dict | None:
return self.try_api("GET", f"/stacks/{stack_id}/", write=False)
@staticmethod
def services_of(stack: dict | None) -> dict[str, dict]:
if not stack:
return {}
svcs = stack.get("services")
if isinstance(svcs, dict):
return svcs
if isinstance(svcs, list):
return {(s.get("serviceName") or s.get("name") or s.get("id")): s for s in svcs if (s.get("serviceName") or s.get("name") or s.get("id"))}
return {}
def project_uuid(self, project_id: str) -> str:
if self._project_uuid:
return self._project_uuid
proj = self.try_api("GET", f"/projects/{project_id}/", write=False, fatal=False)
self._project_uuid = (proj or {}).get("id") or project_id
return self._project_uuid
# ── Operationen ──
def deploy_service(self, *, stack_id: str, service: str, volume: str, definition: dict, container_port: str,
project_id: str, hostname: str | None) -> None:
stack = self.get_stack(stack_id)
existing = set(self.services_of(stack))
protected = existing - {service}
if protected:
self.log(f"geschützte bestehende Services (bleiben unangetastet): {sorted(protected)}")
self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {"name": volume}}}, write=True)
self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: definition}}, write=True)
svc_id = "<service-id>"
if self.live:
after = self.services_of(self.get_stack(stack_id))
missing = protected - set(after)
if missing:
raise DeployError(f"ABBRUCH: bestehende Services verschwunden nach PATCH: {sorted(missing)}")
svc_id = after.get(service, {}).get("id", service)
self.try_api("POST", f"/stacks/{stack_id}/services/{svc_id}/actions/recreate/", write=True)
self.log("Post-Write-Guard: alle vorherigen Services noch vorhanden ✔")
if hostname:
self.ensure_ingress(project_id, hostname, svc_id, container_port)
def ensure_ingress(self, project_id: str, hostname: str, container_id: str, port: str) -> None:
listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False)
items = listing if isinstance(listing, list) else (listing or {}).get("items", [])
if any(i.get("hostname") == hostname for i in items):
self.log(f"Ingress {hostname} existiert bereits → ok")
return
body = {"hostname": hostname, "projectId": self.project_uuid(project_id),
"paths": [{"path": "/", "target": {"container": {"id": container_id, "portProtocol": f"{port}/tcp"}}}]}
res = self.try_api("POST", "/ingresses", body, write=True, fatal=False)
if self.live:
self.log(f"Ingress {hostname} angelegt (Auto-TLS)" if res is not None else
f"⚠ Ingress {hostname} NICHT angelegt – Token braucht Domain/Ingress-Recht (Service läuft trotzdem).")
def offboard_service(self, *, stack_id: str, service: str, volume: str, project_id: str, hostname: str | None) -> None:
self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: {}}}, write=True)
self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {}}}, write=True)
if hostname:
listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False)
items = listing if isinstance(listing, list) else (listing or {}).get("items", [])
for i in items:
if i.get("hostname") == hostname:
self.try_api("DELETE", f"/ingresses/{i['id']}", write=True, fatal=False)
self.log(f"Ingress {hostname} entfernt")

312
app/services/onyva_login.py Normal file
View File

@@ -0,0 +1,312 @@
"""Abgleich der OIDC-Clients mit der Developer-API von login.onyva.dev (`/api/v1`, siehe LoginSeite/docs/developer-api.md).
Pro Tenant gibt es einen Soll-Zustand (`tenant.oidc`). Bei jedem Deploy wird der Ist-Zustand gelesen und bei Bedarf angelegt/angepasst:
mode = off nichts anfassen
mode = adopt (Standard für importierte Altclients) nur sicherstellen, dass die Redirect-URIs des Tenants enthalten sind
mode = managed Redirect-URIs, auth_mode, totp_mode, allow_registration, backchannel_logout_uri exakt wie konfiguriert
Freigaben (Memberships): `members` werden angelegt/aktualisiert; mit members_mode = exact werden zusätzliche Freigaben entfernt.
Sicherheit: Lesen (GET) läuft immer, wenn ein Key da ist. SCHREIBEN nur mit apply=True – und apply erlaubt die Plattform nur,
wenn OIDC_SYNC_LIVE=true oder DEPLOY_LIVE=true. Das `client_secret` ist nur bei Anlage/Rotation sichtbar und wird SOFORT verschlüsselt gespeichert.
"""
from __future__ import annotations
import os
import re
import time
from datetime import datetime, timezone
from typing import Callable
import httpx
from sqlalchemy import select
from app.db import session
from app.models import Secret, Setting, Tenant
from app.services import crypto, guard, store
MODES = ("off", "adopt", "managed")
AUTH_MODES = ("required", "optional", "disabled")
TOTP_MODES = ("disabled", "optional", "required")
ROLES = ("viewer", "editor", "admin")
DEFAULTS = {"mode": "adopt", "auth_mode": "required", "totp_mode": "disabled", "allow_registration": False,
"backchannel_logout_uri": None, "members": [], "members_mode": "add", "last_sync": None}
class OnyvaError(RuntimeError):
pass
def key_present() -> bool:
return bool(os.getenv("ONYVA_LOGIN_API_KEY", "").strip())
def writes_allowed() -> bool:
return os.getenv("OIDC_SYNC_LIVE", "false").lower() == "true" or os.getenv("DEPLOY_LIVE", "false").lower() == "true"
def base_url(db) -> str:
s = db.get(Setting, "onyva_login_api_base")
if s and s.value:
return s.value.rstrip("/")
issuer = db.get(Setting, "oidc_issuer")
return f"{(issuer.value if issuer and issuer.value else 'https://login.onyva.dev').rstrip('/')}/api/v1"
class OnyvaLogin:
"""Dünner Client für /api/v1/apps. `http(method, url, headers, body) -> (status, data, headers)` ist in Tests ersetzbar."""
def __init__(self, base: str, key: str, http: Callable | None = None, sleep: Callable[[float], None] = time.sleep):
self.base, self.key, self._http, self._sleep = base.rstrip("/"), key, http or self._real, sleep
@staticmethod
def _real(method, url, headers, body):
r = httpx.request(method, url, headers=headers, json=body, timeout=30)
try:
data = r.json() if r.content else None
except ValueError:
data = None
return r.status_code, data, dict(r.headers)
def _req(self, method: str, path: str, body: dict | None = None):
for attempt in range(4):
status, data, headers = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"}, body)
if status == 429 and attempt < 3: # Rate-Limit: 120 Requests / 60 s pro Key
self._sleep(min(int({k.lower(): v for k, v in headers.items()}.get("retry-after", 5)), 60))
continue
if status == 401:
raise OnyvaError("API-Key ungültig, widerrufen oder abgelaufen (401).")
return status, data
raise OnyvaError("Rate-Limit des Login-Systems (429) dauerhaft überschritten.")
def _ok(self, method, path, body=None, ok=(200, 201, 204)):
status, data = self._req(method, path, body)
if status not in ok:
detail = data.get("detail") if isinstance(data, dict) else data
raise OnyvaError(f"{method} {path} → HTTP {status}: {str(detail)[:300]}")
return data
def list_apps(self) -> list[dict]:
return (self._ok("GET", "/apps") or {}).get("apps", [])
def get_app(self, cid: str) -> dict | None:
status, data = self._req("GET", f"/apps/{cid}")
if status == 404:
return None
if status != 200:
raise OnyvaError(f"GET /apps/{cid} → HTTP {status}")
return data
def create_app(self, body: dict) -> dict:
return self._ok("POST", "/apps", body, ok=(201, 200))
def patch_app(self, cid: str, body: dict) -> dict:
return self._ok("PATCH", f"/apps/{cid}", body)
def delete_app(self, cid: str) -> None:
self._ok("DELETE", f"/apps/{cid}", ok=(204, 200))
def rotate_secret(self, cid: str) -> dict:
return self._ok("POST", f"/apps/{cid}/rotate-secret")
def memberships(self, cid: str) -> list[dict]:
return (self._ok("GET", f"/apps/{cid}/memberships") or {}).get("memberships", [])
def put_membership(self, cid: str, email: str, role: str) -> bool:
status, _ = self._req("PUT", f"/apps/{cid}/memberships", {"user_email": email, "role": role})
if status == 404:
return False # Nutzer existiert im Login nicht
if status not in (200, 201):
raise OnyvaError(f"PUT memberships → HTTP {status}")
return True
def delete_membership(self, cid: str, email: str) -> None:
self._req("DELETE", f"/apps/{cid}/memberships/{email}")
def client_from_env(db, http=None) -> OnyvaLogin | None:
key = os.getenv("ONYVA_LOGIN_API_KEY", "").strip()
return OnyvaLogin(base_url(db), key, http) if key else None
# ── Soll-Zustand ────────────────────────────────────────────────────────────
def config_of(t: Tenant) -> dict:
return {**DEFAULTS, **(t.oidc or {})}
def validate_config(cfg: dict) -> dict:
out = {**DEFAULTS, **cfg}
if out["mode"] not in MODES:
raise ValueError("mode: off | adopt | managed")
if out["auth_mode"] not in AUTH_MODES:
raise ValueError("auth_mode: required | optional | disabled")
if out["totp_mode"] not in TOTP_MODES:
raise ValueError("totp_mode: disabled | optional | required")
if out["members_mode"] not in ("add", "exact"):
raise ValueError("members_mode: add | exact")
out["allow_registration"] = bool(out["allow_registration"])
out["backchannel_logout_uri"] = (out["backchannel_logout_uri"] or "").strip() or None
if out["backchannel_logout_uri"] and not out["backchannel_logout_uri"].startswith("https://"):
raise ValueError("Backchannel-Logout-URI muss https:// sein und öffentlich erreichbar")
members, seen = [], set()
for m in out["members"] or []:
email, role = str(m.get("email", "")).strip().lower(), m.get("role", "viewer")
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
raise ValueError(f"Ungültige E-Mail: {email!r}")
if role not in ROLES:
raise ValueError(f"Rolle von {email}: viewer | editor | admin")
if email not in seen:
seen.add(email)
members.append({"email": email, "role": role})
out["members"] = members
out.pop("last_sync", None)
return out
def desired_redirects(base_domain: str, t: Tenant) -> list[str]:
hosts = [f"{t.subdomain or t.id}.{base_domain}"]
if t.custom_domain:
hosts.append(t.custom_domain)
return [f"https://{h}/auth/callback" for h in hosts]
def diff_app(actual: dict, want: dict, mode: str) -> dict:
"""Welche PATCH-Felder wären nötig? adopt: nur fehlende URIs ergänzen; managed: alles exakt."""
changes: dict = {}
have, need = list(actual.get("redirect_uris") or []), want["redirect_uris"]
if mode == "adopt":
missing = [u for u in need if u not in have]
if missing:
changes["redirect_uris"] = have + missing
elif mode == "managed":
if set(have) != set(need):
changes["redirect_uris"] = need
for f in ("auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri"):
if actual.get(f) != want[f]:
changes[f] = want[f]
return changes
def _secret(db, tenant_id: str, key: str) -> str | None:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
return crypto.decrypt(row.value_enc) if row and row.value_enc else None
def _store_credentials(tenant_id: str, client_id: str | None, client_secret: str | None, actor="onyva-login-sync") -> None:
"""Sofort und in eigener Transaktion sichern – das Secret ist danach nicht mehr abrufbar."""
from app.services import tenants as tsvc
with session() as db:
if client_id:
tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_ID", client_id, actor)
if client_secret:
tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_SECRET", client_secret, actor)
# ── Abgleich ────────────────────────────────────────────────────────────────
def sync_tenant(tenant_id: str, *, apply: bool, log: Callable[[str], None] = lambda m: None, client: OnyvaLogin | None = None,
rotate: bool = False, actor: str = "system") -> dict:
"""Gleicht den OIDC-Client des Tenants ab. apply=False → nur lesen und Plan melden.
Rückgabe: {ok, applied, skipped, client_id, actions:[{kind,detail}], warnings:[...], created, error}."""
rep = {"ok": True, "applied": False, "skipped": None, "client_id": None, "actions": [], "warnings": [], "created": False, "error": None}
act = lambda kind, detail: (rep["actions"].append({"kind": kind, "detail": detail}), log(f"OIDC: {detail}")) # noqa: E731
warn = lambda msg: (rep["warnings"].append(msg), log(f"OIDC ⚠ {msg}")) # noqa: E731
if apply and not writes_allowed():
rep.update(ok=False, error="Schreibzugriffe auf das Login-System sind gesperrt (OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv).")
return rep
if apply and not guard.live_allowed(tenant_id):
rep.update(ok=False, error=guard.denial(tenant_id))
return rep
try:
with session() as db:
t = db.get(Tenant, tenant_id)
cfg = config_of(t)
base_domain = (db.get(Setting, "base_domain").value if db.get(Setting, "base_domain") else "example.de")
want = {"name": f"Onyscribe – {t.name}", "redirect_uris": desired_redirects(base_domain, t), "auth_mode": cfg["auth_mode"],
"totp_mode": cfg["totp_mode"], "allow_registration": cfg["allow_registration"], "backchannel_logout_uri": cfg["backchannel_logout_uri"]}
cid, has_secret = _secret(db, tenant_id, "OIDC_CLIENT_ID"), bool(_secret(db, tenant_id, "OIDC_CLIENT_SECRET"))
client = client or client_from_env(db)
mode = cfg["mode"]
if mode == "off":
rep["skipped"] = "OIDC-Verwaltung ist für diesen Tenant aus."
log("OIDC: Verwaltung aus – nichts zu tun")
return rep
if client is None:
rep["skipped"] = "ONYVA_LOGIN_API_KEY nicht gesetzt."
rep["ok"] = not apply # beim echten Deploy ist ein fehlender Key ein Fehler, im Dry-Run nur ein Hinweis
rep["error"] = None if rep["ok"] else rep["skipped"]
log("OIDC: (übersprungen – ONYVA_LOGIN_API_KEY nicht gesetzt)")
return rep
app = client.get_app(cid) if cid else None
new_app = app is None
verb = "" if apply else "würde "
if app is None:
if cid:
warn(f"Client {cid} existiert im Login nicht mehr – es wird ein neuer angelegt.")
body = {k: want[k] for k in ("name", "redirect_uris", "auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri")}
act("create", f"{verb}App „{want['name']}“ anlegen (Redirect: {', '.join(want['redirect_uris'])}; auth_mode={want['auth_mode']})")
if apply:
created = client.create_app(body)
cid = created["client_id"]
_store_credentials(tenant_id, cid, created.get("client_secret"), actor) # ZUERST sichern
rep["created"], has_secret = True, True
log(f"OIDC: App angelegt → {cid} (Secret verschlüsselt gespeichert)")
app = {"redirect_uris": want["redirect_uris"], "auth_mode": want["auth_mode"]}
else:
changes = diff_app(app, want, mode)
if changes:
act("patch", f"{verb}App {cid} anpassen: " + ", ".join(f"{k}={v}" for k, v in changes.items()))
if apply:
client.patch_app(cid, changes)
else:
log(f"OIDC: App {cid} ist abgeglichen")
rep["client_id"] = cid
if not has_secret:
warn("Client-Secret ist nicht in der Admin-DB (beim Login nicht abrufbar) – eintragen oder „Secret rotieren“.")
if rotate and cid:
act("rotate", f"{verb}Secret von {cid} rotieren (altes sofort ungültig)")
if apply:
_store_credentials(tenant_id, None, client.rotate_secret(cid)["client_secret"], actor)
# Freigaben
members = cfg["members"]
effective_auth = want["auth_mode"] if mode == "managed" else (app.get("auth_mode") or want["auth_mode"])
if (members or cfg["members_mode"] == "exact") and (cid or new_app):
current = {} if new_app else {m["email"].lower(): m["role"] for m in client.memberships(cid)}
for m in members:
if current.get(m["email"]) != m["role"]:
act("member_put", f"{verb}{m['email']} als {m['role']} freigeben")
if apply and not client.put_membership(cid, m["email"], m["role"]):
warn(f"{m['email']} existiert im Login nicht – Nutzer dort anlegen, dann erneut abgleichen.")
if cfg["members_mode"] == "exact":
for email in sorted(set(current) - {m["email"] for m in members}):
act("member_delete", f"{verb}Freigabe von {email} entziehen")
if apply:
client.delete_membership(cid, email)
elif effective_auth == "required" and not members:
log("OIDC: auth_mode=required ohne gepflegte Freigaben – bestehende Freigaben bleiben unverändert")
rep["applied"] = apply and bool(rep["actions"])
except OnyvaError as exc:
rep.update(ok=False, error=str(exc))
log(f"OIDC ✘ {exc}")
except Exception as exc: # noqa: BLE001
rep.update(ok=False, error=f"{type(exc).__name__}: {exc}")
log(f"OIDC ✘ {rep['error']}")
if apply:
_record(tenant_id, rep)
return rep
def _record(tenant_id: str, rep: dict) -> None:
with session() as db:
t = db.get(Tenant, tenant_id)
cfg = dict(t.oidc or {})
cfg["last_sync"] = {"ts": datetime.now(timezone.utc).isoformat(), "ok": rep["ok"], "created": rep["created"], "error": rep["error"],
"actions": [a["detail"] for a in rep["actions"]], "warnings": rep["warnings"]}
t.oidc = cfg
store.audit(db, "system", "oidc.sync", tenant_id, "ok" if rep["ok"] else f"Fehler: {rep['error']}")

114
app/services/preflight.py Normal file
View File

@@ -0,0 +1,114 @@
"""Vorflug-Check: ist ein Tenant bereit für einen Live-Deploy? Liest nur (Mittwald-GET, Login-API-GET, eigener Health) und schreibt nichts.
Jeder Punkt: {id, level: ok|warn|fail|info, label, detail, fix}. Ein Deploy ist „bereit“, wenn es kein `fail` gibt.
"""
from __future__ import annotations
import socket
from sqlalchemy import select
from app.db import session
from app.models import Secret, Tenant
from app.services import contract, deploy, guard, onyva_login, preview, store
from app.services.mittwald import DeployError, DryRunSkip, Mittwald
def _item(id_, level, label, detail="", fix=""):
return {"id": id_, "level": level, "label": label, "detail": detail, "fix": fix}
def run(tenant_id: str, *, mw_http=None, oidc_client=None, self_get=None) -> dict:
out: list[dict] = []
add = lambda *a, **k: out.append(_item(*a, **k)) # noqa: E731
with session() as db:
t = db.get(Tenant, tenant_id)
cfg, _ = store.content(db, t, "current")
draft, source, pin = bool(t.draft), t.source, t.core_pin
base_domain, project, stack = deploy.setting(db, "base_domain"), deploy.setting(db, "mittwald_project_id"), deploy.setting(db, "mittwald_stack_id")
registry, admin_url = deploy.setting(db, "registry"), deploy.setting(db, "admin_public_url")
bundle_ok = deploy.setting(db, "core_supports_bundle", "false") == "true"
tag = deploy.effective_tag(db, t)
host = deploy.host_for(db, t)
missing = deploy.missing_secrets(db, t)
oc = onyva_login.config_of(t)
has_oidc_secrets = all(db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == k, Secret.value_enc.is_not(None))) is not None
for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))
stt_provider = (cfg.get("stt") or {}).get("provider") or "mistral"
# Freigaben / Modus
add("live", "ok" if deploy.live_enabled() else "warn", "DEPLOY_LIVE", "aktiv" if deploy.live_enabled() else "nicht aktiv – nur Dry-Run möglich",
"" if deploy.live_enabled() else "Env DEPLOY_LIVE=true der Admin-Plattform setzen und neu starten (erst nach erfolgreichem Dry-Run).")
add("allow", "ok" if guard.live_allowed(tenant_id) else "warn", "Live-Freigabe für diesen Tenant", f"DEPLOY_ALLOWED_TENANTS = {guard.describe()}",
"" if guard.live_allowed(tenant_id) else f"Env DEPLOY_ALLOWED_TENANTS um „{tenant_id}“ ergänzen.")
# Vertrag / Core
add("contract", "ok" if contract.supported() else "fail", "Core-Vertrag", f"Admin-Core {contract.sha()[:10]}, Vertrag v{contract.contract_version()}",
"" if contract.supported() else "Admin-Plattform aktualisieren.")
hex_tag = deploy._hex_tag(tag)
add("tag", "ok" if hex_tag else "warn", "Core-Image-Tag", f":{tag}" + (" (gepinnt)" if pin else ""),
"" if hex_tag else "Besser den vollen Git-SHA als Pin setzen (Tenant → Übersicht → Core-Version) – dann prüft der Health-Check die Core-Version.")
if source == "admin":
add("bundle", "ok" if bundle_ok else "fail", "Core kann Bundle laden", "Einstellung core_supports_bundle", "" if bundle_ok else "Einstellungen → „Core kann Bundle laden“ aktivieren (erst wenn das Image den Bundle-Loader enthält).")
good_url = admin_url.startswith("https://") and "<" not in admin_url
add("adminurl", "ok" if good_url else "fail", "Öffentliche Admin-URL", admin_url or "nicht gesetzt",
"" if good_url else "Einstellungen → „Öffentliche URL der Admin-Plattform“ (https://…): so holt der Tenant-Container sein Bundle.")
if good_url:
try:
status = (self_get or _get)(f"{admin_url.rstrip('/')}/healthz")
add("adminreach", "ok" if status == 200 else "fail", "Admin von außen erreichbar", f"GET /healthz → {status}", "" if status == 200 else "Domain/Proxy in Coolify prüfen.")
except Exception as exc: # noqa: BLE001
add("adminreach", "fail", "Admin von außen erreichbar", f"{type(exc).__name__}: {str(exc)[:120]}", "Domain/Proxy in Coolify prüfen.")
# Mittwald
add("domain", "ok" if base_domain else "fail", "Basis-Domain", base_domain or "nicht gesetzt", "" if base_domain else "Einstellungen → Basis-Domain (= Gitea-Variable DOMAIN).")
add("registry", "ok" if registry else "fail", "Registry", registry or "nicht gesetzt", "" if registry else "Einstellungen → Registry (= Gitea-Variable REGISTRY).")
add("mtoken", "ok" if deploy.token_present() else "fail", "MITTWALD_API_TOKEN", "gesetzt" if deploy.token_present() else "nicht gesetzt",
"" if deploy.token_present() else "Env MITTWALD_API_TOKEN der Admin-Plattform setzen (derselbe Token wie in Gitea).")
add("mproject", "ok" if (project or stack) else "fail", "Mittwald PROJECT_ID / STACK_ID", f"{project or '–'} / {stack or '–'}", "" if (project or stack) else "Einstellungen → PROJECT_ID (und optional STACK_ID).")
if deploy.token_present() and (project or stack):
try:
mw = Mittwald(__import__("os").getenv("MITTWALD_API_TOKEN", ""), __import__("os").getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), False, lambda m: None, mw_http)
sid = mw.resolve_stack_id(project, stack)
services = sorted(Mittwald.services_of(mw.api("GET", f"/stacks/{sid}/", write=False)))
mine = f"app-{tenant_id}"
protected = [s for s in services if s != mine]
add("stack", "ok", "Mittwald-Stack lesbar", f"{len(services)} Services; bestehende bleiben unangetastet: {', '.join(protected) or '–'}")
add("svc", "info", "Service dieses Tenants", f"{mine} " + ("existiert bereits (wird aktualisiert)" if mine in services else "existiert noch nicht (wird angelegt)"))
except (DeployError, DryRunSkip) as exc:
add("stack", "fail", "Mittwald-Stack lesbar", str(exc)[:200], "Token-Rechte, PROJECT_ID/STACK_ID prüfen.")
except Exception as exc: # noqa: BLE001
add("stack", "fail", "Mittwald-Stack lesbar", f"{type(exc).__name__}: {str(exc)[:160]}", "Netzwerk/Token prüfen.")
# Secrets / OIDC / STT
will_create = oc["mode"] == "managed" and onyva_login.key_present() and not has_oidc_secrets
real_missing = [m for m in missing if not (will_create and m in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))]
add("secrets", "ok" if not real_missing else "fail", "Pflicht-Secrets", "vollständig" if not real_missing else "fehlen: " + ", ".join(real_missing),
"" if not real_missing else "Tenant → Secrets (tenantspezifisch) bzw. Einstellungen → Geteilte Secrets (z. B. CORTECS_API_KEY).")
if will_create:
add("oidcnew", "info", "OIDC-Client", "wird beim Deploy automatisch im Login angelegt (Client-ID/-Secret sofort gespeichert)")
elif oc["mode"] != "off" and not onyva_login.key_present():
add("oidckey", "fail", "ONYVA_LOGIN_API_KEY", "nicht gesetzt – OIDC-Abgleich beim Deploy nicht möglich", "Env ONYVA_LOGIN_API_KEY setzen oder OIDC-Verwaltung auf „off“ stellen und Client manuell eintragen.")
elif oc["mode"] != "off":
add("oidckey", "ok", "Login-API", f"Modus {oc['mode']}")
if oc["mode"] != "off" and oc["auth_mode"] == "required" and not oc["members"]:
add("members", "warn", "Freigaben", "auth_mode „required“, aber keine Nutzer gepflegt – niemand kann sich anmelden",
"Domain & Login → Freigaben: mindestens dich eintragen (Nutzer muss im Login existieren).")
if stt_provider != "cortecs":
add("stt", "info", "STT-Provider", stt_provider + " – braucht MISTRAL_API_KEY")
# Inhalt
with session() as db:
t = db.get(Tenant, tenant_id)
try:
val = preview.run(db, t, "current", "validate")
add("render", "ok" if val.get("ok") else "fail", "Veröffentlichter Stand rendert (echter Core)", "; ".join(val.get("errors", []))[:200] or "Web-UI und PDF ok",
"" if val.get("ok") else "Tenant → Editor korrigieren und neu veröffentlichen.")
except Exception as exc: # noqa: BLE001
add("render", "fail", "Rendering", str(exc)[-200:])
if draft:
add("draft", "warn", "Unveröffentlichter Entwurf", "wird NICHT mit deployt", "Erst veröffentlichen, wenn er mit soll.")
add("host", "info", "Ziel-URL", f"https://{host}", "")
ready = not any(i["level"] == "fail" for i in out)
return {"ready": ready, "items": out, "host": host, "tag": tag}
def _get(url: str) -> int:
import httpx
return httpx.get(url, timeout=8, follow_redirects=True).status_code

109
app/services/preview.py Normal file
View File

@@ -0,0 +1,109 @@
"""Live-Vorschau: führt den vendored Core mit dem (Entwurfs-)Tenant in einem Subprozess aus."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from sqlalchemy.orm import Session
from app.models import Tenant
from app.services import store
from app.services.samples import DEFAULT_SAMPLE
from app.settings import PLATFORM_DIR, PREVIEW_CACHE
WORKER = Path(__file__).with_name("preview_worker.py")
TIMEOUT = 90
class PreviewError(RuntimeError):
pass
def core_version() -> str:
from app.services import contract
return contract.sha()[:10]
def sample_for(t: Tenant) -> str:
return t.preview_sample or DEFAULT_SAMPLE
EXT = {"index": "html", "pdf": "pdf", "validate": "json", "prompt": "json"}
class SourceGone(LookupError):
pass
def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]:
"""draft | current | v<N> | ws:<run> (Live-Arbeitsstand des Agenten) | proposal:<msg> (Vorschlag des Agenten)."""
if source.startswith("ws:"):
from app.agent.loop import WORKSPACES
if source[3:] not in WORKSPACES:
raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.")
cfg, files = WORKSPACES[source[3:]]
return dict(cfg), dict(files)
if source.startswith("proposal:"):
from app.agent.loop import proposal_content
from app.models import ChatMessage
m = db.get(ChatMessage, int(source[9:] or 0))
if m is None or m.tenant_id != t.id or not m.proposal:
raise SourceGone("Vorschlag nicht gefunden.")
return proposal_content(db, t, m.proposal)
return store.content(db, t, source)
def run(db: Session, t: Tenant, source: str, kind: str, sample: str | None = None, args: dict | None = None) -> bytes | dict:
cfg, files = resolve_content(db, t, source)
return run_content(db, t, cfg, files, kind, sample, args)
def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None,
args: dict | None = None) -> bytes | dict:
"""Rendert beliebigen Inhalt (cfg + {pfad: sha}) – für Entwurf, Version oder Agent-Workspace."""
sample = sample if sample is not None else sample_for(t)
h = hashlib.sha256(json.dumps([cfg, files, kind, sample, args or {}, core_version()], sort_keys=True).encode())
cached = PREVIEW_CACHE / f"{h.hexdigest()[:32]}.{EXT[kind]}"
if not cached.exists():
_execute(db, t.id, cfg, files, kind, sample, cached, args or {})
data = cached.read_bytes()
return json.loads(data) if EXT[kind] == "json" else data
def _limits() -> None:
import resource
resource.setrlimit(resource.RLIMIT_CPU, (90, 90))
resource.setrlimit(resource.RLIMIT_FSIZE, (200 * 1024 * 1024,) * 2)
os.setsid()
def _execute(db: Session, tenant_id: str, cfg: dict, files: dict, kind: str, sample: str, out: Path, args: dict) -> None:
with tempfile.TemporaryDirectory(prefix="onyscribe-preview-") as tmp_s:
tmp = Path(tmp_s)
shutil.copytree(PLATFORM_DIR / "core", tmp / "core", ignore=shutil.ignore_patterns("__pycache__"))
store.write_tenant_dir(db, cfg, files, tmp / "tenants" / tenant_id)
(tmp / "data").mkdir()
shutil.copy(WORKER, tmp / "preview_worker.py")
env = {
"PATH": os.environ.get("PATH", ""), "HOME": str(tmp), "LANG": "C.UTF-8",
"TENANT": tenant_id, "DATA_DIR": str(tmp / "data"), "AUTH_ENABLED": "false",
"PYTHONPATH": str(tmp), "PYTHONDONTWRITEBYTECODE": "1",
# Kein einziges echtes Secret im Sandbox-Prozess
"OIDC_ISSUER": "https://login.example.invalid", "SESSION_SECRET": "preview",
}
result = tmp / f"result.{out.suffix.lstrip('.')}"
payload = json.dumps({"summary": sample, "title": f"{cfg.get('name', tenant_id)} – Vorschau", **args})
try:
proc = subprocess.run([sys.executable, "-I", "preview_worker.py", kind, str(result)], input=payload,
cwd=tmp, env=env, capture_output=True, text=True, timeout=TIMEOUT, preexec_fn=_limits)
except subprocess.TimeoutExpired as exc:
raise PreviewError("Vorschau-Timeout (Tenant-Code läuft zu lange)") from exc
if proc.returncode != 0 or not result.exists():
raise PreviewError((proc.stderr or proc.stdout or "unbekannter Fehler")[-2500:])
shutil.copy(result, out)

View File

@@ -0,0 +1,65 @@
"""Läuft in einem isolierten Verzeichnis mit Kopie des Cores + materialisiertem Tenant.
Aufruf: python preview_worker.py <index|pdf|validate> <ausgabedatei> (JSON-Eingabe auf stdin)
Führt den ECHTEN Core aus (auch overrides.py) – deshalb nur als Subprozess ohne Secrets/Netz-Keys.
"""
import json
import os
import sys
import traceback
sys.path.insert(0, os.getcwd())
kind, out = sys.argv[1], sys.argv[2]
args = json.loads(sys.stdin.read() or "{}")
def main() -> None:
from core import app as appmod
from core.config import CFG
if kind == "index":
page = appmod._render_index()
open(out, "w", encoding="utf-8").write(page)
elif kind == "pdf":
pdf = appmod.build_pdf(args.get("title") or "Vorschau", args["summary"], "SPRECHER_0: Beispiel.")
open(out, "wb").write(pdf)
elif kind == "prompt":
base = appmod._summary_prompt()
ctx = {"title": args.get("title", ""), "insurance_type": args.get("terminart", ""),
"customer_2": args.get("projekt", ""), "customer_name": args.get("kunde", ""), "customer_address": ""}
final = appmod.HOOKS.build_prompt(base, ctx)
json.dump({"prompt": final, "chars": len(final)}, open(out, "w"))
elif kind == "validate":
errors, warnings = [], []
page = appmod._render_index()
if "@@" in page:
errors.append("Web-UI: nicht ersetzte @@PLATZHALTER@@ im Template")
if not CFG.logo_path:
warnings.append("Kein Logo (logo.svg/logo.png) hinterlegt")
if not CFG.favicon_path:
warnings.append("Kein favicon.svg – es wird ein generisches Favicon verwendet")
try:
pdf = appmod.build_pdf("Validierung", args["summary"], "SPRECHER_0: Beispiel.")
if pdf[:5] != b"%PDF-":
errors.append("PDF-Rendering lieferte kein PDF")
except Exception as exc: # noqa: BLE001
errors.append(f"PDF-Rendering fehlgeschlagen: {exc}")
prompt = CFG.prompt_path.read_text(encoding="utf-8")
if len(prompt.strip()) < 200:
warnings.append("Prompt ist sehr kurz")
json.dump({"ok": not errors, "errors": errors, "warnings": warnings,
"tenant": CFG.id, "prompt_chars": len(prompt)}, open(out, "w"))
try:
main()
except SystemExit:
raise
except BaseException: # noqa: BLE001
tb = traceback.format_exc()
if kind == "validate":
json.dump({"ok": False, "errors": ["Tenant lässt sich nicht laden:\n" + tb[-1500:]], "warnings": []}, open(out, "w"))
else:
sys.stderr.write(tb)
sys.exit(2)

254
app/services/releases.py Normal file
View File

@@ -0,0 +1,254 @@
"""Core-Releases: Eingang (CI), Kompatibilitätsprüfung aller Tenants, Freigabe, Canary-Rollout, Rollback.
Ablauf: CI meldet Image-Tag → Admin (läuft auf genau diesem Core) prüft alle Tenants in der Sandbox → Freigabe →
Rollout (Canary zuerst, Abbruch beim ersten Fehler, Health-Check pro Tenant) → aktiver Tag wird der neue Standard.
Gepinnte Tenants nehmen nicht teil. Echte Rollouts brauchen DEPLOY_LIVE=true; sonst nur Dry-Run.
"""
from __future__ import annotations
import hashlib
import io
import re
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import session
from app.models import CoreRelease, Deployment, Setting, Tenant
from app.services import contract, deploy, guard, preview, store
DATE = re.compile(r"\b\d{1,2}\.\d{1,2}\.\d{2,4}\b")
class ReleaseError(RuntimeError):
pass
# ── Eingang ─────────────────────────────────────────────────────────────────
def announce(db, sha: str, tag: str | None, ref: str, image: str, message: str) -> tuple[CoreRelease, bool]:
if not re.fullmatch(r"[0-9a-fA-F]{7,64}", sha or ""):
raise ReleaseError("sha: 7–64 Hex-Zeichen erwartet")
sha = sha.lower()
r = db.scalar(select(CoreRelease).where(CoreRelease.sha == sha))
created = r is None
if created:
r = CoreRelease(sha=sha, tag=(tag or sha)[:100], ref=ref[:100], image=image[:300], message=message[:500])
db.add(r)
store.audit(db, "ci", "core.release", None, f"{sha[:10]} ({ref}) {message[:80]}")
else:
r.message, r.ref, r.image = message[:500] or r.message, ref[:100] or r.ref, image[:300] or r.image
db.flush()
return r, created
def admin_matches(r: CoreRelease) -> bool:
return contract.same_sha(contract.sha(), r.sha)
def view(r: CoreRelease, active_tag: str) -> dict:
return {"id": r.id, "sha": r.sha, "tag": r.tag, "ref": r.ref, "image": r.image, "message": r.message, "status": r.status,
"announced_at": r.announced_at.isoformat() if r.announced_at else None, "admin_matches": admin_matches(r),
"approved_by": r.approved_by, "compat": r.compat, "rollout_batch": r.rollout_batch, "previous_tag": r.previous_tag,
"active": r.tag == active_tag or contract.same_sha(r.sha, active_tag)}
# ── Kompatibilitätsprüfung ──────────────────────────────────────────────────
def _pdf_fingerprint(pdf: bytes) -> tuple[int, str]:
from pypdf import PdfReader
rd = PdfReader(io.BytesIO(pdf))
text = DATE.sub("<datum>", "\n".join((p.extract_text() or "") for p in rd.pages)) # das Tagesdatum steht im PDF
return len(rd.pages), hashlib.sha256(text.encode()).hexdigest()
def fingerprint(db, t: Tenant) -> dict:
"""Rendert den veröffentlichten Stand mit dem aktuellen Admin-Core und bildet Fingerabdrücke."""
cfg, files = store.content(db, t, "current")
sample = preview.sample_for(t)
fp: dict = {"sample": hashlib.sha256(sample.encode()).hexdigest()[:12]}
val = preview.run_content(db, t, cfg, files, "validate", sample)
fp["validate"] = {"ok": val.get("ok", False), "errors": val.get("errors", []), "warnings": val.get("warnings", [])}
if val.get("ok"):
pages, text_sha = _pdf_fingerprint(preview.run_content(db, t, cfg, files, "pdf", sample))
fp.update(pdf_pages=pages, pdf_text=text_sha,
web=hashlib.sha256(preview.run_content(db, t, cfg, files, "index", sample)).hexdigest(),
prompt=hashlib.sha256(preview.run_content(db, t, cfg, files, "prompt", sample, {"title": "x"})["prompt"].encode()).hexdigest())
fp["schema_problems"] = contract.check_tenant_json(cfg)
return fp
def compare(fp: dict, baseline: dict | None, version: int) -> dict:
"""→ {status: ok|changed|new|error, changed:[…], errors:[…], warnings:[…]}"""
out = {"status": "ok", "changed": [], "errors": fp["validate"]["errors"], "warnings": list(fp["validate"]["warnings"]) + fp.get("schema_problems", [])}
if not fp["validate"]["ok"]:
out["status"] = "error"
return out
if not baseline or baseline.get("version") != version or baseline["fp"].get("sample") != fp["sample"]:
out["status"] = "new"
return out
labels = {"pdf_pages": "PDF-Seitenzahl", "pdf_text": "PDF-Text", "web": "Web-UI", "prompt": "Finaler Prompt"}
out["changed"] = [labels[k] for k in labels if baseline["fp"].get(k) != fp.get(k)]
if out["changed"]:
out["status"] = "changed"
return out
def check(release_id: int, *, force: bool = False, wait: bool = False, workers: int = 3) -> None:
"""Prüft ALLE Tenants (außer archivierten) mit dem Core, auf dem die Admin-Plattform läuft. Ergebnis in release.compat."""
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
if not force and not admin_matches(r):
raise ReleaseError(f"Die Admin-Plattform läuft noch auf Core {contract.sha()[:10]}, das Release ist {r.sha[:10]}. "
"Erst den Admin neu bauen (FROM neues Core-Image) – sonst würde gegen den falschen Core geprüft.")
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status != "archived").order_by(Tenant.id))]
r.compat = {"checked_at": None, "core_sha": contract.sha(), "running": True, "tenants": {}, "summary": {}, "forced": force and not admin_matches(r)}
r.status = "new" if r.status in ("new", "checked") else r.status
def one(tid: str):
with session() as db:
t = db.get(Tenant, tid)
try:
fp = fingerprint(db, t)
res = compare(fp, t.render_baseline, t.current_version)
except Exception as exc: # noqa: BLE001
fp, res = {}, {"status": "error", "changed": [], "errors": [f"{type(exc).__name__}: {str(exc)[-300:]}"], "warnings": []}
return tid, fp, res
def work():
results = {}
with ThreadPoolExecutor(max_workers=workers) as ex:
for tid, fp, res in ex.map(one, ids):
results[tid] = {**res, "fp": fp}
summary = {k: sum(1 for v in results.values() if v["status"] == k) for k in ("ok", "changed", "new", "error")}
with session() as db:
r = db.get(CoreRelease, release_id)
r.compat = {"checked_at": datetime.now(timezone.utc).isoformat(), "core_sha": contract.sha(), "running": False, "tenants": results,
"summary": summary, "forced": bool(r.compat and r.compat.get("forced"))}
if r.status == "new":
r.status = "checked"
store.audit(db, "system", "core.check", None, f"{r.sha[:10]}: {summary}")
th = threading.Thread(target=work, daemon=True)
th.start()
if wait:
th.join()
# ── Freigabe & Rollout ──────────────────────────────────────────────────────
def approve(db, release_id: int, actor: str, acknowledge: bool = False) -> CoreRelease:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
c = r.compat
if not c or c.get("running"):
raise ReleaseError("Erst die Kompatibilitätsprüfung abschließen.")
if not c.get("forced") and not contract.same_sha(c.get("core_sha", ""), r.sha):
raise ReleaseError("Die Prüfung stammt von einem anderen Core als dieses Release – bitte erneut prüfen.")
if c["summary"].get("error"):
bad = [t for t, v in c["tenants"].items() if v["status"] == "error"]
raise ReleaseError(f"Nicht freigebbar: {len(bad)} Tenant(s) mit Fehlern ({', '.join(bad)}).")
if c["summary"].get("changed") and not acknowledge:
raise ReleaseError("Bei einigen Tenants hat sich das Rendering geändert – bitte prüfen und bewusst bestätigen (acknowledge).")
r.status, r.approved_by, r.approved_at = "approved", actor, datetime.now(timezone.utc)
store.audit(db, actor, "core.approve", None, r.sha[:10])
return r
def canary_list(db) -> list[str]:
s = db.get(Setting, "canary_tenants")
return [x.strip() for x in (s.value if s and s.value else "demo").split(",") if x.strip()]
def _set_setting(db, key: str, value: str) -> None:
s = db.get(Setting, key)
if s is None:
s = Setting(key=key)
db.add(s)
s.value = value
def rollout(release_id: int, actor: str, *, dry_run: bool, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
live = not dry_run
if live and not deploy.live_enabled():
raise ReleaseError("Echte Rollouts sind gesperrt (DEPLOY_LIVE != true). Dry-Run ist möglich.")
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
allowed = {"approved", "active", "failed", "rolled_back"} | ({"new", "checked"} if dry_run else set()) # Dry-Run schon vor der Freigabe
if r.status not in allowed:
raise ReleaseError(f"Release ist im Status „{r.status}“ – erst prüfen und freigeben.")
tag, sha = r.tag, r.sha
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin]
if live: # Live: nur für Live-Schreibzugriffe freigegebene Tenants (guard.py)
ids = [t for t in ids if guard.live_allowed(t)]
if not ids:
raise ReleaseError(f"Kein Tenant für Live-Rollouts freigegeben (DEPLOY_ALLOWED_TENANTS = {guard.describe()}).")
canary = canary_list(db)
batch = uuid.uuid4().hex[:10]
if live:
r.status, r.previous_tag, r.rollout_batch = "rolling", deploy.setting(db, "core_image_tag", "latest"), batch
store.audit(db, actor, "core.rollout", None, f"{sha[:10]} → {len(ids)} Tenants")
def done(ok: bool, results: dict):
if not live:
return
with session() as db:
r = db.get(CoreRelease, release_id)
if ok:
r.status = "active"
_set_setting(db, "core_image_tag", tag)
for other in db.scalars(select(CoreRelease).where(CoreRelease.status == "active", CoreRelease.id != release_id)):
other.status = "superseded"
for tid in results: # Baselines für künftige Kompatibilitätsprüfungen
t = db.get(Tenant, tid)
c = ((r.compat or {}).get("tenants") or {}).get(tid)
if t and c and c.get("fp"):
t.render_baseline = {"version": t.current_version, "core": sha, "fp": c["fp"]}
else:
r.status = "failed"
store.audit(db, "system", "core.rollout.done", None, f"{sha[:10]} {'ok' if ok else 'FEHLER'} {results}")
dep_ids = deploy.run_sequence(ids, "dry-run" if dry_run else "deploy", actor, batch=batch, core_tag=tag, http=http, oidc_client=oidc_client,
health=health, canary=canary, wait=wait, on_done=done)
return batch, dep_ids
def rollback_release(release_id: int, actor: str, *, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
"""Alle (nicht gepinnten) Tenants auf den Tag vor diesem Rollout zurück."""
if not deploy.live_enabled():
raise ReleaseError("Echte Rollbacks sind gesperrt (DEPLOY_LIVE != true).")
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None or not r.previous_tag:
raise ReleaseError("Kein vorheriger Tag bekannt – Rollback nicht möglich.")
prev = r.previous_tag
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin and guard.live_allowed(t.id)]
batch = uuid.uuid4().hex[:10]
store.audit(db, actor, "core.rollback", None, f"{r.sha[:10]} → :{prev}")
def done(ok, results):
with session() as db:
r = db.get(CoreRelease, release_id)
if ok:
r.status = "rolled_back"
_set_setting(db, "core_image_tag", prev)
store.audit(db, "system", "core.rollback.done", None, f"{'ok' if ok else 'FEHLER'} {results}")
return batch, deploy.run_sequence(ids, "deploy", actor, batch=batch, core_tag=prev, http=http, oidc_client=oidc_client, health=health, wait=wait, on_done=done)
def tenant_rollback_tag(db, t: Tenant) -> str | None:
"""Letzter erfolgreich live deployter Tag dieses Tenants, der vom aktuellen abweicht."""
for d in db.scalars(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "deploy", Deployment.status == "success",
Deployment.simulated.is_(False)).order_by(Deployment.id.desc())):
if d.image_tag and d.image_tag != t.deployed_image:
return d.image_tag
return None

76
app/services/samples.py Normal file
View File

@@ -0,0 +1,76 @@
"""Beispielprotokolle für die PDF-Vorschau (so, wie der LLM sie liefern würde)."""
DEFAULT_SAMPLE = """===TERMINART===
Kick-off
===/TERMINART===
===PROJEKT===
Muster GmbH
Relaunch 2026
===/PROJEKT===
===KONTAKT===
Max Mustermann - Geschäftsführer
Erika Beispiel - Projektleitung
===/KONTAKT===
Anlass & Kontext
Erstes Kennenlernen und Abstecken des Projektrahmens. Ziel ist ein gemeinsames Verständnis von Umfang, Zeitplan und Zuständigkeiten.
Besprochene Themen
• Zeitplan und Meilensteine bis zum Go-live im Herbst
• Budgetrahmen und Freigabeprozess
• Anbindung des bestehenden CRM
Entscheidungen
• Projektstart im Juli
• Wöchentlicher Jour fixe, dienstags 10:00 Uhr
Action Items
• Angebot bis Freitag senden [Verantwortlich: Erika Beispiel, Frist: 18.07.]
• Zugänge zum CRM bereitstellen [Verantwortlich: Max Mustermann, Frist: 22.07.]
Offene Punkte
• Finale Designrichtung noch nicht entschieden
• Datenschutzfreigabe der Fachabteilung steht aus
Nächste Schritte
• Folgetermin zum Konzept-Review vereinbaren
"""
ROTARY_SAMPLE = """===TERMINART===
Clubmeeting
===/TERMINART===
===PROJEKT===
Lieferketten im Wandel
===/PROJEKT===
Begrüßung
Der Präsident eröffnet das Meeting und begrüßt die anwesenden Freunde sowie zwei Gäste.
Geburtstage
• Anna Beispiel
• Peter Muster
Aktuelle 5 Minuten
Kurzer Bericht zum Stand der Spendenaktion für das Jugendprojekt. Bisher sind 4.200 Euro zusammengekommen.
Happy Euro
• Für die gelungene Veranstaltung am Samstag
• Für das gute Wetter beim Grillfest
Vortrag / Referat
Globalisierung – historische Einordnung
Der Referent ordnet die Entwicklung der globalen Lieferketten seit den 1990er-Jahren ein.
Folgen für die Region
• Abhängigkeit von wenigen Zulieferern
• Chancen durch regionale Wertschöpfung
Dank
Der Präsident dankt dem Referenten für den anregenden Vortrag.
Abschluss
Nächstes Meeting am kommenden Montag.
"""
SAMPLE_TRANSCRIPT = """SPRECHER_0: Guten Morgen zusammen, schön, dass wir uns heute zum Kick-off treffen. Ich bin Erika Beispiel, Projektleitung auf unserer Seite.
SPRECHER_1: Morgen. Max Mustermann, ich bin Geschäftsführer der Muster GmbH. Wir wollen unsere Website bis zum Herbst neu aufsetzen.
SPRECHER_0: Gut. Dann starten wir im Juli. Ich würde einen wöchentlichen Jour fixe vorschlagen, dienstags um zehn.
SPRECHER_1: Passt. Das Budget liegt bei etwa vierzigtausend Euro, die Freigabe läuft über mich.
SPRECHER_0: Ich schicke Ihnen bis Freitag das Angebot. Und wir brauchen von Ihnen die Zugänge zum CRM, möglichst bis zum zweiundzwanzigsten Juli.
SPRECHER_1: Mache ich. Offen ist noch die Designrichtung und ob die Datenschutzabteilung zustimmt.
SPRECHER_0: Dann vereinbaren wir als Nächstes einen Termin zum Konzept-Review.
"""
ROTARY_TRANSCRIPT = """SPRECHER_0: Liebe Freunde, ich begrüße euch zum Clubmeeting und freue mich über zwei Gäste heute Abend.
SPRECHER_0: Geburtstag haben diese Woche Anna Beispiel und Peter Muster, herzlichen Glückwunsch.
SPRECHER_1: Zur Spendenaktion für das Jugendprojekt: Wir haben bisher viertausendzweihundert Euro zusammen.
SPRECHER_2: Happy Euro für das schöne Wetter beim Grillfest und für die gelungene Veranstaltung am Samstag.
SPRECHER_3: Mein Vortrag heute: Globalisierung und Lieferketten. Seit den Neunzigern hat sich die Abhängigkeit von wenigen Zulieferern stark erhöht. Für unsere Region bedeutet das Risiken, aber auch Chancen durch regionale Wertschöpfung.
SPRECHER_0: Vielen Dank für den Vortrag. Nächstes Meeting ist am kommenden Montag.
"""

54
app/services/seed.py Normal file
View File

@@ -0,0 +1,54 @@
"""Seed-Export/-Import: Startdaten (importierte Tenants) als Datei im Repo.
So startet eine frische Instanz (z. B. Coolify) mit den aktuellen Tenants, ohne Zugriff auf das alte Git-Repo.
Secrets-WERTE sind nie enthalten – nur die Schlüsselnamen.
"""
from __future__ import annotations
import base64
import gzip
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Blob, Secret, Setting, Tenant, Version
COLS = ["id", "name", "status", "source", "subdomain", "custom_domain", "domain_status", "cpu", "memory",
"current_version", "deployed_version", "deployed_image", "preview_sample", "oidc", "notes"]
def export(db: Session, path: Path) -> None:
tenants = db.scalars(select(Tenant).order_by(Tenant.id)).all()
shas = set()
out = {"tenants": [], "versions": [], "secrets": [], "settings": {}}
for t in tenants:
out["tenants"].append({c: getattr(t, c) for c in COLS})
for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number)):
out["versions"].append({"tenant_id": t.id, "number": v.number, "message": v.message, "author": v.author,
"config": v.config, "files": v.files})
shas |= set(v.files.values())
for s in db.scalars(select(Secret)):
out["secrets"].append({"tenant_id": s.tenant_id, "key": s.key})
out["settings"] = {s.key: s.value for s in db.scalars(select(Setting))}
out["blobs"] = {sha: {"b64": base64.b64encode(db.get(Blob, sha).content).decode(), "text": db.get(Blob, sha).is_text} for sha in shas}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(gzip.compress(json.dumps(out).encode(), 9))
def load(db: Session, path: Path) -> int:
data = json.loads(gzip.decompress(path.read_bytes()))
for sha, b in data["blobs"].items():
raw = base64.b64decode(b["b64"])
db.add(Blob(sha=sha, content=raw, size=len(raw), is_text=b["text"]))
for t in data["tenants"]:
db.add(Tenant(**t))
db.flush()
for v in data["versions"]:
db.add(Version(**v))
for s in data["secrets"]:
db.add(Secret(tenant_id=s["tenant_id"], key=s["key"], value_enc=None, source="sops-import"))
for k, v in data["settings"].items():
db.add(Setting(key=k, value=v))
return len(data["tenants"])

61
app/services/status.py Normal file
View File

@@ -0,0 +1,61 @@
"""Zustandsabgleich: was läuft wirklich? (Vertrag: GET /health → core.version, bundle.source/version/stale)"""
from __future__ import annotations
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import select
from app.db import session
from app.models import Tenant
from app.services import contract, deploy
_CACHE: dict = {"ts": 0.0, "data": None}
TTL = 30.0
def _http_get(url: str):
import httpx
r = httpx.get(url, timeout=6, follow_redirects=True)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
def probe(host: str, expect_tag: str, expect_bundle: int | None, get=_http_get) -> dict:
out = {"host": host, "reachable": False, "core": None, "bundle": None, "drift": [], "error": None}
try:
status, body = get(f"https://{host}/health")
except Exception as exc: # noqa: BLE001
out["error"] = f"{type(exc).__name__}: {str(exc)[:120]}"
out["drift"].append("nicht erreichbar")
return out
out["reachable"] = status == 200
if status != 200:
out["drift"].append(f"HTTP {status}")
core, bundle = body.get("core"), body.get("bundle")
out["core"], out["bundle"] = core, bundle
if core is None:
out["drift"].append("Core ohne Vertrag (alte Version)")
elif expect_tag and deploy._hex_tag(expect_tag) and not contract.same_sha(core.get("version", ""), expect_tag):
out["drift"].append(f"Core {core.get('version', '')[:10]} statt {expect_tag[:10]}")
if expect_bundle is not None:
if not bundle:
out["drift"].append("meldet keine Bundle-Quelle")
elif bundle.get("source") != "admin" or bundle.get("version") != expect_bundle or bundle.get("stale"):
out["drift"].append(f"Bundle {bundle.get('source')} v{bundle.get('version')}{' (veraltet)' if bundle.get('stale') else ''} statt admin v{expect_bundle}")
elif bundle and bundle.get("source") not in ("git", None):
out["drift"].append(f"Bundle-Quelle {bundle.get('source')}, erwartet git")
return out
def probe_all(get=_http_get, refresh: bool = False, workers: int = 6) -> dict:
if not refresh and _CACHE["data"] is not None and time.time() - _CACHE["ts"] < TTL:
return _CACHE["data"]
with session() as db:
items = []
for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)):
items.append((t.id, deploy.host_for(db, t), deploy.effective_tag(db, t), t.current_version if t.source == "admin" else None))
with ThreadPoolExecutor(max_workers=workers) as ex:
res = list(ex.map(lambda it: (it[0], probe(it[1], it[2], it[3], get)), items))
data = {tid: r for tid, r in res}
_CACHE.update(ts=time.time(), data=data)
return data

225
app/services/store.py Normal file
View File

@@ -0,0 +1,225 @@
"""Inhalte eines Tenants: Blobs, Entwurf, Versionen, Diff, Bundle.
Ein Tenant besteht aus `config` (Inhalt der tenant.json ohne deploy-Block) und
`files` ({pfad: sha}). Bearbeitet wird immer ein Entwurf (tenant.draft);
„Veröffentlichen“ schreibt daraus eine neue unveränderliche Version.
"""
from __future__ import annotations
import difflib
import hashlib
import io
import json
import tarfile
from copy import deepcopy
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import AuditLog, Blob, Tenant, Version
TEXT_SUFFIXES = {".txt", ".html", ".css", ".py", ".json", ".svg", ".md", ".yaml", ".yml", ".csv"}
RESERVED = {"tenant.json"} # wird aus config erzeugt
def is_text_path(path: str) -> bool:
return any(path.lower().endswith(s) for s in TEXT_SUFFIXES)
def put_blob(db: Session, data: bytes, path: str = "") -> str:
sha = hashlib.sha256(data).hexdigest()
if db.get(Blob, sha) is None:
db.add(Blob(sha=sha, content=data, size=len(data), is_text=is_text_path(path)))
db.flush()
return sha
def get_blob(db: Session, sha: str) -> bytes:
b = db.get(Blob, sha)
if b is None:
raise KeyError(sha)
return b.content
def audit(db: Session, actor: str, action: str, tenant_id: str | None = None, detail: str = "") -> None:
db.add(AuditLog(actor=actor, action=action, tenant_id=tenant_id, detail=detail))
def current_version(db: Session, t: Tenant) -> Version | None:
if t.current_version is None:
return None
return db.scalar(select(Version).where(Version.tenant_id == t.id, Version.number == t.current_version))
def get_version(db: Session, tenant_id: str, number: int) -> Version | None:
return db.scalar(select(Version).where(Version.tenant_id == tenant_id, Version.number == number))
def content(db: Session, t: Tenant, source: str = "draft") -> tuple[dict, dict]:
"""(config, files) für source = draft | current | v<N>. Draft fällt auf current zurück."""
if source == "draft" and t.draft:
return deepcopy(t.draft["config"]), dict(t.draft["files"])
if source.startswith("v") and source[1:].isdigit():
v = get_version(db, t.id, int(source[1:]))
else:
v = current_version(db, t)
if v is None:
return {}, {}
return deepcopy(v.config), dict(v.files)
def ensure_draft(db: Session, t: Tenant) -> dict:
"""Arbeitskopie des Entwurfs (nie das am ORM-Objekt hängende Dict mutieren)."""
if t.draft:
return json.loads(json.dumps(t.draft))
cfg, files = content(db, t, "current")
return {"config": cfg, "files": files}
def _save_draft(t: Tenant, draft: dict) -> None:
t.draft = json.loads(json.dumps(draft))
flag_modified(t, "draft")
def set_config(db: Session, t: Tenant, config: dict) -> None:
d = ensure_draft(db, t)
config = {k: v for k, v in config.items() if k != "deploy"}
config["id"] = t.id
d["config"] = config
_save_draft(t, d)
_sync_draft_state(db, t)
def set_file(db: Session, t: Tenant, path: str, data: bytes) -> str:
path = clean_path(path)
d = ensure_draft(db, t)
sha = put_blob(db, data, path)
d["files"][path] = sha
_save_draft(t, d)
_sync_draft_state(db, t)
return sha
def delete_file(db: Session, t: Tenant, path: str) -> None:
d = ensure_draft(db, t)
d["files"].pop(path, None)
_save_draft(t, d)
_sync_draft_state(db, t)
def clean_path(path: str) -> str:
path = path.strip().lstrip("/")
if not path or ".." in path.split("/") or path in RESERVED or "\\" in path:
raise ValueError(f"Ungültiger Dateipfad: {path!r}")
return path
def _sync_draft_state(db: Session, t: Tenant) -> None:
"""Entwurf, der identisch zur aktuellen Version ist, wieder löschen."""
if t.draft and not diff_summary(db, t, "current", "draft")["changed"]:
t.draft = None
def discard_draft(db: Session, t: Tenant) -> None:
t.draft = None
def publish(db: Session, t: Tenant, message: str, author: str) -> Version:
if not t.draft:
raise ValueError("Kein Entwurf zum Veröffentlichen.")
number = (t.current_version or 0) + 1
last = db.scalar(select(Version.number).where(Version.tenant_id == t.id).order_by(Version.number.desc()))
number = max(number, (last or 0) + 1)
v = Version(tenant_id=t.id, number=number, message=message.strip() or f"Version {number}", author=author,
config=t.draft["config"], files=t.draft["files"])
db.add(v)
t.current_version = number
t.draft = None
t.name = v.config.get("name", t.name)
audit(db, author, "version.publish", t.id, f"v{number}: {v.message}")
db.flush()
return v
def restore_as_draft(db: Session, t: Tenant, number: int) -> None:
v = get_version(db, t.id, number)
if v is None:
raise KeyError(number)
t.draft = {"config": deepcopy(v.config), "files": dict(v.files)}
_sync_draft_state(db, t)
# ── Diff ────────────────────────────────────────────────────────────────────
def diff_summary(db: Session, t: Tenant, a: str, b: str, with_text: bool = False) -> dict:
return diff_content(db, content(db, t, a), content(db, t, b), with_text)
def diff_content(db: Session, left: tuple[dict, dict], right: tuple[dict, dict], with_text: bool = False) -> dict:
ca, fa = left
cb, fb = right
items = []
if json.dumps(ca, sort_keys=True) != json.dumps(cb, sort_keys=True):
items.append(_item("tenant.json", "geändert", db, json.dumps(ca, indent=2, ensure_ascii=False, sort_keys=True),
json.dumps(cb, indent=2, ensure_ascii=False, sort_keys=True), True, with_text))
for path in sorted(set(fa) | set(fb)):
sa, sb = fa.get(path), fb.get(path)
if sa == sb:
continue
kind = "neu" if sa is None else "gelöscht" if sb is None else "geändert"
text = is_text_path(path)
ta = get_blob(db, sa).decode("utf-8", "replace") if (sa and text and with_text) else ""
tb = get_blob(db, sb).decode("utf-8", "replace") if (sb and text and with_text) else ""
items.append(_item(path, kind, db, ta, tb, text, with_text))
return {"changed": bool(items), "items": items}
def _item(path, kind, db, ta, tb, text, with_text):
out = {"path": path, "kind": kind, "text": text}
if with_text and text:
lines = list(difflib.unified_diff(ta.splitlines(), tb.splitlines(), "vorher", "nachher", lineterm="", n=2))
out["diff"] = lines
out["added"] = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
out["removed"] = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
return out
# ── Bundle (genau das, was ein Tenant-Container beim Start abholt) ──────────
def tenant_json_bytes(config: dict) -> bytes:
return (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()
def materialize(db: Session, t: Tenant, source: str, target) -> dict:
"""Schreibt das Tenant-Verzeichnis (tenant.json + Dateien) nach `target`."""
cfg, files = content(db, t, source)
write_tenant_dir(db, cfg, files, target)
return cfg
def write_tenant_dir(db: Session, cfg: dict, files: dict, target) -> None:
target.mkdir(parents=True, exist_ok=True)
(target / "tenant.json").write_bytes(tenant_json_bytes(cfg))
for path, sha in files.items():
p = target / path
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(get_blob(db, sha))
def bundle_tar(db: Session, t: Tenant, source: str = "current") -> tuple[bytes, dict]:
cfg, files = content(db, t, source)
manifest = {"tenant": t.id, "version": t.current_version if source != "draft" else None,
"files": {"tenant.json": hashlib.sha256(tenant_json_bytes(cfg)).hexdigest(), **files}}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
def add(name, data):
ti = tarfile.TarInfo(name)
ti.size = len(data)
ti.mtime = 0
tar.addfile(ti, io.BytesIO(data))
add("tenant.json", tenant_json_bytes(cfg))
for path, sha in sorted(files.items()):
add(path, get_blob(db, sha))
add("MANIFEST.json", json.dumps(manifest, indent=2).encode())
return buf.getvalue(), manifest

95
app/services/tenants.py Normal file
View File

@@ -0,0 +1,95 @@
"""Tenant-Lebenszyklus: anlegen, klonen, Secrets, Cut-over-Check."""
from __future__ import annotations
import re
from copy import deepcopy
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Deployment, Secret, Tenant, Version
from app.services import crypto, defaults, store
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,38}$")
def create(db: Session, tenant_id: str, name: str, clone_from: str | None, actor: str) -> Tenant:
tenant_id = tenant_id.strip().lower()
if not SLUG.match(tenant_id):
raise ValueError("ID: nur a-z, 0-9 und Bindestrich, 2–39 Zeichen.")
if db.get(Tenant, tenant_id):
raise ValueError(f"Tenant „{tenant_id}“ existiert bereits.")
t = Tenant(id=tenant_id, name=name.strip() or tenant_id, subdomain=tenant_id, domain_status="none", source="admin",
oidc={"mode": "managed"})
db.add(t)
db.flush()
if clone_from:
src = db.get(Tenant, clone_from)
cfg, files = store.content(db, src, "current")
cfg = deepcopy(cfg)
cfg.update({"id": tenant_id, "name": t.name, "title": f"{t.name} – Gesprächsprotokoll",
"description": f"Gesprächsprotokoll-Tool für {t.name}", "ai_detect_org": t.name, "org_short": t.name})
# Personenbezogene/mandantenspezifische Dateien nicht mitklonen
files = {p: s for p, s in files.items() if not p.startswith("namensliste")}
t.cpu, t.memory = src.cpu, src.memory
else:
cfg, files = defaults.skeleton_config(tenant_id, t.name), {}
db.add(Version(tenant_id=tenant_id, number=1, message="Angelegt" + (f" (Kopie von {clone_from})" if clone_from else ""),
author=actor, config=cfg, files=files))
t.current_version = 1
for key, val in (("SESSION_SECRET", defaults.new_token()), ("ADMIN_BUNDLE_TOKEN", defaults.new_token())):
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=crypto.encrypt(val), source="manual"))
for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"):
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=None, source="manual"))
store.audit(db, actor, "tenant.create", tenant_id, f"Kopie von {clone_from}" if clone_from else "leer")
db.flush()
return t
def secret_rows(db: Session, tenant_id: str) -> list[dict]:
rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))}
keys = list(dict.fromkeys(defaults.REQUIRED_SECRETS + [k for k in rows if k not in defaults.REQUIRED_SECRETS]))
out = []
for k in keys:
r = rows.get(k)
out.append({
"key": k, "set": bool(r and r.value_enc), "source": r.source if r else "",
"required": k in defaults.REQUIRED_SECRETS, "help": defaults.SECRET_HELP.get(k, ""),
"updated_at": r.updated_at.isoformat() if r and r.updated_at else None,
})
return out
def set_secret(db: Session, tenant_id: str, key: str, value: str | None, actor: str) -> None:
if not re.match(r"^[A-Z][A-Z0-9_]{1,60}$", key):
raise ValueError("Schlüssel: GROSSBUCHSTABEN_MIT_UNTERSTRICH")
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
if r is None:
r = Secret(tenant_id=tenant_id, key=key)
db.add(r)
r.value_enc = crypto.encrypt(value) if value else None
r.source = "manual"
store.audit(db, actor, "secret.set" if value else "secret.clear", tenant_id, key)
def check_bundle_token(db: Session, tenant_id: str, token: str) -> bool:
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "ADMIN_BUNDLE_TOKEN"))
return bool(r and r.value_enc and token and crypto.decrypt(r.value_enc) == token)
def cutover_checks(db: Session, t: Tenant, validation: dict | None) -> list[dict]:
secrets_ok = all(s["set"] for s in secret_rows(db, t.id) if s["required"])
imported = db.scalar(select(Version).where(Version.tenant_id == t.id, Version.author == "import")) is not None
dry = db.scalar(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "dry-run",
Deployment.status == "success").order_by(Deployment.id.desc()))
return [
{"id": "content", "label": "Inhalt aus Git importiert & Hash-gleich verifiziert" if imported else "Inhalt in der Datenbank",
"ok": True},
{"id": "validate", "label": "Validierung (Core-Rendering von Web-UI und PDF)", "ok": bool(validation and validation.get("ok"))},
{"id": "secrets", "label": "Alle Pflicht-Secrets in der Admin-DB gesetzt", "ok": secrets_ok},
{"id": "oidc", "label": "OIDC-Client abgeglichen (Login-API)" if (t.oidc or {}).get("mode", "adopt") != "off" else "OIDC-Verwaltung aus",
"ok": (t.oidc or {}).get("mode", "adopt") == "off" or bool(((t.oidc or {}).get("last_sync") or {}).get("ok"))},
{"id": "domain", "label": "Domain verifiziert", "ok": t.domain_status == "verified"},
{"id": "dryrun", "label": "Dry-Run erfolgreich", "ok": dry is not None},
{"id": "published", "label": "Keine unveröffentlichten Änderungen", "ok": not t.draft},
]

33
app/settings.py Normal file
View File

@@ -0,0 +1,33 @@
"""Zentrale Konfiguration (Env-getrieben)."""
from __future__ import annotations
import base64
import hashlib
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if not os.getenv("ONYSCRIBE_SKIP_DOTENV"): # lokale Entwicklung: .env im Projektordner einlesen (Docker/Coolify setzen die Env direkt)
try:
from dotenv import load_dotenv
load_dotenv(ROOT / ".env", override=False) # echte Umgebungsvariablen haben Vorrang
except ImportError:
pass
DATA = ROOT / ".data"
DATA.mkdir(exist_ok=True)
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA / 'onyscribe-admin.db'}")
# Entwicklungs-Fallback: abgeleiteter Key. In Produktion SECRET_KEY setzen!
_raw = os.getenv("SECRET_KEY", "").strip()
SECRET_KEY = _raw or base64.urlsafe_b64encode(hashlib.sha256(b"onyscribe-admin-dev").digest()).decode()
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true"
# Vendored Snapshot des Plattform-Cores (Preview führt den echten Core aus)
PLATFORM_DIR = Path(os.getenv("PLATFORM_DIR", ROOT / "vendor" / "platform"))
PREVIEW_CACHE = ROOT / ".preview-cache"
PREVIEW_CACHE.mkdir(exist_ok=True)
# Pfad zur bestehenden transkript-platform (nur für den Importer)
LEGACY_PLATFORM = Path(os.getenv("LEGACY_PLATFORM", ROOT.parent / "transkript-platform"))
APP_NAME = "Onyscribe Admin"

170
app/static/admin.css Normal file
View File

@@ -0,0 +1,170 @@
:root{
--creme:#f4efe8; --dark:#1a1a1a; --cyan:#00cccc; --cyan-d:#009e9e; --white:#fff; --stone:#eceae4; --mist:#f7f4f0;
--border:#ddd8d0; --gray:#888; --mid:#555; --ok:#2a8a2a; --warn:#e0a000; --err:#c0392b;
--font:"Liberation Sans",Arial,Helvetica,sans-serif; --mono:ui-monospace,"JetBrains Mono","Liberation Mono",Menlo,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{font-family:var(--font);background:var(--creme);color:#2c2c2c;font-size:13px;line-height:1.45;display:flex}
a{color:inherit}
h1,h2,h3{margin:0;color:var(--dark)}
button,input,select,textarea{font:inherit}
.muted{color:var(--gray)} .mono{font-family:var(--mono);font-size:12px} .grow{flex:1;min-width:0}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.nowrap{white-space:nowrap}
/* ── Sidebar ─────────────────────────────────────────────── */
.side{width:196px;flex:none;background:var(--dark);color:#d8d4cc;display:flex;flex-direction:column;height:100vh;position:sticky;top:0;border-right:2px solid var(--cyan)}
.brand{padding:18px 18px 12px}
.brand b{color:#fff;font-size:19px;letter-spacing:-.3px}
.brand span{display:block;color:var(--cyan);font-size:9.5px;font-weight:700;letter-spacing:2px;text-transform:uppercase}
.nav{padding:6px 8px;display:flex;flex-direction:column;gap:1px}
.nav a{display:flex;gap:9px;align-items:center;padding:7px 10px;border-radius:7px;text-decoration:none;color:#bdb8ae;font-weight:600}
.nav a:hover{background:#262626;color:#fff} .nav a.on{background:#262626;color:#fff;box-shadow:inset 3px 0 0 var(--cyan)}
.nav svg{width:15px;height:15px;flex:none}
.side-foot{margin-top:auto;padding:12px 16px;font-size:11px;color:#8a867e;border-top:1px solid #2c2c2c;display:flex;flex-direction:column;gap:6px}
.pill{display:inline-block;border-radius:4px;padding:1px 7px;font-weight:700;letter-spacing:.8px;font-size:9.5px;text-transform:uppercase;width:fit-content}
.pill.lock{background:#3a2f12;color:#f0c55a} .pill.live{background:#4a1d17;color:#ff9a8a} .pill.dev{background:#2c2c2c;color:var(--cyan)}
.side-foot a{color:#bdb8ae}
/* ── Seiten-Layout ───────────────────────────────────────── */
.main{flex:1;min-width:0;display:flex;flex-direction:column;height:100vh;overflow:auto}
.page{padding:22px 28px 50px;max-width:1500px;width:100%}
.eyebrow{font-size:10.5px;font-weight:700;letter-spacing:1.8px;color:var(--gray);text-transform:uppercase}
.eyebrow::before{content:"— ";color:var(--cyan)}
.title{font-size:24px;font-weight:700;letter-spacing:-.4px;margin:2px 0 0}
.lead{color:var(--mid);margin:4px 0 0;max-width:760px}
.head{display:flex;gap:16px;align-items:flex-end;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap}
/* ── Cards ───────────────────────────────────────────────── */
.card{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
.card + .card,.card + .grid2,.grid2 + .card{margin-top:12px}
.card h3{font-size:13.5px;margin-bottom:8px}
.card .sub{color:var(--gray);font-size:12px;margin:-4px 0 10px}
.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.grid2 > .card{margin:0!important}
.kpis{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.kpi{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:7px 13px;display:flex;gap:9px;align-items:baseline;cursor:default}
.kpi b{font-size:18px;color:var(--dark)} .kpi span{color:var(--gray);font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
.kpi.click{cursor:pointer} .kpi.click:hover{border-color:var(--cyan)}
/* ── Badges / Buttons ────────────────────────────────────── */
.badge{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--stone);color:var(--mid);white-space:nowrap;line-height:1.6}
.badge.ok{background:#e3f3e3;color:var(--ok)} .badge.warn{background:#fbf0cf;color:#8a6200}
.badge.err{background:#f8dcd8;color:var(--err)} .badge.cyan{background:#d6f5f5;color:#007878} .badge.dark{background:var(--dark);color:#fff}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--white);color:var(--dark);border-radius:8px;padding:6px 12px;font-weight:700;cursor:pointer;text-decoration:none;font-size:12.5px;white-space:nowrap}
.btn:hover{border-color:var(--dark)} .btn.primary{background:var(--dark);color:#fff;border-color:var(--dark)}
.btn.cyan{background:var(--cyan);border-color:var(--cyan);color:#002b2b} .btn.cyan:hover{background:var(--cyan-d);border-color:var(--cyan-d);color:#fff}
.btn.danger{color:var(--err)} .btn.danger:hover{border-color:var(--err)} .btn.sm{padding:3px 9px;font-size:11.5px;border-radius:6px}
.btn[disabled],.btn.disabled{opacity:.45;pointer-events:none}
.seg{display:inline-flex;background:var(--stone);border-radius:8px;padding:2px;gap:1px}
.seg button{border:0;background:none;padding:4px 10px;border-radius:6px;font-weight:700;color:var(--mid);cursor:pointer;font-size:12px}
.seg button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 2px rgba(0,0,0,.14)}
.chipbar{display:flex;gap:5px;flex-wrap:wrap}
.fchip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:3px 11px;font-size:12px;font-weight:700;color:var(--mid);cursor:pointer}
.fchip.on{background:var(--dark);color:#fff;border-color:var(--dark)}
input.search{border:1px solid var(--border);border-radius:8px;padding:6px 10px;min-width:220px;background:#fff}
/* ── Tabellen ────────────────────────────────────────────── */
table.t{width:100%;border-collapse:collapse}
table.t th{text-align:left;font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--gray);padding:6px 9px;border-bottom:1px solid var(--border);white-space:nowrap}
table.t td{padding:7px 9px;border-bottom:1px solid var(--stone);vertical-align:middle}
table.t tr:last-child td{border-bottom:0}
table.t.hover tbody tr{cursor:pointer} table.t.hover tbody tr:hover td{background:var(--mist)}
.logo-box{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--mist);display:grid;place-items:center;flex:none;overflow:hidden}
.logo-box img{max-width:28px;max-height:28px}
.logo-box.lg{width:120px;height:56px} .logo-box.lg img{max-width:104px;max-height:44px}
.swatches{display:inline-flex;gap:2px;vertical-align:middle}
.swatches i{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.14);display:block}
.dotc{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;background:var(--stone)}
.dotc.ok{background:var(--ok)} .dotc.warn{background:var(--warn)} .dotc.err{background:var(--err)} .dotc.cyan{background:var(--cyan)}
/* ── Tenant-Seite ────────────────────────────────────────── */
.thead{padding:12px 20px 10px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--creme);position:sticky;top:0;z-index:6}
.thead h1{font-size:17px;letter-spacing:-.2px;line-height:1.2}
.crumb{font-size:11px;color:var(--gray)} .crumb a{text-decoration:none} .crumb a:hover{color:var(--dark)}
.dirty{display:inline-flex;gap:6px;align-items:center;font-size:11.5px;font-weight:700;color:#8a6200;background:#fbf0cf;border-radius:999px;padding:2px 10px}
.dirty::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--warn)}
.mid{min-width:0}
.tl{display:grid;grid-template-columns:176px minmax(0,1fr);flex:1;min-height:0;align-items:start}
.tl.open{grid-template-columns:176px minmax(340px,5fr) minmax(360px,6fr)}
.snav{position:sticky;top:53px;padding:12px 8px 20px 14px;display:flex;flex-direction:column;gap:1px;max-height:calc(100vh - 53px);overflow:auto}
.snav h6{margin:12px 8px 3px;font-size:9.5px;letter-spacing:1.6px;text-transform:uppercase;color:var(--gray)}
.snav h6:first-child{margin-top:0}
.snav button{display:flex;align-items:center;gap:7px;border:0;background:none;text-align:left;padding:6px 9px;border-radius:7px;font-weight:600;color:var(--mid);cursor:pointer;width:100%}
.snav button:hover{background:var(--stone);color:var(--dark)} .snav button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 3px rgba(0,0,0,.1),inset 3px 0 0 var(--cyan)}
.snav .n{margin-left:auto;font-size:10.5px}
.pane{padding:14px 18px 60px;min-width:0;container-type:inline-size}
@container (max-width:780px){.grid2{grid-template-columns:1fr}.fgrid.c3{grid-template-columns:1fr 1fr}}
.panel{position:sticky;top:53px;height:calc(100vh - 53px);padding:10px 20px 14px 4px;display:none;flex-direction:column;gap:8px;min-width:0}
.tl.open .panel{display:flex}
.pbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.pv-frame{flex:1;background:var(--white);border:1px solid var(--border);border-radius:10px;position:relative;overflow:hidden;display:flex;justify-content:center;background-image:radial-gradient(#e6e1d8 1px,transparent 1px);background-size:14px 14px}
.pv-frame iframe{border:0;background:#fff;height:100%;width:100%;transition:width .2s;box-shadow:0 0 0 1px var(--border)}
.pv-frame.tablet iframe{width:768px;max-width:100%} .pv-frame.mobile iframe{width:390px;max-width:100%}
.pv-load{position:absolute;inset:0;background:rgba(244,239,232,.72);display:none;place-items:center;font-weight:700;color:var(--mid)} .pv-load.on{display:grid}
.pv-err{position:absolute;inset:0;background:#fff;padding:16px;overflow:auto;display:none;color:var(--err);font-family:var(--mono);font-size:12px;white-space:pre-wrap} .pv-err.on{display:block}
.hide{display:none!important}
/* ── Formulare ───────────────────────────────────────────── */
.fgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px} .fgrid.c3{grid-template-columns:repeat(3,minmax(0,1fr))}
.fgrid .full{grid-column:1/-1}
label.f{display:block;font-size:10.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--gray)}
label.f > input,label.f > select,label.f > textarea{display:block;width:100%;margin-top:3px;text-transform:none;letter-spacing:0;font-weight:400;color:#222}
input[type=text],input[type=email],input[type=url],input[type=password],input[type=number],select,textarea{border:1px solid var(--border);border-radius:7px;padding:5px 9px;background:#fff;outline:none;font-size:12.5px}
input:focus,select:focus,textarea:focus{border-color:var(--cyan);box-shadow:0 0 0 3px rgba(0,204,204,.16)}
textarea{width:100%;resize:vertical;line-height:1.5}
textarea.code{font-family:var(--mono);font-size:12px;tab-size:4;min-height:300px;background:#fbfaf8;white-space:pre}
.colors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 14px}
.colorf{display:flex;gap:7px;align-items:center;min-width:0}
.colorf input[type=color]{width:24px;height:24px;border:1px solid var(--border);border-radius:5px;padding:0;background:none;cursor:pointer;flex:none}
.colorf span{font-size:11px;color:var(--mid);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.colorf input[type=text]{width:74px;padding:2px 5px;font-family:var(--mono);font-size:11.5px}
.chk{display:flex;gap:8px;align-items:flex-start;padding:5px 0}.chk input{margin-top:2px;accent-color:var(--dark)}
.hint{font-size:11.5px;color:var(--gray)}
.banner{border-radius:8px;padding:7px 12px;font-size:12.5px;margin-bottom:10px;border:1px solid}
.banner.warn{background:#fdf6df;border-color:#ecd48a;color:#6f5200} .banner.info{background:#e6f8f8;border-color:#a8e6e6;color:#005e5e}
.banner.err{background:#fbe6e3;border-color:#efb5ad;color:#8e2418} .banner.ok{background:#e8f5e8;border-color:#b3dcb3;color:#1e661e}
details.fold{border:1px solid var(--border);border-radius:9px;background:var(--white);margin-top:10px}
details.fold > summary{cursor:pointer;padding:8px 13px;font-weight:700;list-style:none;display:flex;gap:8px;align-items:center}
details.fold > summary::before{content:"▸";color:var(--gray);transition:.15s} details.fold[open] > summary::before{transform:rotate(90deg)}
details.fold > .in{padding:2px 14px 13px}
.kv{display:grid;grid-template-columns:104px 1fr;gap:5px 10px;font-size:12.5px} .kv dt{color:var(--gray);font-weight:700;font-size:10.5px;letter-spacing:.9px;text-transform:uppercase;padding-top:2px} .kv dd{margin:0}
.check{display:flex;gap:8px;padding:4px 0;align-items:center;font-size:12.5px}
.dot{width:17px;height:17px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;flex:none;background:var(--stone);color:var(--gray)}
.dot.ok{background:var(--ok);color:#fff} .dot.no{background:#f2d9d5;color:var(--err)}
.console{background:#141414;color:#d6f5e6;font-family:var(--mono);font-size:12px;border-radius:8px;padding:11px 13px;white-space:pre-wrap;min-height:90px;max-height:340px;overflow:auto}
.console .sim{color:#7fd8d8}
pre.json{background:#141414;color:#e6e6e6;border-radius:8px;padding:11px;font-size:11.5px;overflow:auto;max-height:300px;font-family:var(--mono);margin:6px 0 0}
.diff{font-family:var(--mono);font-size:11.5px;background:#fbfaf8;border:1px solid var(--border);border-radius:7px;overflow:auto;max-height:260px;margin:5px 0 10px}
.diff div{padding:0 9px;white-space:pre} .diff .a{background:#e3f6e3;color:#1c5a1c} .diff .r{background:#fbe3df;color:#8e2418} .diff .h{color:var(--gray);background:var(--stone)}
.empty{padding:22px;text-align:center;color:var(--gray)}
.copy{cursor:pointer;border-bottom:1px dashed var(--gray)}
/* ── Agent-Chat ──────────────────────────────────────────── */
.chat{display:flex;flex-direction:column;flex:1;min-height:0}
.agpane{position:sticky;top:53px;height:calc(100vh - 53px);padding:12px 12px 14px 18px}
.agpane.hide{display:none!important}
.msgs{flex:1;overflow:auto;display:flex;flex-direction:column;gap:9px;padding:2px 2px 10px}
.bub{max-width:94%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;font-size:12.5px}
.bub.user{align-self:flex-end;background:var(--dark);color:#fff;border-bottom-right-radius:3px}
.bub.assistant{align-self:flex-start;background:var(--white);border:1px solid var(--border);border-bottom-left-radius:3px}
.steps{display:flex;flex-direction:column;gap:3px;margin:0 0 6px}
.step{display:flex;gap:6px;align-items:baseline;font-size:11.5px;color:var(--mid);background:var(--mist);border:1px solid var(--stone);border-radius:6px;padding:2px 8px;white-space:normal;overflow-wrap:anywhere}
.step b{font-family:var(--mono);color:#007878;flex:none} .step.err{background:#fbe6e3;border-color:#efb5ad} .step.err b{color:var(--err)}
.thought{color:var(--gray);font-style:italic;font-size:12px;margin:0 0 5px}
.prop{margin-top:8px;border:1px dashed var(--cyan);border-radius:8px;padding:8px 10px;background:#f0fbfb}
.chat-in{display:flex;gap:6px;border-top:1px solid var(--border);padding-top:8px}
.chat-in textarea{min-height:38px;max-height:120px}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:7px}
.chip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:2px 10px;font-size:11.5px;cursor:pointer}.chip:hover{border-color:var(--cyan)}
.typing{align-self:flex-start;color:var(--gray);font-size:11.5px}
/* ── Modal / Toast ───────────────────────────────────────── */
.modal-bg{position:fixed;inset:0;background:rgba(20,20,20,.5);display:none;place-items:center;z-index:50;padding:20px}.modal-bg.on{display:grid}
.modal{background:var(--creme);border-radius:12px;width:min(780px,100%);max-height:90vh;overflow:auto;padding:20px 22px;border:1px solid var(--border);box-shadow:0 20px 60px rgba(0,0,0,.35)}
.modal h2{font-size:17px;margin-bottom:10px}.modal .acts{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
#toast{position:fixed;bottom:18px;right:18px;background:var(--dark);color:#fff;padding:9px 15px;border-radius:9px;font-weight:700;z-index:99;display:none;border-left:4px solid var(--cyan);max-width:420px}#toast.err{border-color:var(--err)}
@media (max-width:1250px){.grid2{grid-template-columns:1fr}.tl.open{grid-template-columns:150px minmax(300px,1fr) minmax(320px,1fr)}}
@media (max-width:800px){.side{width:56px}.side .brand span,.nav a span,.side-foot{display:none}.tl,.tl.open{grid-template-columns:1fr}.snav{position:static;flex-direction:row;flex-wrap:wrap;max-height:none}.snav h6{display:none}}

598
app/static/tenant.js Normal file
View File

@@ -0,0 +1,598 @@
/* Tenant-Detailseite: eine Navigation (links) · Formular (Mitte) · Vorschau/Agent (rechts) */
let S = null;
let SCHEMA = null;
const ui = {sec: 'overview', mode: 'edit', open: true, pvKind: 'web', pvSrc: 'draft', pvDev: '', agentSrc: null};
const SECTIONS = [
['overview', 'Übersicht', null, false],
['brand', 'Marke & Farben', 'Gestaltung', true], ['texts', 'Absender & PDF-Texte', 'Gestaltung', true], ['pdf', 'PDF-Layout', 'Gestaltung', true],
['prompt', 'Prompt', 'Inhalt', true], ['files', 'Dateien', 'Inhalt', true],
['func', 'Funktion & STT', 'Verhalten', true], ['code', 'Hook-Code', 'Verhalten', true], ['adv', 'tenant.json', 'Verhalten', true],
['domain', 'Domain & Login', 'Betrieb', false], ['secrets', 'Secrets', 'Betrieb', false], ['deploy', 'Deploy', 'Betrieb', false], ['versions', 'Verlauf', 'Betrieb', false],
];
const A = p => `/api/tenants/${TID}${p}`;
const $ = s => document.querySelector(s);
const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
function setPath(o, p, v){ const ks = p.split('.'); let c = o; ks.slice(0,-1).forEach(k => c = (c[k] ??= {})); c[ks.at(-1)] = v; }
function unsetPath(o, p){ const ks = p.split('.'); const chain = [o]; let c = o; for(const k of ks.slice(0,-1)){ c = c?.[k]; if(c == null) return; chain.push(c); } delete c[ks.at(-1)]; for(let i = ks.length - 2; i >= 0; i--){ if(Object.keys(chain[i+1]).length === 0) delete chain[i][ks[i]]; else break; } }
function debounce(fn, ms){ let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
function shade(hex, f){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.max(0,Math.min(255,Math.round(x*f)))); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
function tint(hex, a){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.round(x+(255-x)*a)); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
const copyText = t => navigator.clipboard.writeText(t).then(()=>toast('Kopiert'));
const secDef = k => SECTIONS.find(s => s[0] === k);
const kindLabel = {'dry-run':'Dry-Run','deploy':'Deploy','offboard-dry':'Offboard (Dry-Run)','offboard':'Offboard'};
/* ───────── Laden & Rahmen ───────── */
async function loadSchema(){ try{ const c = await api('GET', '/api/contract'); SCHEMA = c.schema; }catch(e){ SCHEMA = null; } }
async function load(keep){
S = await api('GET', A('/state'));
renderHead(); renderNav();
if(!keep) route();
}
function route(){
const h = (location.hash||'').slice(1);
if(secDef(h)) ui.sec = h;
renderSection();
}
window.addEventListener('hashchange', route);
function renderHead(){
$('#h-name').textContent = S.name;
const st = S.status !== 'active' ? `<span class="badge warn">${S.status}</span>` : '';
$('#h-badges').innerHTML = `${S.has_draft?'<span class="dirty">Entwurf</span>':''}${st}<span class="badge dark">v${S.current_version}</span>
<span class="badge ${S.deployed_version===S.current_version?'ok':'warn'}">deployt v${S.deployed_version??'–'}</span>
<span class="badge ${S.source==='admin'?'cyan':''}">Quelle: ${S.source==='admin'?'Admin':'Git'}</span>`;
$('#h-actions').innerHTML = S.has_draft
? `<button class="btn sm" onclick="discardDraft()">Verwerfen</button><button class="btn sm cyan" onclick="openPublish()">Prüfen &amp; veröffentlichen</button>` : '';
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
}
function renderNav(extra){
let g = null, out = '';
const nfiles = Object.keys(S.files).length;
const badges = {files: nfiles ? `<span class="n muted">${nfiles}</span>` : '', versions: `<span class="n muted">${S.current_version}</span>`,
secrets: extra?.missing ? `<span class="n"><span class="dotc err" style="margin:0"></span></span>` : '', code: S.files['overrides.py'] ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : '',
deploy: S.deployed_version !== S.current_version ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : ''};
for(const [k, l, grp] of SECTIONS){
if(grp !== g){ g = grp; if(grp) out += `<h6>${grp}</h6>`; }
out += `<button class="${ui.sec===k&&ui.mode==='edit'?'on':''}" onclick="gotoSec('${k}')">${l}${badges[k]||''}</button>`;
}
$('#snav').innerHTML = out;
}
function gotoSec(k){ ui.mode = 'edit'; if(location.hash === '#'+k) route(); else location.hash = k; }
/* Layout: Mitte = Formular ODER Agent-Chat; die Live-Vorschau rechts bleibt in beiden Fällen sichtbar (Betriebsbereiche ohne Vorschau). */
function layout(){
ui.open = secDef(ui.sec)[3] || ui.mode === 'agent';
$('#tl').classList.toggle('open', ui.open);
$('#pane').classList.toggle('hide', ui.mode === 'agent');
$('#ag-wrap').classList.toggle('hide', ui.mode !== 'agent');
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
if(ui.open) refreshPreview();
}
$('#h-panel').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p; renderNav(); layout(); if(ui.mode === 'agent') $('#chat-text').focus(); });
function renderSection(){
renderNav(); layout();
({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv,
domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec]();
}
/* ───────── Speichern (Entwurf) ───────── */
const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; };
function afterSave(r){
S.has_draft = r.has_draft; renderHead(); saveState(r.has_draft ? 'Entwurf gespeichert' : 'Entspricht der veröffentlichten Version');
refreshPreviewSoon();
}
const saveConfigNow = async () => { try { saveState('Speichere …'); afterSave(await api('PUT', A('/config'), S.config)); } catch(e){ toast(e.message,1); saveState('Fehler'); } };
const saveConfig = debounce(saveConfigNow, 500);
async function saveFileNow(path, content){ try { saveState('Speichere …'); afterSave(await api('PUT', A('/files/'+path), {content})); } catch(e){ toast(e.message,1); saveState(e.message); } }
const saveFile = debounce(saveFileNow, 600);
async function deleteFile(path){ afterSave(await api('DELETE', A('/files/'+path))); await load(true); renderSection(); }
async function uploadFile(input, path){
const f = input.files[0]; if(!f) return;
const fd = new FormData(); fd.append('file', f);
const r = await fetch(A('/upload?path='+encodeURIComponent(path||f.name)), {method:'POST', body: fd});
if(!r.ok){ toast((await r.json()).detail||'Upload fehlgeschlagen',1); return; }
await load(true); renderSection(); refreshPreviewSoon(); toast('Hochgeladen'); headLogo(); renderHead();
}
function headLogo(){ const n = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null; $('#h-logo').innerHTML = n?`<img src="${A('/files/'+n)}?source=draft&t=${Date.now()}" alt="">`:''; }
async function discardDraft(){
if(!confirm('Alle Änderungen im Entwurf verwerfen?')) return;
await api('POST', A('/draft/discard')); await load(true); renderSection(); headLogo(); refreshPreview(true); toast('Entwurf verworfen');
}
const saveInd = () => `<span class="hint" id="save-ind">${S.has_draft?'Entwurf vorhanden':'Entspricht der veröffentlichten Version'}</span>`;
const title = (t, sub) => `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px" class="grow">${t}${sub?` <span class="muted" style="font-weight:400;font-size:12px">${sub}</span>`:''}</h2>${saveInd()}</div>`;
/* ───────── Live-Vorschau ───────── */
let pvSeq = 0;
async function refreshPreview(force){
if(!ui.open) return;
const seq = ++pvSeq, kind = ui.pvKind;
$('#f-web').style.display = kind==='web'?'':'none'; $('#f-pdf').style.display = kind==='pdf'?'':'none';
$('#pv-frame').className = 'pv-frame ' + (kind==='web'?ui.pvDev:'');
$('#pv-dev').style.display = kind==='web'?'':'none';
$('#pv-load').classList.add('on'); $('#pv-err').classList.remove('on');
const t0 = performance.now();
try {
const src = ui.pvSrc === 'agent' ? (ui.agentSrc || 'draft') : ui.pvSrc;
const r = await fetch(`/preview/${TID}/${kind}?source=${encodeURIComponent(src)}&_=${force?Date.now():''}`);
if(seq !== pvSeq) return;
if(!r.ok){ $('#pv-err').textContent = await r.text(); $('#pv-err').classList.add('on'); }
else if(kind==='web'){ $('#f-web').srcdoc = await r.text(); }
else { const b = await r.blob(); $('#f-pdf').src = URL.createObjectURL(b) + '#toolbar=0&navpanes=0'; }
$('#pv-info').textContent = `Core ${S.core_version} · ${Math.round(performance.now()-t0)} ms`;
} catch(e){ $('#pv-err').textContent = String(e); $('#pv-err').classList.add('on'); }
$('#pv-load').classList.remove('on');
}
const refreshPreviewSoon = debounce(() => refreshPreview(), 900);
const refreshPreviewLive = debounce(() => refreshPreview(), 250);
function setPvSrc(src){
ui.pvSrc = src; $('#pv-agent').classList.toggle('hide', !ui.agentSrc);
document.querySelectorAll('#pv-src button').forEach(x => x.classList.toggle('on', x.dataset.s === src));
}
document.addEventListener('click', e => {
const b = e.target.closest('#pv-kind button, #pv-dev button, #pv-src button'); if(!b) return;
const g = b.parentElement.id;
b.parentElement.querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b));
if(g==='pv-kind') ui.pvKind = b.dataset.k; if(g==='pv-dev') ui.pvDev = b.dataset.d; if(g==='pv-src') ui.pvSrc = b.dataset.s;
refreshPreview();
});
/* ───────── Übersicht ───────── */
async function sOverview(){
const c = S.config, col = c.colors||{};
$('#pane').innerHTML = `
<div class="grid2">
<div class="card"><h3>Status</h3>
<dl class="kv">
<dt>URL</dt><dd><a href="https://${esc(S.host)}" target="_blank">${esc(S.host)}</a> <span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'Domain ok':S.domain_status==='pending'?'DNS offen':'nicht eingerichtet'}</span></dd>
<dt>Version</dt><dd>v${S.current_version} veröffentlicht · v${S.deployed_version??'–'} deployt ${S.deployed_version!==S.current_version?'<span class="badge warn">veraltet</span>':''}</dd>
<dt>Quelle</dt><dd>${S.source==='admin'?'Admin (Bundle zur Laufzeit)':'Git – ins Image gebacken'}</dd>
<dt>Core</dt><dd><span class="mono">:${esc(String(S.core.tag).slice(0,12))}</span> ${S.core.pin?'<span class="badge warn">gepinnt</span>':'<span class="badge">folgt Rollouts</span>'} <span class="hint">· Admin-Vorschau: ${esc(S.core.sha)}</span>
<span id="run-state"></span> <button class="btn sm" onclick="probeMe()">Läuft?</button></dd>
<dt>STT</dt><dd>${esc((c.stt||{}).provider||'mistral')} ${esc((c.stt||{}).model||'')}</dd>
<dt>Dateien</dt><dd>${Object.keys(S.files).map(f=>`<span class="badge">${esc(f)}</span>`).join(' ')||'–'}</dd>
<dt>Marke</dt><dd class="swatches" style="gap:3px">${Object.entries(col).map(([k,v])=>`<i title="${esc(S.color_labels[k]||k)} ${esc(v)}" style="width:16px;height:16px;background:${esc(v)}"></i>`).join('')}</dd>
</dl>
<div class="row" style="margin-top:10px"><a class="btn sm" href="/tenants/${TID}/bundle.tar.gz">Bundle (.tar.gz)</a><button class="btn sm" onclick="runValidate()">Validieren</button></div>
<div id="val" style="margin-top:8px"></div>
</div>
<div class="card"><h3>Cut-over auf die Admin-Plattform</h3>
<p class="sub">Bis alles grün ist, bleibt der Tenant auf <b>Git</b>. Umschalten = <span class="mono">TENANT_SOURCE=admin</span> beim nächsten Deploy.</p>
<div id="cut"><div class="muted">Prüfe …</div></div>
</div>
</div>
<div class="grid2" style="margin-top:12px">
<div class="card"><h3>Betrieb</h3>
<div class="fgrid c3"><label class="f">CPU<input type="number" step="0.25" min="0.1" max="8" id="m-cpu" value="${S.cpu}"></label>
<label class="f">RAM<input type="text" id="m-mem" value="${esc(S.memory)}"></label>
<label class="f">Status<select id="m-status">${['active','paused','archived'].map(x=>`<option ${S.status===x?'selected':''}>${x}</option>`).join('')}</select></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveMeta()">Speichern</button><span class="hint">Ressourcen wirken beim nächsten Deploy. „paused/archived“ nimmt den Tenant aus „Alle neu deployen“.</span></div>
</div>
<div class="card"><h3>Notizen</h3>
<textarea id="m-notes" rows="4" placeholder="Ansprechpartner, Besonderheiten, Vereinbarungen …">${esc(S.notes||'')}</textarea>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveMeta()">Speichern</button></div>
</div>
</div>
<div class="card" style="margin-top:12px"><h3>Core-Version</h3><p class="sub">Neue Core-Releases rollt die Admin-Plattform zentral aus (Menü „Core“). Ein <b>Pin</b> hält diesen Tenant auf einem festen Tag – er nimmt dann an Rollouts nicht teil.</p>
<div class="row"><input type="text" id="m-pin" value="${esc(S.core.pin||'')}" placeholder="kein Pin (folgt dem aktiven Core)" style="width:260px"><button class="btn sm" onclick="savePin()">Pin setzen / aufheben</button></div></div>
<details class="fold"><summary>Gefahrenbereich</summary><div class="in">
<p class="hint">Offboard entfernt Service, Volume und Ingress aus dem Mittwald-Stack (Dry-Run zeigt vorher, was passiert). Löschen entfernt nur die Daten in dieser Datenbank – und nur für archivierte Tenants.</p>
<div class="row"><button class="btn sm" onclick="offboard(true)">Offboard-Dry-Run</button><button class="btn sm danger ${S.live?'':'disabled'}" id="b-off" onclick="offboard(false)">Offboard (live)</button>
<button class="btn sm danger" onclick="deleteOidcApp()">OIDC-Client im Login löschen …</button><button class="btn sm danger" onclick="deleteTenant()">Tenant in der DB löschen …</button></div><div class="console hide" id="con-off" style="margin-top:8px"></div></div></details>`;
const [cut, mode, sec] = await Promise.all([api('GET', A('/cutover')), api('GET', '/api/deploy/mode'), api('GET', A('/secrets'))]);
renderNav({missing: sec.some(s => s.required && !s.set)});
$('#b-off')?.classList.toggle('disabled', !mode.live);
const ok = cut.checks.every(x=>x.ok);
$('#cut').innerHTML = cut.checks.map(x=>`<div class="check"><span class="dot ${x.ok?'ok':'no'}">${x.ok?'✓':'!'}</span><span>${esc(x.label)}</span></div>`).join('') +
`<div class="row" style="margin-top:10px">${cut.source==='admin'
? `<span class="badge cyan">Quelle: Admin</span><button class="btn sm" onclick="setSource('git')">Zurück auf Git</button>`
: `<button class="btn sm cyan" ${ok?'':'disabled'} onclick="setSource('admin')">Auf Admin-Quelle umstellen</button>${ok?'':'<span class="hint">noch nicht alle Bedingungen erfüllt</span>'}`}</div>`;
}
async function savePin(){ try{ await api('PUT', A('/core-pin'), {pin: $('#m-pin').value}); await load(true); toast('Core-Pin gespeichert – wirksam beim nächsten Deploy'); sOverview(); }catch(e){ toast(e.message,1) } }
async function probeMe(){
$('#run-state').innerHTML = '<span class="muted">prüfe …</span>';
try{ const r = (await api('GET', '/api/status?refresh=1'))[TID]; $('#run-state').innerHTML = !r ? '' : r.reachable && !r.drift.length ? `<span class="badge ok">läuft: Core ${esc((r.core?.version||'?').slice(0,10))}${r.bundle?.source==='admin'?' · Bundle v'+r.bundle.version:''}</span>` : `<span class="badge err">${esc(r.drift.join(' · ')||'nicht erreichbar')}</span>`; }
catch(e){ $('#run-state').innerHTML = `<span class="badge err">${esc(e.message)}</span>`; }
}
async function saveMeta(){ try{ await api('PUT', A('/meta'), {cpu:$('#m-cpu').value, memory:$('#m-mem').value, status:$('#m-status').value, notes:$('#m-notes').value}); await load(true); toast('Gespeichert'); sOverview(); }catch(e){ toast(e.message,1) } }
async function setSource(s){ await api('PUT', A('/source'), {source:s}); await load(true); sOverview(); toast('TENANT_SOURCE='+s+' – wirksam beim nächsten Deploy'); }
async function runValidate(){ $('#val').innerHTML='<span class="muted">Validiere mit dem echten Core …</span>'; $('#val').innerHTML = valHtml(await api('POST', A('/validate?source=current'))); }
const valHtml = r => `<div class="banner ${r.ok?'ok':'err'}" style="margin:0">${r.ok?'✔ Validierung bestanden':'✘ Fehler'}${(r.errors||[]).map(e=>`<div class="mono" style="white-space:pre-wrap">${esc(e)}</div>`).join('')}${(r.warnings||[]).map(e=>`<div>⚠ ${esc(e)}</div>`).join('')}</div>`;
async function offboard(dry){
if(!dry && !confirm('Service, Volume und Ingress dieses Tenants wirklich vom Mittwald-Stack entfernen?')) return;
try{ const r = await api('POST', A('/offboard'), {dry_run:dry}); followDeploy(r.id, $('#con-off')); }catch(e){ toast(e.message,1) }
}
async function deleteOidcApp(){
const id = prompt('OIDC-Client dieses Tenants im Login-System LÖSCHEN (nicht umkehrbar, beendet alle Logins der App). Zur Bestätigung die Tenant-ID eingeben:'); if(id===null) return;
try{ await api('DELETE', A(`/oidc/app?confirm=${encodeURIComponent(id)}`)); toast('OIDC-Client gelöscht'); await load(true); sOverview(); }catch(e){ toast(e.message,1) }
}
async function deleteTenant(){
const id = prompt('Alle Daten dieses Tenants in der Datenbank löschen (Versionen, Secrets, Chat). Zur Bestätigung die ID eingeben:'); if(id===null) return;
try{ await api('DELETE', `/api/tenants/${TID}?confirm=${encodeURIComponent(id)}`); location.href='/'; }catch(e){ toast(e.message,1) }
}
/* ───────── Formular-Helfer ───────── */
function bindCfg(root){
root.querySelectorAll('[data-cfg]').forEach(el => {
el.addEventListener('input', () => {
const p = el.dataset.cfg;
const v = el.type==='checkbox' ? el.checked : el.type==='number' ? (el.value === '' ? '' : Number(el.value)) : el.value;
if(v === '' && !['name'].includes(p) && !p.startsWith('office.') && !p.startsWith('colors.')) unsetPath(S.config, p); else setPath(S.config, p, v);
if(el.type==='color'){ const t = root.querySelector(`[data-cfg="${p}"][type=text]`); if(t) t.value = v; }
if(el.type==='text' && el.dataset.color){ const c = root.querySelector(`[data-cfg="${p}"][type=color]`); if(c && /^#[0-9a-f]{6}$/i.test(v)) c.value = v; }
saveConfig();
});
});
}
const fld = (label, path, o={}) => `<label class="f ${o.full?'full':''}">${label}<input type="${o.type||'text'}" data-cfg="${path}" value="${esc(getPath(S.config,path)??'')}" ${o.ph?`placeholder="${esc(o.ph)}"`:''}></label>`;
/* ───────── Gestaltung ───────── */
function sBrand(){
const col = S.config.colors||{};
const logo = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null;
$('#pane').innerHTML = title('Marke & Farben') + `
<div class="card"><h3>Name &amp; Titel</h3><div class="fgrid">${fld('Name','name')}${fld('Seitentitel (Browser-Tab)','title')}${fld('Beschreibung','description',{full:1})}</div></div>
<div class="card"><h3>Logo &amp; Favicon</h3><div class="row" style="align-items:flex-start;gap:20px">
<div><div class="logo-box lg">${logo?`<img src="${A('/files/'+logo)}?source=draft&t=${Date.now()}">`:'<span class="muted">kein Logo</span>'}</div>
<label class="f" style="margin-top:6px">Logo (SVG/PNG)<input type="file" accept=".svg,.png" onchange="uploadFile(this, this.files[0].name.toLowerCase().endsWith('.svg')?'logo.svg':'logo.png')"></label>
<div class="hint">Word-Export braucht zusätzlich <span class="mono">logo.png</span>.</div></div>
<div><div class="logo-box lg" style="width:56px">${S.files['favicon.svg']?`<img src="${A('/files/favicon.svg')}?source=draft&t=${Date.now()}">`:'<span class="muted">–</span>'}</div>
<label class="f" style="margin-top:6px">Favicon (SVG)<input type="file" accept=".svg" onchange="uploadFile(this,'favicon.svg')"></label></div></div></div>
<div class="card"><div class="row" style="margin-bottom:8px"><h3 style="margin:0" class="grow">Farben</h3><button class="btn sm" onclick="deriveFromAccent()" title="dunkel / mittel / hell / extra hell aus dem Akzent berechnen">Töne aus Akzent ableiten</button></div>
<div class="colors">${Object.keys(S.color_labels).map(k=>`<div class="colorf"><input type="color" data-cfg="colors.${k}" value="${esc(col[k]||'#000000')}"><span title="${esc(k)}">${esc(S.color_labels[k])}</span><input type="text" data-color="1" data-cfg="colors.${k}" value="${esc(col[k]||'')}"></div>`).join('')}</div></div>`;
bindCfg($('#pane'));
}
function deriveFromAccent(){
const a = (S.config.colors||{}).accent; if(!/^#[0-9a-f]{6}$/i.test(a)) return toast('Akzent muss #RRGGBB sein',1);
Object.assign(S.config.colors, {accent_dark:shade(a,.72), accent_mid:shade(a,1.12), accent_light:tint(a,.88), accent_xlight:tint(a,.94)});
saveConfigNow(); sBrand();
}
function sTexts(){
$('#pane').innerHTML = title('Absender & PDF-Texte') + `
<div class="card"><h3>Absender / Kopf- &amp; Fußzeile</h3><p class="sub">Erscheint im PDF und im Word-Export.</p><div class="fgrid">
${fld('Name','office.name')}${fld('Zusatz (Rechtsform)','office.subtitle')}${fld('Adresse','office.address',{full:1})}${fld('Telefon','office.tel')}${fld('E-Mail','office.email')}${fld('Website','office.website',{full:1})}</div></div>
<div class="card"><h3>PDF-Beschriftungen</h3><div class="fgrid">${fld('Bezeichnung „Projekt“ (z. B. „Thema“)','pdf_projekt_label',{ph:'Projekt'})}${fld('Fußzeilen-Hinweis','pdf_footer_note',{ph:'Standard des Cores'})}</div></div>`;
bindCfg($('#pane'));
}
/* Text-Datei-Editor mit „Standard des Cores“ */
async function fileEditor(path, o){
const has = !!S.files[path]; let body = '';
try { const r = await fetch(A('/files/'+path)+'?source=draft'); if(r.ok) body = await r.text(); } catch(e){}
const w = document.createElement('div'); w.className = 'card';
w.innerHTML = `<div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">${esc(o.title)}</h3>
${has ? `<span class="badge cyan">eigene Datei</span><button class="btn sm danger" data-act="reset">Auf Core-Standard</button>` : `<span class="badge">Core-Standard</span><button class="btn sm" data-act="custom">Eigene Version anlegen</button>`}</div>
${o.sub?`<p class="sub">${o.sub}</p>`:''}
<textarea class="code" ${has?'':'readonly'} spellcheck="false" style="min-height:${o.h||300}px;${has?'':'opacity:.7'}">${esc(body)}</textarea>`;
const ta = w.querySelector('textarea');
ta.addEventListener('input', () => saveFile(path, ta.value));
ta.addEventListener('keydown', e => { if(e.key==='Tab'){ e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); ta.dispatchEvent(new Event('input')); } });
w.querySelector('[data-act=custom]')?.addEventListener('click', async () => { await saveFileNow(path, body); await load(true); renderSection(); });
w.querySelector('[data-act=reset]')?.addEventListener('click', async () => { if(confirm('Eigene Datei löschen und den Core-Standard verwenden?')) await deleteFile(path); });
return w;
}
async function sPdf(){
$('#pane').innerHTML = title('PDF-Layout') + `<div class="banner info">HTML + CSS (WeasyPrint). Datenfelder bleiben gleich, nur das Aussehen ist frei. Die Vorschau nutzt das Beispielprotokoll unten.</div><div id="e1"></div><div id="e2"></div>
<details class="fold"><summary>Beispielprotokoll für die Vorschau</summary><div class="in"><textarea class="code" id="sample" style="min-height:200px">${esc(S.sample)}</textarea>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveSample()">Speichern &amp; neu rendern</button><span class="hint">Format wie vom LLM geliefert; gilt nur für die Vorschau.</span></div></div></details>`;
$('#e1').appendChild(await fileEditor('protocol.html', {title:'protocol.html', sub:'Jinja-Template des PDFs.', h:300}));
$('#e2').appendChild(await fileEditor('protocol.css', {title:'protocol.css', sub:'Farben kommen als Jinja-Variablen aus der Marke.', h:300}));
}
async function saveSample(){ await api('PUT', A('/sample'), {sample: $('#sample').value}); S.sample = $('#sample').value; toast('Beispiel gespeichert'); ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); refreshPreview(true); }
/* ───────── Inhalt ───────── */
async function sPrompt(){
$('#pane').innerHTML = title('Prompt') + `
<div class="card"><h3>Selbstbeschreibung</h3><p class="sub">Ersetzt <span class="mono">@@ORG@@ · @@ORG_SHORT@@ · @@ORG_PROFILE@@</span> im Standard-Prompt.</p><div class="fgrid">
${fld('Organisation (voll)','ai_detect_org')}${fld('Kurzname im Protokolltext','org_short')}${fld('Ein Satz: was die Organisation macht','org_profile',{full:1})}</div></div><div id="ed"></div>
<details class="fold" id="ptest"><summary>Prompt testen (echter LLM-Lauf) <span class="badge">kostet Cortecs-Guthaben</span></summary><div class="in">
${S.llm_ready ? '' : '<div class="banner warn">Kein Cortecs-Key hinterlegt – unter Einstellungen → Geteilte Secrets setzen.</div>'}
<div class="fgrid"><label class="f">Termin-Art (optional)<input type="text" id="pt-art" placeholder="aus Gespräch erkennen"></label><label class="f">Projektname (optional)<input type="text" id="pt-proj"></label>
<label class="f full">Beispieltranskript<textarea id="pt-tr" rows="6" class="code" style="min-height:120px">${esc(S.sample_transcript)}</textarea></label></div>
<div class="row" style="margin:8px 0"><button class="btn sm cyan" id="pt-go" onclick="promptTest()" ${S.llm_ready?'':'disabled'}>LLM-Lauf mit Entwurfs-Prompt</button><button class="btn sm" onclick="showFinalPrompt()">Finalen Prompt anzeigen</button><span class="hint" id="pt-info"></span></div>
<div id="pt-out"></div></div></details>`;
bindCfg($('#pane'));
$('#ed').appendChild(await fileEditor('prompt.txt', {title:'prompt.txt', sub:'Ohne eigene Datei gilt der Standard-Prompt des Cores. Hook-Code kann den Prompt zusätzlich erweitern (siehe „Finalen Prompt anzeigen“).', h:420}));
}
async function promptTest(){
$('#pt-go').disabled = true; $('#pt-info').textContent = 'LLM läuft … (bis zu 1–2 Minuten)'; $('#pt-out').innerHTML = '';
try{
const r = await api('POST', A('/prompt-test'), {transcript:$('#pt-tr').value, terminart:$('#pt-art').value, projekt:$('#pt-proj').value});
$('#pt-info').textContent = `${r.model} · ${r.input_tokens}→${r.output_tokens} Tokens · Prompt ${r.prompt_chars} Zeichen${r.truncated?' · ⚠ abgeschnitten (max_tokens)':''}`;
window._ptText = r.text;
$('#pt-out').innerHTML = `<pre class="json" style="white-space:pre-wrap;max-height:340px;background:#fbfaf8;color:#222;border:1px solid var(--border)">${esc(r.text)}</pre><div class="row" style="margin-top:6px"><button class="btn sm" onclick="usePtAsSample()">Als PDF-Beispiel übernehmen &amp; ansehen</button></div>`;
}catch(e){ $('#pt-info').textContent = ''; $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
$('#pt-go').disabled = !S.llm_ready;
}
async function usePtAsSample(){ await api('PUT', A('/sample'), {sample: window._ptText}); S.sample = window._ptText; ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); setPvSrc('draft'); refreshPreview(true); toast('Als Beispiel gesetzt'); }
async function showFinalPrompt(){
try{ const r = await api('GET', A('/final-prompt')); $('#pt-out').innerHTML = `<div class="hint">${r.chars} Zeichen – so geht der Prompt (nach Platzhaltern und Hooks) an das LLM:</div><pre class="json" style="white-space:pre-wrap;max-height:380px">${esc(r.prompt)}</pre>`; }
catch(e){ $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
function sFiles(){
const rows = Object.entries(S.files).map(([p,m]) => `<tr><td class="mono">${esc(p)} ${m.personal?'<span class="badge warn" title="Für den KI-Agenten gesperrt">personenbezogen</span>':''}</td><td>${(m.size/1024).toFixed(1)} KB</td><td>${m.text?'Text':'Binär'}</td><td style="text-align:right" class="nowrap">${m.text&&!['prompt.txt','protocol.html','protocol.css','overrides.py'].includes(p)?`<button class="btn sm" onclick="openTextFile('${esc(p)}')">Bearbeiten</button> `:''}<button class="btn sm danger" onclick="if(confirm('Datei löschen?'))deleteFile('${esc(p)}')">Löschen</button></td></tr>`).join('');
$('#pane').innerHTML = title('Dateien') + `<div class="card"><table class="t"><thead><tr><th>Pfad</th><th>Größe</th><th>Typ</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=4 class="empty">Keine Dateien</td></tr>'}</tbody></table>
<div class="row" style="margin-top:10px"><label class="btn sm">+ Datei hochladen<input type="file" hidden onchange="uploadFile(this)"></label><span class="hint">z. B. <span class="mono">namensliste.txt</span> – lesbar für Hook-Code im Container. Namenslisten sind für den KI-Agenten automatisch gesperrt.</span></div><div id="tf" style="margin-top:10px"></div></div>`;
}
async function openTextFile(p){ const w = await fileEditor(p, {title:p, h:260}); $('#tf').innerHTML=''; $('#tf').appendChild(w); }
/* ───────── Verhalten ───────── */
/* Felder, die der Core im Schema deklariert, aber keine eigene Maske haben, erscheinen automatisch hier (neue Core-Optionen ohne Admin-Änderung). */
const BESPOKE_PATHS = ['name','title','description','pdf_projekt_label','pdf_footer_note','ai_detect_org','org_short','org_profile'];
const isBespoke = p => BESPOKE_PATHS.includes(p) || p.startsWith('colors.') || p.startsWith('office.');
function schemaField(f){
const raw = getPath(S.config, f.path), cur = raw === undefined ? f.default : raw;
const help = f.help ? ` <span class="hint">${esc(f.help)}</span>` : '';
if(f.type === 'bool') return `<label class="chk"><input type="checkbox" data-cfg="${f.path}" ${cur?'checked':''}><span><b>${esc(f.label)}</b>${help}</span></label>`;
if(f.type === 'enum') return `<label class="f">${esc(f.label)}<select data-cfg="${f.path}"><option value="" ${!raw?'selected':''}>Standard (${esc(f.default ?? 'Core')})</option>${(f.options||[]).map(o=>`<option ${raw===o?'selected':''}>${esc(o)}</option>`).join('')}</select>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
if(f.type === 'color') return `<label class="f">${esc(f.label)}<input type="text" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? '')}"></label>`;
if(f.type === 'text') return `<label class="f full">${esc(f.label)}<textarea rows="3" data-cfg="${f.path}" placeholder="${esc(f.default ?? '')}">${esc(raw ?? '')}</textarea>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
return `<label class="f">${esc(f.label)}<input type="${f.type==='number'?'number':'text'}" step="any" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? 'Standard')}">${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
}
function sFunc(){
if(!SCHEMA) return sFuncLegacy();
const groups = {};
SCHEMA.fields.filter(f => !isBespoke(f.path)).forEach(f => (groups[f.group] ??= []).push(f));
const order = Object.keys(groups);
const known = ['Funktion', 'Spracherkennung'];
const card = (g) => { const fs = groups[g], basic = fs.filter(f=>!f.advanced), adv = fs.filter(f=>f.advanced);
const grid = list => `<div class="${list.every(f=>f.type==='bool')?'':'fgrid'}">${list.map(schemaField).join('')}</div>`;
return `<div class="card"><h3>${esc(g)}${known.includes(g)?'':' <span class="badge cyan">neu im Core</span>'}</h3>${basic.length?grid(basic):''}${adv.length?`<details class="fold"><summary>Erweitert</summary><div class="in">${grid(adv)}</div></details>`:''}
${g==='Spracherkennung'?'<p class="hint" style="margin-top:6px">Bei „cortecs“ wird der geteilte CORTECS_API_KEY genutzt – ein eigener Mistral-Key ist nicht nötig.</p>':''}</div>`; };
$('#pane').innerHTML = title('Funktion & STT') + `<p class="hint" style="margin:-4px 0 8px">Aus dem Schema des Cores (Version ${esc(S.core?.sha||'')}) erzeugt – neue Optionen erscheinen hier automatisch.</p>` + order.map(card).join('');
bindCfg($('#pane'));
}
function sFuncLegacy(){
const stt = S.config.stt||{};
const chk = (path, label, hint, def) => { const v = getPath(S.config,path); return `<label class="chk"><input type="checkbox" data-cfg="${path}" ${(v===undefined?def:v)?'checked':''}><span><b>${label}</b> <span class="hint">${hint}</span></span></label>`; };
$('#pane').innerHTML = title('Funktion & STT') + `<div class="banner warn">Dieser Core liefert kein Schema (Vertrag) – feste Maske.</div>
<div class="card"><h3>Funktionen (Flags)</h3>
${chk('flags.ui_kontakt_field','Feld „Ansprechpartner Kunde“','Aus: Feld in der Web-UI ausgeblendet (z. B. Vereine).',true)}
${chk('flags.multi_audio','Mehrere Audiodateien (bis 5)','Aus: nur eine Datei pro Protokoll.',true)}</div>
<div class="card"><h3>Spracherkennung (STT)</h3><div class="fgrid">
<label class="f">Provider<select data-cfg="stt.provider"><option value="" ${!stt.provider?'selected':''}>Standard (mistral)</option><option ${stt.provider==='cortecs'?'selected':''}>cortecs</option><option ${stt.provider==='mistral'?'selected':''}>mistral</option></select></label>
${fld('Modell','stt.model',{ph:'voxtral-mini-transcribe-2602'})}</div></div>`;
bindCfg($('#pane'));
}
async function sCode(){
const has = !!S.files['overrides.py'];
$('#pane').innerHTML = title('Hook-Code') + `<div class="banner warn"><b>Code läuft im Tenant-Container</b> mit dessen Secrets. In der Vorschau läuft er nur in einer Sandbox ohne Secrets (CPU-/Datei-Limits); Veröffentlichen verlangt eine ausdrückliche Freigabe. Erst Flags/Dateien nutzen; Wiederkehrendes gehört in den Core.</div>
<div class="card"><h3>Verfügbare Hooks</h3><dl class="kv" style="grid-template-columns:150px 1fr"><dt>build_prompt</dt><dd>Prompt vor dem LLM-Aufruf anpassen</dd><dt>transform_transcript</dt><dd>Transkript nachbearbeiten</dd><dt>pdf_context</dt><dd>Daten fürs PDF-Template ändern</dd><dt>extra_routes</dt><dd>zusätzliche FastAPI-Routen</dd></dl></div><div id="ed"></div>`;
if(has) $('#ed').appendChild(await fileEditor('overrides.py', {title:'overrides.py', sub:'Syntax wird beim Speichern geprüft.', h:460}));
else $('#ed').innerHTML = `<div class="card empty">Dieser Tenant nutzt keinen Hook-Code.<br><br><button class="btn" onclick="createHook()">overrides.py anlegen</button></div>`;
}
async function createHook(){ await saveFileNow('overrides.py', `"""Tenant-Hooks für ${S.id}."""\nfrom core.hooks import TenantHooks\n\n\nclass Hooks(TenantHooks):\n def build_prompt(self, base_prompt: str, ctx: dict) -> str:\n return base_prompt\n`); await load(true); renderSection(); }
function sAdv(){
$('#pane').innerHTML = title('tenant.json') + `<div class="card"><p class="sub">Alle Felder, auch solche ohne Formular. Gültiges JSON wird automatisch übernommen. Der <span class="mono">deploy</span>-Block ist nicht Teil des Bundles (Domain/Ressourcen verwaltet die Plattform).</p>
<textarea class="code" id="rawjson" style="min-height:460px">${esc(JSON.stringify(S.config,null,2))}</textarea><div class="hint" id="rawerr" style="color:var(--err)"></div></div>`;
$('#rawjson').addEventListener('input', e => { try { S.config = JSON.parse(e.target.value); $('#rawerr').textContent=''; saveConfig(); } catch(x){ $('#rawerr').textContent = 'Ungültiges JSON: '+x.message; } });
}
/* ───────── Veröffentlichen ───────── */
async function openPublish(){
modal('m-pub', true); $('#pub-body').innerHTML = '<div class="muted">Validiere (echter Core) und berechne Änderungen …</div>'; $('#pub-go').disabled = true;
const [val, diff] = await Promise.all([api('POST', A('/validate?source=draft')), api('GET', A('/diff?a=current&b=draft'))]);
const code = diff.items.some(i => i.path==='overrides.py');
$('#pub-body').innerHTML = valHtml(val) + `<h3 style="margin:12px 0 4px">${diff.items.length} geänderte Datei(en)</h3>` + diff.items.map(diffHtml).join('') +
(code ? `<label class="chk banner warn"><input type="checkbox" id="codeok"><span><b>Freigabe Hook-Code:</b> Ich habe die Änderungen an <span class="mono">overrides.py</span> geprüft.</span></label>` : '') +
`<label class="f" style="margin-top:10px">Commit-Nachricht<input type="text" id="pubmsg" placeholder="Was wurde geändert?"></label>`;
const upd = () => { $('#pub-go').disabled = !val.ok || (code && !$('#codeok').checked); };
$('#codeok')?.addEventListener('change', upd); upd();
}
const diffHtml = i => `<div style="margin-top:8px"><div class="row"><b class="mono">${esc(i.path)}</b><span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${i.kind}</span>${i.added!=null?`<span class="hint">+${i.added} −${i.removed}</span>`:''}</div>${i.diff?`<div class="diff">${i.diff.map(l=>`<div class="${l.startsWith('+')&&!l.startsWith('+++')?'a':l.startsWith('-')&&!l.startsWith('---')?'r':l.startsWith('@@')?'h':''}">${esc(l)||' '}</div>`).join('')}</div>`:'<div class="hint">Binärdatei</div>'}</div>`;
async function publish(){
try { const r = await api('POST', A('/publish'), {message: $('#pubmsg').value}); modal('m-pub', false); await load(true); renderSection(); toast('Version v'+r.version+' veröffentlicht'); }
catch(e){ toast(e.message, 1); }
}
/* ───────── Agent ───────── */
const CHIPS = ['Setze die Akzentfarbe auf #0B6E4F','Füge dem Prompt hinzu: Nenne Beträge immer in Euro mit Tausenderpunkt.','Lege ein eigenes PDF-CSS an und mache die Überschriften größer.','Prüfe den Tenant auf Probleme.'];
async function initAgent(){
$('#ag-chips').innerHTML = CHIPS.map(s=>`<span class="chip" onclick="sendChat(this.textContent)">${esc(s)}</span>`).join('');
$('#chat-text').addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendChat(); } });
const st = await api('GET', '/api/llm/status?tenant=' + TID);
const src = {shared:'geteiltes Secret', env:'Env/.env', tenant:'Key des Tenants'}[st.source];
$('#ag-mode').textContent = st.configured ? `${st.model} · ${st.api}` : 'Demo-Modus – kein Key gefunden';
$('#ag-mode').className = 'badge ' + (st.configured ? 'cyan' : 'warn');
$('#ag-mode').title = st.configured ? 'Cortecs-Key aus: ' + src : (st.error || 'Key unter Einstellungen → Geteilte Secrets setzen oder Env CORTECS_API_KEY');
if(st.error) $('#ag-usage').textContent = '⚠ ' + st.error;
const h = await api('GET', A('/chat')); const box = $('#msgs');
if(!h.length) box.innerHTML = '<div class="empty">Der Agent arbeitet auf einem Entwurf und schlägt Änderungen nur vor – du gibst sie frei.</div>';
else h.forEach(m => box.appendChild(msgEl(m))); box.scrollTop = 1e9;
}
function stepsHtml(steps){ return (steps||[]).map(s=>`<div class="step ${s.error?'err':''}"><b>${esc(s.tool)}</b><span class="mono">${esc(s.arg)}</span> → ${esc(s.result)}</div>`).join(''); }
function propHtml(m){
if(!m.proposal) return '';
const items = (m.proposal.items||[]).map(i=>`<span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${esc(i.path)}</span>`).join(' ');
return `<div class="prop"><b>Vorschlag:</b> ${esc(m.proposal.summary)}<div style="margin:5px 0">${items}</div><div class="row">${m.proposal_status==='open'
? `<button class="btn sm" onclick="showProposalDiff(${m.id})">Diff ansehen</button><button class="btn sm cyan" onclick="decide(${m.id},'apply')">In Entwurf übernehmen</button><button class="btn sm" onclick="decide(${m.id},'reject')">Verwerfen</button>`
: `<span class="badge ${m.proposal_status==='applied'?'ok':''}">${m.proposal_status==='applied'?'übernommen – prüfen & veröffentlichen':'verworfen'}</span>`}</div></div>`;
}
function msgEl(m){
const d = document.createElement('div'); d.className = 'bub '+m.role; d.id = 'm'+(m.id||'');
const u = m.usage && m.usage.in ? `<div class="hint" style="margin-top:5px">${esc(m.usage.model||'')} · ${m.usage.in}→${m.usage.out} Tokens</div>` : '';
d.innerHTML = (m.steps&&m.steps.length?`<div class="steps">${stepsHtml(m.steps)}</div>`:'') + esc(m.content) + propHtml(m) + u; return d;
}
async function sendChat(text){
const ta = $('#chat-text'); text = text || ta.value.trim(); if(!text) return; ta.value = '';
ui.mode = 'agent'; layout();
const box = $('#msgs'); box.querySelector('.empty')?.remove();
box.appendChild(msgEl({role:'user', content:text}));
const live = document.createElement('div'); live.className = 'bub assistant'; live.innerHTML = '<div class="steps"></div><div class="typing">Agent arbeitet …</div>'; box.appendChild(live); box.scrollTop = 1e9;
$('#chat-send').disabled = true;
try {
const r = await fetch(A('/chat'), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message:text})});
if(!r.ok) throw new Error((await r.json()).detail || 'Fehler '+r.status);
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = '';
while(true){
const {value, done} = await rd.read(); if(done) break; buf += dec.decode(value, {stream:true});
let i; while((i = buf.indexOf('\n\n')) >= 0){
const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2);
if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); }
else if(ev.type==='changed'){ refreshPreviewLive(); }
else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev]));
else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `<div class="thought">${esc(ev.text)}</div>`);
else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text;
else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message));
if(ev.message.proposal){ ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); }
refreshPreview(true); }
box.scrollTop = 1e9;
}
}
} catch(e){ live.remove(); toast(e.message, 1); }
$('#chat-send').disabled = false;
}
async function decide(id, d){
const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m));
ui.agentSrc = null; setPvSrc('draft');
if(d==='apply'){ await load(true); renderNav(); headLogo(); if(ui.sec==='overview') sOverview(); toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); }
refreshPreview(true);
}
async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede zum Entwurf.</div>'; modal('m-diff', true); }
async function clearChat(){ if(!confirm('Chatverlauf dieses Tenants löschen?')) return; await api('DELETE', A('/chat')); $('#msgs').innerHTML = '<div class="empty">Verlauf geleert.</div>'; }
/* ───────── Betrieb ───────── */
async function sDomain(){
const dnsTarget = `${S.subdomain}.${S.base_domain}`;
const o = await api('GET', A('/oidc')); window._oidc = o;
const logout = `https://${S.host}/`;
const modeHelp = {off:'Nichts im Login anfassen (Client wird manuell gepflegt).', adopt:'Bestehenden Client übernehmen: fehlende Redirect-URIs ergänzen, sonst nichts ändern.', managed:'Client vollständig aus dieser Konfiguration verwalten (anlegen, Redirect-URIs, auth/totp, Freigaben).'};
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Domain & Login</h2></div>
<div class="grid2">
<div class="card"><h3>Domain</h3>
<div class="fgrid"><label class="f">Subdomain<input type="text" id="d-sub" value="${esc(S.subdomain)}"></label><label class="f">Basis-Domain<input type="text" value="${esc(S.base_domain)}" disabled></label>
<label class="f full">Eigene Domain (optional)<input type="text" id="d-cust" value="${esc(S.custom_domain)}" placeholder="protokoll.kunde.de"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveDomain()">Speichern</button><button class="btn sm" onclick="checkDomain()">DNS prüfen</button>
<span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'verifiziert':S.domain_status==='pending'?'DNS ausstehend':'nicht eingerichtet'}</span></div>
<div id="dns-msg" class="hint" style="margin-top:6px"></div>
${S.custom_domain?`<table class="t" style="margin-top:10px"><thead><tr><th>Typ</th><th>Name</th><th>Wert</th></tr></thead><tbody><tr><td>CNAME</td><td class="mono">${esc(S.custom_domain)}</td><td class="mono copy" onclick="copyText('${esc(dnsTarget)}')">${esc(dnsTarget)}</td></tr></tbody></table><p class="hint">Alternativ A-Record auf die Projekt-IP aus mStudio. TLS stellt Mittwald automatisch aus.</p>`:''}
<p class="hint" style="margin-top:8px">Redirect-URIs im Login ergeben sich aus Subdomain und eigener Domain und werden beim Deploy mit abgeglichen.</p></div>
<div class="card"><h3>OIDC-Client <span class="muted" style="font-weight:400">· Abgleich über die Login-Developer-API bei jedem Deploy</span></h3>
<div class="row" style="margin-bottom:8px">
<span class="badge ${o.api_key?'ok':'err'}">API-Key ${o.api_key?'vorhanden':'fehlt'}</span>
<span class="badge ${o.writes_allowed?'warn':''}">Schreiben ${o.writes_allowed?'erlaubt':'gesperrt'}</span>
<span class="badge ${o.client_id?'ok':'warn'} mono">${o.client_id?esc(o.client_id):'noch kein Client'}</span>
<span class="badge ${o.secret_set?'ok':'warn'}">Secret ${o.secret_set?'gesetzt':'fehlt'}</span></div>
<div class="fgrid">
<label class="f full">Verwaltung<select id="oc-mode">${['off','adopt','managed'].map(m=>`<option value="${m}" ${o.mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<div class="hint full" id="oc-modehelp" style="margin-top:-4px">${modeHelp[o.mode]}</div>
<label class="f">Zugriff (auth_mode)<select id="oc-auth">${['required','optional','disabled'].map(m=>`<option ${o.auth_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<label class="f">2FA (totp_mode)<select id="oc-totp">${['disabled','optional','required'].map(m=>`<option ${o.totp_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<label class="f full">Backchannel-Logout-URI (optional, öffentlich, https)<input type="text" id="oc-bc" value="${esc(o.backchannel_logout_uri||'')}"></label>
<label class="chk full"><input type="checkbox" id="oc-reg" ${o.allow_registration?'checked':''}><span><b>Selbstregistrierung</b> <span class="hint">Nutzer dürfen sich über diese App selbst registrieren.</span></span></label></div>
<div style="margin:4px 0 8px"><div class="hint">Redirect-URIs (Soll):</div>${o.redirect_uris.map(u=>`<div class="mono copy" onclick="copyText('${esc(u)}')">${esc(u)}</div>`).join('')}
<div class="hint">Logout-Ziel: <span class="mono">${esc(logout)}</span> (Core: OIDC_LOGOUT_REDIRECT_URI)</div></div>
<div class="row"><button class="btn sm" onclick="saveOidc(true)">Speichern</button>
<button class="btn sm" onclick="syncOidc(false)" ${o.api_key?'':'disabled'} title="Liest nur – zeigt, was beim Deploy passieren würde">Gegen Login prüfen</button>
<button class="btn sm cyan" onclick="syncOidc(true)" ${o.api_key&&o.writes_allowed&&o.mode!=='off'?'':'disabled'} title="${o.writes_allowed?'':'Gesperrt: OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv'}">Jetzt abgleichen</button>
<button class="btn sm danger" onclick="syncOidc(true,true)" ${o.api_key&&o.writes_allowed&&o.client_id?'':'disabled'} title="Neues Secret erzeugen; das alte wird sofort ungültig">Secret rotieren</button></div>
${o.last_sync?`<div class="hint" style="margin-top:6px">Letzter Abgleich: ${fmt(o.last_sync.ts)} · <span class="${o.last_sync.ok?'':'muted'}">${o.last_sync.ok?'✔ ok':'✘ '+esc(o.last_sync.error||'')}</span>${o.last_sync.created?' · Client neu angelegt':''}</div>`:''}
<div id="oc-out" style="margin-top:8px"></div></div></div>
<div class="grid2" style="margin-top:12px">
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Freigaben</h3>
<button class="btn sm" onclick="importMembers()" ${o.api_key&&o.client_id?'':'disabled'} title="Aktuelle Freigaben aus dem Login lesen und hier übernehmen">Aus Login übernehmen</button></div>
<p class="sub">Wer darf die App nutzen (bei auth_mode „required“). Nutzer müssen im Login bereits existieren.</p>
<table class="t" id="oc-members"><tbody></tbody></table>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="addMember()">+ Nutzer</button>
<label class="f" style="display:flex;align-items:center;gap:6px;margin-left:auto">Abgleich<select id="oc-mm" style="width:auto;margin:0"><option value="add" ${o.members_mode==='add'?'selected':''}>nur ergänzen</option><option value="exact" ${o.members_mode==='exact'?'selected':''}>exakt (zusätzliche entziehen)</option></select></label></div></div>
<div class="card"><h3>Manuell eintragen</h3><p class="sub">Falls der Client nicht automatisch verwaltet wird (Verwaltung „off“) oder das Secret aus dem Altbestand übernommen werden soll.</p>
<div class="fgrid"><label class="f">Client-ID<input type="text" id="o-id" placeholder="${o.client_id?esc(o.client_id)+' – neu eintragen zum Ändern':'noch nicht gesetzt'}"></label>
<label class="f">Client-Secret<input type="password" id="o-sec" placeholder="${o.secret_set?'●●●● gesetzt':'noch nicht gesetzt'}"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveManual()">Speichern</button></div></div></div>
<div class="card" style="margin-top:12px"><h3>Bundle-Abruf durch den Container</h3><p class="sub">So holt der Container beim Start seine Konfiguration (ohne Secrets); Token = <span class="mono">ADMIN_BUNDLE_TOKEN</span>. Der Core kann das noch nicht – Schnittstelle steht.</p>
<pre class="json">curl -H "Authorization: Bearer $ADMIN_BUNDLE_TOKEN" \\\n ${location.origin}/api/tenants/${TID}/bundle -o bundle.tar.gz</pre></div>`;
renderMembers(o.members);
$('#oc-mode').addEventListener('change', e => { $('#oc-modehelp').textContent = modeHelp[e.target.value]; });
}
function renderMembers(ms){
window._members = ms.map(m => ({...m}));
$('#oc-members tbody').innerHTML = window._members.length ? window._members.map((m,i)=>`<tr><td><input type="text" value="${esc(m.email)}" onchange="_members[${i}].email=this.value.trim().toLowerCase()" style="width:100%"></td>
<td style="width:100px"><select onchange="_members[${i}].role=this.value">${['viewer','editor','admin'].map(r=>`<option ${m.role===r?'selected':''}>${r}</option>`).join('')}</select></td>
<td style="width:30px"><button class="btn sm danger" onclick="_members.splice(${i},1);renderMembers(_members)">✕</button></td></tr>`).join('') : '<tr><td class="empty">Keine Freigaben gepflegt – bestehende bleiben unverändert (Abgleich „nur ergänzen“).</td></tr>';
}
function addMember(){ window._members.push({email:'', role:'viewer'}); renderMembers(window._members); }
async function saveOidc(toastOk){
try{
await api('PUT', A('/oidc'), {mode:$('#oc-mode').value, auth_mode:$('#oc-auth').value, totp_mode:$('#oc-totp').value, allow_registration:$('#oc-reg').checked,
backchannel_logout_uri:$('#oc-bc').value, members:window._members.filter(m=>m.email), members_mode:$('#oc-mm').value});
if(toastOk) { toast('OIDC-Konfiguration gespeichert'); sDomain(); } return true;
}catch(e){ toast(e.message,1); return false; }
}
async function syncOidc(apply, rotate){
if(!await saveOidc(false)) return;
if(rotate && !confirm('Secret rotieren? Das alte Secret ist sofort ungültig – der Container braucht danach einen Deploy.')) return;
if(apply && !rotate && !confirm('Jetzt im Login-System schreiben (Client anlegen/anpassen, Freigaben setzen)?')) return;
$('#oc-out').innerHTML = '<span class="muted">Frage das Login-System …</span>';
try{
const r = await api('POST', A('/oidc/sync'), {apply, rotate});
const acts = r.actions.map(a=>`<div>• ${esc(a.detail)}</div>`).join('') || (r.skipped?`<div>${esc(r.skipped)}</div>`:'<div>✔ Alles abgeglichen – keine Änderungen nötig.</div>');
$('#oc-out').innerHTML = `<div class="banner ${r.ok?(r.actions.length&&!apply?'info':'ok'):'err'}" style="margin:0"><b>${apply?'Abgleich':'Prüfung (nur lesen)'}:</b> ${r.ok?'':'✘ '+esc(r.error)}${acts}${r.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</div>`;
if(apply){ const keep = $('#oc-out').innerHTML; await load(true); await sDomain(); $('#oc-out').innerHTML = keep; }
}catch(e){ $('#oc-out').innerHTML = `<div class="banner err" style="margin:0">${esc(e.message)}</div>`; }
}
async function importMembers(){ try{ const o = await api('POST', A('/oidc/import-members')); renderMembers(o.members); toast(o.members.length+' Freigaben übernommen – speichern nicht vergessen'); }catch(e){ toast(e.message,1) } }
async function saveManual(){ const id=$('#o-id').value, sc=$('#o-sec').value; if(id) await api('PUT',A('/secrets/OIDC_CLIENT_ID'),{value:id}); if(sc) await api('PUT',A('/secrets/OIDC_CLIENT_SECRET'),{value:sc}); toast('Gespeichert'); sDomain(); }
async function saveDomain(){ try{ await api('PUT', A('/domain'), {subdomain:$('#d-sub').value, custom_domain:$('#d-cust').value}); await load(true); sDomain(); toast('Domain gespeichert'); }catch(e){ toast(e.message,1) } }
async function checkDomain(){ $('#dns-msg').textContent='Prüfe …'; const r = await api('POST', A('/domain/check')); await load(true); await sDomain(); $('#dns-msg').textContent = r.message; }
async function sSecrets(){
const sec = await api('GET', A('/secrets')); renderNav({missing: sec.some(s=>s.required&&!s.set)});
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Secrets</h2></div><div class="card"><p class="sub">Verschlüsselt in der Datenbank. Werte werden nie angezeigt und gelangen nie in Agent-Kontext oder Bundle – nur beim Deploy als Env an den Container.</p>
<table class="t"><thead><tr><th>Schlüssel</th><th>Status</th><th>Bedeutung</th><th></th></tr></thead><tbody>${sec.map(s=>`<tr><td class="mono nowrap">${esc(s.key)}${s.required?' <span class="badge">Pflicht</span>':''}</td>
<td>${s.set?'<span class="badge ok">gesetzt</span>':s.source==='sops-import'?'<span class="badge warn">noch in SOPS</span>':'<span class="badge err">fehlt</span>'}</td><td class="muted">${esc(s.help)}</td>
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="setSecret('${s.key}')">Wert setzen</button> ${['SESSION_SECRET','ADMIN_BUNDLE_TOKEN'].includes(s.key)?`<button class="btn sm" onclick="genSecret('${s.key}')">Neu erzeugen</button>`:''}</td></tr>`).join('')}</tbody></table>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="addSecret()">+ Weiteres Secret</button><span class="hint">Bestehende Tenants: <span class="mono">python scripts/import_tenants.py --with-secrets</span> liest SOPS und verschlüsselt neu.</span></div></div>`;
}
async function setSecret(k){ const v = prompt('Neuer Wert für '+k+':'); if(v===null) return; await api('PUT', A('/secrets/'+k), {value:v}); toast('Gespeichert'); sSecrets(); }
async function addSecret(){ const k = prompt('Name (z. B. MISTRAL_API_KEY):'); if(!k) return; await setSecret(k.trim().toUpperCase()); }
async function genSecret(k){ if(!confirm(k+' neu erzeugen? Der Container braucht danach einen Deploy.')) return; await api('PUT', A('/secrets/'+k), {generate:true}); toast('Neu erzeugt'); sSecrets(); }
async function followDeploy(id, con){
con.classList.remove('hide'); con.style.display = 'block';
const tick = async () => { const d = await api('GET','/api/deployments/'+id); con.innerHTML = esc(d.log).replace(/\[DRY-RUN\]/g,'<span class="sim">[DRY-RUN]</span>'); con.scrollTop = 1e9;
if(d.status==='running'||d.status==='queued') setTimeout(tick,500); else { await load(true); if(ui.sec==='deploy') sDeploy(true); } };
tick();
}
async function sDeploy(keepConsole){
const [plan, hist] = await Promise.all([api('GET', A('/plan')), api('GET', '/api/deployments')]);
const mine = hist.filter(d => d.tenant === TID); const m = plan.mode;
const old = keepConsole ? $('#con')?.innerHTML : null;
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Deploy</h2></div>
${m.live?`<div class="banner ${m.tenant_allowed?'err':'warn'}">Live-Modus aktiv · freigegebene Tenants: <b>${esc(m.allowed)}</b> ${m.tenant_allowed?'– dieser Tenant darf live deployen.':'– dieser Tenant ist <b>nicht</b> freigegeben (nur Dry-Run).'}</div>`:'<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (<span class="mono">DEPLOY_LIVE</span> nicht aktiv). Dry-Run läuft mit demselben Code und zeigt, was geschrieben würde.</div>'}
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Vorflug-Check <span class="muted" style="font-weight:400">· ist alles bereit für den Live-Deploy? (liest nur)</span></h3><button class="btn sm" onclick="runPreflight()">Prüfen</button></div><div id="pf"><span class="hint">Noch nicht ausgeführt.</span></div></div>
<div class="card" style="margin-top:12px"><h3>Geplanter Rollout</h3>
<dl class="kv"><dt>Service</dt><dd class="mono">${esc(plan.service)}</dd><dt>Image</dt><dd class="mono">${esc(plan.patch.image)}</dd><dt>Version</dt><dd>v${S.current_version} ${S.has_draft?'<span class="badge warn">Entwurf wird nicht deployt</span>':''}</dd><dt>Ingress</dt><dd class="mono">${esc(plan.ingress.host)} → ${esc(plan.ingress.target)}</dd>
<dt>Voraussetzungen</dt><dd><span class="badge ${m.token?'ok':'warn'}">API-Token</span> <span class="badge ${m.project?'ok':'warn'}">Projekt-ID</span> ${S.source==='admin'?`<span class="badge ${m.core_bundle?'ok':'err'}">Core-Bundle</span>`:''}
<span class="badge ${plan.oidc.mode==='off'?'':plan.oidc.key?'ok':'warn'}" title="Wird bei jedem Deploy vorher mit der Login-API abgeglichen">OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}</span></dd></dl>
${plan.missing_secrets.length?`<div class="banner warn" style="margin-top:8px">Secrets ohne Wert: <span class="mono">${esc(plan.missing_secrets.join(', '))}</span></div>`:''}
<details class="fold"><summary>Service-Definition (PATCH-Body, Secrets maskiert)</summary><div class="in"><pre class="json">${esc(JSON.stringify(plan.patch,null,2))}</pre></div></details>
<div class="row" style="margin-top:10px"><button class="btn" onclick="startDeploy(true)">Dry-Run</button><button class="btn danger ${m.live?'':'disabled'}" onclick="rollbackTenant()" title="Auf den vorherigen erfolgreich deployten Core-Tag zurück">Zurückrollen</button><button class="btn cyan ${m.live&&m.tenant_allowed?'':'disabled'}" onclick="startDeploy(false)" title="${!m.live?'Gesperrt: DEPLOY_LIVE nicht aktiv':!m.tenant_allowed?'Gesperrt: Tenant nicht in DEPLOY_ALLOWED_TENANTS':''}">Deployen</button></div>
<div class="console hide" id="con" style="margin-top:10px"></div></div>
<div class="card" style="margin-top:12px"><h3>Verlauf</h3>${mine.length?`<table class="t"><thead><tr><th>Zeit</th><th>Art</th><th>Version</th><th>Status</th></tr></thead><tbody>${mine.map(d=>`<tr><td>${fmt(d.created_at)}</td><td>${kindLabel[d.kind]||d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version}</td><td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':''}">${d.status}</span></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Noch keine Deployments für diesen Tenant.</div>'}</div>`;
if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; }
}
async function runPreflight(){
$('#pf').innerHTML = '<span class="muted">Prüfe (liest Mittwald-Stack, Login-API, Rendering) …</span>';
try{
const r = await api('GET', A('/preflight'));
const dot = {ok:'ok', fail:'no', warn:'no', info:''}; const ic = {ok:'✓', fail:'✕', warn:'!', info:'i'};
$('#pf').innerHTML = `<div class="banner ${r.ready?'ok':'err'}" style="margin-bottom:6px">${r.ready?'✔ Bereit für den Live-Deploy (keine harten Fehler)':'✘ Noch nicht bereit – bitte die roten Punkte beheben'} · Ziel <span class="mono">${esc(r.host)}</span> · Core <span class="mono">:${esc(String(r.tag).slice(0,12))}</span></div>`+
r.items.map(i=>`<div class="check"><span class="dot ${dot[i.level]}" ${i.level==='warn'?'style="background:#fbf0cf;color:#8a6200"':''}>${ic[i.level]}</span><span><b>${esc(i.label)}</b> <span class="muted">${esc(i.detail)}</span>${i.fix?`<div class="hint">→ ${esc(i.fix)}</div>`:''}</span></div>`).join('');
}catch(e){ $('#pf').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } }
async function startDeploy(dry){
if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return;
try{ const r = await api('POST', A('/deploy'), {dry_run:dry}); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) }
}
/* ───────── Verlauf ───────── */
async function sVersions(){
const v = await api('GET', A('/versions'));
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Verlauf</h2></div><div class="card"><p class="sub">Jede Veröffentlichung ist ein unveränderlicher Snapshot. Wiederherstellen legt die alte Version als <b>Entwurf</b> an – nichts wird überschrieben.</p>
<table class="t"><thead><tr><th>Version</th><th>Nachricht</th><th>Autor</th><th>Zeit</th><th>Dateien</th><th></th></tr></thead><tbody>${v.map(x=>`<tr><td class="nowrap"><b>v${x.number}</b> ${x.current?'<span class="badge dark">aktuell</span>':''} ${x.deployed?'<span class="badge ok">deployt</span>':''}</td><td>${esc(x.message)}</td><td>${esc(x.author)}</td><td class="nowrap">${fmt(x.created_at)}</td><td>${x.files}</td>
<td style="text-align:right" class="nowrap">${x.current?'':`<button class="btn sm" onclick="showDiff('v${x.number}','current','v${x.number} → aktuell')">Diff</button> <button class="btn sm" onclick="restoreV(${x.number})">Als Entwurf</button>`}</td></tr>`).join('')}</tbody></table></div>`;
}
async function showDiff(a,b,t){ const d = await api('GET', A(`/diff?a=${a}&b=${b}`)); $('#diff-title').textContent = 'Diff '+t; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede.</div>'; modal('m-diff', true); }
async function restoreV(n){ if(!confirm('v'+n+' als Entwurf wiederherstellen? Ein bestehender Entwurf wird ersetzt.')) return; await api('POST', A(`/versions/${n}/restore`)); await load(true); location.hash = 'brand'; renderSection(); toast('v'+n+' liegt jetzt im Entwurf'); }
(async () => { await loadSchema(); await load(); initAgent(); })();

13
app/templates/audit.html Normal file
View File

@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Audit-Log · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head"><div><div class="eyebrow">Nachvollziehbarkeit</div><h1 class="title">Audit-Log.</h1>
<p class="lead">Wer hat wann was geändert, veröffentlicht oder deployt – inklusive Freigaben von Agent-Vorschlägen.</p></div></div>
<div class="card">
<table class="t"><thead><tr><th>Zeit</th><th>Wer</th><th>Aktion</th><th>Tenant</th><th>Detail</th></tr></thead><tbody>
{% for r in rows %}<tr><td>{{ r.ts|dt }}</td><td>{{ r.actor }}</td><td><span class="badge">{{ r.action }}</span></td><td>{{ r.tenant_id or '–' }}</td><td class="muted">{{ r.detail }}</td></tr>
{% else %}<tr><td colspan="5" class="empty">Noch keine Einträge.</td></tr>{% endfor %}
</tbody></table></div>
</div>
{% endblock %}

44
app/templates/base.html Normal file
View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}Onyscribe Admin{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231a1a1a'/%3E%3Cpath d='M20 22l14 10-14 10' fill='none' stroke='%2300cccc' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/admin.css">
</head>
<body>
<aside class="side">
<div class="brand"><b>onyva</b><span>Scribe Admin</span></div>
<nav class="nav">
<a href="/" class="{{ 'on' if nav=='dashboard' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg><span>Tenants</span></a>
<a href="/deployments" class="{{ 'on' if nav=='deployments' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg><span>Deployments</span></a>
<a href="/core" class="{{ 'on' if nav=='core' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 22V12M3 7l9 5 9-5"/></svg><span>Core</span></a>
<a href="/settings" class="{{ 'on' if nav=='settings' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10l2 2M19 5l-2 2M7 17l-2 2"/></svg><span>Einstellungen</span></a>
<a href="/audit" class="{{ 'on' if nav=='audit' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg><span>Audit-Log</span></a>
</nav>
<div class="side-foot">
<span class="pill {{ 'live' if deploy_live() else 'lock' }}" title="Env DEPLOY_LIVE der Admin-Plattform">Deploy: {{ 'live' if deploy_live() else 'gesperrt · Dry-Run' }}</span>
{% set u = request.state.user if request is defined else None %}
{% if u and u.email and u.email != 'dev@local' %}<span>{{ u.email }}<br><a href="/auth/logout">Abmelden</a></span>{% else %}<span class="pill dev">Login: aus</span>{% endif %}
</div>
</aside>
<div class="main">{% block body %}{% endblock %}</div>
<div id="toast"></div>
<script>
function toast(msg, err){const t=document.getElementById('toast');t.textContent=msg;t.className=err?'err':'';t.style.display='block';clearTimeout(window._tt);window._tt=setTimeout(()=>t.style.display='none',3200)}
async function api(method, url, body){
const o={method,headers:{}};
if(body!==undefined){o.headers['Content-Type']='application/json';o.body=JSON.stringify(body)}
const r=await fetch(url,o);
let d=null;try{d=await r.json()}catch(e){}
if(!r.ok){throw new Error((d&&(d.detail||d.message))||('Fehler '+r.status))}
return d;
}
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function modal(id,on){document.getElementById(id).classList.toggle('on',on)}
function fmt(d){return d?new Date(d).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}):'–'}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

90
app/templates/core.html Normal file
View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Core · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head"><div><div class="eyebrow">Zusammenspiel mit dem Core</div><h1 class="title">Core-Releases.</h1>
<p class="lead">Die Core-CI meldet neue Images hierher. Die Admin-Plattform prüft damit <b>alle</b> Tenants, gibt nach deiner Freigabe aus und rollt Canary-zuerst aus – mit Health-Check pro Tenant und Rollback.</p></div>
<button class="btn" onclick="load()">Aktualisieren</button></div>
<div id="warn"></div>
<div class="grid2">
<div class="card"><h3>Core der Admin-Plattform</h3><dl class="kv" id="admin-core"></dl>
<p class="hint" style="margin-top:8px">Vorschau, Sandbox und Formulare nutzen genau diesen Core. Im Container ist das Admin-Image <span class="mono">FROM</span> das Core-Image gebaut – Schriften, Python-Pakete und WeasyPrint sind damit identisch zur Produktion.</p></div>
<div class="card"><h3>Rollout-Regeln</h3><dl class="kv" id="rules"></dl>
<div class="row" style="margin-top:8px"><label class="f" style="flex:1">Canary-Tenants (zuerst, kommagetrennt)<input type="text" id="canary" placeholder="demo"></label><button class="btn sm" onclick="saveCanary()" style="align-self:flex-end">Speichern</button></div>
<p class="hint" style="margin-top:6px">Gepinnte Tenants (Tenant → Übersicht → Core-Version) nehmen nicht teil. Der Rollout bricht beim ersten Fehler ab; Echt-Rollouts brauchen <span class="mono">DEPLOY_LIVE=true</span>.</p></div>
</div>
<div class="card" style="margin-top:12px"><h3>Releases</h3><div id="rel"></div></div>
</div>
<div class="modal-bg" id="m-roll"><div class="modal"><h2 id="roll-title">Rollout</h2><p class="hint" id="roll-hint"></p><table class="t" id="roll-table"><tbody></tbody></table>
<div class="acts"><button class="btn" onclick="modal('m-roll',false);load()">Schließen</button></div></div></div>
{% endblock %}
{% block scripts %}
{% raw %}<script>
let D = null, open_ = {}, timer = null;
const stLabel = {new:'neu', checked:'geprüft', approved:'freigegeben', rolling:'wird ausgerollt', active:'aktiv', failed:'fehlgeschlagen', rejected:'abgelehnt', superseded:'abgelöst', rolled_back:'zurückgerollt'};
const stCls = {active:'ok', approved:'cyan', checked:'', new:'warn', rolling:'cyan', failed:'err', rejected:'err', superseded:'', rolled_back:'warn'};
async function load(){
D = await api('GET','/api/core/releases');
const a = D.admin_core;
document.getElementById('admin-core').innerHTML = `<dt>Core-SHA</dt><dd class="mono">${esc(a.sha.slice(0,12))} <span class="muted">(${esc(a.source)})</span></dd><dt>Gebaut</dt><dd>${a.built_at?fmt(a.built_at):'–'}</dd>
<dt>Vertrag</dt><dd>v${a.contract} ${a.supported?'<span class="badge ok">unterstützt</span>':'<span class="badge err">nicht unterstützt</span>'}</dd><dt>Schema</dt><dd>${a.fields??'–'} Felder · ${a.env??'–'} Env-Einträge <span class="muted mono">${esc((a.schema_sha||'').slice(0,8))}</span></dd>`;
document.getElementById('rules').innerHTML = `<dt>Aktiver Tag</dt><dd class="mono">:${esc(String(D.active_tag).slice(0,12))}</dd><dt>Release-Eingang</dt><dd>${D.intake?'<span class="badge ok">Token gesetzt</span>':'<span class="badge warn">CORE_RELEASE_TOKEN fehlt</span>'} <span class="hint">CI: Job „notify-admin“</span></dd><dt>Rollout</dt><dd>${D.live?'<span class="badge err">live erlaubt</span>':'<span class="badge warn">gesperrt · nur Dry-Run</span>'}</dd>`;
document.getElementById('canary').value = D.canary.join(', ');
document.getElementById('warn').innerHTML = a.supported ? '' : '<div class="banner err">Dieser Core nutzt einen Vertrag, den diese Admin-Version nicht kennt – Admin aktualisieren, bevor etwas ausgerollt wird.</div>';
renderRel();
if(D.releases.some(r=>r.compat&&r.compat.running||r.status==='rolling')){ clearTimeout(timer); timer = setTimeout(load, 2500); }
}
function matrix(r){
const c = r.compat; if(!c) return '<div class="hint">Noch nicht geprüft.</div>';
const rows = Object.entries(c.tenants||{}).map(([t,v])=>`<tr><td><b>${esc(t)}</b></td><td><span class="badge ${v.status==='ok'?'ok':v.status==='error'?'err':v.status==='changed'?'warn':''}">${{ok:'unverändert',changed:'Rendering geändert',new:'neu (keine Baseline)',error:'Fehler'}[v.status]}</span></td>
<td class="muted">${v.changed.length?esc(v.changed.join(', ')):''}${v.errors.map(e=>`<div class="mono" style="color:var(--err)">${esc(e).slice(0,300)}</div>`).join('')}${v.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</td></tr>`).join('');
return `<div class="hint" style="margin:4px 0">${c.running?'Prüfung läuft …':'Geprüft '+fmt(c.checked_at)+' mit Core '+esc((c.core_sha||'').slice(0,10))}${c.forced?' · <b>Abweichender Admin-Core (erzwungen)</b>':''} · ${Object.entries(c.summary||{}).filter(([k,v])=>v).map(([k,v])=>v+'× '+k).join(', ')}</div>
<table class="t"><tbody>${rows}</tbody></table>`;
}
function renderRel(){
document.getElementById('rel').innerHTML = D.releases.length ? `<table class="t"><thead><tr><th>Release</th><th>Nachricht</th><th>Gemeldet</th><th>Status</th><th>Admin</th><th></th></tr></thead><tbody>`+D.releases.map(r=>{
const c = r.compat, sum = c&&c.summary ? c.summary : null;
return `<tr><td class="nowrap"><span class="mono">${esc(r.sha.slice(0,10))}</span> ${r.active?'<span class="badge ok">aktiv</span>':''}<div class="hint">${esc(r.ref)}</div></td><td>${esc(r.message)}</td><td class="nowrap">${fmt(r.announced_at)}</td>
<td><span class="badge ${stCls[r.status]||''}">${stLabel[r.status]||r.status}</span>${sum?`<div class="hint">${sum.ok||0} ok · ${sum.changed||0} geändert · ${sum.new||0} neu · ${sum.error||0} Fehler</div>`:''}</td>
<td>${r.admin_matches?'<span class="badge ok">gleicher Core</span>':'<span class="badge warn" title="Admin muss FROM diesem Core neu gebaut werden">Admin älter/anders</span>'}</td>
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="tg(${r.id})">Matrix</button>
<button class="btn sm" onclick="act(${r.id},'check')">Prüfen</button>
<button class="btn sm" ${['checked','approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'} onclick="approve(${r.id})">Freigeben</button>
<button class="btn sm" onclick="rollout(${r.id},true)">Dry-Run</button>
<button class="btn sm cyan ${D.live&&['approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'}" onclick="rollout(${r.id},false)">Ausrollen</button>
${r.previous_tag?`<button class="btn sm danger ${D.live?'':'disabled'}" onclick="rollback(${r.id})">Zurückrollen</button>`:''}
${['new','checked'].includes(r.status)?`<button class="btn sm danger" onclick="act(${r.id},'reject')">Ablehnen</button>`:''}</td></tr>
<tr style="display:${open_[r.id]?'table-row':'none'}"><td colspan="6">${matrix(r)}</td></tr>`}).join('')+'</tbody></table>'
: '<div class="empty">Noch kein Release gemeldet. Die Core-CI meldet Images per POST /api/core/releases (Job „notify-admin“).</div>';
}
function tg(id){ open_[id] = !open_[id]; renderRel(); }
async function act(id, what){
try{ await api('POST', `/api/core/releases/${id}/${what}`, what==='check' ? {force: !D.releases.find(r=>r.id===id).admin_matches && confirm('Die Admin-Plattform läuft nicht auf diesem Core. Trotzdem mit dem aktuellen Admin-Core prüfen (nur Entwicklung)?')} : {}); toast(what==='check'?'Prüfung gestartet':'ok'); open_[id]=true; load(); }
catch(e){ toast(e.message,1) }
}
async function approve(id){
const r = D.releases.find(x=>x.id===id); const changed = r.compat&&r.compat.summary&&r.compat.summary.changed;
if(changed && !confirm(changed+' Tenant(s) rendern anders als zuvor (siehe Matrix). Bewusst freigeben?')) return;
try{ await api('POST', `/api/core/releases/${id}/approve`, {acknowledge: !!changed}); toast('Freigegeben'); load(); }catch(e){ toast(e.message,1) }
}
async function rollout(id, dry){
if(!dry && !confirm('Release jetzt Canary-zuerst auf alle (nicht gepinnten) Tenants ausrollen? Bei einem Fehler wird abgebrochen.')) return;
let r; try{ r = await api('POST', `/api/core/releases/${id}/rollout`, {dry_run: dry}); }catch(e){ return toast(e.message,1) }
followBatch(r.batch, dry ? 'Dry-Run des Rollouts' : 'Rollout läuft');
}
async function rollback(id){
if(!confirm('Alle Tenants auf den Tag vor diesem Rollout zurückrollen?')) return;
try{ const r = await api('POST', `/api/core/releases/${id}/rollback`); followBatch(r.batch, 'Rollback läuft'); }catch(e){ toast(e.message,1) }
}
function followBatch(batch, title){
document.getElementById('roll-title').textContent = title; document.getElementById('roll-hint').textContent = 'Reihenfolge: Canary zuerst, dann der Rest; Abbruch beim ersten Fehler.'; modal('m-roll', true);
const tick = async () => {
const rows = await api('GET', '/api/deployments?batch=' + batch);
document.querySelector('#roll-table tbody').innerHTML = rows.map(d=>{ const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10); const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''; return `<tr><td><b>${esc(d.tenant)}</b></td><td class="mono">:${esc(String(d.image).slice(0,10))}</td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`; }).join('');
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick, 800);
}; tick();
}
async function saveCanary(){ try{ await api('PUT','/api/settings',{canary_tenants: document.getElementById('canary').value}); toast('Gespeichert'); }catch(e){ toast(e.message,1) } }
load();
</script>{% endraw %}
{% endblock %}

View File

@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}Tenants · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head">
<div><div class="eyebrow">Übersicht</div><h1 class="title">Tenants.</h1></div>
<div class="row">
<button class="btn" onclick="probe()" title="Fragt /health jedes Tenants ab: läuft der erwartete Core und das erwartete Bundle?">Zustand prüfen</button>
<button class="btn" onclick="deployAll(true)" title="Plan für alle Tenants, ohne Schreibzugriff">Dry-Run aller</button>
<button class="btn {{ '' if deploy_live() else 'disabled' }}" onclick="deployAll(false)" title="{{ '' if deploy_live() else 'Gesperrt: DEPLOY_LIVE ist nicht aktiv' }}">Alle neu deployen</button>
<button class="btn cyan" onclick="modal('m-new',true)">+ Neuer Tenant</button>
</div>
</div>
<div class="kpis">
<div class="kpi click" data-f="all"><b>{{ stats.tenants }}</b><span>Tenants</span></div>
<div class="kpi click" data-f="admin"><b>{{ stats.admin }}</b><span>Quelle Admin</span></div>
<div class="kpi click" data-f="outdated"><b>{{ stats.outdated }}</b><span>Deploy veraltet</span></div>
<div class="kpi click" data-f="draft"><b>{{ stats.drafts }}</b><span>Entwürfe</span></div>
<div class="kpi click" data-f="secrets"><b>{{ stats.secrets }}</b><span>Secrets offen</span></div>
<div class="kpi"><b class="mono" style="font-size:13px">:{{ tag }}</b><span>Core {{ core }}</span></div>
</div>
<div class="card" style="padding:10px 12px">
<div class="row" style="margin:2px 2px 8px">
<input class="search" id="q" placeholder="Tenant suchen …" autofocus>
<div class="chipbar" id="chips">
{% for k,l in [('all','Alle'),('draft','Entwurf'),('outdated','Deploy veraltet'),('secrets','Secrets offen'),('git','Git'),('admin','Admin'),('code','mit Hook-Code'),('paused','pausiert / archiviert')] %}<span class="fchip {{ 'on' if k=='all' }}" data-f="{{ k }}">{{ l }}</span>{% endfor %}
</div>
</div>
<table class="t hover" id="tt"><thead><tr><th>Tenant</th><th>Version</th><th>Quelle</th><th>Marke</th><th>Anpassungen</th><th>Ressourcen</th><th>Läuft</th><th>Hinweise</th></tr></thead><tbody>
{% for r in rows %}
<tr onclick="location.href='/tenants/{{ r.t.id }}'" data-q="{{ (r.t.id ~ ' ' ~ r.t.name ~ ' ' ~ r.host)|lower }}"
data-flags="{{ 'draft ' if r.t.draft }}{{ 'outdated ' if r.outdated }}{{ 'secrets ' if r.missing_secrets }}{{ r.t.source }} {{ 'code ' if r.has_code }}{{ 'paused ' if r.t.status!='active' }}">
<td><div class="row" style="flex-wrap:nowrap"><div class="logo-box">{% if r.logo %}<img src="{{ r.logo }}" alt="">{% endif %}</div>
<div><b>{{ r.t.name }}</b>{% if r.t.status!='active' %} <span class="badge warn">{{ r.t.status }}</span>{% endif %}<br><span class="muted">{{ r.host }}</span></div></div></td>
<td class="nowrap"><span class="badge dark">v{{ r.t.current_version }}</span>
<span class="muted"><span class="dotc {{ 'warn' if r.outdated else 'ok' }}"></span>{{ 'deployt v'~(r.t.deployed_version or '–') if r.outdated else 'deployt' }}</span></td>
<td><span class="badge {{ 'cyan' if r.t.source=='admin' else '' }}">{{ 'Admin' if r.t.source=='admin' else 'Git' }}</span></td>
<td><span class="swatches">{% for k in ['accent','accent_dark','accent_light','bg','red'] %}<i style="background:{{ r.cfg.get('colors',{}).get(k,'#ddd') }}"></i>{% endfor %}</span></td>
<td class="muted">STT {{ r.stt }}{% if r.custom_prompt %} · Prompt{% endif %}{% if r.custom_pdf %} · PDF{% endif %}{% if r.has_code %} · <span style="color:#8a6200;font-weight:700">Code</span>{% endif %}</td>
<td class="muted nowrap">{{ r.t.cpu }} CPU · {{ r.t.memory }}</td>
<td class="run nowrap" data-t="{{ r.t.id }}"><span class="muted">–</span></td>
<td>{% if r.t.draft %}<span class="badge warn">Entwurf</span> {% endif %}{% if r.missing_secrets %}<span class="badge err">{{ r.missing_secrets }} Secret(s)</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody></table>
<div class="empty hide" id="none">Keine Tenants für diesen Filter.</div>
</div>
<div class="card" style="margin-top:12px"><h3>Letzte Deployments <a href="/deployments" class="muted" style="font-weight:400;font-size:12px">· alle ansehen</a></h3>
{% if last %}<table class="t"><tbody>{% for d in last %}<tr><td class="nowrap">{{ d.created_at|dt }}</td><td><b>{{ d.tenant_id or 'alle' }}</b></td><td>{{ d.kind }}</td><td>v{{ d.version }}</td>
<td><span class="badge {{ 'ok' if d.status=='success' else 'err' if d.status in ('failed',) else '' }}">{{ d.status }}</span></td></tr>{% endfor %}</tbody></table>
{% else %}<div class="empty">Noch keine Deployments.</div>{% endif %}
</div>
</div>
<div class="modal-bg" id="m-new"><div class="modal" style="max-width:520px">
<h2>Neuer Tenant</h2>
<div class="fgrid">
<label class="f">Name<input type="text" id="n-name" placeholder="z. B. Musterklinik GmbH"></label>
<label class="f">ID (Slug)<input type="text" id="n-id" placeholder="musterklinik"></label>
<label class="f full">Ausgangspunkt
<select id="n-clone"><option value="">Leer – Core-Standard (Prompt, PDF-Layout, Farben)</option>
{% for r in rows %}<option value="{{ r.t.id }}">Kopie von {{ r.t.name }}</option>{% endfor %}</select></label>
</div>
<p class="hint" style="margin-top:8px">SESSION_SECRET und Bundle-Token werden erzeugt. OIDC-Client und Cost-Key folgen unter „Domain &amp; Login“ / „Secrets“.</p>
<div class="acts"><button class="btn" onclick="modal('m-new',false)">Abbrechen</button><button class="btn cyan" onclick="createTenant()">Anlegen</button></div>
</div></div>
<div class="modal-bg" id="m-all"><div class="modal">
<h2 id="all-title">Rollout</h2><p class="hint" id="all-hint"></p>
<table class="t" id="all-table"><tbody></tbody></table>
<div class="acts"><button class="btn" onclick="modal('m-all',false);location.reload()">Schließen</button></div>
</div></div>
{% endblock %}
{% block scripts %}
<script>
let flt='all';
function apply(){
const q=document.getElementById('q').value.toLowerCase().trim(); let n=0;
document.querySelectorAll('#tt tbody tr').forEach(tr=>{
const ok=(!q||tr.dataset.q.includes(q))&&(flt==='all'||tr.dataset.flags.split(' ').includes(flt)); tr.style.display=ok?'':'none'; n+=ok;});
document.getElementById('none').classList.toggle('hide',n>0);
document.querySelectorAll('#chips .fchip').forEach(c=>c.classList.toggle('on',c.dataset.f===flt));
}
document.getElementById('q').addEventListener('input',apply);
document.querySelectorAll('[data-f]').forEach(e=>e.addEventListener('click',()=>{flt=e.dataset.f;apply()}));
document.getElementById('n-name').addEventListener('input',e=>{
const idEl=document.getElementById('n-id'); if(idEl.dataset.touched) return;
idEl.value=e.target.value.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,38);
});
document.getElementById('n-id').addEventListener('input',e=>e.target.dataset.touched=1);
async function probe(){
document.querySelectorAll('td.run').forEach(td=>td.innerHTML='<span class="muted">…</span>');
try{
const r = await api('GET','/api/status?refresh=1');
document.querySelectorAll('td.run').forEach(td=>{ const x=r[td.dataset.t]; if(!x){ td.innerHTML='<span class="muted">–</span>'; return; }
td.innerHTML = x.reachable && !x.drift.length ? `<span class="badge ok" title="Core ${esc(x.core?.version||'')}">${esc((x.core?.version||'?').slice(0,7))}${x.bundle?.source==='admin'?' · b'+x.bundle.version:''}</span>` : `<span class="badge err" title="${esc(x.drift.join(' · '))}">${esc(x.drift[0]||'Abweichung')}</span>`; });
toast('Zustand aktualisiert');
}catch(e){ toast(e.message,1) }
}
async function createTenant(){
try{const r=await api('POST','/api/tenants',{id:document.getElementById('n-id').value,name:document.getElementById('n-name').value,clone_from:document.getElementById('n-clone').value||null});location.href='/tenants/'+r.id+'#brand'}
catch(e){toast(e.message,1)}
}
async function deployAll(dry){
if(!dry && !confirm('Alle aktiven Tenants nacheinander neu deployen? Beim ersten Fehler wird abgebrochen.')) return;
let r; try{r=await api('POST','/api/deploy-all',{dry_run:dry})}catch(e){return toast(e.message,1)}
document.getElementById('all-title').textContent=dry?'Dry-Run aller Tenants':'Rollout aller Tenants';
document.getElementById('all-hint').textContent=dry?'Es wird nichts geschrieben – nur der Plan geprüft.':'Sequenziell mit Health-Check; Abbruch beim ersten Fehler.';
modal('m-all',true);
const tick=async()=>{
const rows=await api('GET','/api/deployments?batch='+r.batch);
document.querySelector('#all-table tbody').innerHTML=rows.map(d=>{
const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10);
const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':'';
return `<tr><td><b>${esc(d.tenant)}</b></td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`}).join('');
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick,700);
};tick();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Deployments · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head">
<div><div class="eyebrow">Rollout</div><h1 class="title">Deployments.</h1>
<p class="lead">Differenzielles PATCH auf den Mittwald-Stack (nie PUT), Namensraum <span class="mono">app-</span>, Guard für fremde Container, Abbruch beim ersten Fehler.</p></div>
<div class="row"><button class="btn" onclick="run(true)">Dry-Run aller</button><button class="btn cyan {{ '' if deploy_live() else 'disabled' }}" onclick="run(false)">Alle neu deployen</button></div>
</div>
{% if not deploy_live() %}<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (Env <span class="mono">DEPLOY_LIVE</span> der Admin-Plattform ist nicht <span class="mono">true</span>). Dry-Runs laufen mit demselben Code und zeigen exakt, was geschrieben würde.</div>{% endif %}
<div class="card" style="margin-bottom:12px"><div class="row"><b>Core-Image</b><span class="badge dark mono">:{{ tag }}</span>
<span class="muted">Image-Tag und Registry unter Einstellungen. Nach dem Core-Umbau holen Tenants ihr Bundle zur Laufzeit – dann genügt ein Neustart statt Image-Build.</span></div></div>
<div class="card"><h3>Verlauf</h3><div id="list"></div></div>
</div>
{% endblock %}
{% block scripts %}
<script>
let open_={};
async function load(){
const rows=await api('GET','/api/deployments');
document.getElementById('list').innerHTML=rows.length?`<table class="t"><thead><tr><th>#</th><th>Zeit</th><th>Tenant</th><th>Art</th><th>Version</th><th>Image</th><th>Status</th><th></th></tr></thead><tbody>`+rows.map(d=>`
<tr><td>${d.id}</td><td class="nowrap">${fmt(d.created_at)}</td><td><b>${esc(d.tenant||'alle')}</b></td><td>${d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version??'–'}</td><td class="mono">:${esc(d.image)}</td>
<td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''}">${d.status}</span></td><td><button class="btn sm" onclick="tg(${d.id})">Log</button></td></tr>
<tr style="display:${open_[d.id]?'table-row':'none'}"><td colspan="8"><div class="console">${esc(d.log||'(leer)')}</div></td></tr>`).join('')+'</tbody></table>':'<div class="empty">Noch keine Deployments.</div>';
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(load,900);
}
function tg(id){open_[id]=!open_[id];load()}
async function run(dry){ if(!dry&&!confirm('Alle aktiven Tenants nacheinander neu deployen?'))return; try{await api('POST','/api/deploy-all',{dry_run:dry});toast('Rollout gestartet');load()}catch(e){toast(e.message,1)} }
load();
</script>
{% endblock %}

133
app/templates/settings.html Normal file
View File

@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}Einstellungen · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head"><div><div class="eyebrow">Plattform</div><h1 class="title">Einstellungen.</h1>
<p class="lead">Geteilte Werte für alle Tenants – bisher Gitea-„Variables“ und <span class="mono">secrets/shared.enc.yaml</span>.</p></div>
<button class="btn cyan" onclick="save()">Alles speichern</button></div>
<div class="grid2">
<div class="card"><h3>Hosting &amp; Deploy</h3>
<div class="fgrid">
<label class="f">Basis-Domain<input type="text" data-k="base_domain" value="{{ v.base_domain }}"></label>
<label class="f">Core-Image-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}"></label>
<label class="f">Mittwald PROJECT_ID<input type="text" data-k="mittwald_project_id" value="{{ v.mittwald_project_id }}" placeholder="p-xxxxxx"></label>
<label class="f">Mittwald STACK_ID<input type="text" data-k="mittwald_stack_id" value="{{ v.mittwald_stack_id }}" placeholder="s-xxxxxx (optional)"></label>
<label class="f">Registry<input type="text" data-k="registry" value="{{ v.registry }}" placeholder="git.example.de/owner"></label>
<label class="f">Image-Name<input type="text" data-k="image_name" value="{{ v.image_name }}"></label>
<label class="f full">Öffentliche URL der Admin-Plattform (für ADMIN_URL im Container)<input type="text" data-k="admin_public_url" value="{{ v.admin_public_url }}" placeholder="https://admin.example.de"></label>
</div>
<div style="margin-top:10px">
<div class="check"><span class="dot {{ 'ok' if mode.token else 'no' }}">{{ '✓' if mode.token else '!' }}</span><span><span class="mono">MITTWALD_API_TOKEN</span> {{ 'gesetzt (lesende Dry-Run-Aufrufe möglich)' if mode.token else 'nicht gesetzt (Dry-Run ohne Lesezugriff)' }}</span></div>
<div class="check"><span class="dot {{ 'ok' if mode.live else 'no' }}">{{ '✓' if mode.live else '–' }}</span><span><span class="mono">DEPLOY_LIVE</span> {{ 'true – echte Deployments erlaubt' if mode.live else 'nicht aktiv – Schreibzugriffe gesperrt' }}</span></div>
<div class="check"><span class="dot {{ 'ok' if mode.allowed != 'keiner' else 'no' }}">{{ '✓' if mode.allowed != 'keiner' else '–' }}</span><span><span class="mono">DEPLOY_ALLOWED_TENANTS</span> = <b>{{ mode.allowed }}</b> <span class="hint">– nur diese Tenants werden live angefasst (Mittwald, Login-API)</span></span></div>
<label class="chk"><input type="checkbox" data-kb="core_supports_bundle" {{ 'checked' if v.core_supports_bundle=='true' }}><span><b>Core kann Bundle laden</b> <span class="hint">(<span class="mono">TENANT_SOURCE=admin</span>) – erst nach dem Core-Umbau aktivieren. Solange aus, sind Deploys von Tenants mit Quelle „Admin“ gesperrt.</span></span></label>
</div>
</div>
<div class="card"><h3>Login-API (OIDC-Clients automatisch verwalten)</h3>
<div class="check"><span class="dot {{ 'ok' if oidc.api_key else 'no' }}">{{ '✓' if oidc.api_key else '!' }}</span><span><span class="mono">ONYVA_LOGIN_API_KEY</span> {{ 'gesetzt' if oidc.api_key else 'nicht gesetzt – Abgleich beim Deploy nur als Hinweis (Dry-Run) bzw. Fehler (live)' }}</span></div>
<div class="check"><span class="dot {{ 'ok' if oidc.writes_allowed else 'no' }}">{{ '✓' if oidc.writes_allowed else '–' }}</span><span><span class="mono">OIDC_SYNC_LIVE</span> / <span class="mono">DEPLOY_LIVE</span> {{ 'aktiv – Schreiben im Login erlaubt' if oidc.writes_allowed else 'nicht aktiv – nur Lesen/Plan' }}</span></div>
<div class="fgrid" style="margin-top:6px"><label class="f full">API-Basis (leer = Issuer + /api/v1)<input type="text" data-k="onyva_login_api_base" value="{{ v.onyva_login_api_base }}" placeholder="{{ oidc.api_base }}"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="testOidc()" {{ '' if oidc.api_key else 'disabled' }}>Verbindung testen</button><span class="hint" id="oidc-info">Liest GET /apps mit dem Key. Der Key liegt als Env der Admin-Plattform (wie ein Admin-Passwort), nie in der DB.</span></div>
</div>
<div class="card"><h3>Login (OIDC) &amp; Limits</h3>
<div class="fgrid">
<label class="f">Issuer (Tenant-Container)<input type="text" data-k="oidc_issuer" value="{{ v.oidc_issuer }}"></label>
<label class="f">Auth in Tenant-Containern<select data-k="auth_enabled"><option {{ 'selected' if v.auth_enabled=='true' }}>true</option><option {{ 'selected' if v.auth_enabled=='false' }}>false</option></select></label>
<label class="f">Cookie secure<select data-k="app_cookie_secure"><option {{ 'selected' if v.app_cookie_secure=='true' }}>true</option><option {{ 'selected' if v.app_cookie_secure=='false' }}>false</option></select></label>
<label class="f">Max. Dateigröße (MB)<input type="number" data-k="max_file_size_mb" value="{{ v.max_file_size_mb }}"></label>
</div>
<div class="banner info" style="margin:10px 0 0">Der Login der Admin-Plattform selbst wird per Env konfiguriert (<span class="mono">AUTH_ENABLED, OIDC_CLIENT_ID/SECRET, OIDC_REDIRECT_URI, ADMIN_ALLOWED_EMAILS</span>) – siehe README.</div>
</div>
<div class="card"><h3>LLM &amp; Transkription (Cortecs)</h3>
<div class="fgrid">
<label class="f full">Cortecs Base-URL<input type="text" data-k="cortecs_base_url" value="{{ v.cortecs_base_url }}"></label>
<label class="f">Protokoll-Modell (LLM_MODEL)<input type="text" data-k="llm_model" value="{{ v.llm_model }}"></label>
<label class="f">LLM_MAX_TOKENS / TEMPERATURE<div class="row" style="flex-wrap:nowrap"><input type="number" data-k="llm_max_tokens" value="{{ v.llm_max_tokens }}"><input type="text" data-k="llm_temperature" value="{{ v.llm_temperature }}" style="width:70px"></div></label>
<label class="f">Mistral-Modell<input type="text" data-k="mistral_transcribe_model" value="{{ v.mistral_transcribe_model }}"></label>
<label class="f">Mistral-Timeout (s)<input type="number" data-k="mistral_timeout" value="{{ v.mistral_timeout }}"></label>
</div>
<h3 style="margin-top:12px">Geteilte Secrets</h3>
{% for k in ['CORTECS_API_KEY','SMTP_PASSWORD'] %}
<div class="row" style="padding:3px 0"><span class="mono" style="width:150px">{{ k }}</span>
<span class="badge {{ 'ok' if shared.get(k) else 'warn' }}">{{ 'gesetzt' if shared.get(k) else 'extern (SOPS) – noch nicht übernommen' }}</span>
<button class="btn sm" onclick="setShared('{{ k }}')">Wert setzen</button></div>
{% endfor %}
</div>
<div class="card"><h3>KI-Agent (Admin-Chat)</h3>
<div class="row" style="margin-bottom:8px"><span class="badge {{ 'ok' if llm_ready else 'warn' }}">{{ 'Cortecs-Key vorhanden – echter Agent' if llm_ready else 'kein Key – Demo-Modus' }}</span></div>
<div class="fgrid">
<label class="f">Agent-Modell<input type="text" data-k="agent_model" id="agent_model" value="{{ v.agent_model }}" list="models"></label>
<label class="f">Protokoll<select data-k="agent_api"><option value="messages" {{ 'selected' if v.agent_api=='messages' }}>Anthropic Messages (/v1/messages)</option><option value="chat" {{ 'selected' if v.agent_api=='chat' }}>OpenAI Chat Completions</option></select></label>
</div>
<datalist id="models"></datalist>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="loadModels()">Modelle von Cortecs laden</button><span class="hint" id="models-info">Liest GET /v1/models mit dem geteilten Key und markiert Modelle mit Tool-Unterstützung.</span></div>
<div id="models-list" style="margin-top:8px"></div>
</div>
<div class="card"><h3>E-Mail (SMTP)</h3>
<div class="fgrid"><label class="f">SMTP_HOST<input type="text" data-k="smtp_host" value="{{ v.smtp_host }}"></label><label class="f">SMTP_PORT<input type="number" data-k="smtp_port" value="{{ v.smtp_port }}"></label>
<label class="f">SMTP_USER<input type="text" data-k="smtp_user" value="{{ v.smtp_user }}"></label><label class="f">SMTP_FROM<input type="text" data-k="smtp_from" value="{{ v.smtp_from }}"></label></div>
<h3 style="margin-top:12px">Kostenerfassung</h3>
<div class="fgrid"><label class="f">COST_API_BASE<input type="text" data-k="cost_api_base" value="{{ v.cost_api_base }}"></label><label class="f">COST_TRACKING_ENABLED<input type="text" data-k="cost_tracking_enabled" value="{{ v.cost_tracking_enabled }}" placeholder="true"></label></div>
</div>
<div class="card" style="grid-column:1/-1"><h3>Weitere Core-Einstellungen <span class="muted" style="font-weight:400">· aus dem Schema des Cores erzeugt, gelten für alle Tenants</span></h3>
<p class="sub">Alles, was der Core als geteilte Einstellung deklariert und oben noch keine eigene Maske hat. Neue Core-Variablen erscheinen hier automatisch und gehen beim Deploy ins Container-Env. Leer = Standard des Cores.</p>
<div id="dyn"><span class="muted">Lade …</span></div></div>
<div class="card"><h3>Rollout</h3>
<div class="fgrid"><label class="f">Canary-Tenants (kommagetrennt)<input type="text" data-k="canary_tenants" value="{{ v.canary_tenants }}" placeholder="demo"></label><label class="f">Aktiver Core-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}" disabled></label></div>
<p class="hint" style="margin-top:6px">Der aktive Tag ändert sich durch Core-Releases (Menü „Core“).</p></div>
<div class="card"><h3>Core</h3>
<dl class="kv"><dt>Vendored Core</dt><dd class="mono">{{ core }}</dd><dt>Bedeutung</dt><dd>Live-Vorschau und Validierung führen genau diesen Stand aus (<span class="mono">vendor/platform</span>, aktualisieren mit <span class="mono">scripts/sync_core.sh</span>).</dd></dl>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function save(){
const body={};document.querySelectorAll('[data-k]').forEach(e=>body[e.dataset.k]=e.value);
document.querySelectorAll('[data-kb]').forEach(e=>body[e.dataset.kb]=e.checked?'true':'false');
try{await api('PUT','/api/settings',body);toast('Gespeichert')}catch(e){toast(e.message,1)}
}
async function setShared(k){
const v=prompt('Neuer Wert für '+k+' (wird verschlüsselt gespeichert):'); if(v===null)return;
try{await api('PUT','/api/shared-secrets/'+k,{value:v});toast('Gespeichert');location.reload()}catch(e){toast(e.message,1)}
}
async function testOidc(){
const i=document.getElementById('oidc-info'); i.textContent='Teste …';
try{const r=await api('POST','/api/oidc/test'); i.textContent='✔ Verbindung ok – '+r.apps+' App(s) im Login'}catch(e){i.textContent='✘ '+e.message}
}
async function loadDyn(){
const box=document.getElementById('dyn');
try{
const [c, cur] = await Promise.all([api('GET','/api/contract'), api('GET','/api/settings')]);
if(!c.schema){ box.innerHTML='<div class="hint">Dieser Core liefert kein Schema – nur die festen Einstellungen oben.</div>'; return; }
const have=new Set([...document.querySelectorAll('[data-k]')].map(e=>e.dataset.k));
const rest=c.schema.env.filter(e=>e.kind==='setting'&&e.scope==='shared'&&!have.has(e.setting));
const groups={}; rest.forEach(e=>(groups[e.group]??=[]).push(e));
const field=e=>{const val=cur[e.setting]??''; const ph=e.default??''; const inp=e.type==='bool'?`<select data-k="${esc(e.setting)}"><option value="" ${val===''?'selected':''}>Standard (${esc(ph||'Core')})</option><option ${val==='true'?'selected':''}>true</option><option ${val==='false'?'selected':''}>false</option></select>`:`<input type="text" data-k="${esc(e.setting)}" value="${esc(val)}" placeholder="${esc(ph||'Standard')}">`;
return `<label class="f">${esc(e.label)} <span class="mono" style="text-transform:none;letter-spacing:0">${esc(e.name)}</span>${inp}${e.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(e.help)}</span>`:''}</label>`;};
box.innerHTML=Object.entries(groups).map(([g,list])=>{const adv=list.filter(e=>e.advanced), basic=list.filter(e=>!e.advanced);
return `<h3 style="margin:10px 0 4px;font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:1.2px">${esc(g)}</h3>${basic.length?`<div class="fgrid">${basic.map(field).join('')}</div>`:''}${adv.length?`<details class="fold"><summary>Erweitert (${adv.length})</summary><div class="in"><div class="fgrid">${adv.map(field).join('')}</div></div></details>`:''}`}).join('')||'<div class="hint">Alle Core-Einstellungen haben oben eine eigene Maske.</div>';
}catch(e){ box.innerHTML='<div class="hint">Schema nicht ladbar: '+esc(e.message)+'</div>'; }
}
loadDyn();
async function loadModels(){
const info=document.getElementById('models-info'); info.textContent='Lade …';
try{
const m=await api('GET','/api/llm/models');
document.getElementById('models').innerHTML=m.map(x=>`<option value="${esc(x.id)}">`).join('');
document.getElementById('models-list').innerHTML=`<div class="chipbar">${m.map(x=>`<span class="fchip" onclick="document.getElementById('agent_model').value='${esc(x.id)}'" title="${x.tools===true?'Tool-Calling: ja':x.tools===false?'Tool-Calling: nein':'Tool-Calling: unbekannt – testen'}">${esc(x.id)} ${x.tools===true?'🔧':x.tools===false?'✖':'?'}</span>`).join('')}</div>`;
info.textContent=m.length+' Modelle · 🔧 = Tools laut Cortecs, ? = unbekannt. Klick übernimmt die ID.';
}catch(e){info.textContent=e.message}
}
</script>
{% endblock %}

51
app/templates/tenant.html Normal file
View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{{ t.name }} · Onyscribe Admin{% endblock %}
{% block body %}
<div class="thead">
<div class="logo-box" id="h-logo">{% if logo %}<img src="{{ logo }}" alt="">{% endif %}</div>
<div class="grow" style="min-width:160px">
<div class="crumb"><a href="/">Tenants</a> / <span class="mono">{{ t.id }}</span></div>
<h1 id="h-name">{{ t.name }}</h1>
</div>
<div class="row" id="h-badges"></div>
<div class="row" id="h-actions"></div>
<div class="seg" id="h-panel" title="Mitte: Formular oder KI-Agent – die Vorschau rechts bleibt sichtbar">
<button data-p="edit">Bearbeiten</button><button data-p="agent">KI-Agent</button>
</div>
</div>
<div class="tl open" id="tl">
<nav class="snav" id="snav"></nav>
<div class="mid">
<main class="pane" id="pane"><div class="empty">Lade …</div></main>
<section class="chat agpane hide" id="ag-wrap">
<div class="pbar" style="margin-bottom:6px"><span class="badge" id="ag-mode">Agent</span><span class="hint grow" id="ag-usage"></span><button class="btn sm" title="Verlauf löschen" onclick="clearChat()">Verlauf leeren</button></div>
<div class="msgs" id="msgs"></div>
<div class="chips" id="ag-chips"></div>
<div class="chat-in"><textarea id="chat-text" rows="2" placeholder="Was soll angepasst werden? (Farben, Prompt, PDF-Layout, Flags …) – die Vorschau rechts zeigt jede Änderung sofort."></textarea><button class="btn cyan" id="chat-send" onclick="sendChat()">Senden</button></div>
</section>
</div>
<aside class="panel" id="panel">
<div class="pbar">
<div class="seg" id="pv-kind"><button data-k="web" class="on">Web-App</button><button data-k="pdf">PDF</button></div>
<div class="seg" id="pv-dev"><button data-d="" class="on">Desktop</button><button data-d="tablet">Tablet</button><button data-d="mobile">Mobil</button></div>
<div class="seg" id="pv-src"><button data-s="draft" class="on">Entwurf</button><button data-s="current">Veröffentlicht</button><button data-s="agent" id="pv-agent" class="hide">Agent-Stand</button></div>
<span class="grow"></span><span class="hint" id="pv-info"></span><button class="btn sm" onclick="refreshPreview(true)" title="Neu rendern">↻</button>
</div>
<div class="pv-frame" id="pv-frame">
<iframe id="f-web" sandbox="allow-scripts allow-popups" title="Web-Vorschau"></iframe>
<iframe id="f-pdf" style="display:none" title="PDF-Vorschau"></iframe>
<div class="pv-load" id="pv-load">Rendert mit dem echten Core …</div><div class="pv-err" id="pv-err"></div>
</div>
</aside>
</div>
<div class="modal-bg" id="m-pub"><div class="modal">
<h2>Änderungen prüfen &amp; veröffentlichen</h2><div id="pub-body"></div>
<div class="acts"><button class="btn" onclick="modal('m-pub',false)">Abbrechen</button><button class="btn cyan" id="pub-go" onclick="publish()">Als neue Version veröffentlichen</button></div>
</div></div>
<div class="modal-bg" id="m-diff"><div class="modal"><h2 id="diff-title">Diff</h2><div id="diff-body"></div><div class="acts"><button class="btn" onclick="modal('m-diff',false)">Schließen</button></div></div></div>
{% endblock %}
{% block scripts %}
<script>const TID={{ t.id|tojson }};</script>
<script src="/static/tenant.js"></script>
{% endblock %}

32
app/templating.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi.templating import Jinja2Templates
from app.settings import APP_NAME, ROOT
templates = Jinja2Templates(directory=str(ROOT / "app" / "templates"))
BERLIN = ZoneInfo("Europe/Berlin")
def dt(value: datetime | None, fmt: str = "%d.%m.%Y %H:%M") -> str:
if not value:
return "–"
if value.tzinfo is None:
from datetime import timezone
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(BERLIN).strftime(fmt)
templates.env.filters["dt"] = dt
templates.env.globals["APP_NAME"] = APP_NAME
def _live() -> bool:
from app.services import deploy
return deploy.live_enabled()
templates.env.globals["deploy_live"] = _live