Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 926296d0d7 | |||
| 578d39a3ad | |||
| 8e6a5f9196 | |||
| cf313f7abf | |||
| a413f53577 | |||
| ecb4b1ad0a | |||
| 39f10f7378 |
@@ -12,7 +12,7 @@
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
||||
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
|
||||
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.35-62E6A7">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.42-62E6A7">
|
||||
</p>
|
||||
<p>
|
||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||
@@ -40,14 +40,14 @@ It's intentionally narrower than a general-purpose proxy manager. You describe *
|
||||
| Upload a ZIP or `index.html` and publish static files on a domain and/or a direct port | Point a domain at Plex, Jellyfin, Vaultwarden, or any HTTP app — TLS, HSTS, and headers included | Send one or more domains to a canonical destination with 301/302/307/308 | Forward raw TCP/UDP ports straight to a service — game servers, SSH, anything that isn't HTTP |
|
||||
|
||||
- **Automatic HTTPS** — Caddy issues and renews public certificates; internal, HTTP-only, and uploaded custom-certificate modes are also supported.
|
||||
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, throughput, uptime, memory, disk, and version info at a glance.
|
||||
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, and throughput at a glance, plus a live resource panel (CPU, memory, swap, disk, network, uptime) reading real container-scoped cgroup v2 stats, not host-wide numbers, and auto-refreshing while the page is open.
|
||||
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
|
||||
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes, plus an administrator-side override to disable a locked-out user's 2FA when they've lost their authenticator and used up their recovery codes.
|
||||
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
|
||||
- **API access tokens** — issue scoped (full-access or read-only), optionally expiring bearer tokens for scripts and integrations, revocable at any time.
|
||||
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
|
||||
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
|
||||
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page with environment/integration status, gateway sync, scheduled jobs, and storage usage.
|
||||
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page (Administration) with the same live resource panel as the Dashboard, environment/integration status, gateway sync, scheduled jobs, storage usage, and version/database/public IP details.
|
||||
- **SQLite-backed persistence** — no external database container; everything lives under one `/data` volume.
|
||||
|
||||
Hosted uploads remain static-only (HTML, CSS, JS, images, fonts, downloads). Dynamic applications are connected as Proxy Hosts instead — Site Gateway does not execute uploaded PHP, Node, Python, or database code.
|
||||
|
||||
+16
@@ -196,3 +196,19 @@ Roughly in priority order:
|
||||
`v0.16.34` fixes the System tab hero panel's helper text not lining up across columns -- Network and Throughput don't have a meaningful usage bar (neither is a percentage of anything), so that bar was hidden with `display:none`, which removes it from the flex layout entirely rather than just hiding it. The other four columns (CPU, Memory, Swap, Disk) still have their bar taking up space between the value and the detail line, so Network and Throughput's detail text sat visibly higher than everyone else's, breaking the row's shared baseline. Switched to `visibility:hidden`, which keeps the bar's space reserved without drawing it, so all six columns now keep identical vertical rhythm and every detail line lands on the same line.
|
||||
|
||||
`v0.16.35` unifies the Dashboard's Runtime/System panel with the Administration > System tab's hero panel instead of the two showing different, disagreeing numbers for the same underlying stats. The Dashboard's Memory tile used to read `process.memoryUsage().rss` -- the Node process's own footprint, not the container's real usage -- while the System tab's hero (added in v0.16.29-34) correctly read cgroup v2's `memory.current`. There was no CPU stat on the Dashboard at all, and "Site Gateway data" plus "Storage available" were two separate numbers where the System tab's Disk stat already combined them into one coherent, `DATA_DIR_LIMIT_GB`-aware percentage. Rather than keep two implementations in sync by hand, the Dashboard's panel is now the exact same hero component -- same markup builder, same `renderHeroPanel()` function, same `/api/system/health` endpoint, same polling-while-visible pattern -- so the two can't disagree again, because there's only one implementation computing the numbers. The Dashboard's copy shows CPU, Memory, Swap, Disk, Network, and Uptime; Throughput is left out there since the Dashboard already has its own "requests / min" chip in its metric strip and showing the same number twice added nothing. Uptime keeps ticking client-side exactly as it did before (same `updateDashboardUptime()` timer, just now living inside the hero's sixth slot instead of a standalone tile). Everything else that used to live in that panel -- Site Gateway version, Caddy version, Database status, and Public IP -- moved to the Administration > System tab's existing Version panel, alongside the Site Gateway version and access-URL details already shown there, so nothing was lost, it just now lives with the rest of the deployment's operational metadata instead of being split across two pages. The Dashboard's hero grid also gets its own CSS breakpoint (3 columns by default, 2 below 900px) rather than reusing the System tab's viewport-keyed breakpoints, since it sits inside the Dashboard's half-width two-column layout rather than a full-width panel and would otherwise stay cramped at 6 columns on an ordinary desktop window.
|
||||
|
||||
`v0.16.36` opens `/api/system/health` up to every signed-in user instead of administrators only, so the Dashboard's hero panel (unified with the System tab's in v0.16.35) actually populates for standard users instead of sitting on dashes forever. It's a read-only endpoint with nothing destructive or sensitive behind it -- live CPU/memory/swap/disk/network numbers a standard user could already roughly infer from the Dashboard running fast or slow -- so it now follows the same no-admin-gate pattern as `/api/dashboard` rather than the stricter pattern used by the rest of `/api/system/*` (storage breakdown, restart, restart-policy), which stay administrator-only since those are either configuration detail or capable of restarting the container. Worth keeping in mind: when `DATA_DIR_LIMIT_GB` is set, each poll of this endpoint does a real recursive walk of `/data` to compute Site Gateway's own footprint (see v0.16.32) -- with multiple people viewing the Dashboard at once, each on their own 7-second timer, that's now multiple concurrent walks instead of one administrator's. Not a problem at ordinary usage levels, but worth revisiting (e.g. a shared, briefly-cached snapshot) if it's ever noticeably heavy with a lot of concurrent viewers.
|
||||
|
||||
`v0.16.37` combines the documentation catch-up for the hero panel arc (v0.16.29-36: README feature bullets and the environment-variable table, plus new "Live resource panel" and "Version" sections in the in-app manual's Administration System article, and an updated Dashboard "Runtime & System" article) with a real fix found while reviewing the Dashboard's hero panel live: the Throughput chip next to it (requests/min) was still only updating on the old 30-second `refreshDashboard()` timer, not the hero's 7-second poll, even though `/api/system/health` already computes that exact number on every call and the Dashboard was just discarding it. `refreshDashboardHero()` now also updates the Throughput chip from that same response, so it refreshes on the same cadence as the rest of the hero instead of lagging behind it by up to 23 seconds.
|
||||
|
||||
`v0.16.38` fixes the Dashboard hero's Uptime tile reliably showing "0m" right after a page load or refresh, before slowly counting up from there rather than showing the real elapsed time immediately. Root cause: a 1-second ticker (`setInterval(() => updateDashboardUptime(), 1000)`) has always run independently of the real data fetch, calling `updateDashboardUptime()` with no argument once a second while the Dashboard is visible. The function's old anchor logic (`window.__dashboardStartedAt || (window.__dashboardStartedAt = ...)`) treated a bare, argument-less call as "anchor starts now" (zero elapsed), and because that anchor was set-once, a later call carrying the real `uptimeSeconds` from `/api/dashboard` was then a no-op -- the wrong zero-based anchor had already won the race, almost every time, since the ticker fires every second and the dashboard fetch takes at least one network round trip. `updateDashboardUptime()` now only ever sets the anchor from a real, finite `seconds` value, and does so every time real data arrives rather than once -- so it can't be raced by the bare ticker call (which now just re-renders using whatever anchor already exists, or does nothing until one does), and it also self-corrects if the container genuinely restarts while the page stays open, instead of drifting forever from a stale first anchor. Separately, confirmed by inspection (not a bug, but worth documenting): the Administration System tab's Version panel does *not* tick client-side the way the Dashboard's hero does -- its Uptime is a static string recomputed only when the page's shared dashboard data refetches (on load, or every 30 seconds while the Dashboard view specifically is the active one), so it can go visibly stale while sitting on the Administration tab. Left as-is for now since it's presented as build/version metadata rather than a live stat, but flagged in case a ticking version is wanted there too.
|
||||
|
||||
|
||||
`v0.16.39` retires the Dashboard Uptime tile's separate 1-second client-side ticker, the same mechanism behind the v0.16.38 "resets to 0m on page load" bug, in favor of treating Uptime as just another field on the shared 7-second `/api/system/health` poll that already drives CPU, memory, swap, disk, and network on both the Dashboard and the Administration > System tab's hero panel. Revisiting the ticker after fixing its race condition, it turned out to be solving a precision problem the display doesn't actually have: `formatDuration()` only ever renders minute-level granularity ("2h 59m"), never seconds, so a per-second tick never changed what was on screen between one 7-second poll and the next. `systemHealthSnapshot()` now includes `uptimeSeconds` (from `process.uptime()`, the same source `dashboardSnapshot()` already used), and `renderHeroPanel()`'s former `includeThroughput` boolean became a `sixthSlot` option ("throughput" for the System tab, which has no other requests/min display, or "uptime" for the Dashboard, which already shows Throughput in its own chip) so the sixth hero slot can be either stat without a special case. The client ticker, its anchor state, and the whole race-condition class it enabled are gone: one poll, one code path, six stats, no anchor to get out of sync.
|
||||
|
||||
|
||||
`v0.16.40` moves the Administration → System tab's Version panel Uptime figure onto the same 7-second `/api/system/health` poll driving the hero panel above it, instead of only updating when the page's slower, general dashboard snapshot refetches (on load, or every ~30 seconds while the Dashboard view specifically is active). Found while reviewing the v0.16.39 change: the hero's own Uptime slot was now current to the second, but the separate Version-panel line right below it — the same number, shown twice on the same page — could still be stale by up to half a minute or more. `renderSystemStatus()`'s Uptime is now wrapped in its own `#system-version-uptime` span and updated by a small `updateSystemVersionUptime(health)` helper, called both from the initial render and from the same 7-second timer that already refreshes the hero, so the two Uptime figures on that page can no longer drift apart.
|
||||
|
||||
`v0.16.41` cuts redundant work out of the app's shared `refresh()` cycle -- the single function that populates nearly every page (Hosted Sites, Proxy Hosts, Redirects, Streams, Access Lists, Dashboard, and Certificates all pull from it) -- after a user reported the whole site feeling slow to refresh, most concretely on a plain reload of Hosted Sites or Proxy Hosts. Two real causes, found by reading the actual request path rather than guessing: first, `certificateInventory()` (which walks the certificate directories and parses every `.crt`/`.pem` file on disk) was being fully recomputed from scratch on every single call, and `refresh()` calls it twice per cycle -- once via `/api/dashboard`, once via `/api/certificates` -- so a normal page load did that walk-and-parse work twice for identical results. It now carries a short (3 second) in-memory cache, well under the 7-second hero-poll interval, so back-to-back calls within a cycle share one real disk walk instead of two, and nothing on screen goes more than one cycle stale. Second, and the bigger one: `refreshPendingProxies()` -- triggered whenever a page loads with any enabled proxy that doesn't have cached upstream-health data yet, which is the common case right after a page load or a new proxy -- was calling the *entire* `refresh()` again at +1s, +2s, and +3s until every proxy's health came back. That meant a single pending proxy could quietly trigger three additional full 8-endpoint refetches (each with its own pair of certificate walks) in the six seconds after a page appeared to have finished loading. It now re-fetches only `/api/proxies` on those retries, since upstream health is all it was ever waiting on. Together these remove the two largest sources of duplicated, unnecessary work from the most-used code path in the app; whether they fully account for the reported slowness or whether a client-side rendering cost remains to find is still open and being evaluated against a real before/after comparison.
|
||||
|
||||
`v0.16.42` finds and fixes the real, dominant cause of the site-wide slowness reported after v0.16.41: a live Network-tab capture from the user's own browser showed a flood of requests to `/api/logs/prune/preview`, some queued for over 15 seconds, with unrelated requests (`/api/dashboard`, `/api/system/health`, `/api/system/security`, `/api/system/storage`) stuck at nearly identical multi-second times in the same batch -- the signature of one blocking operation stalling everything behind it, not several independently slow endpoints. Root cause: `renderRetentionPreview()`'s `setInterval(..., 2000)` polls that endpoint every 2 seconds forever, on every page of the app, not just Administration -> Logs & retention, because its "does the panel exist" guard checks a `<section>` that's written into `index.html` from page load and only ever CSS-hidden -- so the guard was always true, everywhere. There was also no protection against a new poll firing while a previous one was still in flight, so once the server answered slower than 2 seconds even once, requests piled up and never caught back up. Compounding it: `previewPruneEvents()` runs five synchronous SQLite COUNT queries, and one of them (`audit_events`) had no index at all -- a full table scan, every call -- and because this app's SQLite queries run synchronously, that scan doesn't just slow its own request, it blocks the entire Node process for every other request being served at that moment. Fixed on both sides: `renderRetentionPreview()` and the sibling `renderRetentionRunStatus()` (previously also running unconditionally every 500ms) now check that the retention panel is actually visible, not just present in the DOM, before doing any work, and an in-flight guard stops a new preview poll from starting until the last one has landed; `audit_events` now has the same `(instance_id, created_at)` index every sibling events table already had. Together these should remove the vast majority of the "8-10 seconds to load a simple page" behavior reported after v0.16.41 -- that fix (deduplicating certificate-inventory work) was real but minor by comparison to this one.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.16.35",
|
||||
"version": "0.16.42",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
+31
-11
@@ -147,7 +147,6 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") {
|
||||
|
||||
|
||||
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
|
||||
// Dashboard tiles share one baseline accent (green) and switch to the existing
|
||||
// --warning / --danger tokens when the thing they count is actually in trouble --
|
||||
// the same mechanism the "Needs attention" chip already used.
|
||||
@@ -214,7 +213,6 @@ function renderDashboard() {
|
||||
$("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`;
|
||||
$("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`;
|
||||
$("#health-checked").innerHTML = `<span class="live-dot" id="health-live-dot"></span>Last checked ${formatTime(data.checkedAt)}`;
|
||||
updateDashboardUptime(data.system.uptimeSeconds);
|
||||
// Memory/Data/Storage/Version/Database/Public IP moved to the Administration > System tab's
|
||||
// Version panel -- the Dashboard's own Runtime/System panel is now the shared hero component
|
||||
// (see renderHeroPanel/refreshDashboardHero), which reads real container-scoped CPU/memory/
|
||||
@@ -228,7 +226,6 @@ function renderDashboard() {
|
||||
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
|
||||
}
|
||||
|
||||
setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
|
||||
|
||||
|
||||
// --- Card rendering helpers (icons, permissions) -----------------------------------
|
||||
@@ -536,12 +533,22 @@ function render() {
|
||||
|
||||
// --- Data refresh helpers ------------------------------------------------------------------
|
||||
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } }
|
||||
// Polls just /api/proxies for upstream health that wasn't ready yet on the last refresh() --
|
||||
// e.g. right after a page load or a new proxy, before its first health check has completed.
|
||||
// This used to call the full refresh() (all 8 endpoints, including two redundant certificate
|
||||
// walks via /api/dashboard + /api/certificates), up to 3 times in a row -- meaning a single
|
||||
// pending proxy could quietly trigger 3 extra full-app refetches over 6 seconds. Since all it
|
||||
// actually needs is fresh upstream status, it now re-fetches only /api/proxies.
|
||||
async function refreshPendingProxies(ids = []) {
|
||||
const pending = new Set(ids.map(String));
|
||||
for (const delay of [1000, 2000, 3000]) {
|
||||
if (!pending.size) return;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
await refresh();
|
||||
try {
|
||||
state.proxies = await api("/api/proxies");
|
||||
render();
|
||||
window.renderExtendedViews?.();
|
||||
} catch { /* Keep the last-known proxy list if this poll fails; the next delay tries again. */ }
|
||||
for (const proxy of state.proxies) if (pending.has(String(proxy.id)) && proxy.upstream) pending.delete(String(proxy.id));
|
||||
}
|
||||
}
|
||||
@@ -550,13 +557,24 @@ async function refreshDashboard() {
|
||||
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
|
||||
finally { button.disabled = false; button.classList.remove("spinning"); }
|
||||
}
|
||||
// Populates the Dashboard's hero panel (CPU/memory/swap/disk/network -- Throughput is skipped
|
||||
// here since the Dashboard already has its own live-requests chip, and Uptime is handled by the
|
||||
// existing updateDashboardUptime() ticker rather than this endpoint) directly from
|
||||
// Populates the Dashboard's hero panel (CPU/memory/swap/disk/network/uptime) directly from
|
||||
// /api/system/health, the same call and the same renderHeroPanel() the Administration > System
|
||||
// tab's hero uses, so the two can never show different numbers for the same live stat again.
|
||||
// Uptime uses sixthSlot: "uptime" here (the System tab uses the default "throughput" slot instead,
|
||||
// since the Dashboard already has its own live-requests chip elsewhere -- see below). There's no
|
||||
// separate ticker or anchor for Uptime anymore: formatDuration() only ever shows minute-level
|
||||
// granularity, so refreshing it on this same 7s poll as everything else is all the precision the
|
||||
// display needs, and it removes a whole class of ticker/anchor race-condition bugs for free.
|
||||
async function refreshDashboardHero() {
|
||||
try { const health = await api("/api/system/health"); window.renderHeroPanel?.("dashboard-hero", health, { includeThroughput: false }); }
|
||||
try {
|
||||
const health = await api("/api/system/health");
|
||||
window.renderHeroPanel?.("dashboard-hero", health, { sixthSlot: "uptime" });
|
||||
// /api/system/health already computes throughput.liveRequests (the hero just doesn't display
|
||||
// it here, since the Dashboard shows it in its own chip instead -- see sixthSlot above).
|
||||
// Reuse that number to keep the chip on the same 7s cadence as the hero, instead of leaving it
|
||||
// on the separate 30s refreshDashboard() timer, which was the actual bug being reported here.
|
||||
const throughputTotal = $("#dash-throughput-total"); if (throughputTotal && health.throughput) throughputTotal.textContent = health.throughput.liveRequests ?? 0;
|
||||
}
|
||||
catch { /* Hero keeps its last-known values if a poll fails -- same behavior as the System tab's own hero. */ }
|
||||
}
|
||||
|
||||
@@ -578,9 +596,11 @@ async function boot() {
|
||||
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
|
||||
// Dashboard hero panel: one immediate load so it isn't sitting on dashes until the first
|
||||
// 7-second tick, then the same lightweight poll-while-visible pattern as the System tab's
|
||||
// hero uses, gated on the Dashboard actually being the visible view.
|
||||
if (state.view === "overview" && canAdmin()) refreshDashboardHero().catch(() => {});
|
||||
if (!state.dashboardHeroTimer) state.dashboardHeroTimer = setInterval(() => { if (state.view === "overview" && canAdmin() && !$("#dashboard").classList.contains("hidden")) refreshDashboardHero().catch(() => {}); }, 7000);
|
||||
// hero uses, gated on the Dashboard actually being the visible view. Available to every
|
||||
// signed-in user, not just administrators -- /api/system/health is read-only and shows
|
||||
// nothing a standard user couldn't already infer from the Dashboard running slow or fast.
|
||||
if (state.view === "overview") refreshDashboardHero().catch(() => {});
|
||||
if (!state.dashboardHeroTimer) state.dashboardHeroTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboardHero().catch(() => {}); }, 7000);
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
|
||||
+41
-12
@@ -318,8 +318,20 @@ function normalizeRetentionLayout() { const form = document.querySelector('[data
|
||||
normalizeRetentionLayout();
|
||||
function cleanRetentionLabels() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form) return; const descriptions = { 'Access logs':'High-volume request records.', 'Gateway activity':'Operational and configuration events.', 'Audit logs':'Administrative accountability records.', 'Certificate events':'Certificate issuance and health changes.', 'Security events':'Authentication and security-related events.' }; [...form.querySelectorAll('label:not(.check-control)')].forEach(field => { const text = field.firstChild; const name = text?.textContent?.trim().replace(/ \(days\)$/, ''); if (!text || !descriptions[name]) return; if (!text.textContent.includes('(days)')) text.textContent = `${name} (days)`; let help = field.querySelector('small'); if (!help) { help = document.createElement('small'); field.append(help); } help.textContent = descriptions[name]; }); }
|
||||
setTimeout(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 0); setInterval(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 300);
|
||||
function renderRetentionRunStatus() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); const form = panel?.querySelector('.retention-form'); if (!panel || !form) return; const value = state.settings?.logsRetention?.lastRunAt ? state.settings.logsRetention : null; let status = panel.querySelector('.retention-run-status'); if (!status) { status = document.createElement('div'); status.className = 'retention-run-status muted'; const actions = form.querySelector('.dialog-actions'); if (actions) actions.before(status); else form.append(status); } status.textContent = value ? `Last run: ${value.lastRunMode || 'manual'} · ${new Date(value.lastRunAt).toLocaleString()} · Snapshot: ${value.lastRunSnapshot || 'available'}` : 'No pruning run yet.'; }
|
||||
async function renderRetentionPreview() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let preview = panel.querySelector('.retention-preview'); if (!preview) { preview = document.createElement('div'); preview.className = 'retention-preview muted'; const form = panel.querySelector('.retention-form'); const status = panel.querySelector('.retention-run-status'); (status || form)?.before(preview); } try { const data = await api('/api/logs/prune/preview'); const counts = data.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0); preview.textContent = data.enabled ? `Eligible to prune: ${total} records · Access ${counts.access || 0} · Activity ${counts.activity || 0} · Certificates ${counts.certificate || 0} · Security ${counts.security || 0} · Audit ${counts.audit || 0}` : 'Pruning is disabled. Enable automatic pruning to preview eligible records.'; } catch { preview.textContent = 'Prune preview unavailable.'; } }
|
||||
function renderRetentionRunStatus() { if (!state.user || !isRetentionPanelVisible()) return; const panel = document.querySelector('[data-admin-panel="retention"]'); const form = panel?.querySelector('.retention-form'); if (!panel || !form) return; const value = state.settings?.logsRetention?.lastRunAt ? state.settings.logsRetention : null; let status = panel.querySelector('.retention-run-status'); if (!status) { status = document.createElement('div'); status.className = 'retention-run-status muted'; const actions = form.querySelector('.dialog-actions'); if (actions) actions.before(status); else form.append(status); } status.textContent = value ? `Last run: ${value.lastRunMode || 'manual'} · ${new Date(value.lastRunAt).toLocaleString()} · Snapshot: ${value.lastRunSnapshot || 'available'}` : 'No pruning run yet.'; }
|
||||
// The retention panel's <section> lives in index.html from page load (just CSS-hidden until its
|
||||
// admin tab is selected), so "does the panel element exist" was never a real visibility check --
|
||||
// it's always true, on every page of the app. That let this run forever, everywhere, not just on
|
||||
// Administration > Logs & retention. Combined with no guard against overlapping calls, a single
|
||||
// slow response (previewPruneEvents() runs several SQLite COUNT queries, synchronously, blocking
|
||||
// the whole server while they run) let requests pile up faster than the server could drain them --
|
||||
// confirmed via a live Network-tab capture showing this same request queued for 15+ seconds while
|
||||
// unrelated requests (dashboard, health, security, storage) sat stuck at nearly the same time,
|
||||
// waiting behind it. isRetentionPanelVisible() below checks the panel is both present AND not
|
||||
// hidden, and retentionPreviewInFlight prevents a new poll from starting until the last one lands.
|
||||
function isRetentionPanelVisible() { const panel = document.querySelector('[data-admin-panel="retention"]'); return Boolean(panel && !panel.classList.contains("hidden")); }
|
||||
let retentionPreviewInFlight = false;
|
||||
async function renderRetentionPreview() { if (!state.user || !isRetentionPanelVisible() || retentionPreviewInFlight) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let preview = panel.querySelector('.retention-preview'); if (!preview) { preview = document.createElement('div'); preview.className = 'retention-preview muted'; const form = panel.querySelector('.retention-form'); const status = panel.querySelector('.retention-run-status'); (status || form)?.before(preview); } retentionPreviewInFlight = true; try { const data = await api('/api/logs/prune/preview'); const counts = data.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0); preview.textContent = data.enabled ? `Eligible to prune: ${total} records · Access ${counts.access || 0} · Activity ${counts.activity || 0} · Certificates ${counts.certificate || 0} · Security ${counts.security || 0} · Audit ${counts.audit || 0}` : 'Pruning is disabled. Enable automatic pruning to preview eligible records.'; } catch { preview.textContent = 'Prune preview unavailable.'; } finally { retentionPreviewInFlight = false; } }
|
||||
async function renderRetentionHistory() { if (!state.user) return; const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let history = panel.querySelector('.retention-history'); if (!history) { history = document.createElement('div'); history.className = 'retention-history'; (panel.querySelector('.retention-run-status') || panel.querySelector('.retention-form'))?.after(history); } try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(0, 50); history.innerHTML = `<div class="retention-history-heading"><strong>Prune history</strong><span>${rows.length} runs</span></div>` + (rows.length ? `<div class="retention-history-list">${rows.map(item => `<div class="retention-history-row"><span class="status-dot ${item.status === 'error' ? 'disabled' : 'running'}"></span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}</small></span></div>`).join('')}</div>` : '<p class="muted">No pruning runs recorded yet.</p>'); } catch { history.innerHTML = '<p class="muted">Prune history unavailable.</p>'; } }
|
||||
function ensureRetentionLoadMore() { const history = document.querySelector('.retention-history'); if (!history || history.querySelector('[data-retention-load-more]')) return; const button = document.createElement('button'); button.className = 'text-button retention-load-more'; button.dataset.retentionLoadMore = 'true'; button.textContent = 'Load more'; history.append(button); }
|
||||
document.addEventListener('click', async event => { const button = event.target.closest('[data-retention-load-more]'); if (!button) return; try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(50); const list = button.parentElement.querySelector('.retention-history-list'); rows.forEach(item => { const row = document.createElement('div'); row.className = 'retention-history-row'; row.innerHTML = `<span class="status-dot ${item.status === 'error' ? 'disabled' : 'running'}"></span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}</small></span>`; list?.append(row); }); button.remove(); } catch { button.textContent = 'History unavailable'; } });
|
||||
@@ -595,8 +607,12 @@ function setHeroStat(prefix, key, { value, percent, detail, tone } = {}) {
|
||||
// isn't available (e.g. no cgroup v2, no readable network interfaces, swap disabled on the host).
|
||||
// Throughput is System-tab-only -- the Dashboard already shows live requests/min in its own chip,
|
||||
// so `includeThroughput: false` there skips it rather than showing the same number twice.
|
||||
function renderHeroPanel(prefix, health, { includeThroughput } = { includeThroughput: true }) {
|
||||
const keys = includeThroughput ? ["cpu", "memory", "swap", "disk", "network", "throughput"] : ["cpu", "memory", "swap", "disk", "network"];
|
||||
function renderHeroPanel(prefix, health, { sixthSlot = "throughput" } = {}) {
|
||||
// sixthSlot picks what the panel's sixth stat is: "throughput" (Administration > System, since
|
||||
// that page has no other requests/min display) or "uptime" (the Dashboard, which already has
|
||||
// its own Throughput chip elsewhere -- showing it twice added nothing). Both come straight off
|
||||
// the same /api/system/health poll as everything else here, no separate ticker or anchor.
|
||||
const keys = ["cpu", "memory", "swap", "disk", "network", sixthSlot];
|
||||
if (!document.querySelector(`#${prefix}-${keys[0]}-value`)) return;
|
||||
if (!health) { keys.forEach(key => setHeroStat(prefix, key, { value: "\u2014", detail: "Unavailable" })); return; }
|
||||
const tone = percent => percent >= 90 ? "critical" : percent >= 75 ? "warning" : "";
|
||||
@@ -622,7 +638,8 @@ function renderHeroPanel(prefix, health, { includeThroughput } = { includeThroug
|
||||
else setHeroStat(prefix, "disk", { value: "\u2014", detail: "Disk stats unavailable" });
|
||||
if (health.network) setHeroStat(prefix, "network", { value: formatRate(health.network.rxBytesPerSec + health.network.txBytesPerSec), percent: 0, detail: `\u2193 ${formatRate(health.network.rxBytesPerSec)} \u00b7 \u2191 ${formatRate(health.network.txBytesPerSec)}` });
|
||||
else setHeroStat(prefix, "network", { value: "\u2014", detail: "Sampling\u2026" });
|
||||
if (includeThroughput) setHeroStat(prefix, "throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" });
|
||||
if (sixthSlot === "throughput") setHeroStat(prefix, "throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" });
|
||||
else if (sixthSlot === "uptime") setHeroStat(prefix, "uptime", Number.isFinite(health.uptimeSeconds) ? { value: formatDuration(health.uptimeSeconds), detail: "Since last restart" } : { value: "\u2014", detail: "Unavailable" });
|
||||
}
|
||||
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
|
||||
function renderSystemPanel() {
|
||||
@@ -686,11 +703,19 @@ function renderSystemPanel() {
|
||||
if (!state.systemHealthTimer) state.systemHealthTimer = setInterval(() => {
|
||||
const systemPanel = document.querySelector('[data-admin-panel="system"]');
|
||||
if (state.view !== "administration" || !systemPanel || systemPanel.classList.contains("hidden")) return;
|
||||
api("/api/system/health").then(health => renderHeroPanel("system-hero", health, { includeThroughput: true })).catch(() => {});
|
||||
api("/api/system/health").then(health => { renderHeroPanel("system-hero", health); updateSystemVersionUptime(health); }).catch(() => {});
|
||||
}, 7000);
|
||||
}
|
||||
renderSystemStatus(panel);
|
||||
}
|
||||
// Updates just the Version panel's Uptime figure from a fresh /api/system/health payload --
|
||||
// kept separate from the rest of renderSystemStatus() so it can be called on the fast 7s hero
|
||||
// poll without re-rendering (or re-fetching) everything else in that panel.
|
||||
function updateSystemVersionUptime(health) {
|
||||
const el = document.querySelector("#system-version-uptime");
|
||||
if (!el) return;
|
||||
el.textContent = Number.isFinite(health?.uptimeSeconds) ? formatDuration(health.uptimeSeconds) : "Unavailable";
|
||||
}
|
||||
async function renderSystemStatus(panel) {
|
||||
panel = panel || document.querySelector('[data-admin-panel="system"]');
|
||||
if (!panel) return;
|
||||
@@ -710,16 +735,19 @@ async function renderSystemStatus(panel) {
|
||||
}
|
||||
if (syncStatus) { const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); syncStatus.textContent = drift ? "Configuration drift detected \u2014 the running gateway no longer matches the last known-good configuration." : `Gateway configuration is in sync. Last reload: ${state.dashboard?.gateway?.lastReload ? formatTime(state.dashboard.gateway.lastReload) : "unknown"}.`; syncStatus.className = drift ? "muted status-warning" : "muted"; }
|
||||
if (version) {
|
||||
// Uptime, Caddy version, Database status, and Public IP used to live on the Dashboard's
|
||||
// Runtime/System panel -- that panel is now the shared hero component (CPU/memory/swap/disk/
|
||||
// network), so this operational metadata moved here instead, reusing the same system.* fields
|
||||
// from the global dashboard snapshot rather than a separate fetch.
|
||||
// Caddy version, Database status, and Public IP used to live on the Dashboard's Runtime/System
|
||||
// panel -- that panel is now the shared hero component (CPU/memory/swap/disk/network), so this
|
||||
// operational metadata moved here instead, reusing the same system.* fields from the global
|
||||
// dashboard snapshot rather than a separate fetch. Uptime is the one exception: it's wrapped in
|
||||
// its own #system-version-uptime span and kept current by updateSystemVersionUptime(), called
|
||||
// from the same 7-second /api/system/health poll that drives the hero panel above, instead of
|
||||
// only refreshing on the slower ~30s dashboard snapshot like the rest of this block.
|
||||
const sys = state.dashboard?.system || {};
|
||||
const uptime = Number.isFinite(sys.uptimeSeconds) ? formatDuration(sys.uptimeSeconds) : "Unavailable";
|
||||
const database = sys.databaseEngine ? `${extendedEscape(sys.databaseEngine)} \u00b7 ${extendedEscape(sys.databaseStatus || "unknown")} \u00b7 ${formatBytes(sys.databaseBytes)}` : "Unavailable";
|
||||
const publicIp = sys.publicIp || (sys.publicIpError ? "Unavailable" : "Checking\u2026");
|
||||
const publicIpDetail = sys.publicIpError ? `check failed \u00b7 ${extendedEscape(sys.publicIpError)}` : sys.publicIpCheckedAt ? `checked ${extendedEscape(formatTime(sys.publicIpCheckedAt))}` : "not yet checked";
|
||||
version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")} \u00b7 Caddy ${extendedEscape(sys.caddyVersion || "unknown")}<br>Uptime: ${uptime} \u00b7 Database: ${database} \u00b7 Public IP: ${extendedEscape(publicIp)} (${publicIpDetail})<br>Access this dashboard at: <code>${extendedEscape(location.origin)}</code><br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> · Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
|
||||
version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")} \u00b7 Caddy ${extendedEscape(sys.caddyVersion || "unknown")}<br>Uptime: <span id="system-version-uptime">${uptime}</span> \u00b7 Database: ${database} \u00b7 Public IP: ${extendedEscape(publicIp)} (${publicIpDetail})<br>Access this dashboard at: <code>${extendedEscape(location.origin)}</code><br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> · Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
|
||||
}
|
||||
try {
|
||||
const [sec, store, policy, health] = await Promise.all([
|
||||
@@ -728,7 +756,8 @@ async function renderSystemStatus(panel) {
|
||||
api("/api/system/restart-policy"),
|
||||
api("/api/system/health").catch(() => null),
|
||||
]);
|
||||
renderHeroPanel("system-hero", health, { includeThroughput: true });
|
||||
renderHeroPanel("system-hero", health);
|
||||
if (health) updateSystemVersionUptime(health);
|
||||
if (security) security.innerHTML = [
|
||||
{ ok: !sec.adminPasswordIsDefault, label: "ADMIN_PASSWORD", detail: sec.adminPasswordIsDefault ? "Still using the built-in default \u2014 set this before exposing the dashboard." : "Configured." },
|
||||
{ ok: !sec.sessionSecretIsDefault, label: "SESSION_SECRET", detail: sec.sessionSecretIsDefault ? "Not set \u2014 sessions are keyed off the admin credentials instead of an independent secret." : "Configured." },
|
||||
|
||||
File diff suppressed because one or more lines are too long
+21
-2
@@ -64,6 +64,14 @@ let caddyVersion = "Unknown";
|
||||
const recentActivity = [];
|
||||
const upstreamHealth = new Map();
|
||||
const certificateStatusCache = new Map();
|
||||
// Certificate inventory is expensive (it walks and parses every certificate file on disk) and is
|
||||
// recomputed on every call with no memoization. It's called twice per client refresh() cycle --
|
||||
// once from /api/dashboard, once from /api/certificates -- and that whole cycle can itself repeat
|
||||
// several times in a row (see refreshPendingProxies in app.js), so a short time-based cache here
|
||||
// collapses that duplicate work into a single real disk walk every few seconds. The window is kept
|
||||
// well under the 7s hero-poll interval so nothing ever appears more than one cycle stale.
|
||||
const CERTIFICATE_INVENTORY_CACHE_MS = 3000;
|
||||
let certificateInventoryCache = null; // { at: number, value: object }
|
||||
const loginAttempts = new Map();
|
||||
const rateLimitBuckets = new Map();
|
||||
let dockerSocketMounted = false;
|
||||
@@ -222,6 +230,10 @@ async function systemHealthSnapshot() {
|
||||
})() : null,
|
||||
network: networkRate,
|
||||
throughput: { liveRequests: storage.performanceLiveCount(60) },
|
||||
// Same source dashboardSnapshot() already uses for its own uptime figure -- included here too
|
||||
// so the Dashboard's hero panel can show Uptime as a plain value on the same 7s poll as every
|
||||
// other hero stat, instead of a separate client-side ticker anchored against a one-time fetch.
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -878,6 +890,7 @@ function certificateNames(certificate) {
|
||||
}
|
||||
|
||||
async function certificateInventory() {
|
||||
if (certificateInventoryCache && Date.now() - certificateInventoryCache.at < CERTIFICATE_INVENTORY_CACHE_MS) return certificateInventoryCache.value;
|
||||
const configured = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))]
|
||||
.filter(item => item.enabled && item.domain && item.tls !== "http");
|
||||
const configuredDomains = configured.flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
|
||||
@@ -904,7 +917,9 @@ async function certificateInventory() {
|
||||
});
|
||||
for (const certificate of certificates) { const previous = certificateStatusCache.get(certificate.domain); if (previous && previous !== certificate.status) recordActivity(`Certificate status changed for ${certificate.domain}: ${previous} → ${certificate.status}.`, certificate.status === "healthy" ? "ok" : "error"); certificateStatusCache.set(certificate.domain, certificate.status); }
|
||||
const latestError = recentActivity.find(item => item.status === "error" && /cert|tls|acme|caddy|gateway/i.test(item.message)) || null;
|
||||
return { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates };
|
||||
const result = { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates };
|
||||
certificateInventoryCache = { at: Date.now(), value: result };
|
||||
return result;
|
||||
}
|
||||
|
||||
async function pruneOrphanedCertificates(candidateDomains) {
|
||||
@@ -1713,8 +1728,12 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
|
||||
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
|
||||
|
||||
// --- System tab: storage usage, restart-policy check, and self-restart -----------------------------------
|
||||
// Read-only, non-destructive live resource stats (CPU/memory/swap/disk/network/throughput) --
|
||||
// shown on the Dashboard for every signed-in user, same as the rest of the Dashboard's health
|
||||
// panel, and additionally on the Administration > System tab's hero for administrators. Unlike
|
||||
// most /api/system/* routes this intentionally isn't administrator-gated, since there's nothing
|
||||
// here a standard user couldn't already infer from the Dashboard being slow or fast.
|
||||
app.get("/api/system/health", async (req, res, next) => {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
try { res.json(await systemHealthSnapshot()); } catch (error) { next(error); }
|
||||
});
|
||||
app.get("/api/system/storage", async (req, res, next) => {
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id));
|
||||
CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS audit_events_instance_created ON audit_events(instance_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
||||
|
||||
Reference in New Issue
Block a user