feat(admin): Phase 3 — Laufzeit-Konfiguration ohne Server-Neustart
Neue Tabelle config_overrides in SQLite; RuntimeSettings-Wrapper liest
überschreibbare Felder mit 30s TTL-Cache aus der DB und fällt auf
.env-Werte zurück. dependencies.py und quota.py nutzen runtime_settings
als Default statt des statischen Settings-Singletons.
16 Felder überschreibbar: STT/LLM/TTS-Provider, LLM-Modelle, Stimmen,
Systemprompt, Temperatur, Tageskontingent, Normalisierung u.a.
Backend: GET/PUT/DELETE /api/admin/config/{key}
Admin-UI: neuer Tab "⚙ Einstellungen" mit Inline-Edit und Reset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
296138ac1e
commit
5d022aaf63
7 changed files with 285 additions and 9 deletions
|
|
@ -9,6 +9,7 @@ from pydantic import BaseModel
|
||||||
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.dependencies import get_store
|
from app.dependencies import get_store
|
||||||
|
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
|
||||||
from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
|
from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -249,6 +250,55 @@ async def delete_pronunciation(lang: str, section: str, key: str):
|
||||||
return {"deleted": key}
|
return {"deleted": key}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Laufzeit-Konfiguration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ConfigValue(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/config", dependencies=[Depends(require_admin)])
|
||||||
|
async def get_runtime_config():
|
||||||
|
"""Gibt alle überschreibbaren Einstellungen mit aktuellem Wert zurück."""
|
||||||
|
overrides = get_store().get_config_overrides()
|
||||||
|
result = []
|
||||||
|
for key, (label, type_str, hint) in RUNTIME_SETTABLE.items():
|
||||||
|
base_val = getattr(settings, key, None)
|
||||||
|
effective_val = getattr(runtime_settings, key, base_val)
|
||||||
|
result.append({
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"type": type_str,
|
||||||
|
"hint": hint,
|
||||||
|
"base_value": str(base_val) if base_val is not None else "",
|
||||||
|
"override_value": overrides.get(key),
|
||||||
|
"effective_value": str(effective_val) if effective_val is not None else "",
|
||||||
|
"is_overridden": key in overrides,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)])
|
||||||
|
async def set_runtime_config(key: str, body: ConfigValue):
|
||||||
|
"""Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig)."""
|
||||||
|
if key not in RUNTIME_SETTABLE:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
||||||
|
get_store().set_config_override(key, body.value.strip())
|
||||||
|
invalidate_cache()
|
||||||
|
return {"key": key, "value": body.value.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)])
|
||||||
|
async def delete_runtime_config(key: str):
|
||||||
|
"""Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück)."""
|
||||||
|
if key not in RUNTIME_SETTABLE:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
||||||
|
deleted = get_store().delete_config_override(key)
|
||||||
|
invalidate_cache()
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.")
|
||||||
|
return {"deleted": key}
|
||||||
|
|
||||||
|
|
||||||
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
|
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
|
||||||
|
|
||||||
@router.websocket("/admin/log")
|
@router.websocket("/admin/log")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.config import Settings, settings
|
from app.config import Settings, settings
|
||||||
|
from app.runtime_config import runtime_settings
|
||||||
from app.errors import UnknownComponentError
|
from app.errors import UnknownComponentError
|
||||||
from app.audio.router import AudioRouter
|
from app.audio.router import AudioRouter
|
||||||
from app.audio.endpoints.input.local_default import LocalDefaultInput
|
from app.audio.endpoints.input.local_default import LocalDefaultInput
|
||||||
|
|
@ -93,15 +94,18 @@ def _from_registry(registry: dict, name: str, kind: str, cfg: Settings):
|
||||||
return factory(cfg)
|
return factory(cfg)
|
||||||
|
|
||||||
|
|
||||||
def get_stt_provider(name: str | None = None, cfg: Settings = settings):
|
def get_stt_provider(name: str | None = None, cfg=None):
|
||||||
|
cfg = cfg or runtime_settings
|
||||||
return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg)
|
return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg)
|
||||||
|
|
||||||
|
|
||||||
def get_llm_provider(name: str | None = None, cfg: Settings = settings):
|
def get_llm_provider(name: str | None = None, cfg=None):
|
||||||
|
cfg = cfg or runtime_settings
|
||||||
return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg)
|
return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg)
|
||||||
|
|
||||||
|
|
||||||
def get_tts_provider(name: str | None = None, cfg: Settings = settings):
|
def get_tts_provider(name: str | None = None, cfg=None):
|
||||||
|
cfg = cfg or runtime_settings
|
||||||
return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg)
|
return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -190,8 +194,9 @@ def resolve_route(
|
||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
overrides: dict | None = None,
|
overrides: dict | None = None,
|
||||||
cfg: Settings = settings,
|
cfg=None,
|
||||||
) -> ResolvedRoute:
|
) -> ResolvedRoute:
|
||||||
|
cfg = cfg or runtime_settings
|
||||||
"""Loest die effektive Route auf.
|
"""Loest die effektive Route auf.
|
||||||
|
|
||||||
Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request.
|
Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request.
|
||||||
|
|
@ -247,7 +252,8 @@ def _resolve_normalize_level(tts_provider: str, cfg: Settings) -> str:
|
||||||
return level
|
return level
|
||||||
|
|
||||||
|
|
||||||
def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator:
|
def build_orchestrator(route: ResolvedRoute, cfg=None) -> Orchestrator:
|
||||||
|
cfg = cfg or runtime_settings
|
||||||
return Orchestrator(
|
return Orchestrator(
|
||||||
stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg),
|
stt=_provider_chain(STT_REGISTRY, route.stt_provider, cfg.stt_fallback, "stt", cfg),
|
||||||
llm=_provider_chain(LLM_REGISTRY, route.llm_provider, cfg.llm_fallback, "llm", cfg),
|
llm=_provider_chain(LLM_REGISTRY, route.llm_provider, cfg.llm_fallback, "llm", cfg),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ Limit aus Settings (`daily_request_limit`), pro Nutzer ueber `prefs.daily_reques
|
||||||
ueberschreibbar. 0 bedeutet unbegrenzt.
|
ueberschreibbar. 0 bedeutet unbegrenzt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.config import settings, Settings
|
|
||||||
from app.metrics import metrics
|
from app.metrics import metrics
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,7 +14,10 @@ class QuotaExceededError(Exception):
|
||||||
super().__init__(f"Daily request limit reached ({count}/{limit})")
|
super().__init__(f"Daily request limit reached ({count}/{limit})")
|
||||||
|
|
||||||
|
|
||||||
def effective_limit(user, cfg: Settings = settings) -> int:
|
def effective_limit(user, cfg=None) -> int:
|
||||||
|
if cfg is None:
|
||||||
|
from app.runtime_config import runtime_settings
|
||||||
|
cfg = runtime_settings
|
||||||
pref = user.prefs.get("daily_request_limit") if user and user.prefs else None
|
pref = user.prefs.get("daily_request_limit") if user and user.prefs else None
|
||||||
if pref is not None:
|
if pref is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -25,7 +27,7 @@ def effective_limit(user, cfg: Settings = settings) -> int:
|
||||||
return cfg.daily_request_limit
|
return cfg.daily_request_limit
|
||||||
|
|
||||||
|
|
||||||
def enforce_quota(user, store, cfg: Settings = settings) -> None:
|
def enforce_quota(user, store, cfg=None) -> None:
|
||||||
"""Wirft QuotaExceededError, wenn das Tageslimit erreicht ist."""
|
"""Wirft QuotaExceededError, wenn das Tageslimit erreicht ist."""
|
||||||
limit = effective_limit(user, cfg)
|
limit = effective_limit(user, cfg)
|
||||||
if limit and limit > 0:
|
if limit and limit > 0:
|
||||||
|
|
|
||||||
97
app/runtime_config.py
Normal file
97
app/runtime_config.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Laufzeit-Konfigurationsüberschreibungen aus der Datenbank.
|
||||||
|
|
||||||
|
Einzelne Settings-Felder können zur Laufzeit via Admin-UI geändert werden,
|
||||||
|
ohne den Server neu zu starten. Die Werte liegen in der Tabelle
|
||||||
|
`config_overrides` und werden mit 30s TTL gecacht.
|
||||||
|
|
||||||
|
Nur Felder aus RUNTIME_SETTABLE sind überschreibbar — alle anderen
|
||||||
|
kommen weiterhin aus .env / TOML / pydantic-settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import Settings, settings as _base
|
||||||
|
|
||||||
|
# (label, type_str, hint)
|
||||||
|
RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = {
|
||||||
|
"default_stt_provider": ("STT-Provider (Standard)", "str", "openrouter | faster-whisper"),
|
||||||
|
"default_llm_provider": ("LLM-Provider (Standard)", "str", "openrouter | local-openai-compatible"),
|
||||||
|
"default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox"),
|
||||||
|
"default_language": ("Sprache (Standard)", "str", "de | en | …"),
|
||||||
|
"openrouter_llm_model": ("LLM-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-lite"),
|
||||||
|
"openrouter_tts_model": ("TTS-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-tts-preview"),
|
||||||
|
"openrouter_tts_voice": ("TTS-Stimme (OpenRouter)", "str", "z.B. Zephyr, Puck, Kore"),
|
||||||
|
"piper_voice": ("TTS-Stimme (piper)", "str", "z.B. de_DE-thorsten-high"),
|
||||||
|
"local_llm_system_prompt": ("Systemprompt (lokal)", "str", "Freier Text"),
|
||||||
|
"local_llm_temperature": ("Temperatur (lokal)", "float", "0.0–2.0"),
|
||||||
|
"local_llm_max_tokens": ("Max. Tokens (lokal)", "int", "0 = kein Limit"),
|
||||||
|
"tts_normalize_level": ("TTS-Normalisierung", "str", "auto | full | light | off"),
|
||||||
|
"audio_stream_default": ("Audio-Streaming Standard", "bool", "true | false"),
|
||||||
|
"memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"),
|
||||||
|
"memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"),
|
||||||
|
"daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_TTL = 30.0
|
||||||
|
_cache: dict[str, str] = {}
|
||||||
|
_cache_time: float = 0.0
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(key: str, raw: str) -> Any:
|
||||||
|
_, type_str, _ = RUNTIME_SETTABLE[key]
|
||||||
|
try:
|
||||||
|
if type_str == "bool":
|
||||||
|
return raw.strip().lower() in ("1", "true", "yes")
|
||||||
|
if type_str == "int":
|
||||||
|
return int(raw)
|
||||||
|
if type_str == "float":
|
||||||
|
return float(raw)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cache() -> dict[str, str]:
|
||||||
|
global _cache, _cache_time
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
if now - _cache_time < _TTL:
|
||||||
|
return _cache
|
||||||
|
try:
|
||||||
|
from app.dependencies import get_store
|
||||||
|
_cache = get_store().get_config_overrides()
|
||||||
|
_cache_time = now
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
global _cache_time
|
||||||
|
with _lock:
|
||||||
|
_cache_time = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSettings:
|
||||||
|
"""Wraps Settings; liest überschreibbare Felder aus der DB (30s TTL)."""
|
||||||
|
|
||||||
|
def __init__(self, base: Settings):
|
||||||
|
object.__setattr__(self, "_base", base)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name in RUNTIME_SETTABLE:
|
||||||
|
overrides = _get_cache()
|
||||||
|
if name in overrides:
|
||||||
|
return _coerce(name, overrides[name])
|
||||||
|
return getattr(object.__getattribute__(self, "_base"), name)
|
||||||
|
|
||||||
|
# Delegiere Pydantic-Metadaten ans Basis-Objekt.
|
||||||
|
@property
|
||||||
|
def model_fields(self):
|
||||||
|
return self._base.model_fields
|
||||||
|
|
||||||
|
|
||||||
|
runtime_settings = RuntimeSettings(_base)
|
||||||
32
app/store.py
32
app/store.py
|
|
@ -152,6 +152,15 @@ class Store(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_all_usage(self) -> list[dict]: ...
|
def get_all_usage(self) -> list[dict]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_config_overrides(self) -> dict[str, str]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_config_override(self, key: str, value: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_config_override(self, key: str) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
class SQLiteStore(Store):
|
class SQLiteStore(Store):
|
||||||
def __init__(self, db_path: str):
|
def __init__(self, db_path: str):
|
||||||
|
|
@ -216,6 +225,11 @@ class SQLiteStore(Store):
|
||||||
snippet TEXT NOT NULL,
|
snippet TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS config_overrides (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
# Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da).
|
# Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da).
|
||||||
|
|
@ -551,3 +565,21 @@ class SQLiteStore(Store):
|
||||||
" GROUP BY u.user_id ORDER BY total_requests DESC",
|
" GROUP BY u.user_id ORDER BY total_requests DESC",
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def get_config_overrides(self) -> dict[str, str]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute("SELECT key, value FROM config_overrides").fetchall()
|
||||||
|
return {r["key"]: r["value"] for r in rows}
|
||||||
|
|
||||||
|
def set_config_override(self, key: str, value: str) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO config_overrides(key, value, updated_at) VALUES(?,?,?)"
|
||||||
|
" ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||||||
|
(key, value, _now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_config_override(self, key: str) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM config_overrides WHERE key=?", (key,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,7 @@ function switchAdminTab(name) {
|
||||||
if (name === "metrics") loadMetrics();
|
if (name === "metrics") loadMetrics();
|
||||||
if (name === "words") loadWoerterbuch();
|
if (name === "words") loadWoerterbuch();
|
||||||
if (name === "log") { /* User muss manuell verbinden */ }
|
if (name === "log") { /* User muss manuell verbinden */ }
|
||||||
|
if (name === "settings") loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".admin-tab").forEach((btn) =>
|
document.querySelectorAll(".admin-tab").forEach((btn) =>
|
||||||
|
|
@ -968,3 +969,75 @@ function disconnectLog() {
|
||||||
$("#log-connect").addEventListener("click", connectLog);
|
$("#log-connect").addEventListener("click", connectLog);
|
||||||
$("#log-disconnect").addEventListener("click", disconnectLog);
|
$("#log-disconnect").addEventListener("click", disconnectLog);
|
||||||
$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; });
|
$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; });
|
||||||
|
|
||||||
|
// ════════════════════════════════════════
|
||||||
|
// TAB: EINSTELLUNGEN
|
||||||
|
// ════════════════════════════════════════
|
||||||
|
async function loadSettings() {
|
||||||
|
const container = $("#settings-content");
|
||||||
|
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||||
|
const data = await adminFetch("/api/admin/config");
|
||||||
|
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
|
||||||
|
container.innerHTML = "";
|
||||||
|
data.forEach((item) => container.appendChild(buildSettingRow(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSettingRow(item) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
const overridden = item.is_overridden;
|
||||||
|
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border " +
|
||||||
|
(overridden
|
||||||
|
? "border-amber-300 dark:border-amber-600"
|
||||||
|
: "border-slate-200 dark:border-slate-700") +
|
||||||
|
" p-4";
|
||||||
|
|
||||||
|
const inputId = "cfg-" + item.key;
|
||||||
|
const isLong = item.type === "str" && item.base_value.length > 60;
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="flex items-start justify-between gap-3 mb-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<label for="${inputId}" class="font-medium text-sm">${escHtml(item.label)}</label>
|
||||||
|
<span class="ml-2 text-xs font-mono text-slate-400">${escHtml(item.key)}</span>
|
||||||
|
${overridden ? '<span class="ml-2 text-xs rounded-full bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-2 py-0.5">überschrieben</span>' : ""}
|
||||||
|
</div>
|
||||||
|
${overridden ? `<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Zurücksetzen</button>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-end flex-wrap">
|
||||||
|
${isLong
|
||||||
|
? `<textarea id="${inputId}" rows="3"
|
||||||
|
class="flex-1 min-w-0 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"
|
||||||
|
>${escHtml(item.effective_value)}</textarea>`
|
||||||
|
: `<input id="${inputId}" type="text" value="${escHtml(item.effective_value)}"
|
||||||
|
class="flex-1 min-w-0 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" />`
|
||||||
|
}
|
||||||
|
<button class="save-btn rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-2 font-medium transition-colors whitespace-nowrap">Speichern</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-1.5">
|
||||||
|
<span class="font-medium">.env:</span> <span class="font-mono">${escHtml(item.base_value) || "—"}</span>
|
||||||
|
<span class="ml-3 text-slate-300 dark:text-slate-600">·</span>
|
||||||
|
<span class="ml-2">${escHtml(item.hint)}</span>
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const inp = wrap.querySelector(`#${inputId}`);
|
||||||
|
|
||||||
|
wrap.querySelector(".save-btn").addEventListener("click", async () => {
|
||||||
|
const val = inp.value ?? inp.textContent ?? "";
|
||||||
|
const res = await adminFetch(`/api/admin/config/${item.key}`, "PUT", { value: val });
|
||||||
|
if (res !== null) loadSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetBtn = wrap.querySelector(".reset-btn");
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener("click", async () => {
|
||||||
|
if (!confirm(`Override für „${item.label}" entfernen?\nDer .env-Wert gilt danach wieder.`)) return;
|
||||||
|
const res = await adminFetch(`/api/admin/config/${item.key}`, "DELETE");
|
||||||
|
if (res !== null) loadSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#load-settings").addEventListener("click", loadSettings);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="metrics">📊 Metriken</button>
|
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="metrics">📊 Metriken</button>
|
||||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="words">🔤 Wörterbuch</button>
|
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="words">🔤 Wörterbuch</button>
|
||||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="log">📋 Log</button>
|
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="log">📋 Log</button>
|
||||||
|
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="settings">⚙ Einstellungen</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Tab Content Container -->
|
<!-- Tab Content Container -->
|
||||||
|
|
@ -164,6 +165,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Tab: Einstellungen ── -->
|
||||||
|
<div id="admin-tab-settings" class="hidden h-full overflow-y-auto p-4">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||||
|
<h3 class="font-semibold text-base">Laufzeit-Einstellungen</h3>
|
||||||
|
<button id="load-settings"
|
||||||
|
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mb-4">Änderungen wirken sofort — kein Server-Neustart nötig. „Zurücksetzen" entfernt den Override und der .env-Wert gilt wieder.</p>
|
||||||
|
<div id="settings-content" class="space-y-2">
|
||||||
|
<p class="text-sm text-slate-400">lade …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /Tab Content Container -->
|
</div><!-- /Tab Content Container -->
|
||||||
</div><!-- /Admin Panel -->
|
</div><!-- /Admin Panel -->
|
||||||
|
|
||||||
|
|
@ -202,6 +218,6 @@
|
||||||
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
|
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/app.js?v=11"></script>
|
<script src="/app.js?v=12"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue