Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0030820cab | |||
| 3bda4088c4 | |||
| 547f14d874 | |||
| 424ee26e00 | |||
| 8bca71d9b9 | |||
| a199806554 | |||
| 0685f4ee84 | |||
| bf4859c41e | |||
| 54e9a95bd4 | |||
| 84ba1f2426 | |||
| b227bb568a | |||
| 7b11730c8e | |||
| 6cd51a8a3f | |||
| 7d5842448e | |||
| 40a68b3b96 |
@@ -12,7 +12,7 @@
|
|||||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
<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="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="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
||||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.43-62E6A7">
|
<img alt="Version" src="https://img.shields.io/badge/version-0.16.58-62E6A7">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||||
|
|||||||
+30
-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.
|
- **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.
|
- **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.
|
- **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.
|
- **Dynamic DNS** and **deeper Caddy controls** for advanced users who outgrow the guided options.
|
||||||
- **Rate limiting** and other specialist gateway controls.
|
- **Rate limiting** and other specialist gateway controls.
|
||||||
@@ -214,3 +212,33 @@ Roughly in priority order:
|
|||||||
`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.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.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.
|
||||||
|
|||||||
+2
-2
@@ -76,8 +76,8 @@ x-casaos:
|
|||||||
author: mfwadejr
|
author: mfwadejr
|
||||||
developer: mfwadejr
|
developer: mfwadejr
|
||||||
architectures: ["amd64", "arm64"]
|
architectures: ["amd64", "arm64"]
|
||||||
version: "0.11.101"
|
version: "0.16.58"
|
||||||
update_at: "2026-09-16"
|
update_at: "2026-09-20"
|
||||||
website: https://github.com/mfwadejr/site-gateway2
|
website: https://github.com/mfwadejr/site-gateway2
|
||||||
repo: https://github.com/mfwadejr/site-gateway2
|
repo: https://github.com/mfwadejr/site-gateway2
|
||||||
support: https://github.com/mfwadejr/site-gateway2/issues
|
support: https://github.com/mfwadejr/site-gateway2/issues
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "site-gateway",
|
"name": "site-gateway",
|
||||||
"version": "0.16.43",
|
"version": "0.16.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+42
-26
@@ -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 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);
|
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.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.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.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);
|
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);
|
||||||
@@ -241,12 +241,16 @@ function canAdmin() { return state.user?.role === "administrator"; }
|
|||||||
|
|
||||||
|
|
||||||
// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------
|
// --- 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) {
|
function hostedCard(site) {
|
||||||
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
|
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 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 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>` : "";
|
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) {
|
function proxyCard(proxy) {
|
||||||
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
|
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 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 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";
|
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;
|
$("#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);
|
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"}`;
|
$("#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 || [];
|
const routes = state.readiness?.routes || [];
|
||||||
$("#readiness-list").innerHTML = routes.length ? routes.map(item => {
|
state.certRows = data.certificates.map(cert => ({ cert, readiness: routes.find(item => item.domain === cert.domain) || null }));
|
||||||
const dnsOk = item.dns.healthy, portsOk = item.ports.http && item.ports.https !== false;
|
$("#certificate-list").innerHTML = state.certRows.length ? state.certRows.map((row, index) => {
|
||||||
const tlsOk = ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status);
|
const cert = row.cert, item = row.readiness;
|
||||||
const upstreamOk = !item.upstream || item.upstream.status === "healthy";
|
const dnsOk = item ? item.dns.healthy : null;
|
||||||
const check = item.upstream;
|
const tlsOk = item ? ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status) : null;
|
||||||
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 dnsCell = item ? `<span class="status-dot ${dnsOk ? "running" : "error"}"></span>${dnsOk ? "Resolved" : "Failed"}` : `<span class="status-dot idle"></span>—`;
|
||||||
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>";
|
const tlsCell = item ? `<span class="status-dot ${tlsOk ? "running" : "error"}"></span>${escapeHtml(item.tls.status.replaceAll("-", " "))}` : `<span class="status-dot idle"></span>—`;
|
||||||
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>`;
|
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")}`;
|
||||||
}).join("") : '<p class="quiet-state">No configured domains to check.</p>';
|
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 -------------------------------------------------------------------------
|
// --- Logs view -------------------------------------------------------------------------
|
||||||
function renderLogs() {
|
function renderLogs() {
|
||||||
const data = state.logs; if (!data) return;
|
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 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 severity = $("#event-severity").value, category = $("#event-category").value;
|
||||||
const activity = data.activity.filter(item => (!severity || item.status === severity) && (!category || categoryOf(item.message) === category));
|
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
|
// 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.
|
// 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()));
|
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" });
|
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +509,7 @@ function render() {
|
|||||||
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
||||||
const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
|
const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
|
||||||
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups", adminApiActive = activeAdminTab === "api";
|
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups", adminApiActive = activeAdminTab === "api";
|
||||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || adminApiActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates" || !canAdmin()); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs"); $("#refresh-view").classList.toggle("hidden", state.view === "logs");
|
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || adminApiActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates" || !canAdmin()); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||||
if (overview) {
|
if (overview) {
|
||||||
$("#page-title").textContent = "Dashboard";
|
$("#page-title").textContent = "Dashboard";
|
||||||
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
||||||
@@ -606,9 +622,9 @@ async function refreshPendingProxies(ids = []) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function refreshDashboard() {
|
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(); }
|
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
|
// 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
|
// /api/system/health, the same call and the same renderHeroPanel() the Administration > System
|
||||||
@@ -746,6 +762,7 @@ $("#performance-rows").addEventListener("click", event => {
|
|||||||
const pathsButton = event.target.closest("[data-paths-host]");
|
const pathsButton = event.target.closest("[data-paths-host]");
|
||||||
if (pathsButton) { const paths = state.performanceTopPaths?.[pathsButton.dataset.pathsHost]; if (paths) showTopPaths(pathsButton.dataset.pathsHost, paths); }
|
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);
|
$("#log-status").addEventListener("change", renderLogs);
|
||||||
$("#event-severity").addEventListener("change", renderLogs);
|
$("#event-severity").addEventListener("change", renderLogs);
|
||||||
$("#event-category").addEventListener("change", renderLogs);
|
$("#event-category").addEventListener("change", renderLogs);
|
||||||
@@ -769,7 +786,6 @@ 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 = ""); }));
|
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 --------------------------------
|
// --- 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 refreshCurrentView(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-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"; } });
|
$("#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"; } });
|
||||||
|
|
||||||
@@ -786,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 || "";
|
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") {
|
if (kind === "proxy") {
|
||||||
const scope = "#settings-advanced";
|
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");
|
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"));
|
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");
|
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");
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+30
-11
File diff suppressed because one or more lines are too long
+31
-3
@@ -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.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.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.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 */
|
/* 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}
|
.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}
|
.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)}
|
.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}
|
.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-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{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}
|
.menu-open .menu{display:block}
|
||||||
@@ -149,7 +152,7 @@ header{align-items:flex-end}
|
|||||||
/* Dashboard */
|
/* Dashboard */
|
||||||
.mobile-nav{display:none}
|
.mobile-nav{display:none}
|
||||||
.dashboard-view{margin-top:38px}
|
.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-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{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}
|
.metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85}
|
||||||
@@ -250,6 +253,15 @@ 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}}
|
@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{margin:10px 0 0;color:var(--green);font-size:.72rem}
|
||||||
.upstream-copy.bad{color:var(--danger)}
|
.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}
|
||||||
.activity-mark.bad{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
|
.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{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)}
|
.feature-summary>div{padding:18px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
|
||||||
@@ -265,6 +277,9 @@ header{align-items:flex-end}
|
|||||||
.log-toolbar{display:flex;align-items:end;justify-content:space-between;gap:var(--space-4)}
|
.log-toolbar{display:flex;align-items:end;justify-content:space-between;gap:var(--space-4)}
|
||||||
.log-toolbar label{margin:0;min-width:250px}
|
.log-toolbar label{margin:0;min-width:250px}
|
||||||
.feature-note{margin:var(--space-4) 0}
|
.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)}
|
.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{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}
|
.log-table th,.log-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);white-space:nowrap}
|
||||||
@@ -354,7 +369,7 @@ header{align-items:flex-end}
|
|||||||
|
|
||||||
/* Generic settings-form layout */
|
/* 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{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{grid-template-columns:repeat(4,1fr)}
|
||||||
.settings-form.compact-grid .check-control,.settings-form.compact-grid .dialog-actions{grid-column:auto}
|
.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}
|
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}
|
||||||
@@ -673,7 +688,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
|||||||
/* Documentation manual: intro & search */
|
/* 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{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 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);max-width:850px;line-height:1.6}
|
||||||
.docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}
|
.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{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}
|
.docs-nav .eyebrow{margin:var(--space-1) 8px 8px}
|
||||||
@@ -819,11 +834,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 p:last-child{margin:var(--space-1) 0 0}
|
||||||
.diagnostic-section-heading{margin-bottom:0}
|
.diagnostic-section-heading{margin-bottom:0}
|
||||||
.diagnostic-section-heading + .log-table-wrap{border-top:0;border-radius:0 0 15px 15px}
|
.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}
|
#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}
|
.data-list>.quiet-state,.diagnostic-list>.quiet-state{padding:22px}
|
||||||
.readiness-panel,.log-activity{background:transparent;border:0;padding:0}
|
.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)}
|
.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-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 .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{gap:var(--space-4);padding:18px 20px;align-items:center}
|
||||||
.log-activity .event-row .status-dot{flex:0 0 8px}
|
.log-activity .event-row .status-dot{flex:0 0 8px}
|
||||||
@@ -985,6 +1011,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-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-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{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-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{height:100%;border-radius:var(--radius-full);background:var(--green);transition:width .4s ease}
|
||||||
.system-hero-fill.warning{background:var(--warning)}
|
.system-hero-fill.warning{background:var(--warning)}
|
||||||
|
|||||||
+44
-20
@@ -193,22 +193,31 @@ async function sampleNetworkInterfaces() {
|
|||||||
}
|
}
|
||||||
setInterval(sampleNetworkInterfaces, 5000).unref();
|
setInterval(sampleNetworkInterfaces, 5000).unref();
|
||||||
sampleNetworkInterfaces();
|
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
|
// 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
|
// container-scoped (cgroup v2 + this container's network namespace); disk reuses the same
|
||||||
// statfs-on-the-data-volume approach as /api/system/storage.
|
// statfs-on-the-data-volume approach as /api/system/storage.
|
||||||
async function systemHealthSnapshot() {
|
async function systemHealthSnapshot() {
|
||||||
const assignedLimitGb = numberEnv("DATA_DIR_LIMIT_GB", null);
|
const assignedLimitGb = numberEnv("DATA_DIR_LIMIT_GB", null);
|
||||||
const assignedLimitBytes = assignedLimitGb && assignedLimitGb > 0 ? assignedLimitGb * 1024 ** 3 : 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(),
|
cgroupCpuPercent(),
|
||||||
cgroupMemory(),
|
cgroupMemory(),
|
||||||
cgroupSwap(),
|
cgroupSwap(),
|
||||||
fsp.statfs(dataDir).catch(() => null),
|
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 {
|
return {
|
||||||
cpu,
|
cpu,
|
||||||
memory,
|
memory,
|
||||||
@@ -544,7 +553,6 @@ function applyAdvancedSettings(item, body) {
|
|||||||
if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || "");
|
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.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic";
|
||||||
if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains);
|
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.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders);
|
||||||
if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders);
|
if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders);
|
||||||
if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253);
|
if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253);
|
||||||
@@ -603,19 +611,8 @@ function accessDirectives(accessListId) {
|
|||||||
return output;
|
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) {
|
function commonHostDirectives(item) {
|
||||||
const output = [...accessDirectives(item.accessListId)];
|
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");
|
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)}`);
|
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" : ""}`)}`);
|
if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`);
|
||||||
@@ -807,6 +804,25 @@ async function syncCaddy() {
|
|||||||
|
|
||||||
|
|
||||||
let configDrift = { checkedAt: null, drift: false, detail: null };
|
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 lastUpstreamCheckAt = null;
|
||||||
let lastAccessLogImportAt = null;
|
let lastAccessLogImportAt = null;
|
||||||
let lastKnownGoodCaddyConfig = null;
|
let lastKnownGoodCaddyConfig = null;
|
||||||
@@ -948,7 +964,7 @@ async function domainReadiness(precomputedCertificates) {
|
|||||||
let addresses = [], dnsError = null;
|
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; }
|
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 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 };
|
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 };
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1130,7 +1146,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"}.` });
|
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" });
|
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 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 {
|
return {
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
gateway: { ...gatewayProbe, lastReload: lastGatewayReload },
|
gateway: { ...gatewayProbe, lastReload: lastGatewayReload },
|
||||||
@@ -1157,7 +1173,7 @@ async function dashboardSnapshot(precomputedCertificates) {
|
|||||||
caddyVersion,
|
caddyVersion,
|
||||||
nodeVersion: process.version,
|
nodeVersion: process.version,
|
||||||
databaseEngine: "SQLite",
|
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,
|
databaseBytes: (await fsp.stat(storage.databasePath).catch(() => null))?.size || 0,
|
||||||
publicIp: publicIpState.address,
|
publicIp: publicIpState.address,
|
||||||
publicIpCheckedAt: publicIpState.checkedAt,
|
publicIpCheckedAt: publicIpState.checkedAt,
|
||||||
@@ -1169,6 +1185,8 @@ async function dashboardSnapshot(precomputedCertificates) {
|
|||||||
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
|
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
|
||||||
{ name: "Public IP check", enabled: true, schedule: "60m", lastRunAt: publicIpState.checkedAt || null },
|
{ 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: "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
|
activity: recentActivity
|
||||||
@@ -2579,6 +2597,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();
|
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();
|
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();
|
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 ---------------------------------
|
// --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown ---------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user