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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')">×</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;">
|
||||
|
||||
@@ -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äge für diese Quelle.</div>';
|
||||
return;
|
||||
}
|
||||
c.innerHTML = items.map(e => {
|
||||
const meta = `${formatDateTime(e.ts)} · ${esc(e.admin_username || "-")} · ${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}`;
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren