feat(translation): manueller Übersetzungs-Button im Dashboard
Fremdsprachige Artikel ohne deutsche Fassung lassen sich jetzt manuell über das Verwaltungs-Dashboard übersetzen. Hintergrund: die automatische Übersetzung im Monitor wurde deaktiviert (TRANSLATOR_ENABLED=false), nachdem ein sehr großer Lauf den Refresh-Worker blockiert hatte. - translation_agent.py: Verwaltungs-Adaption des Monitor-Translators (Haiku-Batches), Imports auf shared.agents.claude_client umgestellt - routers/translation.py: Endpoints /api/translation/status, /run und /cancel. Der Lauf läuft als entkoppelter Hintergrund-Task, blockiert keinen Request und ist jederzeit abbrechbar - Dashboard-Karte mit Fortschrittsbalken, Aufwandsschätzung vorab und Abbrechen-Button - test_imports.py: neuen Router in den Smoke-Test aufgenommen Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -59,8 +59,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setupNavTabs();
|
||||
setupOrgDetailTabs();
|
||||
setupForms();
|
||||
setupTranslation();
|
||||
loadDashboard();
|
||||
loadDashboardTokenStats();
|
||||
loadTranslationStatus();
|
||||
loadOrgs();
|
||||
});
|
||||
|
||||
@@ -80,6 +82,7 @@ function setupNavTabs() {
|
||||
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById(`sec-${section}`).classList.add("active");
|
||||
|
||||
if (section === "dashboard") loadTranslationStatus();
|
||||
if (section === "licenses") loadExpiringLicenses();
|
||||
if (section === "audit" && typeof loadAudit === "function") loadAudit();
|
||||
});
|
||||
@@ -652,6 +655,151 @@ function formatDate(iso) {
|
||||
}
|
||||
|
||||
|
||||
// ===== Artikel-Übersetzung =====
|
||||
let translationPollTimer = null;
|
||||
|
||||
function setupTranslation() {
|
||||
const runBtn = document.getElementById("translationRunBtn");
|
||||
const cancelBtn = document.getElementById("translationCancelBtn");
|
||||
if (runBtn) runBtn.addEventListener("click", startTranslation);
|
||||
if (cancelBtn) cancelBtn.addEventListener("click", cancelTranslation);
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
seconds = Math.max(0, Math.round(seconds || 0));
|
||||
if (seconds < 60) return seconds + " Sek.";
|
||||
const min = Math.round(seconds / 60);
|
||||
if (min < 60) return min + " Min.";
|
||||
const h = Math.floor(min / 60), m = min % 60;
|
||||
return h + " Std. " + (m ? m + " Min." : "").trim();
|
||||
}
|
||||
|
||||
function renderTranslation(st) {
|
||||
const info = document.getElementById("translationInfo");
|
||||
const wrap = document.getElementById("translationProgressWrap");
|
||||
const bar = document.getElementById("translationProgressBar");
|
||||
const ptext = document.getElementById("translationProgressText");
|
||||
const runBtn = document.getElementById("translationRunBtn");
|
||||
const cancelBtn = document.getElementById("translationCancelBtn");
|
||||
if (!info || !runBtn) return;
|
||||
|
||||
if (st.running) {
|
||||
runBtn.style.display = "none";
|
||||
cancelBtn.style.display = "";
|
||||
wrap.style.display = "";
|
||||
const pct = st.total > 0 ? Math.round((st.done / st.total) * 100) : 0;
|
||||
bar.style.width = pct + "%";
|
||||
ptext.textContent = `${st.done} / ${st.total} verarbeitet, ${st.translated} übersetzt (${pct}%)`;
|
||||
info.textContent = "Übersetzung läuft…";
|
||||
return;
|
||||
}
|
||||
|
||||
runBtn.style.display = "";
|
||||
cancelBtn.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
|
||||
let resultLine = "";
|
||||
if (st.finished_at && (st.total > 0 || st.error)) {
|
||||
if (st.error) {
|
||||
resultLine = `Letzter Lauf mit Fehler beendet: ${st.error}. `;
|
||||
} else if (st.cancelled) {
|
||||
resultLine = `Letzter Lauf abgebrochen, ${st.translated} von ${st.total} Artikeln übersetzt. `;
|
||||
} else {
|
||||
resultLine = `Letzter Lauf abgeschlossen, ${st.translated} Artikel übersetzt. `;
|
||||
}
|
||||
}
|
||||
|
||||
if (st.pending > 0) {
|
||||
const est = st.estimate || {};
|
||||
info.textContent = resultLine +
|
||||
`${st.pending} Artikel ohne deutsche Übersetzung. ` +
|
||||
`Geschätzt: ${formatDuration(est.seconds)}, ca. $${est.cost_usd}.`;
|
||||
runBtn.disabled = false;
|
||||
} else {
|
||||
info.textContent = resultLine + "Alle Artikel sind übersetzt.";
|
||||
runBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTranslationStatus() {
|
||||
try {
|
||||
const st = await API.get("/api/translation/status");
|
||||
renderTranslation(st);
|
||||
if (st.running && !translationPollTimer) {
|
||||
translationPollTimer = setInterval(pollTranslation, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
const info = document.getElementById("translationInfo");
|
||||
if (info) info.textContent = "Status nicht abrufbar: " + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTranslation() {
|
||||
try {
|
||||
const st = await API.get("/api/translation/status");
|
||||
renderTranslation(st);
|
||||
if (!st.running) {
|
||||
clearInterval(translationPollTimer);
|
||||
translationPollTimer = null;
|
||||
if (st.error) {
|
||||
showToast("Übersetzung mit Fehler beendet", "error");
|
||||
} else if (st.cancelled) {
|
||||
showToast(`Übersetzung abgebrochen, ${st.translated} übersetzt`, "info");
|
||||
} else {
|
||||
showToast(`Übersetzung fertig: ${st.translated} Artikel`, "success");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Translation-Poll fehlgeschlagen:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTranslation() {
|
||||
let st;
|
||||
try {
|
||||
st = await API.get("/api/translation/status");
|
||||
} catch (e) {
|
||||
showToast(e.message || "Status nicht abrufbar", "error");
|
||||
return;
|
||||
}
|
||||
if (st.running) { showToast("Es läuft bereits eine Übersetzung", "info"); return; }
|
||||
if (!st.pending) { showToast("Es gibt nichts zu übersetzen", "info"); return; }
|
||||
|
||||
const est = st.estimate || {};
|
||||
const ok = await showConfirm(
|
||||
"Übersetzung starten",
|
||||
`${st.pending} Artikel werden ins Deutsche übersetzt. ` +
|
||||
`Geschätzte Dauer: ${formatDuration(est.seconds)}, geschätzte Kosten: ca. $${est.cost_usd}. ` +
|
||||
`Der Lauf kann jederzeit abgebrochen werden.`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await API.post("/api/translation/run", {});
|
||||
if (res && res.status === "started") {
|
||||
showToast(`Übersetzung gestartet (${res.pending} Artikel)`, "success");
|
||||
await loadTranslationStatus();
|
||||
if (!translationPollTimer) {
|
||||
translationPollTimer = setInterval(pollTranslation, 3000);
|
||||
}
|
||||
} else {
|
||||
showToast("Es gibt nichts zu übersetzen", "info");
|
||||
loadTranslationStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || "Start fehlgeschlagen", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTranslation() {
|
||||
try {
|
||||
await API.post("/api/translation/cancel", {});
|
||||
showToast("Übersetzung wird abgebrochen…", "info");
|
||||
} catch (e) {
|
||||
showToast(e.message || "Abbruch fehlgeschlagen", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Token-Nutzung =====
|
||||
async function loadOrgTokenUsage(orgId) {
|
||||
try {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren