Create a copy, restore a previous version, or schedule automatic backups.
Storage guidanceBackups are stored in /data/backups. Mount /backups separately to protect against appdata disk failure.
-
Security, health & updates
Configuration safetySite Gateway validates generated Caddy configuration before every reload and retains the active configuration when validation fails.Container updatesUpdates are installed by pulling a new pinned image. Create a backup before changing versions.
Troubleshooting & support
Gateway diagnostics
Run checks and download a redacted report when you need to investigate a gateway issue.
The report includes version, configuration health, certificate readiness, upstream checks, and recent events. Passwords, private keys, session secrets, cookies, and certificate contents are excluded.
+
Security, health & updates
Reliability
Configuration safety & updates
Configuration safety
Validates generated Caddy configuration before every reload and retains the active configuration when validation fails.
Container updates
Installed by pulling a new pinned image. Create a backup before changing versions.
Troubleshooting & support
Gateway diagnostics
Run checks and download a redacted report when you need to investigate a gateway issue.
The report includes version, configuration health, certificate readiness, upstream checks, and recent events. Passwords, private keys, session secrets, cookies, and certificate contents are excluded.
Danger Zone
These actions can permanently remove Site Gateway data. Review each warning carefully before continuing.
Restore defaults
Reset gateway preferences
Restore default site behavior, backup scheduling, certificate thresholds, and interface preferences. Your users, routes, certificates, logs, and backups remain intact.
Permanent action
Factory reset
Deletes all Site Gateway data under /data, including users, routes, certificates, logs, backups, and settings. Docker-mounted files outside /data are not affected. The container restarts at first-install setup.
0No sites running
0No disabled sites
0No issues
Ports 9000–9099
diff --git a/src/public/styles.css b/src/public/styles.css
index 5656ba8..5a3689d 100644
--- a/src/public/styles.css
+++ b/src/public/styles.css
@@ -35,6 +35,7 @@ main{padding-top:24px}.utility-bar{display:flex;align-items:center;justify-conte
.performance-sparkline-labels .time-label{position:absolute;bottom:0;color:var(--muted);font-size:.68rem;white-space:nowrap}
.performance-sparkline-labels .time-label.time-label-end{transform:translateX(-100%)}.row-highlight{background:rgba(98,230,167,.08)}
.user-head-actions{display:flex;align-items:center;gap:10px}
+[data-admin-panel="security"]>h2{margin-bottom:18px}[data-admin-panel="security"]>.dashboard-panel+.dashboard-panel,[data-admin-panel="security"]>.dashboard-panel+form{margin-top:18px}
.user-role-select{appearance:none!important;-webkit-appearance:none!important;height:44px;min-height:44px;width:100%;box-sizing:border-box;padding:0 42px 0 12px;line-height:42px;border:1px solid var(--line);border-radius:9px;background-color:var(--panel);color:var(--text);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23f4f7fb' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px}
:root[data-theme="light"] .user-role-select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23132033' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E")}
@media(max-width:760px){.data-row{grid-template-columns:auto 1fr}.data-row>div:nth-of-type(n+2){grid-column:2}.feature-summary{gap:8px}.feature-summary>div{padding:13px}.log-toolbar{align-items:stretch;flex-direction:column}.log-toolbar label{min-width:0}}
@@ -277,4 +278,5 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:23%;text-align:center}
.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:23%;text-align:center}
.performance-table .count-divider{color:var(--muted);margin:0 2px}
+.performance-table td .http-status.bad[title]{cursor:help;text-decoration:underline dotted;text-underline-offset:3px}
@media(max-width:760px){.performance-table th:nth-child(2),.performance-table td:nth-child(2){display:none}.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:40%}.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:30%}.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:30%}}
diff --git a/src/server.js b/src/server.js
index 30e89b6..4c1285a 100644
--- a/src/server.js
+++ b/src/server.js
@@ -1223,10 +1223,12 @@ app.get("/api/performance", (req, res, next) => {
const host = normalizeDomain(req.query.host);
const hours = Math.min(Math.max(Number.parseInt(req.query.hours, 10) || 6, 1), 168);
const bucketMinutes = hours > 24 ? 60 : 15;
+ const breakdownByHost = new Map();
+ for (const row of storage.performanceErrorBreakdown()) { if (!breakdownByHost.has(row.host)) breakdownByHost.set(row.host, []); breakdownByHost.get(row.host).push({ status: row.status, count: row.count }); }
res.json({
checkedAt: new Date().toISOString(),
liveRequests: storage.performanceLiveCount(60),
- routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null })),
+ routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, errorBreakdown: (breakdownByHost.get(row.host) || []).slice(0, 3) })),
trend: storage.performanceTrend(host, hours, bucketMinutes),
hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort()
});
diff --git a/src/storage.js b/src/storage.js
index fccb03b..8316606 100644
--- a/src/storage.js
+++ b/src/storage.js
@@ -122,6 +122,14 @@ export async function openStorage(dataDir, backupsDir) {
GROUP BY host ORDER BY dayRequests DESC
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
}
+ function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
+ const dayCutoff = new Date(Date.now() - 86400000).toISOString();
+ return db.prepare(`
+ SELECT host, status, COUNT(*) AS count
+ FROM access_events WHERE instance_id=? AND at>=? AND status>=400 AND host IS NOT NULL AND host!=''
+ GROUP BY host, status ORDER BY count DESC
+ `).all(instanceId, dayCutoff);
+ }
function performanceTrend(host = "", hours = 6, bucketMinutes = 15, instanceId = LOCAL_INSTANCE_ID) {
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
@@ -162,5 +170,5 @@ export async function openStorage(dataDir, backupsDir) {
}
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
- return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceTrend, close: () => db.close() };
+ return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, close: () => db.close() };
}