mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Add audit log
This commit is contained in:
12
src/main/resources/db/migration/V7__channel_audit_log.sql
Normal file
12
src/main/resources/db/migration/V7__channel_audit_log.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS channel_audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
broadcaster TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (broadcaster) REFERENCES channels(broadcaster) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS channel_audit_log_broadcaster_idx ON channel_audit_log (broadcaster);
|
||||
CREATE INDEX IF NOT EXISTS channel_audit_log_created_at_idx ON channel_audit_log (created_at);
|
||||
@@ -2386,3 +2386,98 @@ button:disabled:hover {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-body {
|
||||
background:
|
||||
radial-gradient(circle at 0% 30%, rgba(14, 116, 144, 0.12), transparent 32%),
|
||||
radial-gradient(circle at 85% 0%, rgba(59, 130, 246, 0.16), transparent 30%), #0f172a;
|
||||
}
|
||||
|
||||
.audit-frame {
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px clamp(20px, 5vw, 48px) 48px;
|
||||
}
|
||||
|
||||
.audit-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.audit-title h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.audit-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.audit-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.audit-panel {
|
||||
width: 100%;
|
||||
background: rgba(11, 18, 32, 0.92);
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.audit-panel-header h2 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.audit-table-wrapper {
|
||||
margin-top: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.audit-table thead {
|
||||
text-align: left;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
.audit-table th,
|
||||
.audit-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.audit-table tbody tr:hover {
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
|
||||
.audit-empty {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(30, 41, 59, 0.4);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
62
src/main/resources/static/js/audit-log.js
Normal file
62
src/main/resources/static/js/audit-log.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const auditBody = document.getElementById("audit-log-body");
|
||||
const auditEmpty = document.getElementById("audit-empty");
|
||||
|
||||
const formatTimestamp = (value) => {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const renderEntries = (entries) => {
|
||||
auditBody.innerHTML = "";
|
||||
if (!entries || entries.length === 0) {
|
||||
auditEmpty.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
auditEmpty.classList.add("hidden");
|
||||
entries.forEach((entry) => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const timeCell = document.createElement("td");
|
||||
timeCell.textContent = formatTimestamp(entry.createdAt);
|
||||
row.appendChild(timeCell);
|
||||
|
||||
const actorCell = document.createElement("td");
|
||||
actorCell.textContent = entry.actor || "system";
|
||||
row.appendChild(actorCell);
|
||||
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.textContent = entry.action;
|
||||
row.appendChild(actionCell);
|
||||
|
||||
const detailCell = document.createElement("td");
|
||||
detailCell.textContent = entry.details || "";
|
||||
row.appendChild(detailCell);
|
||||
|
||||
auditBody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const loadAuditLog = () =>
|
||||
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/audit`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load audit log (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((entries) => {
|
||||
renderEntries(entries);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
auditEmpty.textContent = "Unable to load audit entries.";
|
||||
auditEmpty.classList.remove("hidden");
|
||||
});
|
||||
|
||||
loadAuditLog();
|
||||
@@ -44,6 +44,12 @@
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
<span class="sr-only">Back to dashboard</span>
|
||||
</a>
|
||||
<a
|
||||
class="button ghost"
|
||||
th:if="${#strings.equalsIgnoreCase(username, broadcaster)}"
|
||||
th:href="${'/view/' + broadcaster + '/audit'}"
|
||||
>Audit log</a
|
||||
>
|
||||
<a
|
||||
class="button ghost"
|
||||
th:href="${'/view/' + broadcaster + '/broadcast'}"
|
||||
|
||||
52
src/main/resources/templates/audit-log.html
Normal file
52
src/main/resources/templates/audit-log.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Audit Log</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="audit-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
|
||||
<div th:insert="~{fragments/staging :: banner}"></div>
|
||||
<div class="audit-frame">
|
||||
<header class="audit-topbar">
|
||||
<div class="audit-title">
|
||||
<p class="eyebrow subtle">CHANNEL AUDIT LOG</p>
|
||||
<h1 th:text="${broadcaster}"></h1>
|
||||
</div>
|
||||
<div class="audit-actions">
|
||||
<a class="button ghost" th:href="${'/view/' + broadcaster + '/admin'}">Back to admin</a>
|
||||
<a class="button" th:href="@{/}">Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="audit-content">
|
||||
<section class="audit-panel">
|
||||
<div class="audit-panel-header">
|
||||
<div>
|
||||
<h2>Recent activity</h2>
|
||||
<p class="subtle">Latest 200 entries for your channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="audit-table-wrapper">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="audit-log-body"></tbody>
|
||||
</table>
|
||||
<div id="audit-empty" class="audit-empty hidden">No audit entries yet.</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ "";
|
||||
</script>
|
||||
<script type="module" src="/js/audit-log.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user