Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6821a30b65 | |||
| 4e4823b0f7 | |||
| 747c776a84 | |||
| 5b5ba9b3be | |||
| 53b47b77a6 | |||
| 146b7f97ae | |||
| 238c5aedc2 | |||
| ba0f3b68e2 | |||
| dbc143dfbd | |||
| a0565eeaf4 | |||
| 9a81453b86 | |||
| 8e2a195ef3 | |||
| 0030820cab | |||
| 3bda4088c4 | |||
| 547f14d874 | |||
| 424ee26e00 | |||
| 8bca71d9b9 | |||
| a199806554 | |||
| 0685f4ee84 | |||
| bf4859c41e | |||
| 54e9a95bd4 | |||
| 84ba1f2426 | |||
| b227bb568a | |||
| 7b11730c8e | |||
| 6cd51a8a3f | |||
| 7d5842448e | |||
| 40a68b3b96 | |||
| bf524e5835 | |||
| 926296d0d7 | |||
| 578d39a3ad |
@@ -8,3 +8,10 @@ ACME_EMAIL=you@example.com
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
BACKUP_PASSWORD=
|
||||
|
||||
# Optional resource-panel tuning (see compose.release.yaml). Leave commented
|
||||
# to run unlimited / compare Disk against the whole volume.
|
||||
# DATA_DIR_LIMIT_GB=30
|
||||
# MEM_LIMIT=2g
|
||||
# CPU_LIMIT=2
|
||||
# CPUSET=0,1
|
||||
|
||||
@@ -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.40-62E6A7">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.58-62E6A7">
|
||||
</p>
|
||||
<p>
|
||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||
|
||||
+60
-2
@@ -118,8 +118,6 @@ Roughly in priority order:
|
||||
|
||||
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain.
|
||||
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
|
||||
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
|
||||
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
|
||||
- **Tailscale integration** — documented patterns exist today (host-level Tailscale for private dashboard access, a sidecar container for proxying to tailnet-only targets, `tailscale serve`/`funnel` for exposing a route without opening router ports), but nothing is built into Site Gateway itself yet.
|
||||
- **Dynamic DNS** and **deeper Caddy controls** for advanced users who outgrow the guided options.
|
||||
- **Rate limiting** and other specialist gateway controls.
|
||||
@@ -208,3 +206,63 @@ Roughly in priority order:
|
||||
|
||||
|
||||
`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.
|
||||
|
||||
`v0.16.43` scopes page refreshes to the page actually being viewed, instead of every refresh across the entire app unconditionally re-fetching everything -- Hosted Sites, Proxy Hosts, Redirects, Streams, Access Lists, Groups, the full Dashboard snapshot, and Certificates -- regardless of which single page triggered it. This was confirmed directly from the user's own account of the behavior ("if I'm on Hosted Sites and click refresh, it appears the whole entire site refreshes") and traced to a single shared `refresh()` function that every action in the app called: creating or editing a hosted site or proxy, toggling one on or off, deleting an entry, saving gateway settings, and re-syncing the gateway all ran the identical 8-endpoint fetch no matter which page initiated it. `refresh()` and its endpoints are now built from one shared map (`REFRESH_ENDPOINTS`), and a new `refreshCurrentView()` fetches only the state keys a `VIEW_REFRESH_KEYS` table says the active view actually renders -- Hosted Sites now refetches just `sites`, Proxy Hosts just `proxies`, Streaming just `streams`, Redirects just `redirects`, Access Lists just `accessLists` and `groups`. Every action listed above that's only ever reachable from one specific view (creating/editing/toggling/deleting a hosted site or proxy) now calls `refreshCurrentView()` instead of the full `refresh()`. Overview keeps the full, unscoped fetch deliberately: its attention list and the sidebar's per-section counts summarize the whole gateway, not one section of it, so scoping it would defeat the page's purpose; the initial page load (`boot()`) and the gateway re-sync button (only reachable from Overview) are unchanged for the same reason. A new generic refresh button (the same "↻" icon `refresh-health` already used) now appears on every page except Logs (which keeps its own dedicated "Refresh logs" button) so every view has an explicit, page-scoped way to pull fresh data without a full browser reload -- previously several views (Hosted, Proxy Hosts, Streaming, Redirects, Access Lists) had no refresh control of their own at all and only ever picked up new data from the page's initial load or the next full-page reload. One deliberate trade-off: sidebar badge counts for sections other than the one currently being viewed are not part of a scoped refresh and can go briefly stale until the next full refresh (a fresh page load, or a visit to Overview) -- intentional, since fetching data a page doesn't display was the entire problem being fixed here.
|
||||
|
||||
`v0.16.44` is a temporary, diagnostic-only release -- no behavior changes, just logging -- added after v0.16.43 (which fixed the app from over-fetching per page) didn't resolve the user's reported 6-14 second page loads. A Network-tab Timing capture the user sent for a single `GET /api/sites` request showed DNS and TCP connection at 0-7ms but "Waiting" (time to first byte) at 7485ms -- almost the entire delay happened server-side, before the app sent back a single byte of what should be a near-instant, in-memory list. Since this codebase's database and JS execution is single-threaded, that pattern (a trivially cheap request taking seconds) points to something else blocking the whole process at that moment, not a cost specific to any one endpoint. The leading suspect: `importAccessLogsToSqlite()`, a job that runs every 30 seconds, reads Caddy's access-log files, JSON-parses and hashes up to 5000 lines, and batch-inserts them -- all synchronous work with nothing to yield the event loop partway through. Rather than ship a fourth guess-based fix, this release adds two pieces of logging visible in the container's own logs: a warning whenever that import job takes over 500ms (broken down into read/hash/insert time), and a warning whenever any request takes over 1 second to answer. The next slow page load should show, in the logs, either the import job's duration lining up with the slow request's timestamp (confirming the suspect) or a different pattern entirely (pointing somewhere else). Both log lines are marked as temporary instrumentation, intended to be removed once the real cause is confirmed and fixed.
|
||||
|
||||
`v0.16.45` fixes the confirmed root cause behind the multi-second page loads reported after v0.16.41-v0.16.43: the user's own container logs, captured with v0.16.44's temporary diagnostics, showed completely unrelated endpoints -- `/api/dashboard`, `/api/system/security`, `/api/logs/prune/preview` -- all finishing within moments of each other at nearly identical ~8.5-9 second durations, right after the container started. That pattern only happens when several requests are queued behind one shared blocking operation, not when each is independently slow. The culprit: `dashboardSnapshot()` (which every `/api/dashboard` fetch runs) called `storage.integrity()` -- a full `PRAGMA integrity_check`, a complete scan of the entire SQLite database file for corruption, one of the most expensive operations SQLite can run -- on every single call, purely to compute one cosmetic "Healthy"/"Needs attention" label. Because this app's SQLite queries run synchronously, that scan didn't just make its own request slow, it froze the entire single-threaded server for its whole duration, on every dashboard fetch, for every user. The fix moves that check off the request path entirely: a new `refreshDatabaseIntegrityCache()` runs the real scan once shortly after startup and then every 30 minutes in the background, caching just the resulting status string, and `dashboardSnapshot()` now reads that cached value instantly instead of re-scanning the whole database on every poll. The (rarely-used, explicitly manual) downloadable support report still runs a live, real-time integrity check, since that's an appropriate place for a slow, thorough scan. v0.16.44's temporary `[perf]` logging stays in place for this release so the fix's effect is directly visible in the container's own logs -- expect no more `[perf] GET ... took` warnings tied to `/api/dashboard` going forward.
|
||||
|
||||
`v0.16.47` makes the page-scoped refresh button (added in v0.16.43) consistent across every view instead of appearing on most pages but not Logs, and removes a now-redundant control. The button is repositioned to always sit top-right, immediately to the right of that page's green primary action button (“+ New hosted site”, “Run certificate check”, “Refresh logs”) when one is present, or in that same top-right spot when a page has no primary action button of its own; it now also appears on the Logs page rather than being hidden there. A dedicated CSS rule (`.page-refresh{width:44px;height:44px}`) makes the button exactly the same height as the app's existing 44px primary-button standard (the same convention already used for the Backups and Retention action rows), so it visually lines up with the button beside it instead of looking undersized next to it. The Live Health panel's own separate “↻” refresh icon has been removed from the Dashboard, since the page-level refresh button sitting a few pixels away now does the identical job (`refreshDashboard()`, which repopulates that same panel); `refreshDashboard()` itself is unchanged and still runs on its normal 30-second Overview timer, it just no longer drives a second, separate icon's spinner.
|
||||
|
||||
`v0.16.48` is a batch covering five separately-reported items. First, it fixes a real layout regression v0.16.47 introduced: reordering the header's action buttons so the page-refresh icon appeared after the green primary button caused `header`'s `justify-content:space-between` to treat every button as its own flex item and redistribute space between all of them, visibly shifting the green button ("Refresh logs", "Run certificate check", etc.) away from its usual position instead of leaving it in place with the icon simply appended beside it. The buttons are now wrapped in a single `.header-actions` container so `header` only ever splits space between the page title and that one group, and the group's own `gap` keeps its buttons hugging together at the right edge exactly as before v0.16.47. Second, it removes the temporary `[perf]` diagnostic logging added in v0.16.44 (the slow-request middleware and the `importAccessLogsToSqlite` timing breakdown), now fully superseded by v0.16.45's fix and no longer needed. Third, it removes the "Block common exploits" per-Proxy-Host toggle entirely -- its regex-based matcher only ever inspected the request path, never the query string, so it never provided the SQL-injection/XSS protection its label implied; the checkbox, its documentation entry, and every server-side and client-side reference to `blockCommonExploits` are gone. Fourth, it applies the same "cache expensive checks instead of recomputing them on every request" fix used for the database-integrity check in v0.16.45 to the System tab hero panel's disk-usage figure: when `DATA_DIR_LIMIT_GB` is set, the hero panel needs a real recursive walk of `/data` to compute its used-space percentage, and that walk was being redone on every single 7-second hero-panel poll, for every concurrent viewer. It's now computed once shortly after boot and refreshed every 60 seconds in the background (`refreshDataDirSizeCache()`), with the hot request path just reading the cached value -- deployments that don't set `DATA_DIR_LIMIT_GB` are unaffected, since they never triggered this walk in the first place. Fifth, the ROADMAP's own "What's next" section is reconciled against the "Shipped" section above it: two items it listed as upcoming (browsable backup/restore history, a Docker container picker for Proxy/Streaming targets) had already shipped and were removed from the list.
|
||||
|
||||
`v0.16.49` fixes the My Account and Documentation pages' cramped spacing between the header subtitle and the first box below it, reported against several earlier releases. The cause was pinned down precisely by measuring pixel gaps across side-by-side screenshots of a correctly-spaced page (Logs) against the two broken ones: Certificates, Performance, and Logs all get their deliberate spacing from one shared rule, `#certificates-view,#performance-view,#logs-view{margin-top:var(--space-7)}`, and My Account and Documentation were simply never added to that selector, so both fell back to a 0px top margin. The fix adds `#account-view` and `#documentation-view` to that same existing rule -- reusing the app's own established spacing value rather than introducing a new one.
|
||||
|
||||
`v0.16.50` adds a **Hide not configured** checkbox to the Performance page's Throughput by domain table, matching the API Access page's existing "Hide revoked" toggle in both behavior and placement: unchecked by default, resets on every page reload (no server round-trip, no persisted setting), and right-aligned inline with the descriptive text above the table rather than inside the table header itself. Checking it filters out any row already tagged with the "Not configured" chip -- domains Caddy has logged requests for that don't match a real Hosted Site, Proxy Host, or Redirect Host -- so a table with a lot of scanning/bot noise pointed at random hostnames can be narrowed down to just the domains actually configured in Site Gateway.
|
||||
|
||||
`v0.16.51` reworks the Certificates page layout, which had three visually inconsistent, unevenly-spaced blocks stacked on top of each other (a bare certificate inventory list, a borderless "Domain readiness" panel, and a fully-bordered "Renewal thresholds" card) -- the last two had no spacing rule between them at all and rendered flush against one another. Certificate inventory and Domain readiness are now merged into a single sticky-header table (`Domain | Status | Days remaining | Issuer | DNS | TLS | Upstream`), reusing the same table pattern already used on the Performance and Access logs pages, with one row per configured domain instead of two separately-rendered lists keyed off the same data. The deep per-certificate fields that used to live in an inline expandable `<details>` row (issuer, serial number, SHA-256 fingerprint, covered domains, valid-from date, upstream check detail) now open in a click-to-view popup dialog instead, reusing the app's existing `.dialog-card` pattern -- keeping every table row a single fixed height for a clean continuous scroll. The "Renewal thresholds" settings form, which doesn't change per-domain, moved out of a permanent third block into a "Configure thresholds" popup opened from the page header, the same way page-level settings are already surfaced elsewhere in the app.
|
||||
|
||||
`v0.16.52` fixes the Certificates page's Upstream column always showing "Not configured" for Hosted Sites, even when the exact same upstream health check was clearly running and healthy on that site's own dashboard card. The cause was a single overly-narrow condition in `domainReadiness()`: `const upstream = item.kind === "Proxy host" ? upstreamHealth.get(item.id) || null : null;` only ever read cached health-check results back out for Proxy hosts, even though `checkAllProxies()` runs that identical check against Hosted Sites too and stores the result in the same `upstreamHealth` map under the same id -- the data existed the whole time, this function just refused to return it for anything that wasn't a Proxy host. The condition now also includes `"Hosted site"`; Redirect hosts are unaffected and correctly continue to show no upstream data, since they have none.
|
||||
|
||||
`v0.16.53` fixes the "Configure thresholds" popup on the Certificates page (added in v0.16.51) rendering with its first input floating oddly beside the title instead of below it. The cause: `.settings-form` is a shared two-column CSS grid, and the dialog's heading block was placed as a plain grid child instead of spanning both columns like every other full-width element in a settings form already does (`.dialog-actions`, error text, a `<label>` wrapping a textarea). That left the "Renewing-soon warning" field sitting in the grid's second column of row one, directly beside the heading text. `.settings-form>.dialog-heading` is now added to that same existing full-span rule, so the heading spans the full width and the three threshold fields lay out normally beneath it.
|
||||
|
||||
`v0.16.54` gives the Certificates table's Upstream column an accurate three-state color treatment instead of collapsing every non-numeric outcome into a single generic "no response." The underlying `upstreamHealth` data already distinguished four real states -- healthy (has a status code), unmonitored (monitoring intentionally turned off for that host), pending (no check has run yet), and a genuine failure (the check ran and errored or timed out) -- and the Hosted Site / Proxy host cards already labeled these correctly ("Monitoring paused", "Upstream check pending", etc.), but the new table only checked for a numeric status code and printed "no response" for everything else, including deliberately paused monitoring. The column now reads: green "running" dot with the status code for a healthy check, amber "idle" dot with "Monitoring paused" or "Check pending" for the two non-issue states, and a new red `.status-dot.bad` (added to styles.css, reusing the existing `--danger` token) with the actual error message for a real failure -- so a glance at the column now tells you whether something needs attention or is simply not being checked by design.
|
||||
|
||||
`v0.16.55` is a batch covering three items reported against the Certificates and Logs pages. First, the "Configure thresholds" link on the Certificates page moves from floating level with the page title down onto the same line as the descriptive text beneath it, using a dedicated `.section-heading-row` flex row instead of the whole heading block being one flex row. Second, the three-state Upstream coloring added to the Certificates table in v0.16.54 (healthy / monitoring paused or check pending / genuine failure) is now applied consistently everywhere else that shows the same `upstreamHealth` data -- the Hosted Site, Proxy Host, and Streaming Host cards' upstream text now also gets a matching amber `.upstream-copy.idle` state for "Monitoring paused" and "Upstream/Target check pending", instead of rendering identically green to a real healthy check the way it did before. Third, the Logs page's "Gateway events" panel is converted from a stacked list of `.event-row` cards into a sticky-header table (`Time | Severity | Category | Message`), matching the Access requests table directly above it on the same page; the Message column is left free to wrap rather than forced onto one line, since event messages are free-form and of varying length, unlike the fixed-format columns elsewhere.
|
||||
|
||||
`v0.16.56` adds the two background jobs from v0.16.45 and v0.16.48 (the SQLite integrity re-scan and the /data disk-usage walk, both moved off the request path and onto their own timers to fix the multi-second dashboard freezes reported at the time) to the Scheduled jobs list shown on the Dashboard and Administration -> System page. Both caches already tracked their own `checkedAt` timestamp internally, they just were never surfaced in the `jobs` array both pages already render from -- "Database integrity check" (every 30 minutes) and "Disk usage refresh" (every 60 seconds) now appear alongside Upstream checks, Scheduled backups, Log pruning, Access-log import, Public IP check, and Configuration drift check, with live last-run timestamps the same as every other entry in that list.
|
||||
|
||||
`v0.16.57` is a batch of four fixes/improvements against the Logs page and the System-runtime views reported after v0.16.55 and v0.16.56. First, the Gateway events table's Time/Severity/Category columns used percentage widths copied from the Performance table, leaving a lot of empty space around short values on wide screens; they're now fixed pixel widths (190px/110px/140px, same approach the Access requests table already uses), giving the free-form Message column the room it needs. Second, the Gateway events table wasn't visually joined to its heading/filter row above it the way Access requests and the Certificates table are -- the join CSS (`border-top:0`, bottom-only radius) that ties a heading box to the table below it existed for every other table on the app except this one, which is now fixed with the same one-line pattern. Third, the Runtime hero panel's Disk stat (shown on both the Dashboard and Administration -> System) now includes the real host free-space figure in its detail line alongside the assigned-quota percentage (e.g. "480 MB used of 2.0 GB assigned · 316 GB free on host") instead of only showing the quota view -- and the now-redundant standalone "Disk" tile in the System page's Disk usage breakdown panel (which showed the same host free/total figures with no quota context) has been removed, since that panel is otherwise scoped to what Site Gateway itself is storing (Sites, Backups, Certificates, Logs, Database). Fourth, the Runtime hero panel's CPU/Memory/Swap/Disk value text now gets the same amber/red coloring the progress bar underneath it already had at the existing 75%/90% thresholds -- previously only the thin bar changed color as a stat approached its limit, while the large percentage number stayed plain white regardless of severity.
|
||||
|
||||
`v0.16.58` fixes the Gateway events column-width fix from v0.16.57 not actually taking effect. The table carries two classes, `performance-table event-table`, and the generic `.performance-table th:nth-child(n+2){width:11.1%;text-align:center}` rule sits later in styles.css than the `.event-table` column rules added in v0.16.57 -- at equal CSS specificity, source order decides, so the later generic rule was silently winning and the fixed pixel widths never applied. The `.event-table` column selectors are now written as `table.event-table th:nth-child(n)`, adding the `table` type selector so they outrank `.performance-table`'s rules by specificity regardless of where either appears in the file; the Severity/Category text also moves from centered to left-aligned, matching the Access requests table and removing the awkward centered-in-a-wide-column look that was part of the same complaint.
|
||||
|
||||
`v0.16.59` is a release-readiness cleanup pass, prompted by an upcoming public release: a genuine CSS bug, and several stale claims in the in-app Documentation manual that had drifted from what the app actually does after the Certificates/Logs/Runtime work in v0.16.51 through v0.16.58. Fixed: the Documentation page's intro paragraph was rendering in the bright body-text color instead of muted gray, because the CSS rule targeting it used `p:last-child`, which stopped matching once the search box `<div>` was added after it as the real last child -- it now has its own dedicated class instead of relying on element position. In the manual itself: the Certificates overview article said Upstream health only applied to Proxy Hosts (true before v0.16.52, not since); the Certificates field-reference article said certificate detail came from "expanding" a row and that thresholds were set from a nonexistent "Administration -> Security & Health" location (they're on the Certificates page itself, both predating and unrelated to this session's changes); the Dashboard and System articles' Scheduled Jobs lists were missing the database-integrity and disk-usage-refresh jobs added in v0.16.56; the System article still claimed the Disk usage breakdown showed total/remaining capacity, which moved to the Runtime panel's Disk stat in v0.16.57; and neither the Dashboard nor System Runtime sections mentioned the value-text threshold coloring added in v0.16.57. The Logs article also picked up one clarifying sentence noting Gateway Events now has the same pinned column-header behavior as Access Logs. Outside the app: the README version badge and the ZimaOS App Store manifest's `version`/`update_at` fields were several releases stale (0.16.50 and 0.11.101 respectively) and are now current.
|
||||
|
||||
`v0.16.60` fixes the Documentation manual's intro header leaving a large block of empty space on wide screens, by capping the whole `.docs-intro` panel at 900px and centering it. Shipped, then reported back as visibly worse: centering the panel broke its alignment with the Contents sidebar and article panel in `.docs-layout` directly below it, which both remain full-width -- the intro now floats disconnected in the middle of the page instead of lining up with the rest of the manual.
|
||||
|
||||
`v0.16.61` reverts v0.16.60's panel-centering approach and fixes the same empty-space issue correctly. The actual cause was the lede paragraph's own `max-width:850px`, set inside a `.docs-intro` panel that already spans the full reading-column width (matching the Contents sidebar + article layout beneath it) -- the paragraph simply stopped well short of the panel's real width. Removing that max-width lets the paragraph flow to fill the panel it already sits in, while the panel itself stays full-width and aligned with the rest of the manual, exactly as it was before v0.16.60.
|
||||
|
||||
`v0.16.62` fixes two Dashboard issues found while reviewing a fresh screenshot for the marketing site. First, the Certificates tile's big number and its "X healthy · Y not detected" line disagreed with each other (e.g. showing "11" above "12 healthy") because they came from two different counts: the big number used `tlsDomains`, a route-count over enabled Hosted Sites and Proxy Hosts with TLS on, while the detail line used the certificate inventory's domain-level summary -- which also includes Redirect Hosts and expands any route with alias domains into one entry per domain. The big number now reads from that same certificate summary the detail line already used, so a route with an alias domain (the actual cause of the 11-vs-12 mismatch) is counted consistently in both places; the underlying route-count (`tlsDomains`) is untouched where it's used to gate the HTTPS/443 health probe, since that is a genuinely different question ("is TLS configured at all") from "how many certificates exist." Second, the Certificates tile's icon was a plain unicode square (`▣`) left over from before the tile had a real icon design -- it's now a small key glyph drawn as an inline SVG in the app's existing icon style (stroke-based, `currentColor`), matching the key icon used for Certificates on the marketing website and requested directly against a reference image.
|
||||
|
||||
`v0.16.63` converts the Backup history panel (Administration -> Backup & Restore) from a stacked-card timeline to the same pinned-header table used everywhere else records are logged -- Access Logs and Gateway Events. It was the one remaining place in the app presenting a log-like record as a list of `.activity-tile` cards (the same component the Dashboard's Recent Activity feed uses) instead of a table, and looked inconsistent sitting next to those two. The table has four columns -- Time, Action (Backup/Restored/Deleted/Imported), Type (Complete/Configuration/Safety), and Detail (size, a note that a safety backup was taken first, or the failure reason) -- reusing the exact `performance-table`/`event-table` CSS and column widths Gateway Events already established, plus the same green/red `status-dot` convention for success vs. failure. No backend or data changes; `/api/backups/history` already returned everything the new columns needed.
|
||||
|
||||
`v0.16.64` fixes two issues reported right after v0.16.63 shipped the new Backup history table. First, creating, deleting, or importing a backup updated the "Stored backups" list immediately but left Backup history showing stale data until something else triggered a full page refresh -- each of those three handlers called `renderBackups()` afterward but never `renderBackupHistory()`; only Restore happened to update it, incidentally, because it runs the app's full `refresh()` cycle afterward for unrelated reasons. All three now also call `renderBackupHistory()` directly, so every action that writes a history entry reflects it immediately, matching what Restore already did by accident. Second, the table looked lopsided -- all its content bunched on the left with a large empty gap on the right -- because it reused Gateway Events' column widths (`190px/110px/140px/auto`), sized for a Message column that's usually full of long text; Backup history's last column is normally a short phrase like "71 MB", so the table stretched to the full panel width with nothing to fill it. The table now has its own column widths (26%/18%/18%/auto) inside a capped max-width (880px) instead of stretching edge-to-edge, so it reads as one balanced block rather than left-aligned content trailing into empty space.
|
||||
|
||||
`v0.16.65` fixes three issues reported against the Stored backups card and the new Backup history table right after v0.16.63/v0.16.64. First, the "Configuration only" type chip used the same background color as the surrounding panel (`var(--panel2)` on `var(--panel2)`), making it functionally invisible next to the green "Complete" chip -- it now uses the app's purple accent, tinted the same way every other colored chip already is. Second, the "Site Gateway x.x.x" version line under each chip sat flush with the column's left edge while the chip's own text started a few pixels in from its padding, reading as slightly misaligned -- a `.chip+small{margin-left:var(--space-2)}` rule now lines the two up. Third, and most visible: Backup history's width cap from v0.16.64 (880px) was the wrong fix for the column-spacing complaint -- capping the table made it the one log-style table in the app that doesn't span the panel's full width like Access Logs and Gateway Events do, which is a bigger inconsistency than the spacing problem it was meant to solve. The cap is removed; the wider percentage-based columns introduced alongside it (26%/18%/18%/auto) stay, so the table is full-width again but its columns are no longer crowded into the first ~440px the way the borrowed Gateway Events pixel-widths had them.
|
||||
|
||||
`v0.16.66` replaces the generic "X updated" activity message every edit route wrote to Gateway Events -- Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts each logged one identical line regardless of what was actually changed in the save, so a rename, a domain change, a TLS toggle, and a health-check edit were all indistinguishable in the log. Each of the four `PATCH` routes now snapshots the relevant fields before applying the request body, diffs them against the saved result, and writes one comma-joined line naming exactly what changed in that save -- e.g. `Proxy host "Plex" updated — renamed from "Plex Media", target changed to 192.168.1.20:32400, TLS set to automatic.` Multiple fields changed in a single save produce one combined line, not one line per field; saving a form with no actual changes (an Edit dialog opened and immediately saved) now writes nothing at all, instead of the previous generic entry firing unconditionally. Primary fields (name, domain/aliases, target, TLS, HSTS, Access List, health-check on/off, and per-type fields like redirect code/path-preservation or streaming port/protocol) are called out individually; the long tail of advanced settings (custom headers, custom Caddy config, load-balancing upstreams/policy, upstream TLS overrides, and the fine-grained health-check parameters) are bucketed into a single "advanced settings updated" line to keep the summary readable rather than enumerating every possible field.
|
||||
|
||||
`v0.16.67` converts the Administration -> Users -> Audit log tab from a card-row list (`.event-row` divs with a checkmark/bang `.activity-mark`) to the same pinned-header table every other log-type page now uses -- Gateway Events, Access Logs, and Backup history (v0.16.63). It was the last page still on the older list pattern, noticed immediately after v0.16.66 added several new distinct audit entries and made the mismatch obvious side by side with Gateway Events. The table has four columns -- Time, User, Result, Action -- reusing the exact `performance-table`/`event-table` CSS and the standard green/red `status-dot` convention in place of the old custom `.activity-mark` dot. No backend or data changes; `/api/audit` already returned everything the columns needed. The now-orphaned `#audit-list` div styling (border, background, max-height, the bespoke activity-mark colors) is removed along with it, since the table's own wrapper classes already provide the equivalent scrolling/sticky-header container.
|
||||
|
||||
`v0.16.68` is a quick follow-on to v0.16.67: the Audit log's new table had no surrounding card, so it sat flush against the tab content instead of appearing inside the same bordered, elevated "tile" every other tab-hosted table uses -- Backup history's `dashboard-panel backup-history-section` wrapper on the Backups tab being the closest match. The Audit log's table markup is now wrapped in an equivalent `dashboard-panel audit-log-section` with an "Audit log" eyebrow above the existing heading, matching Backup history's structure exactly (search/filter row and table unchanged). Purely a wrapper/markup change -- no new CSS, no data or behavior changes.
|
||||
|
||||
`v0.16.69` closes a gap flagged after reviewing the ZimaOS compose file: `compose.yaml`, `compose.release.yaml`, and `compose.zimaos.yaml` all documented `DATA_DIR_LIMIT_GB` and the `mem_limit`/`cpus`/`cpuset` resource-panel options in README/install.html but never actually carried them as commented-optional entries the way the marketing site's install guide showed -- someone copying a real compose file instead of the docs page got none of that guidance. All three now include the same commented-out block (env-var style in the two static compose files, `${VAR}`-substituted and wired through `.env.example` in `compose.release.yaml`). This also folds in the marketing site's `install.html`, which had drifted out of sync with an already-updated draft and was missing the same options on the user's machine -- resynced so the published guide matches what ships in the repo.
|
||||
|
||||
`v0.16.70` fixes the Access Logs table (Administration > Logs, "Access requests"), the last page still on its own pre-unification CSS: `.log-table` pinned the Status and Duration columns with `position:sticky` and drew a divider `box-shadow` on each -- a horizontal-scroll affordance none of the other log-type pages use -- and left the Request column at a fixed width instead of stretching to fill the panel, so wide viewports showed an empty gap past Duration. Both are removed: the two trailing columns are back in normal table flow, and Request now takes `width:auto` to absorb the remaining space, matching how Gateway Events, Backup history, and the Audit log already size their last column. Purely a CSS change -- no markup, data, or behavior changes.
|
||||
|
||||
`v0.16.71` fixes a Gateway Events / Audit log message that slipped past v0.16.66: the Hosted Site file-upload route ("Replace files") still wrote the old, un-prefixed `Files replaced for "<name>".` line instead of the `Hosted site "<name>" ...` convention every other Hosted Site action uses (created, enabled/disabled, deleted, and the field-level update summaries). It lived in its own route separate from the four `PATCH` handlers v0.16.66 touched, so it was missed at the time and only noticed once a user pointed out the log entry gave no way to tell which route type it belonged to. Now reads `Hosted site "<name>" files replaced.`, matching the rest.
|
||||
|
||||
@@ -20,6 +20,17 @@ services:
|
||||
PUID: ${PUID:-1000}
|
||||
PGID: ${PGID:-1000}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||
# Optional: display-only Disk allowance for the resource panel. Set
|
||||
# DATA_DIR_LIMIT_GB in .env to use.
|
||||
DATA_DIR_LIMIT_GB: ${DATA_DIR_LIMIT_GB:-}
|
||||
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||
# Administration > System tab's live resource panel can only show usage
|
||||
# against the whole host. Uncomment and set in .env to give CPU/Memory a
|
||||
# real, container-scoped denominator (MEM_LIMIT, e.g. "2g"; CPU_LIMIT,
|
||||
# e.g. "2"; or CPUSET, e.g. "0,1", to pin cores instead of a count).
|
||||
# mem_limit: ${MEM_LIMIT}
|
||||
# cpus: ${CPU_LIMIT}
|
||||
# cpuset: ${CPUSET}
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
|
||||
@@ -43,6 +43,20 @@ services:
|
||||
|
||||
# Optional: certificate account email, passed to Caddy's ACME client.
|
||||
ACME_EMAIL: ""
|
||||
|
||||
# Optional: display-only Disk allowance for the resource panel (e.g. a
|
||||
# smaller dedicated share) -- usage/free space still come from the real
|
||||
# volume, this just gives the panel a number to measure against.
|
||||
# DATA_DIR_LIMIT_GB: 30
|
||||
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||
# Administration > System tab's live resource panel can only show usage
|
||||
# against the whole host, which is rarely meaningful on a shared machine.
|
||||
# Setting these gives CPU/Memory a real, container-scoped denominator.
|
||||
# mem_limit: 2g
|
||||
# cpus: "2"
|
||||
# Pinning specific cores is also supported instead of (or alongside) a
|
||||
# count -- the resource panel reads whichever one Docker actually applied.
|
||||
# cpuset: "0,1"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
+17
-2
@@ -30,6 +30,21 @@ services:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
ACME_EMAIL: ""
|
||||
|
||||
# Optional: display-only Disk allowance for the resource panel (e.g. a
|
||||
# smaller dedicated share) -- usage/free space still come from the real
|
||||
# volume, this just gives the panel a number to measure against.
|
||||
# DATA_DIR_LIMIT_GB: 30
|
||||
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||
# Administration > System tab's live resource panel can only show usage
|
||||
# against the whole host, which is rarely meaningful on a shared ZimaOS
|
||||
# box. Setting these gives CPU/Memory a real, container-scoped
|
||||
# denominator instead.
|
||||
# mem_limit: 2g
|
||||
# cpus: "2"
|
||||
# Pinning specific cores is also supported instead of (or alongside) a
|
||||
# count -- the resource panel reads whichever one Docker actually applied.
|
||||
# cpuset: "0,1"
|
||||
ports:
|
||||
- target: 8080
|
||||
published: "8080"
|
||||
@@ -76,8 +91,8 @@ x-casaos:
|
||||
author: mfwadejr
|
||||
developer: mfwadejr
|
||||
architectures: ["amd64", "arm64"]
|
||||
version: "0.11.101"
|
||||
update_at: "2026-09-16"
|
||||
version: "0.16.58"
|
||||
update_at: "2026-09-20"
|
||||
website: https://github.com/mfwadejr/site-gateway2
|
||||
repo: https://github.com/mfwadejr/site-gateway2
|
||||
support: https://github.com/mfwadejr/site-gateway2/issues
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.16.40",
|
||||
"version": "0.16.71",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
+127
-33
@@ -94,7 +94,7 @@ function advancedFormBody(form, body, scoped) {
|
||||
const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback);
|
||||
const checked = (name) => scoped ? Boolean(scoped.formEl.querySelector(`${scoped.scope} [name="${name}"]`)?.checked) : form.has(name);
|
||||
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
|
||||
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure"); body.blockCommonExploits = checked("blockCommonExploits");
|
||||
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure");
|
||||
body.accessListId = read("accessListId", body.accessListId || "");
|
||||
body.requestHeaders = parseHeaderLines(read("requestHeadersText")); body.responseHeaders = parseHeaderLines(read("responseHeadersText")); body.compression = read("compression", "automatic"); body.customConfig = read("customConfig");
|
||||
body.locations = String(form.get("customLocationsText") || "").split("\n").map(line => { const [path, target, behavior] = line.split("|").map(value => value.trim()); return path && target ? { path, target, stripPrefix:behavior.toLowerCase() === "strip" } : null; }).filter(Boolean);
|
||||
@@ -123,7 +123,7 @@ document.addEventListener("submit", async event => {
|
||||
try {
|
||||
await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); }
|
||||
$("#settings-dialog").close(); await refresh(); toast("Gateway settings applied.");
|
||||
$("#settings-dialog").close(); await refreshCurrentView(); toast("Gateway settings applied.");
|
||||
}
|
||||
catch (error) { $("#settings-error").textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
@@ -181,7 +181,7 @@ function renderDashboard() {
|
||||
$("#dash-hosted-detail").textContent = healthCopy(data.hosted, "sites");
|
||||
$("#dash-proxy-total").textContent = data.proxies.total;
|
||||
$("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes");
|
||||
$("#dash-tls-total").textContent = data.tlsDomains;
|
||||
$("#dash-tls-total").textContent = data.certificates.total;
|
||||
$("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains";
|
||||
$("#dash-redirect-total").textContent = state.redirects?.length || 0;
|
||||
$("#dash-stream-total").textContent = state.streams?.length || 0;
|
||||
@@ -241,12 +241,16 @@ function canAdmin() { return state.user?.role === "administrator"; }
|
||||
|
||||
|
||||
// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------
|
||||
function upstreamStateClass(enabled, upstream) {
|
||||
if (!enabled || upstream?.status === "unmonitored" || !upstream || upstream.status === "pending") return "idle";
|
||||
return upstream.status === "healthy" ? "" : "bad";
|
||||
}
|
||||
function hostedCard(site) {
|
||||
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
|
||||
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
|
||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
|
||||
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
|
||||
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
|
||||
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${upstreamStateClass(site.enabled, site.upstream)}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
|
||||
}
|
||||
function proxyCard(proxy) {
|
||||
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
|
||||
@@ -254,7 +258,7 @@ function proxyCard(proxy) {
|
||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
|
||||
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
|
||||
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
|
||||
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
|
||||
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${upstreamStateClass(proxy.enabled, proxy.upstream)}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -265,26 +269,36 @@ function renderCertificates() {
|
||||
$("#cert-healthy").textContent = data.summary.healthy; $("#cert-30").textContent = data.summary.within30Days; $("#cert-7").textContent = data.summary.within7Days; $("#cert-warning").textContent = data.summary.warning + data.summary.critical + data.summary.expired + data.summary.mismatch; $("#cert-pending").textContent = data.summary.pending;
|
||||
const ageMinutes = (Date.now() - new Date(data.checkedAt).getTime()) / 60000, stale = ageMinutes > (data.thresholds?.staleMinutes || 10);
|
||||
$("#cert-last-checked").textContent = `Last checked ${formatTime(data.checkedAt)} · ${stale ? "data may be stale" : "current"}`;
|
||||
$("#certificate-list").innerHTML = data.certificates.length ? data.certificates.map(cert => `<details class="certificate-row"><summary><span class="status-dot ${cert.status === "healthy" ? "running" : cert.status === "pending" ? "idle" : "error"}"></span><span><strong>${escapeHtml(cert.domain)}</strong><small>${escapeHtml(cert.kind)} · ${escapeHtml(cert.name)} · ${escapeHtml(cert.source)}</small></span><span><strong>${cert.expiresAt ? `${cert.daysRemaining} days remaining` : cert.status === "mismatch" ? "Domain mismatch" : "Not detected"}</strong><small>${cert.expiresAt ? `Expires ${formatTime(cert.expiresAt)}` : cert.mismatch ? `Covers: ${(cert.coveredNames || []).map(escapeHtml).join(", ") || "no DNS names"}` : "No stored certificate was found"}</small></span></summary><dl class="certificate-details"><div><dt>Status</dt><dd>${escapeHtml(cert.status)}</dd></div><div><dt>Valid from</dt><dd>${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}</dd></div><div><dt>Issuer</dt><dd>${escapeHtml(cert.issuer || "—")}</dd></div><div><dt>Covered domains</dt><dd>${escapeHtml((cert.coveredNames || []).join(", ") || "—")}</dd></div><div><dt>Serial number</dt><dd>${escapeHtml(cert.serialNumber || "—")}</dd></div><div><dt>SHA-256 fingerprint</dt><dd>${escapeHtml(cert.fingerprint || "—")}</dd></div><div><dt>Last detected update</dt><dd>${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}</dd></div></dl></details>`).join("") : '<p class="quiet-state padded">No HTTPS domains are configured.</p>';
|
||||
renderReadiness();
|
||||
}
|
||||
|
||||
|
||||
// --- Domain readiness (used inside the Certificates view) ---------------------------
|
||||
function renderReadiness() {
|
||||
const routes = state.readiness?.routes || [];
|
||||
$("#readiness-list").innerHTML = routes.length ? routes.map(item => {
|
||||
const dnsOk = item.dns.healthy, portsOk = item.ports.http && item.ports.https !== false;
|
||||
const tlsOk = ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status);
|
||||
const upstreamOk = !item.upstream || item.upstream.status === "healthy";
|
||||
const check = item.upstream;
|
||||
const message = !dnsOk ? `DNS failed${item.dns.error ? ` · ${item.dns.error}` : ""}` : !item.ports.http ? "HTTP port 80 is not responding inside the container" : item.ports.https === false ? "HTTPS port 443 is not responding inside the container" : !tlsOk ? `TLS ${item.tls.status.replaceAll("-", " ")}` : !upstreamOk ? `Upstream ${check?.error || "unavailable"}` : `Ready · DNS ${item.dns.addresses.join(", ")}${check ? ` · upstream ${check.httpStatus || "responding"}` : ""}`;
|
||||
const upstreamDetail = check ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${check.httpStatus ?? "no response"}${check.responseMs != null ? ` · ${check.responseMs} ms` : ""} · ${check.attempts || 1} attempt${(check.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(check.checkedAt))}</dd></div>${check.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(check.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>";
|
||||
return `<details class="certificate-row readiness-row"><summary><span class="status-dot ${dnsOk && portsOk && tlsOk && upstreamOk ? "running" : "error"}"></span><span><strong>${escapeHtml(item.domain)}</strong><small>${escapeHtml(message)}</small></span></summary><dl class="certificate-details"><div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${upstreamDetail}</dl></details>`;
|
||||
}).join("") : '<p class="quiet-state">No configured domains to check.</p>';
|
||||
state.certRows = data.certificates.map(cert => ({ cert, readiness: routes.find(item => item.domain === cert.domain) || null }));
|
||||
$("#certificate-list").innerHTML = state.certRows.length ? state.certRows.map((row, index) => {
|
||||
const cert = row.cert, item = row.readiness;
|
||||
const dnsOk = item ? item.dns.healthy : null;
|
||||
const tlsOk = item ? ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status) : null;
|
||||
const dnsCell = item ? `<span class="status-dot ${dnsOk ? "running" : "error"}"></span>${dnsOk ? "Resolved" : "Failed"}` : `<span class="status-dot idle"></span>—`;
|
||||
const tlsCell = item ? `<span class="status-dot ${tlsOk ? "running" : "error"}"></span>${escapeHtml(item.tls.status.replaceAll("-", " "))}` : `<span class="status-dot idle"></span>—`;
|
||||
const upstreamCell = !item ? `<span class="status-dot idle"></span>—` : !item.upstream || item.upstream.status === "unmonitored" ? `<span class="status-dot idle"></span>Monitoring paused` : item.upstream.status === "pending" ? `<span class="status-dot idle"></span>Check pending` : item.upstream.status === "healthy" ? `<span class="status-dot running"></span>${item.upstream.httpStatus}` : `<span class="status-dot bad"></span>${escapeHtml(item.upstream.error || "Unavailable")}`;
|
||||
const statusLabel = cert.status === "mismatch" ? "Domain mismatch" : cert.status.charAt(0).toUpperCase() + cert.status.slice(1);
|
||||
return `<tr class="cert-table-row" data-index="${index}" tabindex="0"><td><strong>${escapeHtml(cert.domain)}</strong><br><small class="muted">${escapeHtml(cert.kind)} · ${escapeHtml(cert.source)}</small></td><td><span class="status-dot ${cert.status === "healthy" ? "running" : cert.status === "pending" ? "idle" : "error"}"></span>${escapeHtml(statusLabel)}</td><td>${cert.expiresAt ? `${cert.daysRemaining} days` : "—"}</td><td>${escapeHtml(cert.issuer || "—")}</td><td>${dnsCell}</td><td>${tlsCell}</td><td>${upstreamCell}</td></tr>`;
|
||||
}).join("") : '<tr><td colspan="7" class="quiet-state">No HTTPS domains are configured.</td></tr>';
|
||||
}
|
||||
|
||||
|
||||
// --- Certificate detail popup (deep fields for a single certificate row) ------------
|
||||
function openCertificateDetail(row) {
|
||||
const cert = row.cert, item = row.readiness;
|
||||
$("#cert-detail-title").textContent = cert.domain;
|
||||
$("#cert-detail-eyebrow").textContent = `${cert.kind} · ${cert.source}`;
|
||||
const certRows = `<div><dt>Status</dt><dd>${escapeHtml(cert.status)}</dd></div><div><dt>Valid from</dt><dd>${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}</dd></div><div><dt>Expires</dt><dd>${cert.expiresAt ? escapeHtml(formatTime(cert.expiresAt)) : "—"}</dd></div><div><dt>Issuer</dt><dd>${escapeHtml(cert.issuer || "—")}</dd></div><div><dt>Covered domains</dt><dd>${escapeHtml((cert.coveredNames || []).join(", ") || "—")}</dd></div><div><dt>Serial number</dt><dd>${escapeHtml(cert.serialNumber || "—")}</dd></div><div><dt>SHA-256 fingerprint</dt><dd>${escapeHtml(cert.fingerprint || "—")}</dd></div><div><dt>Last detected update</dt><dd>${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}</dd></div>`;
|
||||
const readinessRows = item ? `<div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${item.upstream ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${item.upstream.httpStatus ?? "no response"}${item.upstream.responseMs != null ? ` · ${item.upstream.responseMs} ms` : ""} · ${item.upstream.attempts || 1} attempt${(item.upstream.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(item.upstream.checkedAt))}</dd></div>${item.upstream.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(item.upstream.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>"}` : "<div><dt>Domain readiness</dt><dd>No readiness data available for this domain.</dd></div>";
|
||||
$("#cert-detail-body").innerHTML = certRows + readinessRows;
|
||||
$("#certificate-detail-dialog").showModal();
|
||||
}
|
||||
$("#certificate-list").addEventListener("click", event => { const row = event.target.closest(".cert-table-row"); if (!row) return; const data = state.certRows?.[Number(row.dataset.index)]; if (data) openCertificateDetail(data); });
|
||||
$("#certificate-list").addEventListener("keydown", event => { if (event.key !== "Enter" && event.key !== " ") return; const row = event.target.closest(".cert-table-row"); if (!row) return; event.preventDefault(); const data = state.certRows?.[Number(row.dataset.index)]; if (data) openCertificateDetail(data); });
|
||||
$("#cert-threshold-trigger").addEventListener("click", () => { renderHealthSettings(); $("#health-settings-dialog").showModal(); });
|
||||
|
||||
|
||||
// --- Logs view -------------------------------------------------------------------------
|
||||
function renderLogs() {
|
||||
const data = state.logs; if (!data) return;
|
||||
@@ -296,7 +310,7 @@ function renderLogs() {
|
||||
const categoryOf = message => /cert|tls|https/i.test(message) ? "certificate" : /health|upstream|response|fetch/i.test(message) ? "health" : /login|user|password|access/i.test(message) ? "authentication" : /backup|restore/i.test(message) ? "backup" : /config|route|host|gateway|reload/i.test(message) ? "configuration" : "system";
|
||||
const severity = $("#event-severity").value, category = $("#event-category").value;
|
||||
const activity = data.activity.filter(item => (!severity || item.status === severity) && (!category || categoryOf(item.message) === category));
|
||||
$("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; return `<div class="event-row"><span class="status-dot ${indicatorClass}" aria-label="${escapeHtml(item.status || "ok")}"></span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}</small></span></div>`; }).join("") : '<div class="gateway-empty-state"><span class="status-dot"></span><strong>No matching gateway events</strong><small>Try a different severity or category filter.</small></div>';
|
||||
$("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; const severityLabel = item.status === "error" ? "Error" : item.status === "warning" ? "Warning" : "Normal"; return `<tr><td>${escapeHtml(formatTime(item.at))}</td><td><span class="status-dot ${indicatorClass}"></span>${severityLabel}</td><td>${escapeHtml(eventCategory)}</td><td>${escapeHtml(item.message)}</td></tr>`; }).join("") : '<tr><td colspan="4" class="quiet-state">No matching gateway events. Try a different severity or category filter.</td></tr>';
|
||||
}
|
||||
|
||||
|
||||
@@ -390,7 +404,9 @@ function renderPerformance() {
|
||||
// Hosted Site / Proxy Host / Redirect Host -- those fall through to the Default Site handler
|
||||
// instead of a real backend. Badge those rows so they read as log history, not live config.
|
||||
const configuredDomains = new Set([...state.sites, ...state.proxies, ...state.redirects].flatMap(item => [item.domain, ...(item.domains || [])]).filter(Boolean).map(domain => domain.toLowerCase()));
|
||||
$("#performance-rows").innerHTML = routes.length ? routes.map(route => { const unconfigured = !configuredDomains.has((route.host || "").toLowerCase()); return `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}${unconfigured ? ' <span class="chip unconfigured-chip" title="No Hosted Site, Proxy Host, or Redirect Host currently matches this domain -- these requests hit the Default Site handler instead of a real backend.">Not configured</span>' : ""}</td><td>${countCell(route.hourRequests)}</td><td>${countCell(route.dayRequests)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}</td><td>${(route.dayVisitors || 0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`; }).join("") : '<tr><td colspan="8" class="quiet-state">No requests have been logged yet.</td></tr>';
|
||||
const isUnconfigured = route => !configuredDomains.has((route.host || "").toLowerCase());
|
||||
const visibleRoutes = state.performanceHideUnconfigured ? routes.filter(route => !isUnconfigured(route)) : routes;
|
||||
$("#performance-rows").innerHTML = visibleRoutes.length ? visibleRoutes.map(route => { const unconfigured = isUnconfigured(route); return `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}${unconfigured ? ' <span class="chip unconfigured-chip" title="No Hosted Site, Proxy Host, or Redirect Host currently matches this domain -- these requests hit the Default Site handler instead of a real backend.">Not configured</span>' : ""}</td><td>${countCell(route.hourRequests)}</td><td>${countCell(route.dayRequests)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}</td><td>${(route.dayVisitors || 0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`; }).join("") : `<tr><td colspan="8" class="quiet-state">${routes.length ? "No configured domains match the current filter — uncheck \u201cHide not configured\u201d to see them." : "No requests have been logged yet."}</td></tr>`;
|
||||
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
@@ -532,20 +548,83 @@ 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; }); } }
|
||||
// Each entry is the state key a call populates and the fetch that populates it. refresh() (the
|
||||
// full, unscoped fetch) and refreshCurrentView() (the page-scoped fetch, see below) both build
|
||||
// their request list from this single map, so adding a new piece of shared state only ever means
|
||||
// adding one line here.
|
||||
const REFRESH_ENDPOINTS = {
|
||||
sites: () => api("/api/sites"),
|
||||
proxies: () => api("/api/proxies"),
|
||||
redirects: () => api("/api/redirects"),
|
||||
streams: () => api("/api/streams"),
|
||||
accessLists: () => api("/api/access-lists"),
|
||||
groups: () => canAdmin() ? api("/api/groups") : Promise.resolve([]),
|
||||
dashboard: () => api("/api/dashboard"),
|
||||
certificates: () => api("/api/certificates"),
|
||||
};
|
||||
// Which of the keys above each view actually renders. A view not listed here (certificates, logs,
|
||||
// performance, administration, account, documentation) already loads its own data separately via
|
||||
// loadFeatureView() and never called refresh() at all, so it isn't included. Overview intentionally
|
||||
// lists everything: its attention list and the sidebar's per-section counts summarize the whole
|
||||
// gateway, not one section of it, so a scoped fetch there would defeat the point of the page.
|
||||
const VIEW_REFRESH_KEYS = {
|
||||
overview: Object.keys(REFRESH_ENDPOINTS),
|
||||
hosted: ["sites"],
|
||||
proxies: ["proxies"],
|
||||
streaming: ["streams"],
|
||||
redirects: ["redirects"],
|
||||
access: ["accessLists", "groups"],
|
||||
};
|
||||
async function refreshKeys(keys) {
|
||||
const results = await Promise.allSettled(keys.map(key => REFRESH_ENDPOINTS[key]()));
|
||||
results.forEach((result, index) => { if (result.status === "fulfilled") state[keys[index]] = result.value; });
|
||||
}
|
||||
function maybeRefreshPendingProxies() {
|
||||
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; }); }
|
||||
}
|
||||
// The original, unscoped refresh -- fetches every shared list plus the dashboard and certificate
|
||||
// summaries in one pass. Kept for cases that genuinely need everything at once: first page load
|
||||
// (boot()) and the Overview page, whose attention list and counts summarize the entire gateway.
|
||||
async function refresh() { await refreshKeys(Object.keys(REFRESH_ENDPOINTS)); state.loaded = true; render(); window.renderExtendedViews?.(); maybeRefreshPendingProxies(); }
|
||||
// The page-scoped refresh: fetches only the state a given view actually renders, instead of
|
||||
// unconditionally re-fetching sites, proxies, redirects, streams, access lists, groups, the full
|
||||
// dashboard snapshot, and certificates every single time -- regardless of which one page the user
|
||||
// is looking at. This was the original, most direct cause behind "refreshing one page refetches
|
||||
// the whole site": every action (create, edit, toggle, delete) and every manual refresh called the
|
||||
// same all-8-endpoints refresh() no matter which view triggered it. Sidebar badge counts for
|
||||
// sections other than the current view are not re-fetched by this path and can go briefly stale
|
||||
// until the next full refresh() (a fresh page load, or a visit to Overview) -- an intentional
|
||||
// trade for not fetching data the current page doesn't display.
|
||||
async function refreshCurrentView() {
|
||||
const keys = VIEW_REFRESH_KEYS[state.view] || Object.keys(REFRESH_ENDPOINTS);
|
||||
await refreshKeys(keys);
|
||||
state.loaded = true; render(); window.renderExtendedViews?.();
|
||||
if (keys.includes("proxies")) maybeRefreshPendingProxies();
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
async function refreshDashboard() {
|
||||
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
|
||||
$("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
|
||||
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
|
||||
finally { button.disabled = false; button.classList.remove("spinning"); }
|
||||
finally { /* no-op: the Live Health panel's own refresh icon was removed in favor of the page-level refresh button */ }
|
||||
}
|
||||
// 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
|
||||
@@ -635,6 +714,21 @@ $("#dashboard-view").addEventListener("click", event => { const target = event.t
|
||||
|
||||
// --- Logs & Performance filter controls -----------------------------------------------------
|
||||
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||
// Generic page-scoped refresh button, shown on every view except Logs (which already has its own
|
||||
// "Refresh logs" button wired to loadFeatureView()). Uses refreshCurrentView() for the shared-list
|
||||
// views (Overview, Hosted, Proxy Hosts, Streaming, Redirects, Access Lists) so it fetches only
|
||||
// what that page renders, and falls back to loadFeatureView() for every other view (Certificates,
|
||||
// Performance, Administration, Account, Documentation), which already load their own data scoped
|
||||
// to themselves.
|
||||
$("#refresh-view").addEventListener("click", async () => {
|
||||
const button = $("#refresh-view"); button.disabled = true; button.classList.add("spinning");
|
||||
try {
|
||||
if (state.view in VIEW_REFRESH_KEYS || state.view === "overview") await refreshCurrentView();
|
||||
else await loadFeatureView();
|
||||
toast("Refreshed.");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { button.disabled = false; button.classList.remove("spinning"); }
|
||||
});
|
||||
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||
@@ -668,6 +762,7 @@ $("#performance-rows").addEventListener("click", event => {
|
||||
const pathsButton = event.target.closest("[data-paths-host]");
|
||||
if (pathsButton) { const paths = state.performanceTopPaths?.[pathsButton.dataset.pathsHost]; if (paths) showTopPaths(pathsButton.dataset.pathsHost, paths); }
|
||||
});
|
||||
$("#performance-hide-unconfigured").addEventListener("change", event => { state.performanceHideUnconfigured = event.target.checked; renderPerformance(); });
|
||||
$("#log-status").addEventListener("change", renderLogs);
|
||||
$("#event-severity").addEventListener("change", renderLogs);
|
||||
$("#event-category").addEventListener("change", renderLogs);
|
||||
@@ -691,9 +786,8 @@ document.addEventListener("keydown", event => { if (event.key === "Escape") clos
|
||||
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
|
||||
|
||||
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
|
||||
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message, "error")));
|
||||
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refreshCurrentView(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refreshCurrentView(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||
|
||||
|
||||
// --- Health-check field visibility polish for the create forms ------------------------------
|
||||
@@ -708,7 +802,7 @@ function openSettings(kind, id) {
|
||||
form.elements.name.value = item.name || ""; form.elements.domain.value = item.domain || ""; form.elements.target.value = item.target || ""; form.elements.tls.value = item.tls || "automatic"; form.elements.hsts.checked = Boolean(item.hsts); if (form.elements.settingsAccessListId) form.elements.settingsAccessListId.value = item.accessListId || "";
|
||||
if (kind === "proxy") {
|
||||
const scope = "#settings-advanced";
|
||||
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "blockCommonExploits", Boolean(item.blockCommonExploits));
|
||||
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic");
|
||||
form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n");
|
||||
setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"));
|
||||
form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; setScoped(form, scope, "upstreamTlsInsecure", Boolean(item.upstreamTlsInsecure)); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || ""); form.elements.upstreamsText.value = (item.upstreams || []).join("\n"); setScoped(form, scope, "lbPolicy", item.lbPolicy || "random");
|
||||
@@ -727,7 +821,7 @@ $("#site-grid").addEventListener("click", async event => {
|
||||
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
|
||||
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
|
||||
closeMenus();
|
||||
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } }
|
||||
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refreshCurrentView(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } }
|
||||
if (action === "settings") openSettings(kind, card.dataset.id);
|
||||
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
|
||||
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
|
||||
@@ -771,7 +865,7 @@ window.openCaddyConfig = openCaddyConfig;
|
||||
|
||||
|
||||
// --- Delete confirmation dialog and replace-files handler ------------------------------------
|
||||
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
|
||||
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refreshCurrentView(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
|
||||
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; });
|
||||
|
||||
|
||||
|
||||
+36
-16
File diff suppressed because one or more lines are too long
+31
-12
File diff suppressed because one or more lines are too long
+41
-23
@@ -52,6 +52,7 @@ h2{letter-spacing:-.025em}
|
||||
.status-dot.running{background:var(--green);box-shadow:0 0 0 4px rgba(var(--green-rgb),.1)}
|
||||
.status-dot.disabled{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
|
||||
.status-dot.error,.status-dot.idle{background:var(--warning);box-shadow:0 0 0 4px rgba(var(--warning-rgb),.09)}
|
||||
.status-dot.bad{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
|
||||
|
||||
/* Hosted Site / Proxy Host cards, card menu, and the toggle switch */
|
||||
.site-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px}
|
||||
@@ -64,6 +65,8 @@ h2{letter-spacing:-.025em}
|
||||
.card-footer{position:absolute;left:20px;right:20px;bottom:20px}
|
||||
.status-pill{display:flex;align-items:center;gap:var(--space-2);text-transform:capitalize;font-size:var(--font-size-sm);color:var(--muted)}
|
||||
.icon-button,.launch{width:34px;height:34px;border-radius:var(--radius-sm);border:1px solid var(--line);display:grid;place-items:center;background:var(--icon-button-bg);color:var(--muted);cursor:pointer;text-decoration:none}
|
||||
.page-refresh{width:44px;height:44px;flex:0 0 auto}
|
||||
.header-actions{display:flex;align-items:center;gap:var(--space-5)}
|
||||
.menu-wrap{position:relative}
|
||||
.menu{display:none;position:absolute;right:0;top:var(--space-7);width:145px;background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-2xs);padding:6px;box-shadow:var(--shadow);z-index:3}
|
||||
.menu-open .menu{display:block}
|
||||
@@ -149,7 +152,7 @@ header{align-items:flex-end}
|
||||
/* Dashboard */
|
||||
.mobile-nav{display:none}
|
||||
.dashboard-view{margin-top:38px}
|
||||
#certificates-view,#performance-view,#logs-view{margin-top:var(--space-7)}
|
||||
#certificates-view,#performance-view,#logs-view,#account-view,#documentation-view{margin-top:var(--space-7)}
|
||||
.metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-4)}
|
||||
.metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));color:var(--text);text-align:left;position:relative;overflow:hidden}
|
||||
.metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85}
|
||||
@@ -250,6 +253,18 @@ header{align-items:flex-end}
|
||||
@media(max-width:760px){.mobile-nav{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:0 0 30px;padding:var(--space-1);border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel)}.mobile-nav button{justify-content:center;text-align:center;padding:9px 5px;font-size:.72rem}.dashboard-view{margin-top:30px}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric-card{padding:var(--space-4)}.metric-card strong{font-size:1.65rem}.dashboard-columns{grid-template-columns:1fr}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-strip{grid-template-columns:1fr}.metric-chip{padding:11px 14px}}
|
||||
.upstream-copy{margin:10px 0 0;color:var(--green);font-size:.72rem}
|
||||
.upstream-copy.bad{color:var(--danger)}
|
||||
.upstream-copy.idle{color:var(--warning)}
|
||||
.event-table-wrap{max-height:min(52vh,620px);overflow:auto;margin-top:var(--space-4)}
|
||||
.event-table-wrap .event-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
|
||||
table.event-table th:nth-child(1),table.event-table td:nth-child(1){width:190px;white-space:nowrap;text-align:left}
|
||||
table.event-table th:nth-child(2),table.event-table td:nth-child(2){width:110px;white-space:nowrap;text-align:left}
|
||||
table.event-table th:nth-child(3),table.event-table td:nth-child(3){width:140px;white-space:nowrap;text-align:left}
|
||||
table.event-table th:nth-child(4),table.event-table td:nth-child(4){width:auto;white-space:normal;overflow-wrap:anywhere;text-align:left}
|
||||
@media(max-width:900px){table.event-table th:nth-child(1),table.event-table td:nth-child(1){width:150px}}
|
||||
.event-table td .status-dot{margin-right:6px;vertical-align:-1px}
|
||||
table.backup-history-table th:nth-child(1),table.backup-history-table td:nth-child(1){width:26%}
|
||||
table.backup-history-table th:nth-child(2),table.backup-history-table td:nth-child(2),table.backup-history-table th:nth-child(3),table.backup-history-table td:nth-child(3){width:18%}
|
||||
table.backup-history-table th:nth-child(4),table.backup-history-table td:nth-child(4){width:auto}
|
||||
.activity-mark.bad{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
|
||||
.feature-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:18px}
|
||||
.feature-summary>div{padding:18px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
|
||||
@@ -265,6 +280,9 @@ header{align-items:flex-end}
|
||||
.log-toolbar{display:flex;align-items:end;justify-content:space-between;gap:var(--space-4)}
|
||||
.log-toolbar label{margin:0;min-width:250px}
|
||||
.feature-note{margin:var(--space-4) 0}
|
||||
.feature-note-row{display:flex;align-items:center;gap:var(--space-4)}
|
||||
.feature-note-row .feature-note{flex:1;min-width:0}
|
||||
.performance-hide-unconfigured{margin:0 0 0 auto;padding:0;border:0;background:transparent;font-size:.85rem;flex-shrink:0;white-space:nowrap}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
|
||||
.log-table{width:100%;border-collapse:collapse;font-size:var(--font-size-md)}
|
||||
.log-table th,.log-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);white-space:nowrap}
|
||||
@@ -354,7 +372,7 @@ header{align-items:flex-end}
|
||||
|
||||
/* Generic settings-form layout */
|
||||
.settings-form{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin-top:22px;padding:22px;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--panel)}
|
||||
.settings-form>label:has(textarea),.settings-form>.dialog-actions,.settings-form>.error{grid-column:1/-1}
|
||||
.settings-form>label:has(textarea),.settings-form>.dialog-actions,.settings-form>.error,.settings-form>.dialog-heading{grid-column:1/-1}
|
||||
.settings-form.compact-grid{grid-template-columns:repeat(4,1fr)}
|
||||
.settings-form.compact-grid .check-control,.settings-form.compact-grid .dialog-actions{grid-column:auto}
|
||||
textarea{display:block;width:100%;min-height:110px;margin-top:7px;padding:var(--space-3);border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--field-bg);color:var(--text);font:inherit;resize:vertical;outline:none}
|
||||
@@ -372,8 +390,9 @@ textarea:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(var(--green-r
|
||||
.redirect-card .card-footer{gap:var(--space-3)}
|
||||
.redirect-card .button{padding:7px 9px;font-size:var(--font-size-xs)}
|
||||
.chip{padding:5px var(--space-2);border-radius:var(--radius-full);background:var(--panel2);color:var(--muted);font-size:var(--font-size-xs)}
|
||||
.chip+small{margin-left:var(--space-2)}
|
||||
.chip.type-complete{background:rgba(var(--green-rgb),.15);color:var(--green)}
|
||||
.chip.type-configuration{background:var(--panel2);color:var(--muted)}
|
||||
.chip.type-configuration{background:color-mix(in srgb,var(--accent-purple) 15%,transparent);color:var(--accent-purple)}
|
||||
.chip.type-encrypted{background:rgba(var(--chip-blue-rgb),.15);color:var(--blue)}
|
||||
.chip.type-unknown{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
|
||||
.backup-summary{display:block;margin-top:6px}
|
||||
@@ -468,25 +487,21 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
||||
#app .upstream-diagnostics summary{padding:0;color:var(--blue);font-size:var(--font-size-xs);font-weight:700;border:0}
|
||||
#app .upstream-diagnostics .upstream-detail{margin:6px 0 0}
|
||||
|
||||
/* Log table layout & column widths */
|
||||
/* Log table layout & column widths -- matches the .event-table/.performance-table
|
||||
pattern used by Gateway Events, Backup history, and the Audit log: sticky header
|
||||
only (no pinned/divided trailing columns), and the Request column absorbs the
|
||||
remaining width instead of leaving a gap on wide panels. */
|
||||
.log-table-wrap{max-height:min(52vh,620px);overflow:auto}
|
||||
.log-table-wrap .log-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
|
||||
.log-section-heading{margin:20px 0 10px}
|
||||
.log-section-heading h2{margin:0;font-size:1.05rem}
|
||||
.log-section-heading p{margin:var(--space-1) 0 0;font-size:.76rem}
|
||||
@media(min-width:761px){#dashboard>aside{position:sticky;top:0;height:100vh;max-height:100vh;overflow-y:auto;align-self:start}}
|
||||
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2),.log-table th:last-child,.log-table td:last-child{position:sticky;background:var(--panel);z-index:2}
|
||||
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2){right:104px;min-width:104px}
|
||||
.log-table th:last-child,.log-table td:last-child{right:0;min-width:104px}
|
||||
.log-table th:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
|
||||
.log-table td:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
|
||||
.log-table-wrap{overflow-x:hidden;overflow-y:auto}
|
||||
.log-table{table-layout:fixed}
|
||||
.log-table th:nth-child(1),.log-table td:nth-child(1){width:190px}
|
||||
.log-table th:nth-child(2),.log-table td:nth-child(2){width:220px}
|
||||
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){width:104px}
|
||||
.log-table th:nth-child(3),.log-table td:nth-child(3){white-space:normal;overflow-wrap:anywhere}
|
||||
.log-table th:last-child,.log-table td:last-child{box-shadow:-1px 0 0 var(--line)}
|
||||
.log-table th:nth-child(3),.log-table td:nth-child(3){width:auto;white-space:normal;overflow-wrap:anywhere}
|
||||
@media(max-width:900px){.log-table th:nth-child(1),.log-table td:nth-child(1){width:150px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:170px}}
|
||||
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){text-align:center}
|
||||
#access-dialog .dialog-heading .close-dialog{display:none}
|
||||
@@ -673,7 +688,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
||||
/* Documentation manual: intro & search */
|
||||
.docs-intro{margin-bottom:22px;padding:var(--space-5);border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--bg);color:var(--text)}
|
||||
.docs-intro h2{margin:0 0 10px}
|
||||
.docs-intro p:last-child{margin:0;color:var(--muted);max-width:850px;line-height:1.6}
|
||||
.docs-intro .docs-lede{margin:0;color:var(--muted);line-height:1.6}
|
||||
.docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}
|
||||
.docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
|
||||
.docs-nav .eyebrow{margin:var(--space-1) 8px 8px}
|
||||
@@ -807,10 +822,6 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.event-technical{margin-top:6px}
|
||||
.event-technical summary{padding:0;color:var(--blue);font-size:.7rem;cursor:pointer}
|
||||
.event-technical code{display:block;margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:var(--font-size-xs)}
|
||||
#audit-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel);scrollbar-gutter:stable}
|
||||
#audit-list .event-row{align-items:center;padding:18px 20px}
|
||||
#audit-list .activity-mark{width:8px;height:8px;min-width:8px;border-radius:50%;padding:0;font-size:0;background:var(--green);box-shadow:0 0 0 4px rgba(var(--green-rgb),.1)}
|
||||
#audit-list .activity-mark.bad{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
|
||||
|
||||
/* Diagnostic section headings & readiness panels */
|
||||
.diagnostic-section-heading{margin:20px 0 10px;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)}
|
||||
@@ -819,11 +830,22 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.diagnostic-section-heading p:last-child{margin:var(--space-1) 0 0}
|
||||
.diagnostic-section-heading{margin-bottom:0}
|
||||
.diagnostic-section-heading + .log-table-wrap{border-top:0;border-radius:0 0 15px 15px}
|
||||
.section-heading-row{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-4)}
|
||||
.section-heading-row .muted{margin:0}
|
||||
.diagnostic-section-heading + .cert-table-wrap{border-top:0;border-radius:0 0 15px 15px}
|
||||
.cert-table-row{cursor:pointer}
|
||||
.cert-table-row:hover{background:rgba(var(--green-rgb),.06)}
|
||||
.cert-table-row:focus{outline:none}
|
||||
.cert-table-row:focus-visible{outline:2px solid var(--green);outline-offset:-2px}
|
||||
.cert-table td{vertical-align:middle}
|
||||
.cert-table td .status-dot{margin-right:6px;vertical-align:-1px}
|
||||
#cert-detail-body{padding:0;background:transparent;border-top:0;margin-top:var(--space-4)}
|
||||
#certificate-list.diagnostic-list,#readiness-list.diagnostic-list,#gateway-log-list.diagnostic-list,#audit-list.diagnostic-list{border-radius:0 0 15px 15px}
|
||||
.data-list>.quiet-state,.diagnostic-list>.quiet-state{padding:22px}
|
||||
.readiness-panel,.log-activity{background:transparent;border:0;padding:0}
|
||||
.readiness-panel .panel-heading,.log-activity .panel-heading{margin:20px 0 0;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)}
|
||||
.log-activity .event-filters{margin:0;padding:0 20px 15px;border-left:1px solid var(--line);border-right:1px solid var(--line);background:var(--panel)}
|
||||
.log-activity .event-table-wrap{border-top:0;border-radius:0 0 15px 15px;margin-top:0}
|
||||
.log-activity .diagnostic-list{border:1px solid var(--line);border-radius:0 0 15px 15px;background:var(--panel);overflow:auto}
|
||||
.log-activity .event-row{gap:var(--space-4);padding:18px 20px;align-items:center}
|
||||
.log-activity .event-row .status-dot{flex:0 0 8px}
|
||||
@@ -968,12 +990,6 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.api-token-hide-revoked span{line-height:normal}
|
||||
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
|
||||
|
||||
/* Backup history timeline */
|
||||
.backup-history-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
|
||||
.backup-history-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.backup-history-copy strong{font-size:var(--font-size-md)}
|
||||
.backup-history-copy small{color:var(--muted);font-size:var(--font-size-xs)}
|
||||
.backup-history-copy small.failed{color:var(--danger)}
|
||||
.check-control.is-disabled{opacity:.55}
|
||||
.check-control.is-disabled span{color:var(--muted)}
|
||||
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
|
||||
@@ -985,6 +1001,8 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.system-hero-stat{min-width:0;display:flex;flex-direction:column;gap:6px}
|
||||
.system-hero-label{color:var(--muted);font-size:var(--font-size-sm);font-weight:650;text-transform:uppercase;letter-spacing:.04em}
|
||||
.system-hero-value{font-size:1.5rem;font-weight:800;line-height:1.1}
|
||||
.system-hero-value.warning{color:var(--warning)}
|
||||
.system-hero-value.critical{color:var(--danger)}
|
||||
.system-hero-bar{height:6px;border-radius:var(--radius-full);background:rgba(var(--bg-rgb),.4);overflow:hidden}
|
||||
.system-hero-fill{height:100%;border-radius:var(--radius-full);background:var(--green);transition:width .4s ease}
|
||||
.system-hero-fill.warning{background:var(--warning)}
|
||||
|
||||
+119
-26
@@ -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;
|
||||
@@ -83,6 +91,21 @@ function recordActivity(message, status = "ok") {
|
||||
try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); }
|
||||
}
|
||||
|
||||
// --- Edit-route change summaries: builds one human-readable, comma-joined line describing
|
||||
// exactly what changed in a single PATCH save, instead of a generic "X updated" message
|
||||
// that looks identical whether a rename, a domain change, or a TLS toggle happened.
|
||||
function describeDomainsChange(previousDomains, nextDomains) {
|
||||
if (previousDomains.join("|") === nextDomains.join("|")) return null;
|
||||
if (!nextDomains.length) return "domain removed";
|
||||
const shown = nextDomains.slice(0, 3).join(", ");
|
||||
return `domain${nextDomains.length > 1 ? "s" : ""} changed to ${shown}${nextDomains.length > 3 ? ` (+${nextDomains.length - 3} more)` : ""}`;
|
||||
}
|
||||
function summarizeUpdate(kind, name, changes) {
|
||||
const list = changes.filter(Boolean);
|
||||
if (!list.length) return;
|
||||
recordActivity(`${kind} “${name}” updated — ${list.join(", ")}.`);
|
||||
}
|
||||
|
||||
async function directorySize(directory) {
|
||||
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
|
||||
const sizes = await Promise.all(entries.map(async entry => {
|
||||
@@ -185,22 +208,31 @@ async function sampleNetworkInterfaces() {
|
||||
}
|
||||
setInterval(sampleNetworkInterfaces, 5000).unref();
|
||||
sampleNetworkInterfaces();
|
||||
// A recursive walk of /data (directorySize()) is only needed when DATA_DIR_LIMIT_GB is set, and
|
||||
// only to compute one denominator-relative percentage -- disk usage doesn't change fast enough to
|
||||
// justify redoing that walk on every single hero-panel poll (every 7 seconds, times every
|
||||
// concurrent viewer). Cached in the background instead, same pattern as refreshDatabaseIntegrityCache()
|
||||
// above: compute once shortly after boot, then on a steady interval, and have the hot request path
|
||||
// just read the cached number.
|
||||
let dataDirSizeCache = { checkedAt: null, bytes: null };
|
||||
async function refreshDataDirSizeCache() {
|
||||
try { dataDirSizeCache = { checkedAt: new Date().toISOString(), bytes: await directorySize(dataDir) }; }
|
||||
catch (error) { console.warn("Could not compute data directory size:", error.message); }
|
||||
}
|
||||
// One combined snapshot for the System tab's hero panel -- CPU/memory/swap/network are all
|
||||
// container-scoped (cgroup v2 + this container's network namespace); disk reuses the same
|
||||
// statfs-on-the-data-volume approach as /api/system/storage.
|
||||
async function systemHealthSnapshot() {
|
||||
const assignedLimitGb = numberEnv("DATA_DIR_LIMIT_GB", null);
|
||||
const assignedLimitBytes = assignedLimitGb && assignedLimitGb > 0 ? assignedLimitGb * 1024 ** 3 : null;
|
||||
const [cpu, memory, swap, disk, appUsedBytes] = await Promise.all([
|
||||
const [cpu, memory, swap, disk] = await Promise.all([
|
||||
cgroupCpuPercent(),
|
||||
cgroupMemory(),
|
||||
cgroupSwap(),
|
||||
fsp.statfs(dataDir).catch(() => null),
|
||||
// Only walk /data (the same directorySize() the storage breakdown below already uses) when
|
||||
// an assigned limit is actually configured -- it's the one case that needs it, and the walk
|
||||
// isn't free, so skip it when the panel is just going to show whole-volume stats anyway.
|
||||
assignedLimitBytes !== null ? directorySize(dataDir) : Promise.resolve(null),
|
||||
]);
|
||||
// See refreshDataDirSizeCache() above -- this used to be a live directorySize() walk on every fetch.
|
||||
const appUsedBytes = assignedLimitBytes !== null ? dataDirSizeCache.bytes : null;
|
||||
return {
|
||||
cpu,
|
||||
memory,
|
||||
@@ -536,7 +568,6 @@ function applyAdvancedSettings(item, body) {
|
||||
if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || "");
|
||||
if (body.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic";
|
||||
if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains);
|
||||
if (body.blockCommonExploits !== undefined) item.blockCommonExploits = Boolean(body.blockCommonExploits);
|
||||
if (body.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders);
|
||||
if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders);
|
||||
if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253);
|
||||
@@ -595,19 +626,8 @@ function accessDirectives(accessListId) {
|
||||
return output;
|
||||
}
|
||||
|
||||
// Static, general-purpose ruleset for the "Block common exploits" toggle — not a full WAF. Rejects
|
||||
// requests whose path matches common exploit-probe patterns before they reach the upstream: directory
|
||||
// traversal, WordPress/PHP admin and scanner paths, dotfile exposure attempts, and SQL-injection-style
|
||||
// query strings. One named matcher + one respond directive per host, so it's cheap to add or remove.
|
||||
const COMMON_EXPLOIT_PATTERN = String.raw`(?i)(\.\./|\.\.\\|/etc/passwd|/wp-login\.php|/wp-admin(?:/|$)|/xmlrpc\.php|/\.env(?:$|\?)|/\.git/|/\.aws/|/vendor/phpunit|/phpunit(?:/|$)|eval\(|base64_decode\(|union(?:\s|%20|\+)+select|<script)`;
|
||||
|
||||
function exploitBlockDirectives(id) {
|
||||
return [` @blocked-exploit-${id} {`, ` path_regexp ${caddyQuote(COMMON_EXPLOIT_PATTERN)}`, " }", ` respond @blocked-exploit-${id} 403`];
|
||||
}
|
||||
|
||||
function commonHostDirectives(item) {
|
||||
const output = [...accessDirectives(item.accessListId)];
|
||||
if (item.blockCommonExploits) output.push(...exploitBlockDirectives(item.id));
|
||||
if (item.compression !== "off") output.push(item.compression === "gzip" ? " encode gzip" : " encode zstd gzip");
|
||||
for (const header of item.responseHeaders || []) output.push(` header ${header.name} ${caddyQuote(header.value)}`);
|
||||
if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`);
|
||||
@@ -799,6 +819,25 @@ async function syncCaddy() {
|
||||
|
||||
|
||||
let configDrift = { checkedAt: null, drift: false, detail: null };
|
||||
// storage.integrity() runs a full PRAGMA integrity_check -- a complete scan of the entire SQLite
|
||||
// database file for corruption. It's one of the most expensive operations SQLite can run, its
|
||||
// cost scales with total database size, and because this app's SQLite queries run synchronously,
|
||||
// it blocks the whole single-threaded server for its full duration while it runs -- not just the
|
||||
// request that triggered it. dashboardSnapshot() used to call it on EVERY /api/dashboard fetch
|
||||
// just to compute one cosmetic "Healthy"/"Needs attention" label, which is why unrelated requests
|
||||
// (confirmed via container logs: /api/system/security, /api/logs/prune/preview) were getting
|
||||
// stuck behind it in lockstep, all finishing at nearly the same multi-second mark regardless of
|
||||
// what they actually needed to do. A dashboard status badge doesn't need a fresh, exhaustive
|
||||
// integrity scan on every single poll -- checking it periodically in the background and caching
|
||||
// the result is more than sufficient, since real corruption doesn't appear and disappear between
|
||||
// one 7-second poll and the next.
|
||||
let databaseIntegrityCache = { checkedAt: null, status: "Healthy" };
|
||||
function refreshDatabaseIntegrityCache() {
|
||||
try {
|
||||
const result = storage.integrity();
|
||||
databaseIntegrityCache = { checkedAt: new Date().toISOString(), status: result.length === 1 && result[0] === "ok" ? "Healthy" : "Needs attention" };
|
||||
} catch (error) { console.warn("Database integrity check failed:", error.message); }
|
||||
}
|
||||
let lastUpstreamCheckAt = null;
|
||||
let lastAccessLogImportAt = null;
|
||||
let lastKnownGoodCaddyConfig = null;
|
||||
@@ -882,6 +921,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 })));
|
||||
@@ -908,7 +948,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) {
|
||||
@@ -937,7 +979,7 @@ async function domainReadiness(precomputedCertificates) {
|
||||
let addresses = [], dnsError = null;
|
||||
try { addresses = [...new Set((await dns.lookup(item.domain, { all: true })).map(value => value.address))]; } catch (error) { dnsError = error.code || error.message; }
|
||||
const certificate = certs.certificates.find(cert => cert.domain === item.domain) || null;
|
||||
const upstream = item.kind === "Proxy host" ? upstreamHealth.get(item.id) || null : null;
|
||||
const upstream = (item.kind === "Proxy host" || item.kind === "Hosted site") ? upstreamHealth.get(item.id) || null : null;
|
||||
return { id: item.id, domain: item.domain, name: item.name, kind: item.kind, dns: { healthy: addresses.length > 0, addresses, error: dnsError }, ports: { http: httpResponding, https: item.tls === "http" ? null : httpsResponding }, tls: item.tls === "http" ? { status: "not-configured" } : { status: certificate?.status || "pending" }, upstream };
|
||||
}));
|
||||
}
|
||||
@@ -1119,7 +1161,7 @@ async function dashboardSnapshot(precomputedCertificates) {
|
||||
for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` });
|
||||
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
|
||||
const disk = await fsp.statfs(dataDir).catch(() => null);
|
||||
const databaseIntegrity = storage.integrity();
|
||||
// See refreshDatabaseIntegrityCache() above -- this used to be a live storage.integrity() call on every fetch.
|
||||
return {
|
||||
checkedAt: new Date().toISOString(),
|
||||
gateway: { ...gatewayProbe, lastReload: lastGatewayReload },
|
||||
@@ -1146,7 +1188,7 @@ async function dashboardSnapshot(precomputedCertificates) {
|
||||
caddyVersion,
|
||||
nodeVersion: process.version,
|
||||
databaseEngine: "SQLite",
|
||||
databaseStatus: databaseIntegrity.length === 1 && databaseIntegrity[0] === "ok" ? "Healthy" : "Needs attention",
|
||||
databaseStatus: databaseIntegrityCache.status,
|
||||
databaseBytes: (await fsp.stat(storage.databasePath).catch(() => null))?.size || 0,
|
||||
publicIp: publicIpState.address,
|
||||
publicIpCheckedAt: publicIpState.checkedAt,
|
||||
@@ -1158,6 +1200,8 @@ async function dashboardSnapshot(precomputedCertificates) {
|
||||
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
|
||||
{ name: "Public IP check", enabled: true, schedule: "60m", lastRunAt: publicIpState.checkedAt || null },
|
||||
{ name: "Configuration drift check", enabled: true, schedule: "10m", lastRunAt: configDrift.checkedAt || null },
|
||||
{ name: "Database integrity check", enabled: true, schedule: "30m", lastRunAt: databaseIntegrityCache.checkedAt || null },
|
||||
{ name: "Disk usage refresh", enabled: true, schedule: "60s", lastRunAt: dataDirSizeCache.checkedAt || null },
|
||||
]
|
||||
},
|
||||
activity: recentActivity
|
||||
@@ -2148,7 +2192,7 @@ app.post("/api/sites/:id/files", upload.single("files"), async (req, res, next)
|
||||
if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." });
|
||||
await installUpload(site, req.file);
|
||||
await syncCaddy();
|
||||
recordActivity(`Files replaced for “${site.name}”.`);
|
||||
recordActivity(`Hosted site “${site.name}” files replaced.`);
|
||||
res.json(publicSite(site));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2171,6 +2215,7 @@ app.patch("/api/sites/:id", async (req, res, next) => {
|
||||
const site = sites.find(item => item.id === req.params.id);
|
||||
if (!site) return res.status(404).json({ error: "Site not found." });
|
||||
const previousDomains = normalizeDomains(site.domain, site.domains);
|
||||
const before = { name: site.name, tls: site.tls, hsts: site.hsts, accessListId: site.accessListId, healthEnabled: site.healthEnabled, healthDetail: JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]) };
|
||||
const domain = normalizeDomain(req.body.domain);
|
||||
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains);
|
||||
const domainError = validateDomains(domains, site.id);
|
||||
@@ -2189,7 +2234,16 @@ app.patch("/api/sites/:id", async (req, res, next) => {
|
||||
await syncCaddy();
|
||||
await pruneOrphanedCertificates(previousDomains);
|
||||
await saveSites();
|
||||
recordActivity(`Gateway settings updated for “${site.name}”.`);
|
||||
const afterHealthDetail = JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]);
|
||||
summarizeUpdate("Hosted site", site.name, [
|
||||
before.name !== site.name ? `renamed from “${before.name}”` : null,
|
||||
describeDomainsChange(previousDomains, domains),
|
||||
before.tls !== site.tls ? `TLS set to ${site.tls}` : null,
|
||||
before.hsts !== site.hsts ? `HSTS ${site.hsts ? "enabled" : "disabled"}` : null,
|
||||
before.accessListId !== site.accessListId ? (site.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||
before.healthEnabled !== site.healthEnabled ? `health checks ${site.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
|
||||
]);
|
||||
res.json(publicSite(site));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2228,6 +2282,7 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
|
||||
const proxy = proxies.find(item => item.id === req.params.id);
|
||||
if (!proxy) return res.status(404).json({ error: "Proxy host not found." });
|
||||
const previousDomains = normalizeDomains(proxy.domain, proxy.domains);
|
||||
const before = { name: proxy.name, target: proxy.target, tls: proxy.tls, hsts: proxy.hsts, accessListId: proxy.accessListId, healthEnabled: proxy.healthEnabled, healthDetail: JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]) };
|
||||
if (req.body.domain !== undefined) {
|
||||
const domain = normalizeDomain(req.body.domain);
|
||||
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains);
|
||||
@@ -2252,7 +2307,17 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
|
||||
await syncCaddy();
|
||||
await pruneOrphanedCertificates(previousDomains);
|
||||
await saveProxies();
|
||||
recordActivity(`Proxy host “${proxy.name}” updated.`);
|
||||
const afterHealthDetail = JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]);
|
||||
summarizeUpdate("Proxy host", proxy.name, [
|
||||
before.name !== proxy.name ? `renamed from “${before.name}”` : null,
|
||||
describeDomainsChange(previousDomains, normalizeDomains(proxy.domain, proxy.domains)),
|
||||
before.target !== proxy.target ? `target changed to ${proxy.target}` : null,
|
||||
before.tls !== proxy.tls ? `TLS set to ${proxy.tls}` : null,
|
||||
before.hsts !== proxy.hsts ? `HSTS ${proxy.hsts ? "enabled" : "disabled"}` : null,
|
||||
before.accessListId !== proxy.accessListId ? (proxy.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||
before.healthEnabled !== proxy.healthEnabled ? `health checks ${proxy.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
|
||||
]);
|
||||
res.json(publicProxy(proxy));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2381,6 +2446,7 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
|
||||
try {
|
||||
const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." });
|
||||
const previousDomains = normalizeDomains(item.domain, item.domains);
|
||||
const before = { name: item.name, target: item.target, accessListId: item.accessListId, enabled: item.enabled, code: item.code, preservePath: item.preservePath, tls: item.tls, hsts: item.hsts };
|
||||
if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; }
|
||||
if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled);
|
||||
for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim();
|
||||
@@ -2389,7 +2455,19 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
|
||||
if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath);
|
||||
if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls;
|
||||
if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts);
|
||||
await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item);
|
||||
await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects();
|
||||
summarizeUpdate("Redirect Host", item.name, [
|
||||
before.name !== item.name ? `renamed from “${before.name}”` : null,
|
||||
describeDomainsChange(previousDomains, normalizeDomains(item.domain, item.domains)),
|
||||
before.target !== item.target ? `destination changed to ${item.target}` : null,
|
||||
before.code !== item.code ? `redirect code changed to ${item.code}` : null,
|
||||
before.preservePath !== item.preservePath ? `path preservation ${item.preservePath ? "enabled" : "disabled"}` : null,
|
||||
before.tls !== item.tls ? `TLS set to ${item.tls}` : null,
|
||||
before.hsts !== item.hsts ? `HSTS ${item.hsts ? "enabled" : "disabled"}` : null,
|
||||
before.accessListId !== item.accessListId ? (item.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||
before.enabled !== item.enabled ? (item.enabled ? "enabled" : "disabled") : null
|
||||
]);
|
||||
res.json(item);
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.delete("/api/redirects/:id", async (req, res, next) => {
|
||||
@@ -2421,6 +2499,7 @@ app.post("/api/streams", async (req, res, next) => {
|
||||
app.patch("/api/streams/:id", async (req, res, next) => {
|
||||
try {
|
||||
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
|
||||
const before = { name: stream.name, port: stream.port, target: stream.target, tcp: stream.tcp, udp: stream.udp, healthEnabled: stream.healthEnabled, enabled: stream.enabled };
|
||||
const next_ = { ...stream };
|
||||
if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; }
|
||||
if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; }
|
||||
@@ -2436,7 +2515,15 @@ app.patch("/api/streams/:id", async (req, res, next) => {
|
||||
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
|
||||
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
|
||||
await saveStreams();
|
||||
recordActivity(`Streaming host “${stream.name}” updated.`);
|
||||
const protocolLabel = (tcp, udp) => tcp && udp ? "TCP+UDP" : tcp ? "TCP" : "UDP";
|
||||
summarizeUpdate("Streaming host", stream.name, [
|
||||
before.name !== stream.name ? `renamed from “${before.name}”` : null,
|
||||
before.port !== stream.port ? `port changed from ${before.port} to ${stream.port}` : null,
|
||||
before.target !== stream.target ? `target changed to ${stream.target}` : null,
|
||||
(before.tcp !== stream.tcp || before.udp !== stream.udp) ? `protocol changed to ${protocolLabel(stream.tcp, stream.udp)}` : null,
|
||||
before.healthEnabled !== stream.healthEnabled ? `health checks ${stream.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||
before.enabled !== stream.enabled ? (stream.enabled ? "enabled" : "disabled") : null
|
||||
]);
|
||||
res.json(publicStream(stream));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -2568,6 +2655,12 @@ setTimeout(() => cleanupOldPruneSnapshots().catch(error => console.warn("Startup
|
||||
setInterval(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
|
||||
setTimeout(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10000).unref();
|
||||
setInterval(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10 * 60000).unref();
|
||||
// Runs the (expensive, synchronous, whole-server-blocking) database integrity scan once shortly
|
||||
// after boot and then every 30 minutes in the background, rather than on every dashboard fetch.
|
||||
setTimeout(refreshDatabaseIntegrityCache, 5000).unref();
|
||||
setInterval(refreshDatabaseIntegrityCache, 30 * 60000).unref();
|
||||
setTimeout(refreshDataDirSizeCache, 5000).unref();
|
||||
setInterval(refreshDataDirSizeCache, 60000).unref();
|
||||
|
||||
|
||||
// --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown ---------------------------------
|
||||
|
||||
@@ -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