234 lines
8.3 KiB
Python
234 lines
8.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
boehmitools – Host-Anwendung.
|
||
|
||
Startet das Dashboard, lädt alle Plugins aus plugins/ und hängt sie unter
|
||
ihrem Mount-Pfad ein. Danach verhält sich jedes Tool so, als liefe es allein:
|
||
eigene Routen, eigenes Backend, eigener Datenordner – nur eben unter einem
|
||
gemeinsamen Dach und mit einheitlicher Oberfläche.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from starlette.routing import Mount
|
||
|
||
from . import importer, registry, settings as settings_module
|
||
from .jobs import JobManager
|
||
from .registry import PluginContext
|
||
from .security import basic_auth_middleware
|
||
from .settings import Settings
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||
PLUGINS_DIR = Path(
|
||
os.environ.get("BOEHMITOOLS_PLUGINS", BASE_DIR / "plugins")
|
||
).resolve()
|
||
SHARED_DIR = BASE_DIR / "shared"
|
||
STATIC_DIR = BASE_DIR / "core" / "static"
|
||
DATA_DIR = Path(os.environ.get("BOEHMITOOLS_DATA", BASE_DIR / "data")).resolve()
|
||
|
||
VERSION = "1.0.0"
|
||
|
||
|
||
def create_app() -> FastAPI:
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
app_settings = Settings.load(DATA_DIR / "settings.json")
|
||
jobs = JobManager()
|
||
|
||
app = FastAPI(
|
||
title="boehmitools",
|
||
version=VERSION,
|
||
docs_url="/api/docs",
|
||
redoc_url=None,
|
||
)
|
||
app.middleware("http")(basic_auth_middleware)
|
||
|
||
app.state.settings = app_settings
|
||
app.state.jobs = jobs
|
||
app.state.plugins = []
|
||
|
||
# ------------------------------------------------------------- Plugins
|
||
loaded: list[registry.LoadedPlugin] = []
|
||
|
||
def mount_plugins(*, reload: bool = False) -> dict[str, Any]:
|
||
"""
|
||
Lädt alle Plugins aus plugins/ und hängt sie ein. Bei ``reload=True``
|
||
werden vorher die bisherigen Plugin-Mounts entfernt und die zugehörigen
|
||
Module aus dem Cache geworfen, damit geänderter oder neuer Code (und neu
|
||
hinzugekommene bzw. entfernte Plugins) ohne Neustart wirksam werden.
|
||
|
||
Statik (HTML/CSS/JS) wird ohnehin bei jedem Aufruf frisch von der Platte
|
||
gelesen; dafür genügt ein Neuladen im Browser. Kernänderungen unter
|
||
core/ erfordern weiterhin einen echten Neustart.
|
||
"""
|
||
if reload:
|
||
# Bisherige Plugin-Mounts aus dem Router nehmen (in place, damit der
|
||
# Router dieselbe Liste weiterbenutzt).
|
||
app.router.routes[:] = [
|
||
r for r in app.router.routes
|
||
if not (isinstance(r, Mount) and (r.name or "").startswith("plugin_"))
|
||
]
|
||
for old in loaded:
|
||
registry.unload(old.meta)
|
||
|
||
neu: list[registry.LoadedPlugin] = []
|
||
for meta in registry.discover(PLUGINS_DIR):
|
||
plugin_data = DATA_DIR / meta.id
|
||
plugin_data.mkdir(parents=True, exist_ok=True)
|
||
ctx = PluginContext(
|
||
meta=meta,
|
||
settings=app_settings,
|
||
jobs=jobs,
|
||
data_dir=plugin_data,
|
||
shared_dir=SHARED_DIR,
|
||
)
|
||
result = registry.load(meta, ctx)
|
||
neu.append(result)
|
||
if result.ok:
|
||
app.mount(meta.mount, result.app, name=f"plugin_{meta.id}")
|
||
print(f"[plugins] {meta.id:<24} → {meta.mount}")
|
||
else:
|
||
print(f"[plugins] {meta.id:<24} → FEHLER: {result.error}")
|
||
|
||
loaded[:] = neu
|
||
app.state.plugins = loaded
|
||
return {
|
||
"gesamt": len(loaded),
|
||
"geladen": sum(1 for p in loaded if p.ok),
|
||
"fehler": [
|
||
{"id": p.meta.id, "error": p.error} for p in loaded if not p.ok
|
||
],
|
||
}
|
||
|
||
mount_plugins()
|
||
|
||
def plugin_payload() -> list[dict[str, Any]]:
|
||
return [
|
||
{**p.meta.public(), "ok": p.ok, "error": p.error}
|
||
for p in loaded
|
||
]
|
||
|
||
# -------------------------------------------------------------- Statik
|
||
app.mount("/shared", StaticFiles(directory=SHARED_DIR), name="shared")
|
||
|
||
# -------------------------------------------------------------- Seiten
|
||
@app.get("/", include_in_schema=False)
|
||
def dashboard():
|
||
return FileResponse(STATIC_DIR / "dashboard.html")
|
||
|
||
@app.get("/settings", include_in_schema=False)
|
||
def settings_page():
|
||
return FileResponse(STATIC_DIR / "settings.html")
|
||
|
||
@app.get("/jobs", include_in_schema=False)
|
||
def jobs_page():
|
||
return FileResponse(STATIC_DIR / "jobs.html")
|
||
|
||
# ----------------------------------------------------------------- API
|
||
@app.get("/api/plugins")
|
||
def api_plugins() -> dict[str, Any]:
|
||
return {"plugins": plugin_payload(), "version": VERSION}
|
||
|
||
@app.post("/api/plugins/reload")
|
||
def api_plugins_reload() -> dict[str, Any]:
|
||
"""Plugins ohne Neustart neu einlesen und einhängen."""
|
||
bericht = mount_plugins(reload=True)
|
||
return {
|
||
"ok": True,
|
||
"plugins": plugin_payload(),
|
||
"version": VERSION,
|
||
**bericht,
|
||
}
|
||
|
||
@app.post("/api/plugins/import")
|
||
async def api_plugins_import(
|
||
file: UploadFile = File(...),
|
||
force: bool = Form(False),
|
||
pruefen_only: bool = Form(False),
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Ein Plugin als ZIP hochladen.
|
||
|
||
Erkannt wird das Plugin an der ``id`` aus seiner ``plugin.json``. Ist
|
||
diese id bereits vorhanden, wird genau jener Ordner ergänzt und
|
||
überschrieben — niemals gelöscht. Der Datenordner (``data/<id>/``)
|
||
liegt außerhalb und bleibt in jedem Fall unberührt.
|
||
"""
|
||
daten = await file.read()
|
||
try:
|
||
if pruefen_only:
|
||
return {"ok": True, "vorschau": importer.pruefe(daten, PLUGINS_DIR)}
|
||
bericht = importer.importiere(
|
||
daten, PLUGINS_DIR, DATA_DIR / "_plugin-sicherungen", force=force)
|
||
except importer.ImportFehler as exc:
|
||
raise HTTPException(400, str(exc))
|
||
|
||
# Frisch eingelesen, damit das Plugin sofort nutzbar ist.
|
||
neu_geladen = mount_plugins(reload=True)
|
||
eigen = next((p for p in loaded if p.meta.id == bericht["id"]), None)
|
||
# Achtung: der Reload-Bericht führt selbst ein Feld „geladen“ (Anzahl).
|
||
# Der Zustand DIESES Plugins heißt deshalb „aktiv“.
|
||
bericht["aktiv"] = bool(eigen and eigen.ok)
|
||
bericht["ladefehler"] = None if not eigen or eigen.ok else eigen.error
|
||
return {**neu_geladen, **bericht, "plugins": plugin_payload()}
|
||
|
||
@app.get("/api/health")
|
||
def api_health() -> dict[str, Any]:
|
||
status = app_settings.status()
|
||
return {
|
||
"status": "ok",
|
||
"version": VERSION,
|
||
"plugins": {p.meta.id: ("ok" if p.ok else "error") for p in loaded},
|
||
"tandoor_configured": status["tandoor"],
|
||
"openai_configured": status["openai"],
|
||
"authentication_enabled": bool(os.environ.get("APP_PASSWORD")),
|
||
"jobs_running": len(jobs.running()),
|
||
}
|
||
|
||
@app.get("/api/settings")
|
||
def api_settings_get() -> dict[str, Any]:
|
||
return {
|
||
"schema": settings_module.schema(),
|
||
"values": app_settings.public(),
|
||
"status": app_settings.status(),
|
||
}
|
||
|
||
@app.post("/api/settings")
|
||
async def api_settings_post(request: Request) -> dict[str, Any]:
|
||
payload = await request.json()
|
||
if not isinstance(payload, dict):
|
||
raise HTTPException(400, "Ungültige Daten.")
|
||
app_settings.update(payload)
|
||
return {
|
||
"ok": True,
|
||
"values": app_settings.public(),
|
||
"status": app_settings.status(),
|
||
}
|
||
|
||
@app.get("/api/jobs")
|
||
def api_jobs_all() -> dict[str, Any]:
|
||
names = {p.meta.id: p.meta.name for p in loaded}
|
||
return {
|
||
"jobs": [
|
||
{**job.info(), "plugin_name": names.get(job.plugin, job.plugin)}
|
||
for job in jobs.list()
|
||
]
|
||
}
|
||
|
||
@app.exception_handler(404)
|
||
async def not_found(request: Request, exc):
|
||
if request.url.path.startswith("/api/"):
|
||
return JSONResponse({"detail": "Nicht gefunden."}, status_code=404)
|
||
return FileResponse(STATIC_DIR / "404.html", status_code=404)
|
||
|
||
return app
|
||
|
||
|
||
app = create_app()
|