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:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
129
app/agent/backends.py
Normal file
129
app/agent/backends.py
Normal 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
175
app/agent/loop.py
Normal 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
24
app/agent/testrun.py
Normal 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
110
app/agent/tools.py
Normal 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
106
app/agent/workspace.py
Normal 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
162
app/auth.py
Normal 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
42
app/db.py
Normal 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
19
app/deps.py
Normal 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
42
app/main.py
Normal 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
149
app/models.py
Normal 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
0
app/routers/__init__.py
Normal file
770
app/routers/api.py
Normal file
770
app/routers/api.py
Normal 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
107
app/routers/pages.py
Normal 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
0
app/services/__init__.py
Normal file
125
app/services/contract.py
Normal file
125
app/services/contract.py
Normal 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
15
app/services/crypto.py
Normal 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
44
app/services/defaults.py
Normal 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
314
app/services/deploy.py
Normal 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
35
app/services/guard.py
Normal 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
151
app/services/importer.py
Normal 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
121
app/services/llm.py
Normal 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
159
app/services/mittwald.py
Normal 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
312
app/services/onyva_login.py
Normal 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
114
app/services/preflight.py
Normal 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
109
app/services/preview.py
Normal 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)
|
||||
65
app/services/preview_worker.py
Normal file
65
app/services/preview_worker.py
Normal 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
254
app/services/releases.py
Normal 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
76
app/services/samples.py
Normal 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
54
app/services/seed.py
Normal 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
61
app/services/status.py
Normal 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
225
app/services/store.py
Normal 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
95
app/services/tenants.py
Normal 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
33
app/settings.py
Normal 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
170
app/static/admin.css
Normal 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
598
app/static/tenant.js
Normal 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 & 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 & 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 & 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- & 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 & 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 & 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
13
app/templates/audit.html
Normal 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
44
app/templates/base.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"'}[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
90
app/templates/core.html
Normal 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 %}
|
||||
123
app/templates/dashboard.html
Normal file
123
app/templates/dashboard.html
Normal 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 & 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 %}
|
||||
31
app/templates/deployments.html
Normal file
31
app/templates/deployments.html
Normal 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
133
app/templates/settings.html
Normal 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 & 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) & 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 & 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
51
app/templates/tenant.html
Normal 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 & 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
32
app/templating.py
Normal 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
|
||||
Reference in New Issue
Block a user