7f7868f7cf
---ci--- phase: 5 milestone: v0.1 status: complete requirements: covered: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20] partial: [] ---/ci---
280 lines
8.0 KiB
HTML
280 lines
8.0 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Audit — CoreCI Broker</title>
|
|
<link rel="stylesheet" href="/ui/shared.css">
|
|
<style>
|
|
.page-sub {
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
margin: 0 0 16px;
|
|
}
|
|
|
|
.table-empty {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.filter-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
|
gap: 12px;
|
|
align-items: end;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.filter-grid .field {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.filter-apply .btn {
|
|
width: 100%;
|
|
}
|
|
|
|
.table-scroll {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.count-line {
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
margin: 12px 0 0;
|
|
}
|
|
|
|
.load-more {
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.badge-denied {
|
|
color: var(--danger);
|
|
border-color: rgba(240, 122, 114, 0.45);
|
|
background: rgba(240, 122, 114, 0.08);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="nav"></div>
|
|
<main class="wrap">
|
|
<section class="card">
|
|
<h1>Audit</h1>
|
|
<p class="page-sub">Read-only trail of broker activity. Set filters and apply to query the audit log.</p>
|
|
<p class="form-error" id="page-error" role="alert" hidden></p>
|
|
<form id="filters" class="filter-grid">
|
|
<div class="field">
|
|
<label for="since">Since</label>
|
|
<input type="datetime-local" id="since" name="since">
|
|
</div>
|
|
<div class="field">
|
|
<label for="until">Until</label>
|
|
<input type="datetime-local" id="until" name="until">
|
|
</div>
|
|
<div class="field">
|
|
<label for="user_id">User ID</label>
|
|
<input type="text" id="user_id" name="user_id" class="mono" autocomplete="off">
|
|
</div>
|
|
<div class="field">
|
|
<label for="app_registration_uuid">App UUID</label>
|
|
<input type="text" id="app_registration_uuid" name="app_registration_uuid" class="mono" autocomplete="off" placeholder="00000000-0000-0000-0000-000000000000">
|
|
</div>
|
|
<div class="field">
|
|
<label for="action">Action</label>
|
|
<select id="action" name="action">
|
|
<option value="">Any action</option>
|
|
<option value="register">register</option>
|
|
<option value="read">read</option>
|
|
<option value="denied">denied</option>
|
|
<option value="login">login</option>
|
|
<option value="logout">logout</option>
|
|
<option value="add_app">add_app</option>
|
|
<option value="remove_app">remove_app</option>
|
|
<option value="add_user">add_user</option>
|
|
<option value="remove_user">remove_user</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="limit">Limit</label>
|
|
<input type="number" id="limit" name="limit" min="1" max="1000" value="100">
|
|
</div>
|
|
<div class="filter-apply">
|
|
<button type="submit" class="btn btn-primary" id="apply">Apply</button>
|
|
</div>
|
|
</form>
|
|
<div class="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>User ID</th>
|
|
<th>App UUID</th>
|
|
<th>Agent ID</th>
|
|
<th>Action</th>
|
|
<th>Path</th>
|
|
<th>Result</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rows"></tbody>
|
|
</table>
|
|
</div>
|
|
<p class="count-line" id="count" hidden></p>
|
|
<div class="load-more">
|
|
<button type="button" class="btn btn-subtle" id="more" hidden>Load more</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<script src="/ui/nav.js"></script>
|
|
<script>
|
|
(function () {
|
|
var API = "/api/broker/audit";
|
|
var FILTERS = ["since", "until", "user_id", "app_registration_uuid", "action"];
|
|
var rows = document.getElementById("rows");
|
|
var pageError = document.getElementById("page-error");
|
|
var filterForm = document.getElementById("filters");
|
|
var limitInput = document.getElementById("limit");
|
|
var countLine = document.getElementById("count");
|
|
var moreButton = document.getElementById("more");
|
|
var state = {filters: {}, cursor: null, shown: 0};
|
|
|
|
function showPageError(message) {
|
|
pageError.textContent = message;
|
|
pageError.hidden = false;
|
|
}
|
|
|
|
function clearPageError() {
|
|
pageError.hidden = true;
|
|
pageError.textContent = "";
|
|
}
|
|
|
|
function messageRow(text) {
|
|
rows.replaceChildren();
|
|
var tr = document.createElement("tr");
|
|
var td = document.createElement("td");
|
|
td.colSpan = 7;
|
|
td.className = "table-empty";
|
|
td.textContent = text;
|
|
tr.appendChild(td);
|
|
rows.appendChild(tr);
|
|
}
|
|
|
|
function textCell(value, mono) {
|
|
var td = document.createElement("td");
|
|
if (mono) {
|
|
td.className = "mono";
|
|
}
|
|
td.textContent = value === null || value === undefined || value === "" ? "—" : String(value);
|
|
return td;
|
|
}
|
|
|
|
function resultBadge(result) {
|
|
var span = document.createElement("span");
|
|
span.className = "badge";
|
|
if (result === "ok") {
|
|
span.className += " badge-active";
|
|
} else if (result === "denied" || result === "error") {
|
|
span.className += " badge-denied";
|
|
}
|
|
span.textContent = result === null || result === undefined || result === "" ? "—" : String(result);
|
|
return span;
|
|
}
|
|
|
|
function renderRow(entry) {
|
|
var tr = document.createElement("tr");
|
|
tr.appendChild(textCell(entry.ts, true));
|
|
tr.appendChild(textCell(entry.user_id, true));
|
|
tr.appendChild(textCell(entry.app_registration_uuid, true));
|
|
tr.appendChild(textCell(entry.agent_id, true));
|
|
tr.appendChild(textCell(entry.action, true));
|
|
tr.appendChild(textCell(entry.path, true));
|
|
var resultCell = document.createElement("td");
|
|
resultCell.appendChild(resultBadge(entry.result));
|
|
tr.appendChild(resultCell);
|
|
return tr;
|
|
}
|
|
|
|
function readFilters() {
|
|
var filters = {};
|
|
FILTERS.forEach(function (name) {
|
|
var value = document.getElementById(name).value.trim();
|
|
if (value) {
|
|
filters[name] = value;
|
|
}
|
|
});
|
|
var limit = parseInt(limitInput.value, 10);
|
|
filters.limit = Number.isFinite(limit) && limit > 0 ? limit : 100;
|
|
return filters;
|
|
}
|
|
|
|
function buildQuery(filters, cursor) {
|
|
var params = [];
|
|
FILTERS.forEach(function (name) {
|
|
if (name === "since" && cursor !== null) {
|
|
return;
|
|
}
|
|
if (filters[name]) {
|
|
params.push(encodeURIComponent(name) + "=" + encodeURIComponent(filters[name]));
|
|
}
|
|
});
|
|
if (cursor !== null) {
|
|
params.push("since=" + encodeURIComponent(cursor));
|
|
}
|
|
params.push("limit=" + encodeURIComponent(String(filters.limit)));
|
|
return "?" + params.join("&");
|
|
}
|
|
|
|
async function load(reset) {
|
|
clearPageError();
|
|
moreButton.hidden = true;
|
|
if (reset) {
|
|
state.cursor = null;
|
|
state.shown = 0;
|
|
countLine.hidden = true;
|
|
countLine.textContent = "";
|
|
messageRow("Loading audit entries…");
|
|
}
|
|
var result = await api(API + buildQuery(state.filters, state.cursor));
|
|
if (!result.ok) {
|
|
var label = result.status === 0 ? "Broker unreachable." : (result.data && result.data.message) || "Could not load audit entries.";
|
|
if (reset) {
|
|
messageRow(label);
|
|
} else {
|
|
showPageError(label);
|
|
moreButton.hidden = false;
|
|
}
|
|
return;
|
|
}
|
|
var data = result.data || {};
|
|
var entries = Array.isArray(data.entries) ? data.entries : [];
|
|
state.cursor = data.next_since === undefined ? null : data.next_since;
|
|
if (reset) {
|
|
rows.replaceChildren();
|
|
}
|
|
entries.forEach(function (entry) {
|
|
rows.appendChild(renderRow(entry));
|
|
});
|
|
state.shown += entries.length;
|
|
if (!state.shown) {
|
|
messageRow("No audit entries match the current filters.");
|
|
countLine.hidden = true;
|
|
return;
|
|
}
|
|
countLine.textContent = state.shown + (state.shown === 1 ? " entry shown." : " entries shown.");
|
|
countLine.hidden = false;
|
|
moreButton.hidden = state.cursor === null;
|
|
}
|
|
|
|
filterForm.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
state.filters = readFilters();
|
|
load(true);
|
|
});
|
|
|
|
moreButton.addEventListener("click", function () {
|
|
load(false);
|
|
});
|
|
|
|
state.filters = readFilters();
|
|
load(true);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html> |