feat(jobs): "Mit anderem Modell verarbeiten" + Verlauf pro Job
Neuer Endpoint POST /api/jobs/{id}/reprocess: erlaubt einen LLM-Lauf
auch auf done/failed-Jobs, sichert Summary/Protokoll/DOCX/PDF in
results/<id>/runs/<ts>-<modell>/ als Snapshot, leert die DB-Pfade,
startet die Pipeline mit dem aktuell auf dem Mac konfigurierten
Ollama-Modell. Transkript bleibt erhalten.
GET /api/jobs/{id}/runs liefert die Snapshot-Liste,
GET /api/jobs/{id}/runs/{run_id}/{file} den Einzeldownload.
UI: zwei neue Buttons auf done/failed-Jobs — "↻ Mit anderem Modell"
und "Verlauf ▾". Reprocess-Dialog zeigt das aktuell konfigurierte
Mac-Modell an, bevor er startet, damit kein Versehen passiert.
Verlauf-Panel listet pro Lauf Modellname + Zeitstempel + Download-
Links für die Snapshot-Dateien.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
031d52c77a
commit
dbf7bca6c7
@ -1,4 +1,7 @@
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
@ -17,6 +20,31 @@ router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
|
||||
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
|
||||
|
||||
# Status, in denen der Job aktiv läuft — Reprocess wird dann verweigert,
|
||||
# damit kein Schreibwettlauf mit der laufenden Pipeline entsteht.
|
||||
_ACTIVE_STATUSES = {
|
||||
JobStatus.QUEUED,
|
||||
JobStatus.TRANSCRIBING,
|
||||
JobStatus.SUMMARIZING,
|
||||
JobStatus.PROTOCOLLING,
|
||||
JobStatus.EXPORTING,
|
||||
}
|
||||
|
||||
|
||||
def _slug(s: str) -> str:
|
||||
"""Dateisystem-sichere Variante eines Modellnamens (z. B. 'gpt-oss:latest' → 'gpt-oss_latest')."""
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") or "unknown"
|
||||
|
||||
|
||||
async def _current_mac_model() -> str:
|
||||
"""Aktuelles Ollama-Modell vom Mac-Worker — fällt auf 'unknown' zurück, wenn unerreichbar."""
|
||||
try:
|
||||
from app.core.mac_client import MacClient
|
||||
h = await MacClient().health()
|
||||
return h.get("ollama_model") or "unknown"
|
||||
except Exception: # noqa: BLE001
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _require_access(job: Job | None, user: User) -> Job:
|
||||
"""404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert)."""
|
||||
@ -130,6 +158,132 @@ async def retry_job(
|
||||
return _job_dict(job, user)
|
||||
|
||||
|
||||
@router.post("/{job_id}/reprocess")
|
||||
async def reprocess_job(
|
||||
job_id: int,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_user),
|
||||
):
|
||||
"""Erlaubt einen erneuten LLM-Lauf auf einem bereits abgeschlossenen Job.
|
||||
|
||||
Anders als /retry blockiert dieser Endpoint nicht bei status=done.
|
||||
Sichert Summary/Protokoll/DOCX/PDF in runs/<ts>-<modell>/ als Snapshot
|
||||
(Transkript bleibt im Hauptordner), startet die Pipeline neu — die
|
||||
Mac-aktuell konfigurierte Ollama-Variante wird dabei verwendet.
|
||||
"""
|
||||
job = _require_access(session.get(Job, job_id), user)
|
||||
if job.status in _ACTIVE_STATUSES:
|
||||
raise HTTPException(409, "Job läuft gerade — bitte warten oder Diagnose prüfen")
|
||||
|
||||
model = await _current_mac_model()
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
run_id = f"{ts}-{_slug(model)}"
|
||||
|
||||
job_dir = settings.result_dir / str(job.id)
|
||||
runs_dir = job_dir / "runs" / run_id
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved: list[str] = []
|
||||
for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"):
|
||||
path_str = getattr(job, attr)
|
||||
if path_str:
|
||||
src = Path(path_str)
|
||||
if src.exists():
|
||||
try:
|
||||
src.replace(runs_dir / src.name)
|
||||
moved.append(src.name)
|
||||
except OSError:
|
||||
pass
|
||||
setattr(job, attr, "")
|
||||
|
||||
(runs_dir / "meta.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"model": model,
|
||||
"profile": job.profile,
|
||||
"snapshot_at": ts,
|
||||
"previous_status": job.status,
|
||||
"files": moved,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
background.add_task(run_pipeline, job.id)
|
||||
return {"job": _job_dict(job, user), "snapshot": run_id, "model_for_new_run": model}
|
||||
|
||||
|
||||
@router.get("/{job_id}/runs")
|
||||
def list_runs(
|
||||
job_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_user),
|
||||
):
|
||||
"""Listet vergangene LLM-Läufe (Snapshots) eines Jobs."""
|
||||
job = _require_access(session.get(Job, job_id), user)
|
||||
runs_dir = settings.result_dir / str(job.id) / "runs"
|
||||
out: list[dict] = []
|
||||
if runs_dir.exists():
|
||||
for d in sorted(runs_dir.iterdir(), reverse=True):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
meta: dict = {}
|
||||
try:
|
||||
meta = json.loads((d / "meta.json").read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
out.append({
|
||||
"run_id": d.name,
|
||||
"model": meta.get("model", "unknown"),
|
||||
"snapshot_at": meta.get("snapshot_at", ""),
|
||||
"profile": meta.get("profile", job.profile),
|
||||
"files": [
|
||||
{"name": f.name, "size": f.stat().st_size}
|
||||
for f in sorted(d.iterdir())
|
||||
if f.is_file() and f.name != "meta.json"
|
||||
],
|
||||
})
|
||||
return {"runs": out}
|
||||
|
||||
|
||||
@router.get("/{job_id}/runs/{run_id}/{filename}")
|
||||
def download_run_artifact(
|
||||
job_id: int,
|
||||
run_id: str,
|
||||
filename: str,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_user),
|
||||
):
|
||||
"""Liefert eine einzelne Datei aus einem Snapshot-Lauf zum Download."""
|
||||
job = _require_access(session.get(Job, job_id), user)
|
||||
if "/" in run_id or ".." in run_id or "/" in filename or ".." in filename:
|
||||
raise HTTPException(400, "Ungültige Pfad-Komponenten")
|
||||
target = settings.result_dir / str(job.id) / "runs" / run_id / filename
|
||||
if not target.exists():
|
||||
raise HTTPException(404, "Snapshot-Datei nicht gefunden")
|
||||
media_map = {
|
||||
".json": "application/json",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".pdf": "application/pdf",
|
||||
}
|
||||
media = media_map.get(target.suffix.lower(), "application/octet-stream")
|
||||
base = (job.title or Path(job.original_name).stem).replace("/", "_")
|
||||
return FileResponse(target, media_type=media, filename=f"{base}-{run_id}-{filename}")
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
@ -85,9 +85,12 @@ function fmtSecs(sec) {
|
||||
|
||||
const RUNNING_STATES = new Set(["queued", "transcribing", "summarizing", "protocolling", "exporting"]);
|
||||
const DIAG_STATES = new Set([...RUNNING_STATES, "failed"]);
|
||||
// Auf welchen Jobs darf das LLM mit aktuellem Mac-Modell neu angefordert werden?
|
||||
const REPROCESS_STATES = new Set(["done", "failed"]);
|
||||
|
||||
// Welche Jobs haben aktuell ein offenes Diag-Panel? Über Re-Renders hinweg merken.
|
||||
// Offene Panels über Re-Renders hinweg merken — pro Panel eine Set-Instanz.
|
||||
const openDiagIds = new Set();
|
||||
const openRunsIds = new Set();
|
||||
|
||||
function liveTimingHtml(job) {
|
||||
if (!RUNNING_STATES.has(job.status) || !job.step_started_at) return "";
|
||||
@ -108,6 +111,17 @@ function diagButtonHtml(job) {
|
||||
return `<button type="button" class="btn-link btn-diag" data-id="${job.id}">Diagnose ${open ? "▴" : "▾"}</button>`;
|
||||
}
|
||||
|
||||
function runsButtonHtml(job) {
|
||||
if (!REPROCESS_STATES.has(job.status)) return "";
|
||||
const open = openRunsIds.has(String(job.id));
|
||||
return `<button type="button" class="btn-link btn-runs" data-id="${job.id}" title="Verlauf der LLM-Läufe (Snapshots) für diesen Job">Verlauf ${open ? "▴" : "▾"}</button>`;
|
||||
}
|
||||
|
||||
function reprocessButtonHtml(job) {
|
||||
if (!REPROCESS_STATES.has(job.status)) return "";
|
||||
return `<button type="button" class="btn-link btn-reprocess" data-id="${job.id}" title="Mit dem aktuell auf dem Mac konfigurierten Ollama-Modell neu verarbeiten — bisheriges Ergebnis wird im Verlauf gesichert">↻ Mit anderem Modell</button>`;
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
@ -332,7 +346,9 @@ function row(job) {
|
||||
${timing ? `<div class="status-timing">${timing}</div>` : ""}
|
||||
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
|
||||
${retryBtn}
|
||||
${reprocessButtonHtml(job)}
|
||||
${diagButtonHtml(job)}
|
||||
${runsButtonHtml(job)}
|
||||
`;
|
||||
|
||||
tr.innerHTML = `
|
||||
@ -355,6 +371,15 @@ function diagRow(job) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
// Dritte Tabellenzeile für das Verlauf-Panel (vergangene LLM-Läufe).
|
||||
function runsRow(job) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "runs-row hidden";
|
||||
tr.dataset.runsId = String(job.id);
|
||||
tr.innerHTML = `<td colspan="6"><div class="runs-content"><div class="muted">Lade Verlauf …</div></div></td>`;
|
||||
return tr;
|
||||
}
|
||||
|
||||
function card(job) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "jc";
|
||||
@ -390,11 +415,16 @@ function card(job) {
|
||||
${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>` : ""}
|
||||
${reprocessButtonHtml(job)}
|
||||
${diagButtonHtml(job)}
|
||||
${runsButtonHtml(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="runs-panel hidden" data-runs-id="${job.id}">
|
||||
<div class="runs-content"><div class="muted">Lade Verlauf …</div></div>
|
||||
</div>
|
||||
<div class="jc-sub">aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}</div>
|
||||
<div class="jc-dl">${dlButtons}</div>
|
||||
`;
|
||||
@ -417,13 +447,18 @@ async function loadJobs() {
|
||||
jobs.forEach((j) => {
|
||||
jobsBody.appendChild(row(j));
|
||||
jobsBody.appendChild(diagRow(j));
|
||||
jobsBody.appendChild(runsRow(j));
|
||||
jobsCards.appendChild(card(j));
|
||||
});
|
||||
// Vorher offene Diagnosen wieder aufklappen + frisch nachladen.
|
||||
// Vorher offene Panels wieder aufklappen + frisch nachladen.
|
||||
openDiagIds.forEach((id) => {
|
||||
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
|
||||
refreshDiag(id);
|
||||
});
|
||||
openRunsIds.forEach((id) => {
|
||||
document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
|
||||
refreshRuns(id);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@ -456,6 +491,67 @@ function renderDiag(diag) {
|
||||
`;
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!isFinite(n) || n < 0) return "?";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function fmtRunStamp(s) {
|
||||
// run-IDs haben das Format YYYYMMDDTHHMMSSZ-<modell> — wir machen daraus eine
|
||||
// lesbare Zeit (UTC → lokal).
|
||||
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/.exec(s || "");
|
||||
if (!m) return s || "";
|
||||
const [, y, mo, d, h, mi, se] = m;
|
||||
const iso = `${y}-${mo}-${d}T${h}:${mi}:${se}Z`;
|
||||
return new Date(iso).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
|
||||
}
|
||||
|
||||
function renderRuns(jobId, data) {
|
||||
const runs = data.runs || [];
|
||||
if (!runs.length) {
|
||||
return `<div class="muted">Noch keine alternativen Läufe gespeichert. Nutze „↻ Mit anderem Modell" um den aktuellen Mac-Modell-Output als neuen Lauf zu erzeugen — der bisherige bleibt dann im Verlauf erhalten.</div>`;
|
||||
}
|
||||
const blocks = runs.map((r) => {
|
||||
const files = (r.files || []).map((f) => {
|
||||
const url = `/api/jobs/${jobId}/runs/${encodeURIComponent(r.run_id)}/${encodeURIComponent(f.name)}`;
|
||||
return `<a class="btn-dl" href="${url}" title="${escapeHtml(f.name)} (${fmtBytes(f.size)}) herunterladen">${escapeHtml(f.name)}</a>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="run-item">
|
||||
<div class="run-head">
|
||||
<span class="run-model"><code>${escapeHtml(r.model)}</code></span>
|
||||
<span class="run-stamp">${escapeHtml(fmtRunStamp(r.run_id))}</span>
|
||||
<span class="muted">Profil: <code>${escapeHtml(r.profile)}</code></span>
|
||||
</div>
|
||||
<div class="run-files">${files || '<span class="muted">(keine Dateien)</span>'}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="runs-meta muted">${runs.length} ${runs.length === 1 ? "vergangener Lauf" : "vergangene Läufe"} — neuestes Ergebnis siehst du wie gewohnt im Job selbst.</div>
|
||||
<div class="runs-list">${blocks}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function refreshRuns(jobId) {
|
||||
let data;
|
||||
try {
|
||||
const r = await api(`/api/jobs/${jobId}/runs`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
data = await r.json();
|
||||
} catch (e) {
|
||||
document.querySelectorAll(`[data-runs-id="${jobId}"] .runs-content`).forEach((el) => {
|
||||
el.innerHTML = `<div class="diag-err">Verlauf konnte nicht geladen werden: ${escapeHtml(e.message)}</div>`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const html = renderRuns(jobId, data);
|
||||
document.querySelectorAll(`[data-runs-id="${jobId}"] .runs-content`).forEach((el) => {
|
||||
el.innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshDiag(jobId) {
|
||||
let data;
|
||||
try {
|
||||
@ -493,6 +589,25 @@ document.addEventListener("click", (ev) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Verlauf-Toggle (Snapshot-Liste vergangener LLM-Läufe)
|
||||
document.addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest(".btn-runs");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
const id = btn.dataset.id;
|
||||
const wasOpen = openRunsIds.has(id);
|
||||
if (wasOpen) {
|
||||
openRunsIds.delete(id);
|
||||
document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.add("hidden"));
|
||||
btn.textContent = "Verlauf ▾";
|
||||
} else {
|
||||
openRunsIds.add(id);
|
||||
document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
|
||||
btn.textContent = "Verlauf ▴";
|
||||
refreshRuns(id);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
// Quelle: Aufnahme bevorzugt, sonst File-Input
|
||||
@ -816,6 +931,54 @@ document.addEventListener("click", async (ev) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Reprocess-Button: erlaubt LLM-Lauf auch auf done/failed-Jobs, sichert vorheriges
|
||||
// Ergebnis als Snapshot und nutzt das aktuell auf dem Mac konfigurierte Modell.
|
||||
document.addEventListener("click", async (ev) => {
|
||||
const btn = ev.target.closest(".btn-reprocess");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
const id = btn.dataset.id;
|
||||
|
||||
// Aktuelles Mac-Modell ziehen, damit der Bestätigungsdialog ehrlich anzeigt,
|
||||
// welches Modell jetzt gleich verwendet wird.
|
||||
let model = "(unbekannt)";
|
||||
try {
|
||||
const h = await api("/api/mac/health");
|
||||
if (h.ok) {
|
||||
const j = await h.json();
|
||||
model = j.mac?.ollama_model || model;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const ok = confirm(
|
||||
`Job #${id} erneut mit dem aktuellen Mac-Modell "${model}" verarbeiten?\n\n` +
|
||||
`Das bisherige Ergebnis (Summary, Protokoll, DOCX, PDF) wird im Verlauf als Snapshot gesichert. ` +
|
||||
`Das Transkript bleibt unverändert — nur die LLM-Schritte laufen neu.`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const oldText = btn.textContent;
|
||||
btn.textContent = "wird gestartet …";
|
||||
try {
|
||||
const r = await api(`/api/jobs/${id}/reprocess`, { method: "POST" });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
alert(d.detail || "Konnte nicht neu verarbeiten.");
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
return;
|
||||
}
|
||||
// Verlauf öffnen, damit man den frischen Snapshot direkt sieht.
|
||||
openRunsIds.add(String(id));
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
alert("Netzwerkfehler beim Reprocess.");
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
bootstrap();
|
||||
checkMac();
|
||||
|
||||
@ -569,6 +569,72 @@ select {
|
||||
}
|
||||
.live-timing { color: var(--muted); }
|
||||
|
||||
/* Reprocess-Button (mit anderem Modell neu verarbeiten) – violetter Akzent,
|
||||
um ihn klar vom warning-gelben "Erneut versuchen" zu trennen */
|
||||
.btn-reprocess {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
margin-left: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(155,89,182,.14);
|
||||
color: #b07cd6;
|
||||
border: 1px solid rgba(155,89,182,.45);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-reprocess:hover { background: rgba(155,89,182,.24); }
|
||||
.btn-reprocess:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
/* Verlauf-Toggle – wie Diagnose, aber leicht andere Farbe */
|
||||
.btn-runs {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
margin-left: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: rgba(52,152,219,.10);
|
||||
border: 1px solid rgba(52,152,219,.35);
|
||||
color: #7fbce8;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-runs:hover { background: rgba(52,152,219,.20); }
|
||||
|
||||
/* Verlauf-Panel: gleiches Grundlayout wie diag, separates Styling für Listen-Items */
|
||||
.runs-row > td {
|
||||
background: rgba(52,152,219,.04);
|
||||
border-top: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.runs-panel {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(52,152,219,.05);
|
||||
border: 1px solid rgba(52,152,219,.18);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.runs-meta { font-size: 12px; margin-bottom: 8px; }
|
||||
.runs-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.run-item {
|
||||
padding: 8px 10px;
|
||||
background: rgba(255,255,255,.03);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.run-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.run-model code { font-size: 12px; padding: 1px 5px; }
|
||||
.run-stamp { font-size: 11.5px; color: var(--muted); }
|
||||
.run-files { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
/* Diagnose-Button – wie btn-retry, aber neutraler Ton */
|
||||
.btn-diag {
|
||||
display: inline-block;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user