feat: kontinuierliche LXC↔Mac-Diagnose + Auto-Retry

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>
This commit is contained in:
root
2026-05-14 09:12:27 +00:00
co-authored by Claude Opus 4.7
parent 354e4c57eb
commit f408a555f8
9 changed files with 558 additions and 48 deletions
+19
View File
@@ -120,6 +120,8 @@ async def retry_job(
job.status = JobStatus.QUEUED
job.progress = 0
job.error = ""
job.step_started_at = None
job.last_heartbeat_at = None
session.add(job)
session.commit()
session.refresh(job)
@@ -128,6 +130,21 @@ async def retry_job(
return _job_dict(job, user)
@router.get("/{job_id}/diag")
async def diag(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
"""Diagnose-Bündel: Job-Status + letzte Mac-Worker-Logzeilen für genau diesen Job."""
from app.core.mac_client import MacClient
job = _require_access(session.get(Job, job_id), user)
client = MacClient()
mac_log = await client.fetch_log(job_id, last=200)
return {
"job": _job_dict(job, user),
"step_started_at": job.step_started_at.isoformat() if job.step_started_at else None,
"last_heartbeat_at": job.last_heartbeat_at.isoformat() if job.last_heartbeat_at else None,
"mac_log": mac_log,
}
@router.get("/{job_id}/download/{kind}")
def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
job = _require_access(session.get(Job, job_id), user)
@@ -158,6 +175,8 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
"profile": j.profile or "meeting",
"created_at": j.created_at.isoformat(),
"updated_at": j.updated_at.isoformat(),
"step_started_at": j.step_started_at.isoformat() if j.step_started_at else None,
"last_heartbeat_at": j.last_heartbeat_at.isoformat() if j.last_heartbeat_at else None,
"has": {
"transcript": bool(j.transcript_path),
"summary": bool(j.summary_path),
+103 -34
View File
@@ -1,13 +1,62 @@
import asyncio
import logging
from pathlib import Path
import httpx
from .config import settings
log = logging.getLogger("voice-agent.mac_client")
# Transient-Fehler, bei denen ein einmaliger Retry sinnvoll ist
# (typisch bei Ollama-Reload oder kurzem Mac-Worker-Restart).
_RETRY_EXCEPTIONS = (
httpx.RemoteProtocolError,
httpx.ConnectError,
httpx.ReadError,
)
class MacClient:
def __init__(self, base_url: str | None = None, timeout: int | None = None):
self.base_url = (base_url or settings.mac_api_url).rstrip("/")
self.timeout = timeout or settings.mac_api_timeout
@staticmethod
def _hdrs(job_id: int | None) -> dict[str, str] | None:
if job_id is None:
return None
return {"X-Job-Id": str(job_id)}
async def _post_json_retry(self, url: str, payload: dict, job_id: int | None) -> dict:
"""JSON-POST mit einmaligem Retry bei transienten Verbindungsabbrüchen."""
for attempt in (0, 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(url, json=payload, headers=self._hdrs(job_id))
r.raise_for_status()
return r.json()
except _RETRY_EXCEPTIONS as e:
if attempt == 0:
log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url)
await asyncio.sleep(3.0)
continue
raise
async def _post_bytes_retry(self, url: str, payload: dict, job_id: int | None) -> bytes:
for attempt in (0, 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(url, json=payload, headers=self._hdrs(job_id))
r.raise_for_status()
return r.content
except _RETRY_EXCEPTIONS as e:
if attempt == 0:
log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url)
await asyncio.sleep(3.0)
continue
raise
async def health(self) -> dict:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/health")
@@ -20,47 +69,67 @@ class MacClient:
r.raise_for_status()
return r.json().get("profiles", [])
async def transcribe(self, audio_path: Path, language: str = "de") -> dict:
async def fetch_log(self, job_id: int, last: int = 200) -> list[str]:
"""Holt die letzten Mac-Worker-Logzeilen für einen Job (für die Diagnose-Anzeige)."""
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"{self.base_url}/api/jobs/{job_id}/log", params={"last": last})
r.raise_for_status()
return r.json().get("lines", [])
except Exception as e: # noqa: BLE001
log.warning("fetch_log job=%s failed: %s", job_id, e)
return []
async def preload(self, profile: str | None = None, job_id: int | None = None) -> dict:
"""Vor-Heizen: Ollama soll das Modell laden, bevor der echte Call läuft."""
return await self._post_json_retry(
f"{self.base_url}/api/preload",
{"profile": profile},
job_id,
)
async def transcribe(self, audio_path: Path, language: str = "de", job_id: int | None = None) -> dict:
# Kein Retry: Datei-Upload ist nicht idempotent, und die Transkription
# ist der teuerste Schritt — Fehler hier sollen sichtbar bleiben.
async with httpx.AsyncClient(timeout=self.timeout) as c:
with audio_path.open("rb") as f:
files = {"audio": (audio_path.name, f, "application/octet-stream")}
data = {"language": language}
r = await c.post(f"{self.base_url}/api/transcribe", files=files, data=data)
r = await c.post(
f"{self.base_url}/api/transcribe",
files=files,
data=data,
headers=self._hdrs(job_id),
)
r.raise_for_status()
return r.json()
async def summarize(self, transcript: str, title: str = "", profile: str | None = None) -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(
f"{self.base_url}/api/summarize",
json={"transcript": transcript, "title": title, "profile": profile},
)
r.raise_for_status()
return r.json()
async def summarize(self, transcript: str, title: str = "", profile: str | None = None,
job_id: int | None = None) -> dict:
return await self._post_json_retry(
f"{self.base_url}/api/summarize",
{"transcript": transcript, "title": title, "profile": profile},
job_id,
)
async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None) -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(
f"{self.base_url}/api/protocol",
json={"transcript": transcript, "summary": summary, "title": title, "profile": profile},
)
r.raise_for_status()
return r.json()
async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None,
job_id: int | None = None) -> dict:
return await self._post_json_retry(
f"{self.base_url}/api/protocol",
{"transcript": transcript, "summary": summary, "title": title, "profile": profile},
job_id,
)
async def export_docx(self, data: dict, profile: str | None = None) -> bytes:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(
f"{self.base_url}/api/export/docx",
json={"data": data, "profile": profile},
)
r.raise_for_status()
return r.content
async def export_docx(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes:
return await self._post_bytes_retry(
f"{self.base_url}/api/export/docx",
{"data": data, "profile": profile},
job_id,
)
async def export_pdf(self, data: dict, profile: str | None = None) -> bytes:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(
f"{self.base_url}/api/export/pdf",
json={"data": data, "profile": profile},
)
r.raise_for_status()
return r.content
async def export_pdf(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes:
return await self._post_bytes_retry(
f"{self.base_url}/api/export/pdf",
{"data": data, "profile": profile},
job_id,
)
+9
View File
@@ -46,6 +46,15 @@ def run_migrations() -> None:
log.info("Migration: füge job.pdf_path hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN pdf_path VARCHAR DEFAULT ''"))
# 5) Diagnose-Felder: step_started_at, last_heartbeat_at
cols = _existing_columns(conn, "job")
if "step_started_at" not in cols:
log.info("Migration: füge job.step_started_at hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN step_started_at DATETIME"))
if "last_heartbeat_at" not in cols:
log.info("Migration: füge job.last_heartbeat_at hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN last_heartbeat_at DATETIME"))
def assign_orphan_jobs_to(user_id: int) -> int:
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
+2
View File
@@ -28,5 +28,7 @@ class Job(SQLModel, table=True):
protocol_path: str = ""
docx_path: str = ""
pdf_path: str = ""
step_started_at: Optional[datetime] = Field(default=None)
last_heartbeat_at: Optional[datetime] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+78 -11
View File
@@ -1,7 +1,9 @@
import asyncio
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Awaitable, TypeVar
from sqlmodel import Session
@@ -12,6 +14,10 @@ from app.models.job import Job, JobStatus
log = logging.getLogger("voice-agent.pipeline")
T = TypeVar("T")
HEARTBEAT_INTERVAL = 3.0 # Sekunden
def _update(job_id: int, **fields) -> None:
with Session(engine) as s:
@@ -32,8 +38,51 @@ def _existing(path_str: str) -> Path | None:
return p if p.exists() and p.stat().st_size > 0 else None
def _start_step(job_id: int, status: str, progress: int) -> None:
"""Setzt status/progress + step_started_at + initialen Heartbeat."""
now = datetime.now(timezone.utc)
_update(
job_id,
status=status,
progress=progress,
step_started_at=now,
last_heartbeat_at=now,
error="",
)
async def _with_heartbeat(job_id: int, coro: Awaitable[T]) -> T:
"""Führt `coro` aus und pingt parallel alle paar Sekunden last_heartbeat_at.
Damit sieht die UI live, ob ein Schritt noch lebt — auch wenn der Mac
minutenlang an einer Ollama-Generierung arbeitet.
"""
stop = asyncio.Event()
async def beat() -> None:
while not stop.is_set():
try:
_update(job_id, last_heartbeat_at=datetime.now(timezone.utc))
except Exception: # noqa: BLE001
pass # Heartbeat darf den Job nie killen
try:
await asyncio.wait_for(stop.wait(), timeout=HEARTBEAT_INTERVAL)
except asyncio.TimeoutError:
pass
beat_task = asyncio.create_task(beat())
try:
return await coro
finally:
stop.set()
try:
await beat_task
except Exception: # noqa: BLE001
pass
async def run_pipeline(job_id: int) -> None:
"""Pipeline: transcribe → summarize → protocol → DOCX.
"""Pipeline: transcribe → preload → summarize → protocol → DOCX → PDF.
Idempotent: bereits vorhandene Zwischenergebnisse werden weiterverwendet,
damit ein 'Erneut versuchen' nicht die teure Transkription wiederholt.
@@ -64,9 +113,9 @@ async def run_pipeline(job_id: int) -> None:
tx = json.loads(existing_tx_path.read_text(encoding="utf-8"))
_update(job_id, status=JobStatus.TRANSCRIBING, progress=45)
else:
_update(job_id, status=JobStatus.TRANSCRIBING, progress=10, error="")
_start_step(job_id, JobStatus.TRANSCRIBING, 10)
log.info("[job %s] transcribe (profile=%s)", job_id, profile)
tx = await client.transcribe(audio_path, language="de")
tx = await _with_heartbeat(job_id, client.transcribe(audio_path, language="de", job_id=job_id))
transcript_path = job_dir / "transcript.json"
transcript_path.write_text(json.dumps(tx, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, transcript_path=str(transcript_path), progress=45)
@@ -75,15 +124,24 @@ async def run_pipeline(job_id: int) -> None:
s.get("text", "") for s in tx.get("segments", [])
)
# 2) SUMMARY
# 2) SUMMARY — vorher Preload, damit Ollama nicht mitten drin lädt
if existing_sum_path:
log.info("[job %s] reuse summary %s", job_id, existing_sum_path.name)
summary = json.loads(existing_sum_path.read_text(encoding="utf-8"))
_update(job_id, status=JobStatus.SUMMARIZING, progress=70)
else:
_update(job_id, status=JobStatus.SUMMARIZING, progress=55)
_start_step(job_id, JobStatus.SUMMARIZING, 50)
log.info("[job %s] preload model", job_id)
try:
await _with_heartbeat(job_id, client.preload(profile=profile, job_id=job_id))
except Exception as e: # noqa: BLE001
log.warning("[job %s] preload failed (non-fatal): %s", job_id, e)
_update(job_id, progress=55)
log.info("[job %s] summarize", job_id)
summary = await client.summarize(transcript_text, title=title, profile=profile)
summary = await _with_heartbeat(
job_id,
client.summarize(transcript_text, title=title, profile=profile, job_id=job_id),
)
summary_path = job_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, summary_path=str(summary_path), progress=70)
@@ -94,9 +152,12 @@ async def run_pipeline(job_id: int) -> None:
protocol = json.loads(existing_pro_path.read_text(encoding="utf-8"))
_update(job_id, status=JobStatus.PROTOCOLLING, progress=85)
else:
_update(job_id, status=JobStatus.PROTOCOLLING, progress=75)
_start_step(job_id, JobStatus.PROTOCOLLING, 75)
log.info("[job %s] protocol", job_id)
protocol = await client.protocol(transcript_text, summary, title=title, profile=profile)
protocol = await _with_heartbeat(
job_id,
client.protocol(transcript_text, summary, title=title, profile=profile, job_id=job_id),
)
protocol_path = job_dir / "protocol.json"
protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, protocol_path=str(protocol_path), progress=85)
@@ -105,9 +166,12 @@ async def run_pipeline(job_id: int) -> None:
if existing_dx_path:
log.info("[job %s] reuse docx %s", job_id, existing_dx_path.name)
else:
_update(job_id, status=JobStatus.EXPORTING, progress=90)
_start_step(job_id, JobStatus.EXPORTING, 90)
log.info("[job %s] export docx", job_id)
docx_bytes = await client.export_docx(protocol, profile=profile)
docx_bytes = await _with_heartbeat(
job_id,
client.export_docx(protocol, profile=profile, job_id=job_id),
)
docx_path = job_dir / "protocol.docx"
docx_path.write_bytes(docx_bytes)
_update(job_id, docx_path=str(docx_path), progress=95)
@@ -119,7 +183,10 @@ async def run_pipeline(job_id: int) -> None:
else:
_update(job_id, status=JobStatus.EXPORTING, progress=96)
log.info("[job %s] export pdf", job_id)
pdf_bytes = await client.export_pdf(protocol, profile=profile)
pdf_bytes = await _with_heartbeat(
job_id,
client.export_pdf(protocol, profile=profile, job_id=job_id),
)
pdf_path = job_dir / "protocol.pdf"
pdf_path.write_bytes(pdf_bytes)
_update(job_id, pdf_path=str(pdf_path), status=JobStatus.DONE, progress=100, error="")
+123 -1
View File
@@ -76,6 +76,38 @@ function fmtDate(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",
@@ -294,10 +326,13 @@ function row(job) {
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 = `
@@ -311,6 +346,15 @@ function row(job) {
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";
@@ -328,6 +372,7 @@ function card(job) {
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>
@@ -341,8 +386,15 @@ function card(job) {
<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>` : ""}
${job.status !== "done" ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}">↻ Erneut versuchen</button>` : ""}
<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>
`;
@@ -364,13 +416,83 @@ async function loadJobs() {
}
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
+90
View File
@@ -560,3 +560,93 @@ select {
text-transform: lowercase;
vertical-align: middle;
}
/* Live-Timing direkt unter dem Status-Pill (läuft seit · letzter Ping vor) */
.status-timing, .jc-timing {
margin-top: 4px;
font-size: 11px;
line-height: 1.3;
}
.live-timing { color: var(--muted); }
/* Diagnose-Button – wie btn-retry, aber neutraler Ton */
.btn-diag {
display: inline-block;
margin-top: 6px;
margin-left: 4px;
padding: 4px 10px;
font-size: 12px;
background: rgba(120,120,120,.10);
border: 1px solid rgba(160,160,160,.35);
color: var(--muted);
border-radius: 5px;
text-decoration: none;
cursor: pointer;
}
.btn-diag:hover { background: rgba(160,160,160,.20); color: inherit; }
/* Aktions-Reihe in der mobilen Karte */
.jc-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
/* Diagnose-Panel: Tabelle (zweite Zeile, full-width) und Karte */
.diag-row > td {
background: rgba(255,255,255,.03);
border-top: 0;
padding: 10px 12px;
}
.diag-panel {
margin-top: 10px;
padding: 10px;
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.08);
border-radius: 6px;
}
.diag-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.diag-meta code {
background: rgba(255,255,255,.08);
padding: 1px 4px;
border-radius: 3px;
}
.diag-err {
margin: 6px 0;
padding: 6px 8px;
background: rgba(231,76,60,.10);
border: 1px solid rgba(231,76,60,.35);
border-radius: 4px;
color: var(--err);
font-size: 12px;
word-break: break-word;
}
.diag-loghead {
margin-top: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted);
}
.diag-log {
margin: 4px 0 0 0;
padding: 8px;
background: rgba(0,0,0,.30);
border: 1px solid rgba(255,255,255,.06);
border-radius: 4px;
max-height: 320px;
overflow: auto;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}