Phase 5: Audit-Spur pro Quelle (ausklappbares Modal)

Backend
- routers/audit.py: GET /api/audit-log nimmt jetzt resource_id als Filter
  (zusätzlich zu resource_type, action, admin_id, from_ts/to_ts).

Frontend
- dashboard.html: modalAudit (Modal) für die Audit-Spur einer Ressource.
- style.css: audit-entry Styles (action-Badge mit Farbcode pro Action-Typ,
  Diff als <details>-Block mit JSON-Pre).
- sources.js:
  - showSourceAudit(id, name) öffnet Modal, lädt /audit-log?resource_type=source&resource_id=...
  - renderAuditEntries: pro Eintrag Action-Badge + Meta (ts/admin/ip) +
    optional ausklappbarer Diff (before/after-JSON)
  - formatDateTime Helper
  - Audit-Button in der Aktionen-Spalte der Grundquellen-Tabelle
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 03:19:32 +00:00
Ursprung 2001815e19
Commit 6b70a7195e
4 geänderte Dateien mit 131 neuen und 0 gelöschten Zeilen

Datei anzeigen

@@ -24,6 +24,7 @@ def _parse_json(s: Optional[str]):
async def list_audit(
action: Optional[str] = None,
resource_type: Optional[str] = None,
resource_id: Optional[int] = None,
admin_id: Optional[int] = None,
from_ts: Optional[str] = None,
to_ts: Optional[str] = None,
@@ -46,6 +47,9 @@ async def list_audit(
if resource_type:
where.append("resource_type = ?")
params.append(resource_type)
if resource_id is not None:
where.append("resource_id = ?")
params.append(resource_id)
if admin_id is not None:
where.append("admin_id = ?")
params.append(admin_id)

Datei anzeigen

@@ -872,3 +872,65 @@ input[type="date"].filter-select { padding: 6px 10px; }
.health-badge-ok { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.health-badge-unknown { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
/* === Audit-Spur (Phase 5) === */
.modal.modal-large {
max-width: 720px;
}
.audit-content {
max-height: 60vh;
overflow-y: auto;
}
.audit-entry {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.02);
}
.audit-entry-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
}
.audit-entry-action {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.audit-action-create { background: rgba(16, 185, 129, 0.2); color: #10b981; }
.audit-action-update { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
.audit-action-delete { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.audit-action-login_success { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.audit-action-login_failed { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.audit-action-login_blocked { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.audit-entry-meta {
color: #94a3b8;
font-size: 12px;
}
.audit-entry-detail {
margin-top: 8px;
}
.audit-entry-detail summary {
cursor: pointer;
font-size: 12px;
color: #94a3b8;
user-select: none;
}
.audit-entry-detail pre {
margin: 6px 0 0 0;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
font-size: 11px;
line-height: 1.4;
overflow-x: auto;
color: #e2e8f0;
max-height: 240px;
}

Datei anzeigen

@@ -642,6 +642,20 @@
</div>
</div>
<!-- Modal: Audit-Spur einer Ressource (Phase 5) -->
<div class="modal-overlay" id="modalAudit">
<div class="modal modal-large">
<div class="modal-header">
<h3 id="auditTitle">Audit-Spur</h3>
<button class="modal-close" onclick="closeModal('modalAudit')">&times;</button>
</div>
<div class="modal-body">
<div id="auditContent" class="audit-content">Lade...</div>
</div>
</div>
</div>
<!-- Modal: Confirm -->
<div class="modal-overlay" id="modalConfirm">
<div class="modal" style="max-width: 400px;">

Datei anzeigen

@@ -63,6 +63,56 @@ async function loadGlobalSources() {
}
async function showSourceAudit(sourceId, sourceName) {
document.getElementById("auditTitle").textContent = `Audit-Spur: ${sourceName}`;
document.getElementById("auditContent").innerHTML = '<div class="text-muted">Lade...</div>';
openModal("modalAudit");
try {
const res = await API.get(`/api/audit-log?resource_type=source&resource_id=${sourceId}&limit=50`);
renderAuditEntries(res.items || []);
} catch (err) {
document.getElementById("auditContent").innerHTML =
`<div class="text-danger">Audit konnte nicht geladen werden: ${esc(err.message || String(err))}</div>`;
}
}
function renderAuditEntries(items) {
const c = document.getElementById("auditContent");
if (!items.length) {
c.innerHTML = '<div class="text-muted">Keine Audit-Eintr&auml;ge f&uuml;r diese Quelle.</div>';
return;
}
c.innerHTML = items.map(e => {
const meta = `${formatDateTime(e.ts)} &middot; ${esc(e.admin_username || "-")} &middot; ${esc(e.ip || "-")}`;
const hasDiff = (e.before && Object.keys(e.before).length) || (e.after && Object.keys(e.after).length);
const diffPayload = JSON.stringify({ before: e.before, after: e.after }, null, 2);
return `
<div class="audit-entry">
<div class="audit-entry-head">
<span class="audit-entry-action audit-action-${esc(e.action)}">${esc(e.action)}</span>
<span class="audit-entry-meta">${meta}</span>
</div>
${hasDiff ? `<details class="audit-entry-detail">
<summary>Diff anzeigen</summary>
<pre>${esc(diffPayload)}</pre>
</details>` : ""}
</div>
`;
}).join("");
}
function formatDateTime(iso) {
if (!iso) return "-";
try {
const d = new Date(iso);
return d.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso; }
}
function renderGlobalStats(stats) {
const bar = document.getElementById("globalStatsBar");
if (!bar) return;
@@ -131,6 +181,7 @@ function renderGlobalSources(sources) {
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>
</td>
</tr>${notesRow}`;