Stage A: swap CSS tokens to the real Gitea dark palette (accent blue #4183c4); add Jinja formatting filters (humanbytes/duration/relative_time/localtime/shorten_path/thousands), a health status-header, and an inline SVG icon sprite; fix two UI bugs — unthemed action pages (htmx swap into #action-slot with a full-page HX-Request fallback) and polling wiping form input (hx-preserve). Stage B: four read-only panels that degrade to notices (never 500) — host resources (/proc, two-sample CPU%), VMware module health, template inventory (.vmem warn + last backup), and a path-contained file browser; backup card becomes a validated template dropdown; expensive dir-size walk is TTL-cached. Adds operations/inventory.py plus host_resources()/vmware_module_health() in operations/host.py. Stage C: parse_clone_name() adds a Job column (job_id + started) to the VM table; nav gains a Gitea link-out derived from the OIDC issuer origin (no fabricated run URLs); audit page gets action icons, relative time, and a client-side filter. Gates: 989 tests pass, coverage 94.12%, ruff + mypy --strict clean. Stage D (service management) intentionally deferred.
82 lines
2.8 KiB
HTML
82 lines
2.8 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Audit log - ci-orchestrator{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="card" id="audit-card">
|
|
<h2><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>Audit log</h2>
|
|
{% if entries %}
|
|
<div class="audit-filter">
|
|
<label for="audit-filter-input">Filter</label>
|
|
<input type="text" id="audit-filter-input" autocomplete="off"
|
|
placeholder="Filter by user, action, target, detail..."
|
|
aria-controls="audit-rows">
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>User</th>
|
|
<th>Action</th>
|
|
<th>Target</th>
|
|
<th>Result</th>
|
|
<th>Detail</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="audit-rows">
|
|
{% for e in entries %}
|
|
{% set icon_id = "i-server" if e.action.startswith("vm.")
|
|
else "i-file" if e.action.startswith("cache.")
|
|
else "i-box" if e.action.startswith("host.")
|
|
else "i-gear" if e.action.startswith("service.")
|
|
else "i-alert" %}
|
|
<tr class="{{ 'row-alert' if e.result == 'error' else '' }}">
|
|
<td title="{{ e.ts | localtime }}">{{ e.ts | relative_time }}</td>
|
|
<td>{{ e.user }}</td>
|
|
<td><svg class="icon" aria-hidden="true"><use href="#{{ icon_id }}"></use></svg> {{ e.action }}</td>
|
|
<td><code>{{ e.target }}</code></td>
|
|
<td>
|
|
{% if e.result == "ok" %}
|
|
<span class="badge badge-success">ok</span>
|
|
{% elif e.result == "error" %}
|
|
<span class="badge badge-danger">error</span>
|
|
{% else %}
|
|
<span class="badge badge-warn">{{ e.result }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ e.detail }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<p>No audit entries yet.</p>
|
|
{% endif %}
|
|
</section>
|
|
{% if entries %}
|
|
{#
|
|
Client-side-only filter (C3): no CSP header is set anywhere in this app
|
|
(see web/app.py / security.py), so a plain inline <script> is safe here --
|
|
no backend round-trip needed for a hide/show over rows already on the
|
|
page. Case-insensitive substring match over each row's full text.
|
|
#}
|
|
<script>
|
|
(function () {
|
|
var input = document.getElementById("audit-filter-input");
|
|
var rows = document.getElementById("audit-rows");
|
|
if (!input || !rows) { return; }
|
|
input.addEventListener("input", function () {
|
|
var q = input.value.trim().toLowerCase();
|
|
var trs = rows.getElementsByTagName("tr");
|
|
for (var i = 0; i < trs.length; i++) {
|
|
var text = trs[i].textContent.toLowerCase();
|
|
trs[i].style.display = (q === "" || text.indexOf(q) !== -1) ? "" : "none";
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|