feat(admin): Admin-Panel Phase 2 — Wörterbuch-CRUD, Live-Log, DB-Export, Metriken-Balken
- Aussprache-Lexikon (de/en) komplett im Browser editierbar: Einträge hinzufügen/löschen in Sektionen Abkürzungen/Einheiten/Begriffe; LRU-Cache wird nach jedem Schreibvorgang automatisch geleert. - Live-Log-Tab: WebSocket auf /api/admin/log streamt journalctl des voice-assistant.service live im Terminal-Style ins Admin-Panel. - DB-Export: SQLite-Datenbank über /api/admin/db-export herunterladen. - Metriken-Tab: CSS-Balkendiagramm (Anfragen je Nutzer) ergänzt. - Dokumentation: §7.5 Admin-Web-Panel, B.5 API-Endpunkte, Sachregister. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b38992cf28
commit
366d71ba26
4 changed files with 424 additions and 11 deletions
133
app/api/admin.py
133
app/api/admin.py
|
|
@ -1,4 +1,10 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
||||
from app.config import settings
|
||||
|
|
@ -7,6 +13,16 @@ from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUp
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
|
||||
_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"}
|
||||
_ALLOWED_LANGS = {"de", "en"}
|
||||
|
||||
|
||||
class PronunciationEntry(BaseModel):
|
||||
section: str
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/admin/request-headers")
|
||||
async def request_headers(request: Request, key: str | None = None):
|
||||
|
|
@ -152,3 +168,118 @@ async def get_user_usage(user_id: str):
|
|||
async def get_all_usage():
|
||||
"""Aggregierte Nutzungsstatistik aller Nutzer."""
|
||||
return get_store().get_all_usage()
|
||||
|
||||
|
||||
# ── Datenbank-Export ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/db-export", dependencies=[Depends(require_admin)])
|
||||
async def export_db():
|
||||
"""Laed die SQLite-Datenbank als Datei herunter (Backup)."""
|
||||
path = Path(settings.db_path)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Datenbank nicht gefunden.")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/octet-stream",
|
||||
filename="voice-assistant.db",
|
||||
headers={"Content-Disposition": 'attachment; filename="voice-assistant.db"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
|
||||
|
||||
def _read_pronunciation(lang: str) -> dict:
|
||||
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
||||
if not path.exists():
|
||||
return {"abbreviations": {}, "units": {}, "terms": {}}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return {
|
||||
"abbreviations": dict(data.get("abbreviations") or {}),
|
||||
"units": dict(data.get("units") or {}),
|
||||
"terms": dict(data.get("terms") or {}),
|
||||
}
|
||||
|
||||
|
||||
def _write_pronunciation(lang: str, data: dict) -> None:
|
||||
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
||||
path.write_text(
|
||||
yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# LRU-Cache des Normalizers invalidieren, damit die Aenderung sofort greift.
|
||||
from app.pipeline.tts_normalizer import _load_lexicon
|
||||
_load_lexicon.cache_clear()
|
||||
|
||||
|
||||
@router.get("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
||||
async def get_pronunciation(lang: str):
|
||||
"""Gibt alle Eintraege des Aussprache-Lexikons zurueck."""
|
||||
if lang not in _ALLOWED_LANGS:
|
||||
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
||||
return _read_pronunciation(lang)
|
||||
|
||||
|
||||
@router.post("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
||||
async def add_pronunciation(lang: str, entry: PronunciationEntry):
|
||||
"""Fuegt einen Eintrag zum Aussprache-Lexikon hinzu oder ueberschreibt ihn."""
|
||||
if lang not in _ALLOWED_LANGS:
|
||||
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
||||
if entry.section not in _ALLOWED_SECTIONS:
|
||||
raise HTTPException(status_code=400, detail=f"Section muss eine von {_ALLOWED_SECTIONS} sein.")
|
||||
if not entry.key.strip() or not entry.value.strip():
|
||||
raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.")
|
||||
data = _read_pronunciation(lang)
|
||||
data[entry.section][entry.key.strip()] = entry.value.strip()
|
||||
_write_pronunciation(lang, data)
|
||||
return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()}
|
||||
|
||||
|
||||
@router.delete("/admin/pronunciation/{lang}/{section}/{key:path}", dependencies=[Depends(require_admin)])
|
||||
async def delete_pronunciation(lang: str, section: str, key: str):
|
||||
"""Loescht einen Eintrag aus dem Aussprache-Lexikon."""
|
||||
if lang not in _ALLOWED_LANGS:
|
||||
raise HTTPException(status_code=400, detail="Unbekannte Sprache.")
|
||||
if section not in _ALLOWED_SECTIONS:
|
||||
raise HTTPException(status_code=400, detail="Unbekannte Section.")
|
||||
data = _read_pronunciation(lang)
|
||||
if key not in data[section]:
|
||||
raise HTTPException(status_code=404, detail=f"Eintrag '{key}' nicht gefunden.")
|
||||
del data[section][key]
|
||||
_write_pronunciation(lang, data)
|
||||
return {"deleted": key}
|
||||
|
||||
|
||||
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
|
||||
|
||||
@router.websocket("/admin/log")
|
||||
async def admin_log_ws(websocket: WebSocket, key: str | None = None):
|
||||
"""Streamt den systemd-Journal-Log des Voice-Assistant-Service live."""
|
||||
# Auth: Admin-Key als Query-Param ODER SSO-Identitaet via Cookie/Header.
|
||||
from app.auth import authenticate, _bearer_token
|
||||
client_host = websocket.client.host if websocket.client else ""
|
||||
token = _bearer_token(websocket.headers.get("authorization")) or key
|
||||
user = authenticate(websocket.headers, client_host, token)
|
||||
if not is_admin_user(user):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"journalctl", "--user", "-f", "-u", "voice-assistant.service",
|
||||
"-n", "100", "--no-pager", "-o", "short",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
await websocket.send_text(line.decode("utf-8", "replace").rstrip())
|
||||
except (WebSocketDisconnect, Exception):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
171
app/web/app.js
171
app/web/app.js
|
|
@ -383,6 +383,8 @@ function switchAdminTab(name) {
|
|||
if (name === "emergency") loadEmergencyEvents();
|
||||
if (name === "status") loadStatus();
|
||||
if (name === "metrics") loadMetrics();
|
||||
if (name === "words") loadWoerterbuch();
|
||||
if (name === "log") { /* User muss manuell verbinden */ }
|
||||
}
|
||||
|
||||
document.querySelectorAll(".admin-tab").forEach((btn) =>
|
||||
|
|
@ -746,6 +748,16 @@ async function loadStatus() {
|
|||
${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Datenbank-Export -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h3 class="font-semibold text-sm mb-1">Datenbank-Backup</h3>
|
||||
<p class="text-xs text-slate-400 mb-3">SQLite-Datenbank als Datei herunterladen (Backup / Migration).</p>
|
||||
<a href="/api/admin/db-export" download="voice-assistant.db"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-xs px-4 py-2 font-medium transition-colors">
|
||||
⬇ voice-assistant.db herunterladen
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
|
||||
|
|
@ -763,6 +775,19 @@ async function loadMetrics() {
|
|||
if (!data.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>'; return; }
|
||||
|
||||
const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0);
|
||||
const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1);
|
||||
|
||||
const bars = data.map((u) => {
|
||||
const pct = Math.round(((u.total_requests || 0) / maxReq) * 100);
|
||||
return `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-28 shrink-0 text-xs text-slate-600 dark:text-slate-300 truncate">${escHtml(u.display_name)}</span>
|
||||
<div class="flex-1 h-4 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-500 dark:bg-blue-600 rounded-full" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="w-10 text-right text-xs font-mono text-slate-500 dark:text-slate-400">${u.total_requests ?? 0}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4">
|
||||
|
|
@ -794,5 +819,151 @@ async function loadMetrics() {
|
|||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h3 class="font-semibold text-sm mb-4">Anfragen je Nutzer</h3>
|
||||
<div class="space-y-2.5">${bars}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: WÖRTERBUCH
|
||||
// ════════════════════════════════════════
|
||||
async function loadWoerterbuch() {
|
||||
const lang = ($("#words-lang") || {}).value || "de";
|
||||
const container = $("#words-content");
|
||||
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
const data = await adminFetch(`/api/admin/pronunciation/${lang}`);
|
||||
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
|
||||
container.innerHTML = "";
|
||||
const sections = [
|
||||
["abbreviations", "Abkürzungen"],
|
||||
["units", "Einheiten"],
|
||||
["terms", "Begriffe / Aussprache"],
|
||||
];
|
||||
for (const [section, label] of sections) {
|
||||
container.appendChild(buildWortSection(label, section, data[section] || {}, lang));
|
||||
}
|
||||
}
|
||||
|
||||
function buildWortSection(title, section, entries, lang) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
|
||||
|
||||
const rows = Object.entries(entries).map(([k, v]) => `
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group">
|
||||
<td class="px-3 py-2 text-xs font-mono text-slate-700 dark:text-slate-300">${escHtml(k)}</td>
|
||||
<td class="px-3 py-2 text-xs text-slate-400">→</td>
|
||||
<td class="px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium">${escHtml(v)}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button class="del-btn text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors text-xs opacity-0 group-hover:opacity-100"
|
||||
data-key="${escHtml(k)}">✕</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
|
||||
wrap.innerHTML = `
|
||||
<h4 class="font-semibold text-sm mb-3 flex items-center justify-between">
|
||||
${escHtml(title)}
|
||||
<span class="text-xs font-normal text-slate-400">${Object.keys(entries).length} Einträge</span>
|
||||
</h4>
|
||||
${rows ? `
|
||||
<div class="overflow-x-auto mb-4">
|
||||
<table class="w-full">
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700/60">${rows}</tbody>
|
||||
</table>
|
||||
</div>` : '<p class="text-xs text-slate-400 italic mb-4">Keine Einträge.</p>'}
|
||||
<form class="add-form flex gap-2 flex-wrap">
|
||||
<input type="text" placeholder="Schlüssel" required
|
||||
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<input type="text" placeholder="Ersetzung" required
|
||||
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 font-medium transition-colors whitespace-nowrap">+ Hinzufügen</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
wrap.querySelectorAll(".del-btn").forEach((btn) =>
|
||||
btn.addEventListener("click", async () => {
|
||||
const key = btn.dataset.key;
|
||||
if (!confirm(`Eintrag „${key}" löschen?`)) return;
|
||||
const res = await adminFetch(
|
||||
`/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(key)}`, "DELETE"
|
||||
);
|
||||
if (res !== null) btn.closest("tr").remove();
|
||||
})
|
||||
);
|
||||
|
||||
wrap.querySelector(".add-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const [keyInp, valInp] = e.target.querySelectorAll("input");
|
||||
const key = keyInp.value.trim();
|
||||
const value = valInp.value.trim();
|
||||
if (!key || !value) return;
|
||||
const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value });
|
||||
if (res !== null) { keyInp.value = ""; valInp.value = ""; loadWoerterbuch(); }
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
$("#load-words").addEventListener("click", loadWoerterbuch);
|
||||
$("#words-lang").addEventListener("change", loadWoerterbuch);
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: LOG
|
||||
// ════════════════════════════════════════
|
||||
let _logWs = null;
|
||||
|
||||
function connectLog() {
|
||||
if (_logWs) return;
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
_logWs = new WebSocket(`${proto}://${location.host}/api/admin/log`);
|
||||
const output = $("#log-output");
|
||||
const logStat = $("#log-status");
|
||||
const btnConn = $("#log-connect");
|
||||
const btnDisc = $("#log-disconnect");
|
||||
|
||||
btnConn.disabled = true;
|
||||
btnDisc.disabled = false;
|
||||
btnDisc.classList.remove("opacity-40");
|
||||
logStat.textContent = "verbinde …";
|
||||
logStat.className = "text-xs text-amber-400";
|
||||
|
||||
_logWs.onopen = () => {
|
||||
logStat.textContent = "verbunden";
|
||||
logStat.className = "text-xs text-emerald-400";
|
||||
output.innerHTML = "";
|
||||
};
|
||||
|
||||
_logWs.onmessage = (e) => {
|
||||
const line = document.createElement("div");
|
||||
line.textContent = e.data;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
};
|
||||
|
||||
_logWs.onerror = () => {
|
||||
logStat.textContent = "Verbindungsfehler";
|
||||
logStat.className = "text-xs text-red-400";
|
||||
};
|
||||
|
||||
_logWs.onclose = () => {
|
||||
_logWs = null;
|
||||
btnConn.disabled = false;
|
||||
btnDisc.disabled = true;
|
||||
btnDisc.classList.add("opacity-40");
|
||||
if (!logStat.className.includes("red")) {
|
||||
logStat.textContent = "getrennt";
|
||||
logStat.className = "text-xs text-slate-400";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function disconnectLog() {
|
||||
if (_logWs) { _logWs.close(); }
|
||||
}
|
||||
|
||||
$("#log-connect").addEventListener("click", connectLog);
|
||||
$("#log-disconnect").addEventListener("click", disconnectLog);
|
||||
$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; });
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@
|
|||
<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="emergency">🚨 Notfälle</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="status">📡 Status</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="log">📋 Log</button>
|
||||
</nav>
|
||||
|
||||
<!-- Tab Content Container -->
|
||||
|
|
@ -124,6 +126,44 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab: Wörterbuch ── -->
|
||||
<div id="admin-tab-words" class="hidden h-full overflow-y-auto p-4">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<h3 class="font-semibold text-base">Aussprache-Lexikon</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="words-lang"
|
||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="de">Deutsch (de)</option>
|
||||
<option value="en">Englisch (en)</option>
|
||||
</select>
|
||||
<button id="load-words"
|
||||
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>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-4">Wirkt nur bei lokalem TTS (Piper, Stufe „full"). <strong>Abkürzungen</strong> ersetzen ganze Token. <strong>Einheiten</strong> wirken nur direkt nach einer Zahl. <strong>Begriffe</strong> für Eigennamen, Fachwörter, Aussprache-Korrekturen.</p>
|
||||
<div id="words-content" class="space-y-6">
|
||||
<p class="text-sm text-slate-400">lade …</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab: Log ── -->
|
||||
<div id="admin-tab-log" class="hidden h-full flex flex-col">
|
||||
<div class="shrink-0 flex items-center gap-3 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/60">
|
||||
<button id="log-connect"
|
||||
class="rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-xs px-3 py-1.5 font-medium transition-colors">▶ Verbinden</button>
|
||||
<button id="log-disconnect" disabled
|
||||
class="rounded-lg border border-slate-300 dark:border-slate-600 text-xs px-3 py-1.5 opacity-40 transition-colors">■ Trennen</button>
|
||||
<span id="log-status" class="text-xs text-slate-400">getrennt</span>
|
||||
<button id="log-clear" class="ml-auto text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">Leeren</button>
|
||||
</div>
|
||||
<div id="log-output"
|
||||
class="flex-1 overflow-y-auto bg-slate-950 text-emerald-300 font-mono text-xs p-4 leading-relaxed whitespace-pre-wrap">
|
||||
<span class="text-slate-500">— Verbinden um Log zu starten —</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /Tab Content Container -->
|
||||
</div><!-- /Admin Panel -->
|
||||
|
||||
|
|
@ -162,6 +202,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>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js?v=8"></script>
|
||||
<script src="/app.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue