Diagnose-Sichtbarkeit (Plan A):
- Job.step_started_at + last_heartbeat_at, Heartbeat-Task pingt alle 3 s
während laufender Mac-Calls
- Mac-Worker hält per X-Job-Id Header einen Log-Ringbuffer pro Job
(200 Zeilen, 1 h TTL); GET /api/jobs/{id}/log liefert ihn aus
- LXC-Endpoint GET /api/jobs/{id}/diag bündelt Job-Status, Stage-Sekunden,
Heartbeat-Alter und Mac-Worker-Log
- Frontend: Live-Timing im Status-Pill ("läuft seit Xs · letzter Ping vor Ys"),
klappbares Diagnose-Panel mit Mac-Log für FAILED + langlaufende Jobs
Robustheit (Plan B):
- MacClient: einmaliger Retry mit 3 s Backoff bei RemoteProtocolError /
ConnectError / ReadError für summarize/protocol/export
- Mac-Worker /api/preload heizt Ollama vor (keep_alive 30 m, num_predict 1);
Pipeline ruft Preload vor summarize, damit Modell-Reload nicht mitten
in der Generierung passiert (Hauptursache "Server disconnected")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
824 lines
30 KiB
JavaScript
824 lines
30 KiB
JavaScript
// ─── DOM-Refs ──────────────────────────────────────────────────────────────
|
|
const form = document.getElementById("upload-form");
|
|
const titleInput = document.getElementById("title");
|
|
const audioInput = document.getElementById("audio");
|
|
const submitBtn = document.getElementById("submit-btn");
|
|
const uploadProgress = document.getElementById("upload-progress");
|
|
const uploadBar = uploadProgress.querySelector(".bar-fill");
|
|
const uploadMsg = document.getElementById("upload-msg");
|
|
const jobsBody = document.getElementById("jobs-body");
|
|
const macStatus = document.getElementById("mac-status");
|
|
|
|
const appMain = document.getElementById("app");
|
|
const authOverlay = document.getElementById("auth-overlay");
|
|
const authTitle = document.getElementById("auth-title");
|
|
const authInfo = document.getElementById("auth-info");
|
|
const authForm = document.getElementById("auth-form");
|
|
const authUsername = document.getElementById("auth-username");
|
|
const authPassword = document.getElementById("auth-password");
|
|
const authSubmit = document.getElementById("auth-submit");
|
|
const authMsg = document.getElementById("auth-msg");
|
|
const userBar = document.getElementById("user-bar");
|
|
const userName = document.getElementById("user-name");
|
|
const userAdminBadge = document.getElementById("user-admin-badge");
|
|
const logoutBtn = document.getElementById("logout-btn");
|
|
|
|
const adminCard = document.getElementById("admin-card");
|
|
const userForm = document.getElementById("user-form");
|
|
const newUsername = document.getElementById("new-username");
|
|
const newPassword = document.getElementById("new-password");
|
|
const newIsAdmin = document.getElementById("new-is-admin");
|
|
const userMsg = document.getElementById("user-msg");
|
|
const usersBody = document.getElementById("users-body");
|
|
|
|
const profileLabel = document.getElementById("profile-label");
|
|
const profileSelect = document.getElementById("profile-select");
|
|
const defaultProfileName = document.getElementById("default-profile-name");
|
|
const headerProfileWrap = document.getElementById("header-profile-wrap");
|
|
const headerProfile = document.getElementById("header-profile");
|
|
|
|
const jobsCards = document.getElementById("jobs-cards");
|
|
const adminToggle = document.getElementById("admin-toggle");
|
|
const adminCollapsible = document.getElementById("admin-card");
|
|
|
|
const recordToggle = document.getElementById("record-toggle");
|
|
const recordToggleLabel = document.getElementById("record-toggle-label");
|
|
const recordPane = document.getElementById("record-pane");
|
|
const recordTimer = document.getElementById("record-timer");
|
|
const recordStatus = document.getElementById("record-status");
|
|
const recPauseBtn = document.getElementById("rec-pause");
|
|
const recStopBtn = document.getElementById("rec-stop");
|
|
const recordResult = document.getElementById("record-result");
|
|
const recordFilename = document.getElementById("record-filename");
|
|
const recordDiscardBtn = document.getElementById("record-discard");
|
|
|
|
let currentUser = null; // { id, username, is_admin, default_profile }
|
|
let mode = "login"; // "login" | "setup"
|
|
let availableProfiles = []; // [{ name, display_name, language }, ...]
|
|
|
|
const DOWNLOADS = [
|
|
{ kind: "pdf", label: "PDF", title: "Protokoll als PDF" },
|
|
{ kind: "docx", label: "DOCX", title: "Protokoll als Word-Dokument" },
|
|
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
|
|
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
|
|
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
|
|
];
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
function escapeHtml(s) {
|
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
}
|
|
|
|
function fmtDate(s) {
|
|
const d = new Date(s);
|
|
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
|
|
}
|
|
|
|
function fmtSecs(sec) {
|
|
if (!isFinite(sec) || sec < 0) return "?";
|
|
if (sec < 60) return `${Math.floor(sec)}s`;
|
|
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
|
|
return `${m}m ${s}s`;
|
|
}
|
|
|
|
const RUNNING_STATES = new Set(["queued", "transcribing", "summarizing", "protocolling", "exporting"]);
|
|
const DIAG_STATES = new Set([...RUNNING_STATES, "failed"]);
|
|
|
|
// Welche Jobs haben aktuell ein offenes Diag-Panel? Über Re-Renders hinweg merken.
|
|
const openDiagIds = new Set();
|
|
|
|
function liveTimingHtml(job) {
|
|
if (!RUNNING_STATES.has(job.status) || !job.step_started_at) return "";
|
|
const now = Date.now();
|
|
const stepSec = (now - new Date(job.step_started_at).getTime()) / 1000;
|
|
let parts = [`läuft seit ${fmtSecs(stepSec)}`];
|
|
if (job.last_heartbeat_at) {
|
|
const beatSec = (now - new Date(job.last_heartbeat_at).getTime()) / 1000;
|
|
const stale = beatSec > 10;
|
|
parts.push(`letzter Ping vor ${fmtSecs(beatSec)}${stale ? " ⚠" : ""}`);
|
|
}
|
|
return `<small class="muted live-timing">${parts.join(" · ")}</small>`;
|
|
}
|
|
|
|
function diagButtonHtml(job) {
|
|
if (!DIAG_STATES.has(job.status)) return "";
|
|
const open = openDiagIds.has(String(job.id));
|
|
return `<button type="button" class="btn-link btn-diag" data-id="${job.id}">Diagnose ${open ? "▴" : "▾"}</button>`;
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const r = await fetch(path, {
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
|
...opts,
|
|
});
|
|
return r;
|
|
}
|
|
|
|
// ─── Auth-Flow ─────────────────────────────────────────────────────────────
|
|
async function bootstrap() {
|
|
const me = await api("/api/auth/me");
|
|
if (me.ok) {
|
|
currentUser = await me.json();
|
|
enterApp();
|
|
return;
|
|
}
|
|
// Nicht eingeloggt — Setup nötig?
|
|
const setupCheck = await api("/api/auth/needs-setup");
|
|
const setupData = await setupCheck.json();
|
|
if (setupData.needs_setup) {
|
|
showAuth("setup");
|
|
} else {
|
|
showAuth("login");
|
|
}
|
|
}
|
|
|
|
function showAuth(which) {
|
|
mode = which;
|
|
authMsg.textContent = "";
|
|
authMsg.className = "msg";
|
|
authForm.reset();
|
|
if (which === "setup") {
|
|
authTitle.textContent = "Initialen Admin anlegen";
|
|
authInfo.textContent =
|
|
"Noch kein Benutzer vorhanden. Lege jetzt den ersten Admin an. Bestehende Jobs werden diesem Konto zugeordnet.";
|
|
authInfo.classList.remove("hidden");
|
|
authPassword.autocomplete = "new-password";
|
|
authPassword.minLength = 8;
|
|
authSubmit.textContent = "Admin anlegen";
|
|
} else {
|
|
authTitle.textContent = "Anmelden";
|
|
authInfo.classList.add("hidden");
|
|
authInfo.textContent = "";
|
|
authPassword.autocomplete = "current-password";
|
|
authPassword.minLength = 1;
|
|
authSubmit.textContent = "Anmelden";
|
|
}
|
|
authOverlay.classList.remove("hidden");
|
|
appMain.classList.add("hidden");
|
|
userBar.classList.add("hidden");
|
|
setTimeout(() => authUsername.focus(), 50);
|
|
}
|
|
|
|
function enterApp() {
|
|
authOverlay.classList.add("hidden");
|
|
appMain.classList.remove("hidden");
|
|
userBar.classList.remove("hidden");
|
|
userName.textContent = currentUser.username;
|
|
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
|
|
adminCard.classList.toggle("hidden", !currentUser.is_admin);
|
|
loadProfiles().then(applyProfileUI);
|
|
loadJobs();
|
|
if (currentUser.is_admin) loadUsers();
|
|
}
|
|
|
|
async function loadProfiles() {
|
|
try {
|
|
const r = await api("/api/profiles");
|
|
if (!r.ok) return;
|
|
const data = await r.json();
|
|
availableProfiles = data.profiles || [];
|
|
} catch (e) {
|
|
console.error("Profile laden fehlgeschlagen", e);
|
|
availableProfiles = [];
|
|
}
|
|
}
|
|
|
|
function profileLabelOf(name) {
|
|
const p = availableProfiles.find((x) => x.name === name);
|
|
return p ? p.display_name : name;
|
|
}
|
|
|
|
function applyProfileUI() {
|
|
// Upload-Dropdown und Header-Selector nur sichtbar wenn ≥2 Profile.
|
|
const showDropdown = availableProfiles.length > 1;
|
|
profileLabel.classList.toggle("hidden", !showDropdown);
|
|
headerProfileWrap.classList.toggle("hidden", !showDropdown);
|
|
|
|
for (const sel of [profileSelect, headerProfile]) {
|
|
sel.innerHTML = "";
|
|
availableProfiles.forEach((p) => {
|
|
const o = document.createElement("option");
|
|
o.value = p.name;
|
|
o.textContent = p.display_name;
|
|
sel.appendChild(o);
|
|
});
|
|
}
|
|
const def = currentUser.default_profile || "meeting";
|
|
profileSelect.value = def;
|
|
headerProfile.value = def;
|
|
defaultProfileName.textContent = profileLabelOf(def);
|
|
}
|
|
|
|
// Sofort-Speichern, sobald der User im Header das Standard-Profil ändert.
|
|
headerProfile.addEventListener("change", async () => {
|
|
const newDefault = headerProfile.value;
|
|
headerProfile.disabled = true;
|
|
try {
|
|
const r = await api("/api/auth/me", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ default_profile: newDefault }),
|
|
});
|
|
if (!r.ok) throw new Error("save failed");
|
|
currentUser = await r.json();
|
|
// Upload-Dropdown synchron mitziehen
|
|
profileSelect.value = currentUser.default_profile;
|
|
defaultProfileName.textContent = profileLabelOf(currentUser.default_profile);
|
|
} catch (e) {
|
|
alert("Konnte Standard-Profil nicht speichern.");
|
|
headerProfile.value = currentUser.default_profile || "meeting";
|
|
} finally {
|
|
headerProfile.disabled = false;
|
|
}
|
|
});
|
|
|
|
authForm.addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
authSubmit.disabled = true;
|
|
authMsg.textContent = "";
|
|
authMsg.className = "msg";
|
|
const body = JSON.stringify({
|
|
username: authUsername.value.trim(),
|
|
password: authPassword.value,
|
|
});
|
|
const url = mode === "setup" ? "/api/auth/setup" : "/api/auth/login";
|
|
try {
|
|
const r = await api(url, { method: "POST", body });
|
|
const data = await r.json();
|
|
if (!r.ok) {
|
|
authMsg.textContent = data.detail || "Fehler bei der Anmeldung";
|
|
authMsg.className = "msg err";
|
|
return;
|
|
}
|
|
currentUser = data;
|
|
if (mode === "setup" && typeof data.migrated_jobs === "number" && data.migrated_jobs > 0) {
|
|
console.info(`${data.migrated_jobs} Bestand-Jobs wurden zugeordnet.`);
|
|
}
|
|
enterApp();
|
|
} catch (e) {
|
|
authMsg.textContent = "Netzwerkfehler";
|
|
authMsg.className = "msg err";
|
|
} finally {
|
|
authSubmit.disabled = false;
|
|
}
|
|
});
|
|
|
|
logoutBtn.addEventListener("click", async () => {
|
|
await api("/api/auth/logout", { method: "POST" });
|
|
currentUser = null;
|
|
jobsBody.innerHTML = "";
|
|
showAuth("login");
|
|
});
|
|
|
|
// ─── Mac-Status ────────────────────────────────────────────────────────────
|
|
async function checkMac() {
|
|
try {
|
|
const r = await fetch("/api/mac/health");
|
|
const j = await r.json();
|
|
if (r.ok && j.reachable) {
|
|
macStatus.textContent = "Mac-Backend: online";
|
|
macStatus.classList.add("ok");
|
|
macStatus.classList.remove("err");
|
|
} else {
|
|
macStatus.textContent = "Mac-Backend: nicht erreichbar";
|
|
macStatus.classList.add("err");
|
|
macStatus.classList.remove("ok");
|
|
}
|
|
} catch {
|
|
macStatus.textContent = "Mac-Backend: Fehler";
|
|
macStatus.classList.add("err");
|
|
}
|
|
}
|
|
|
|
// ─── Jobs ──────────────────────────────────────────────────────────────────
|
|
function row(job) {
|
|
const tr = document.createElement("tr");
|
|
const title = job.title || job.original_name;
|
|
|
|
const dlCell = document.createElement("td");
|
|
dlCell.className = "dl";
|
|
DOWNLOADS.forEach(({ kind, label, title: tip }) => {
|
|
const a = document.createElement("a");
|
|
a.className = "btn-dl";
|
|
a.textContent = label;
|
|
a.title = tip;
|
|
a.href = `/api/jobs/${job.id}/download/${kind}`;
|
|
if (!job.has[kind]) {
|
|
a.classList.add("disabled");
|
|
a.removeAttribute("href");
|
|
a.title = `${tip} — noch nicht verfügbar`;
|
|
}
|
|
dlCell.appendChild(a);
|
|
});
|
|
|
|
const ownerLine = currentUser?.is_admin && job.owner_username
|
|
? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>`
|
|
: "";
|
|
|
|
// Profile-Tag nur sichtbar wenn ≥2 Profile in der Auswahl ODER es ein anderes als "meeting" ist.
|
|
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
|
|
const profileTag = showProfileTag
|
|
? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
|
|
: "";
|
|
|
|
const retryBtn = job.status !== "done"
|
|
? `<button type="button" class="btn-link btn-retry" data-id="${job.id}" title="Verarbeitung wiederholen (Transkript bleibt erhalten)">↻ Erneut versuchen</button>`
|
|
: "";
|
|
const timing = liveTimingHtml(job);
|
|
const statusCell = `
|
|
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
|
|
${timing ? `<div class="status-timing">${timing}</div>` : ""}
|
|
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
|
|
${retryBtn}
|
|
${diagButtonHtml(job)}
|
|
`;
|
|
|
|
tr.innerHTML = `
|
|
<td class="col-id">#${job.id}</td>
|
|
<td class="col-title">${escapeHtml(title)} ${profileTag}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td>
|
|
<td class="col-status">${statusCell}</td>
|
|
<td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
|
|
<td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td>
|
|
`;
|
|
tr.appendChild(dlCell);
|
|
return tr;
|
|
}
|
|
|
|
// Zweite Tabellenzeile direkt unter `tr`, in die das Diagnose-Panel rutscht.
|
|
function diagRow(job) {
|
|
const tr = document.createElement("tr");
|
|
tr.className = "diag-row hidden";
|
|
tr.dataset.diagId = String(job.id);
|
|
tr.innerHTML = `<td colspan="6"><div class="diag-content"><div class="muted">Lade Diagnose …</div></div></td>`;
|
|
return tr;
|
|
}
|
|
|
|
function card(job) {
|
|
const div = document.createElement("div");
|
|
div.className = "jc";
|
|
const title = job.title || job.original_name;
|
|
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
|
|
const profileTag = showProfileTag
|
|
? `<span class="tag-profile">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
|
|
: "";
|
|
const ownerLine = currentUser?.is_admin && job.owner_username
|
|
? `<div class="jc-sub">von ${escapeHtml(job.owner_username)}</div>` : "";
|
|
|
|
const dlButtons = DOWNLOADS.map(({ kind, label, title: tip }) => {
|
|
const dis = !job.has[kind];
|
|
const href = dis ? "" : `href="/api/jobs/${job.id}/download/${kind}"`;
|
|
return `<a class="btn-dl ${dis ? "disabled" : ""}" ${href} title="${escapeHtml(tip)}">${label}</a>`;
|
|
}).join("");
|
|
|
|
const timing = liveTimingHtml(job);
|
|
div.innerHTML = `
|
|
<div class="jc-head">
|
|
<div>
|
|
<div class="jc-title">${escapeHtml(title)} ${profileTag}</div>
|
|
<div class="jc-sub">${escapeHtml(job.original_name)} · <span class="jc-id">#${job.id}</span></div>
|
|
${ownerLine}
|
|
</div>
|
|
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
|
|
</div>
|
|
<div class="jc-status-row">
|
|
<div class="jc-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div></div>
|
|
<small class="muted">${job.progress}%</small>
|
|
</div>
|
|
${timing ? `<div class="jc-timing">${timing}</div>` : ""}
|
|
${job.error ? `<div class="jc-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
|
|
<div class="jc-actions">
|
|
${job.status !== "done" ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}">↻ Erneut versuchen</button>` : ""}
|
|
${diagButtonHtml(job)}
|
|
</div>
|
|
<div class="diag-panel hidden" data-diag-id="${job.id}">
|
|
<div class="diag-content"><div class="muted">Lade Diagnose …</div></div>
|
|
</div>
|
|
<div class="jc-sub">aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}</div>
|
|
<div class="jc-dl">${dlButtons}</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
async function loadJobs() {
|
|
if (!currentUser) return;
|
|
try {
|
|
const r = await api("/api/jobs");
|
|
if (r.status === 401) { showAuth("login"); return; }
|
|
const jobs = await r.json();
|
|
jobsBody.innerHTML = "";
|
|
jobsCards.innerHTML = "";
|
|
if (!jobs.length) {
|
|
jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>';
|
|
jobsCards.innerHTML = '<div class="muted">Noch keine Jobs.</div>';
|
|
return;
|
|
}
|
|
jobs.forEach((j) => {
|
|
jobsBody.appendChild(row(j));
|
|
jobsBody.appendChild(diagRow(j));
|
|
jobsCards.appendChild(card(j));
|
|
});
|
|
// Vorher offene Diagnosen wieder aufklappen + frisch nachladen.
|
|
openDiagIds.forEach((id) => {
|
|
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
|
|
refreshDiag(id);
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
function renderDiag(diag) {
|
|
const job = diag.job;
|
|
const lines = (diag.mac_log || []);
|
|
const stepInfo = diag.step_started_at
|
|
? `Stage <strong>${escapeHtml(job.status)}</strong> seit ${fmtSecs((Date.now() - new Date(diag.step_started_at).getTime()) / 1000)}`
|
|
: "kein Stage-Start gespeichert";
|
|
const beatInfo = diag.last_heartbeat_at
|
|
? `Letzter Heartbeat vor ${fmtSecs((Date.now() - new Date(diag.last_heartbeat_at).getTime()) / 1000)}`
|
|
: "noch kein Heartbeat";
|
|
const errBlock = job.error
|
|
? `<div class="diag-err"><strong>Fehler:</strong> ${escapeHtml(job.error)}</div>`
|
|
: "";
|
|
const logBlock = lines.length
|
|
? `<pre class="diag-log">${escapeHtml(lines.join("\n"))}</pre>`
|
|
: `<div class="muted">Keine Mac-Worker-Logzeilen für diesen Job (Buffer leer oder Mac nicht erreichbar).</div>`;
|
|
return `
|
|
<div class="diag-meta">
|
|
<div>${stepInfo}</div>
|
|
<div>${beatInfo}</div>
|
|
<div>Profil: <code>${escapeHtml(job.profile)}</code></div>
|
|
</div>
|
|
${errBlock}
|
|
<div class="diag-loghead">Mac-Worker-Log (letzte ${lines.length} Zeilen)</div>
|
|
${logBlock}
|
|
`;
|
|
}
|
|
|
|
async function refreshDiag(jobId) {
|
|
let data;
|
|
try {
|
|
const r = await api(`/api/jobs/${jobId}/diag`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
data = await r.json();
|
|
} catch (e) {
|
|
document.querySelectorAll(`[data-diag-id="${jobId}"] .diag-content`).forEach((el) => {
|
|
el.innerHTML = `<div class="diag-err">Diagnose konnte nicht geladen werden: ${escapeHtml(e.message)}</div>`;
|
|
});
|
|
return;
|
|
}
|
|
const html = renderDiag(data);
|
|
document.querySelectorAll(`[data-diag-id="${jobId}"] .diag-content`).forEach((el) => {
|
|
el.innerHTML = html;
|
|
});
|
|
}
|
|
|
|
// Diagnose-Toggle (Tabelle und Karten gemeinsam, Event-Delegation)
|
|
document.addEventListener("click", (ev) => {
|
|
const btn = ev.target.closest(".btn-diag");
|
|
if (!btn) return;
|
|
ev.preventDefault();
|
|
const id = btn.dataset.id;
|
|
const wasOpen = openDiagIds.has(id);
|
|
if (wasOpen) {
|
|
openDiagIds.delete(id);
|
|
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.add("hidden"));
|
|
btn.textContent = "Diagnose ▾";
|
|
} else {
|
|
openDiagIds.add(id);
|
|
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
|
|
btn.textContent = "Diagnose ▴";
|
|
refreshDiag(id);
|
|
}
|
|
});
|
|
|
|
form.addEventListener("submit", (ev) => {
|
|
ev.preventDefault();
|
|
// Quelle: Aufnahme bevorzugt, sonst File-Input
|
|
const file = recordedFile || (audioInput.files.length ? audioInput.files[0] : null);
|
|
if (!file) {
|
|
uploadMsg.textContent = "Bitte Datei wählen oder Aufnahme erstellen.";
|
|
uploadMsg.className = "msg err";
|
|
return;
|
|
}
|
|
const fd = new FormData();
|
|
fd.append("audio", file, file.name);
|
|
fd.append("title", titleInput.value);
|
|
// Profile mitgeben — leer = Server nimmt user.default_profile
|
|
fd.append("profile", profileSelect.value || "");
|
|
|
|
submitBtn.disabled = true;
|
|
uploadProgress.classList.remove("hidden");
|
|
uploadBar.style.width = "0%";
|
|
uploadMsg.textContent = "";
|
|
uploadMsg.className = "msg";
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("POST", "/api/jobs");
|
|
xhr.withCredentials = true;
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) {
|
|
const p = Math.round((e.loaded / e.total) * 100);
|
|
uploadBar.style.width = p + "%";
|
|
}
|
|
};
|
|
xhr.onload = () => {
|
|
submitBtn.disabled = false;
|
|
uploadProgress.classList.add("hidden");
|
|
if (xhr.status === 401) { showAuth("login"); return; }
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
const j = JSON.parse(xhr.responseText);
|
|
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
|
|
uploadMsg.className = "msg ok";
|
|
form.reset();
|
|
discardRecording();
|
|
loadJobs();
|
|
} else {
|
|
let err = "Upload fehlgeschlagen";
|
|
try { err = JSON.parse(xhr.responseText).detail || err; } catch {}
|
|
uploadMsg.textContent = err;
|
|
uploadMsg.className = "msg err";
|
|
}
|
|
};
|
|
xhr.onerror = () => {
|
|
submitBtn.disabled = false;
|
|
uploadProgress.classList.add("hidden");
|
|
uploadMsg.textContent = "Netzwerkfehler beim Upload";
|
|
uploadMsg.className = "msg err";
|
|
};
|
|
xhr.send(fd);
|
|
});
|
|
|
|
// ─── Admin: User-Verwaltung ────────────────────────────────────────────────
|
|
async function loadUsers() {
|
|
if (!currentUser?.is_admin) return;
|
|
try {
|
|
const r = await api("/api/admin/users");
|
|
if (!r.ok) return;
|
|
const users = await r.json();
|
|
usersBody.innerHTML = "";
|
|
users.forEach((u) => {
|
|
const tr = document.createElement("tr");
|
|
const isMe = u.id === currentUser.id;
|
|
tr.innerHTML = `
|
|
<td>#${u.id}</td>
|
|
<td><strong>${escapeHtml(u.username)}</strong>${isMe ? ' <span class="muted">(du)</span>' : ""}</td>
|
|
<td>${u.is_admin ? '<span class="badge admin-badge">Admin</span>' : '<span class="muted">User</span>'}</td>
|
|
<td><small class="muted">${fmtDate(u.created_at)}</small></td>
|
|
<td class="user-actions">
|
|
<button type="button" class="btn-link reset-pw" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Passwort zurücksetzen</button>
|
|
${isMe ? "" : `<button type="button" class="btn-link btn-danger del-user" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Löschen</button>`}
|
|
</td>
|
|
`;
|
|
usersBody.appendChild(tr);
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
userForm.addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
userMsg.textContent = "";
|
|
userMsg.className = "msg";
|
|
const body = JSON.stringify({
|
|
username: newUsername.value.trim(),
|
|
password: newPassword.value,
|
|
is_admin: newIsAdmin.checked,
|
|
});
|
|
const r = await api("/api/admin/users", { method: "POST", body });
|
|
const data = await r.json();
|
|
if (!r.ok) {
|
|
userMsg.textContent = data.detail || "Fehler beim Anlegen";
|
|
userMsg.className = "msg err";
|
|
return;
|
|
}
|
|
userMsg.textContent = `Benutzer '${data.username}' angelegt.`;
|
|
userMsg.className = "msg ok";
|
|
userForm.reset();
|
|
loadUsers();
|
|
});
|
|
|
|
usersBody.addEventListener("click", async (ev) => {
|
|
const t = ev.target;
|
|
if (t.matches(".del-user")) {
|
|
if (!confirm(`Benutzer '${t.dataset.name}' wirklich löschen?\nDie Jobs des Benutzers bleiben erhalten (gehören dann keinem mehr).`)) return;
|
|
const r = await api(`/api/admin/users/${t.dataset.id}`, { method: "DELETE" });
|
|
if (!r.ok) {
|
|
const d = await r.json().catch(() => ({}));
|
|
alert(d.detail || "Löschen fehlgeschlagen");
|
|
}
|
|
loadUsers();
|
|
} else if (t.matches(".reset-pw")) {
|
|
const pw = prompt(`Neues Passwort für '${t.dataset.name}' (min. 8 Zeichen):`);
|
|
if (!pw) return;
|
|
if (pw.length < 8) { alert("Passwort muss min. 8 Zeichen haben"); return; }
|
|
const r = await api(`/api/admin/users/${t.dataset.id}/password`, {
|
|
method: "POST", body: JSON.stringify({ password: pw }),
|
|
});
|
|
if (!r.ok) {
|
|
const d = await r.json().catch(() => ({}));
|
|
alert(d.detail || "Zurücksetzen fehlgeschlagen");
|
|
} else {
|
|
alert("Passwort gesetzt.");
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Audio-Aufnahme (MediaRecorder) ────────────────────────────────────────
|
|
let mediaRecorder = null;
|
|
let recordedChunks = [];
|
|
let recordedFile = null; // File-Objekt nach Stop
|
|
let recStream = null; // MediaStream zum sauberen Schließen
|
|
let recStartedAt = 0;
|
|
let recPausedAt = 0; // wenn paused: Zeitpunkt des Pausierens
|
|
let recElapsedBeforePause = 0; // aufaddierte Aufnahme-Sekunden vor letztem Resume
|
|
let timerInterval = null;
|
|
let wakeLock = null;
|
|
|
|
function pickMimeType() {
|
|
const candidates = [
|
|
"audio/webm;codecs=opus",
|
|
"audio/webm",
|
|
"audio/mp4", // Safari iOS
|
|
"audio/mpeg",
|
|
"", // Browser-Default
|
|
];
|
|
for (const m of candidates) {
|
|
if (m === "" || (window.MediaRecorder && MediaRecorder.isTypeSupported(m))) return m;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function extFromMime(mime) {
|
|
if (!mime) return "webm";
|
|
if (mime.includes("webm")) return "webm";
|
|
if (mime.includes("mp4")) return "m4a";
|
|
if (mime.includes("mpeg")) return "mp3";
|
|
if (mime.includes("ogg")) return "ogg";
|
|
return "webm";
|
|
}
|
|
|
|
function fmtTimer(s) {
|
|
const mm = String(Math.floor(s / 60)).padStart(2, "0");
|
|
const ss = String(Math.floor(s % 60)).padStart(2, "0");
|
|
return `${mm}:${ss}`;
|
|
}
|
|
|
|
function tickTimer() {
|
|
if (!mediaRecorder) return;
|
|
let elapsed = recElapsedBeforePause;
|
|
if (mediaRecorder.state === "recording") elapsed += (Date.now() - recStartedAt) / 1000;
|
|
recordTimer.textContent = fmtTimer(elapsed);
|
|
}
|
|
|
|
async function acquireWakeLock() {
|
|
try {
|
|
if ("wakeLock" in navigator) {
|
|
wakeLock = await navigator.wakeLock.request("screen");
|
|
}
|
|
} catch { /* nicht kritisch */ }
|
|
}
|
|
async function releaseWakeLock() {
|
|
try { if (wakeLock) await wakeLock.release(); } catch {}
|
|
wakeLock = null;
|
|
}
|
|
|
|
async function startRecording() {
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
alert("Dein Browser unterstützt kein Mikrofon — oder die Seite läuft nicht über HTTPS.");
|
|
return;
|
|
}
|
|
try {
|
|
recStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
});
|
|
} catch (e) {
|
|
alert("Mikrofon-Zugriff verweigert oder fehlgeschlagen: " + e.message);
|
|
return;
|
|
}
|
|
|
|
const mime = pickMimeType();
|
|
recordedChunks = [];
|
|
try {
|
|
mediaRecorder = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream);
|
|
} catch (e) {
|
|
alert("MediaRecorder konnte nicht starten: " + e.message);
|
|
cleanupStream();
|
|
return;
|
|
}
|
|
|
|
mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); };
|
|
mediaRecorder.onstop = () => {
|
|
const type = mediaRecorder.mimeType || mime || "audio/webm";
|
|
const ext = extFromMime(type);
|
|
const blob = new Blob(recordedChunks, { type });
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
recordedFile = new File([blob], `aufnahme-${ts}.${ext}`, { type });
|
|
onRecordingFinalized();
|
|
};
|
|
|
|
mediaRecorder.start(1000); // alle 1s ein dataavailable-Event
|
|
recStartedAt = Date.now();
|
|
recPausedAt = 0;
|
|
recElapsedBeforePause = 0;
|
|
recordTimer.textContent = "00:00";
|
|
recordStatus.textContent = "Aufnahme läuft";
|
|
recPauseBtn.textContent = "Pause";
|
|
recordPane.classList.remove("hidden");
|
|
recordResult.classList.add("hidden");
|
|
recordToggle.classList.add("hidden");
|
|
audioInput.value = ""; // konkurrierende Datei-Auswahl löschen
|
|
timerInterval = setInterval(tickTimer, 250);
|
|
acquireWakeLock();
|
|
}
|
|
|
|
function pauseRecording() {
|
|
if (!mediaRecorder) return;
|
|
if (mediaRecorder.state === "recording") {
|
|
mediaRecorder.pause();
|
|
recElapsedBeforePause += (Date.now() - recStartedAt) / 1000;
|
|
recPausedAt = Date.now();
|
|
recordStatus.textContent = "Pausiert";
|
|
recPauseBtn.textContent = "Fortsetzen";
|
|
} else if (mediaRecorder.state === "paused") {
|
|
mediaRecorder.resume();
|
|
recStartedAt = Date.now();
|
|
recordStatus.textContent = "Aufnahme läuft";
|
|
recPauseBtn.textContent = "Pause";
|
|
}
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (!mediaRecorder) return;
|
|
if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
|
|
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
|
cleanupStream();
|
|
releaseWakeLock();
|
|
}
|
|
|
|
function cleanupStream() {
|
|
if (recStream) {
|
|
recStream.getTracks().forEach((t) => t.stop());
|
|
recStream = null;
|
|
}
|
|
}
|
|
|
|
function onRecordingFinalized() {
|
|
recordPane.classList.add("hidden");
|
|
recordResult.classList.remove("hidden");
|
|
recordToggle.classList.remove("hidden");
|
|
recordToggleLabel.textContent = "Neue Aufnahme starten";
|
|
const sizeKB = Math.round(recordedFile.size / 1024);
|
|
recordFilename.textContent = `${recordedFile.name} (${sizeKB} KB)`;
|
|
}
|
|
|
|
function discardRecording() {
|
|
recordedFile = null;
|
|
recordedChunks = [];
|
|
recordResult.classList.add("hidden");
|
|
recordToggleLabel.textContent = "Aufnahme starten";
|
|
recordFilename.textContent = "";
|
|
}
|
|
|
|
recordToggle.addEventListener("click", startRecording);
|
|
recPauseBtn.addEventListener("click", pauseRecording);
|
|
recStopBtn.addEventListener("click", stopRecording);
|
|
recordDiscardBtn.addEventListener("click", discardRecording);
|
|
|
|
// Admin-Toggle (klappbar auf Mobile, immer offen auf Desktop)
|
|
adminToggle.addEventListener("click", () => {
|
|
if (window.matchMedia("(max-width: 700px)").matches) {
|
|
adminCollapsible.classList.toggle("open");
|
|
}
|
|
});
|
|
|
|
// Retry-Button: funktioniert für Tabelle und Karten gleichzeitig (Event-Delegation)
|
|
document.addEventListener("click", async (ev) => {
|
|
const btn = ev.target.closest(".btn-retry");
|
|
if (!btn) return;
|
|
ev.preventDefault();
|
|
const id = btn.dataset.id;
|
|
if (!confirm("Verarbeitung erneut starten?\nDas Transkript bleibt erhalten — nur Zusammenfassung, Protokoll und DOCX werden neu erzeugt.")) return;
|
|
btn.disabled = true;
|
|
btn.textContent = "wird gestartet …";
|
|
try {
|
|
const r = await api(`/api/jobs/${id}/retry`, { method: "POST" });
|
|
if (!r.ok) {
|
|
const d = await r.json().catch(() => ({}));
|
|
alert(d.detail || "Konnte nicht neu starten.");
|
|
return;
|
|
}
|
|
loadJobs();
|
|
} catch (e) {
|
|
alert("Netzwerkfehler beim Neustart.");
|
|
}
|
|
});
|
|
|
|
// ─── Init ──────────────────────────────────────────────────────────────────
|
|
bootstrap();
|
|
checkMac();
|
|
setInterval(() => { if (currentUser) loadJobs(); }, 3000);
|
|
setInterval(checkMac, 15000);
|