Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dda6c517fc | |||
| 887cd2dfcc | |||
| 227823c534 | |||
| 216ce1b094 | |||
| 6821a30b65 | |||
| 4e4823b0f7 | |||
| 747c776a84 | |||
| 5b5ba9b3be | |||
| 53b47b77a6 | |||
| 146b7f97ae | |||
| 238c5aedc2 | |||
| ba0f3b68e2 | |||
| dbc143dfbd | |||
| a0565eeaf4 | |||
| 9a81453b86 | |||
| 8e2a195ef3 | |||
| 0030820cab | |||
| 3bda4088c4 | |||
| 547f14d874 | |||
| 424ee26e00 | |||
| 8bca71d9b9 | |||
| a199806554 | |||
| 0685f4ee84 | |||
| bf4859c41e | |||
| 54e9a95bd4 | |||
| 84ba1f2426 | |||
| b227bb568a |
@@ -8,3 +8,10 @@ ACME_EMAIL=you@example.com
|
|||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
HTTPS_PORT=443
|
HTTPS_PORT=443
|
||||||
BACKUP_PASSWORD=
|
BACKUP_PASSWORD=
|
||||||
|
|
||||||
|
# Optional resource-panel tuning (see compose.release.yaml). Leave commented
|
||||||
|
# to run unlimited / compare Disk against the whole volume.
|
||||||
|
# DATA_DIR_LIMIT_GB=30
|
||||||
|
# MEM_LIMIT=2g
|
||||||
|
# CPU_LIMIT=2
|
||||||
|
# CPUSET=0,1
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
<img alt="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.48-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> ·
|
||||||
|
|||||||
+54
@@ -220,3 +220,57 @@ Roughly in priority order:
|
|||||||
`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.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.48` is a batch covering five separately-reported items. First, it fixes a real layout regression v0.16.47 introduced: reordering the header's action buttons so the page-refresh icon appeared after the green primary button caused `header`'s `justify-content:space-between` to treat every button as its own flex item and redistribute space between all of them, visibly shifting the green button ("Refresh logs", "Run certificate check", etc.) away from its usual position instead of leaving it in place with the icon simply appended beside it. The buttons are now wrapped in a single `.header-actions` container so `header` only ever splits space between the page title and that one group, and the group's own `gap` keeps its buttons hugging together at the right edge exactly as before v0.16.47. Second, it removes the temporary `[perf]` diagnostic logging added in v0.16.44 (the slow-request middleware and the `importAccessLogsToSqlite` timing breakdown), now fully superseded by v0.16.45's fix and no longer needed. Third, it removes the "Block common exploits" per-Proxy-Host toggle entirely -- its regex-based matcher only ever inspected the request path, never the query string, so it never provided the SQL-injection/XSS protection its label implied; the checkbox, its documentation entry, and every server-side and client-side reference to `blockCommonExploits` are gone. Fourth, it applies the same "cache expensive checks instead of recomputing them on every request" fix used for the database-integrity check in v0.16.45 to the System tab hero panel's disk-usage figure: when `DATA_DIR_LIMIT_GB` is set, the hero panel needs a real recursive walk of `/data` to compute its used-space percentage, and that walk was being redone on every single 7-second hero-panel poll, for every concurrent viewer. It's now computed once shortly after boot and refreshed every 60 seconds in the background (`refreshDataDirSizeCache()`), with the hot request path just reading the cached value -- deployments that don't set `DATA_DIR_LIMIT_GB` are unaffected, since they never triggered this walk in the first place. Fifth, the ROADMAP's own "What's next" section is reconciled against the "Shipped" section above it: two items it listed as upcoming (browsable backup/restore history, a Docker container picker for Proxy/Streaming targets) had already shipped and were removed from the list.
|
||||||
|
|
||||||
|
`v0.16.49` fixes the My Account and Documentation pages' cramped spacing between the header subtitle and the first box below it, reported against several earlier releases. The cause was pinned down precisely by measuring pixel gaps across side-by-side screenshots of a correctly-spaced page (Logs) against the two broken ones: Certificates, Performance, and Logs all get their deliberate spacing from one shared rule, `#certificates-view,#performance-view,#logs-view{margin-top:var(--space-7)}`, and My Account and Documentation were simply never added to that selector, so both fell back to a 0px top margin. The fix adds `#account-view` and `#documentation-view` to that same existing rule -- reusing the app's own established spacing value rather than introducing a new one.
|
||||||
|
|
||||||
|
`v0.16.50` adds a **Hide not configured** checkbox to the Performance page's Throughput by domain table, matching the API Access page's existing "Hide revoked" toggle in both behavior and placement: unchecked by default, resets on every page reload (no server round-trip, no persisted setting), and right-aligned inline with the descriptive text above the table rather than inside the table header itself. Checking it filters out any row already tagged with the "Not configured" chip -- domains Caddy has logged requests for that don't match a real Hosted Site, Proxy Host, or Redirect Host -- so a table with a lot of scanning/bot noise pointed at random hostnames can be narrowed down to just the domains actually configured in Site Gateway.
|
||||||
|
|
||||||
|
`v0.16.51` reworks the Certificates page layout, which had three visually inconsistent, unevenly-spaced blocks stacked on top of each other (a bare certificate inventory list, a borderless "Domain readiness" panel, and a fully-bordered "Renewal thresholds" card) -- the last two had no spacing rule between them at all and rendered flush against one another. Certificate inventory and Domain readiness are now merged into a single sticky-header table (`Domain | Status | Days remaining | Issuer | DNS | TLS | Upstream`), reusing the same table pattern already used on the Performance and Access logs pages, with one row per configured domain instead of two separately-rendered lists keyed off the same data. The deep per-certificate fields that used to live in an inline expandable `<details>` row (issuer, serial number, SHA-256 fingerprint, covered domains, valid-from date, upstream check detail) now open in a click-to-view popup dialog instead, reusing the app's existing `.dialog-card` pattern -- keeping every table row a single fixed height for a clean continuous scroll. The "Renewal thresholds" settings form, which doesn't change per-domain, moved out of a permanent third block into a "Configure thresholds" popup opened from the page header, the same way page-level settings are already surfaced elsewhere in the app.
|
||||||
|
|
||||||
|
`v0.16.52` fixes the Certificates page's Upstream column always showing "Not configured" for Hosted Sites, even when the exact same upstream health check was clearly running and healthy on that site's own dashboard card. The cause was a single overly-narrow condition in `domainReadiness()`: `const upstream = item.kind === "Proxy host" ? upstreamHealth.get(item.id) || null : null;` only ever read cached health-check results back out for Proxy hosts, even though `checkAllProxies()` runs that identical check against Hosted Sites too and stores the result in the same `upstreamHealth` map under the same id -- the data existed the whole time, this function just refused to return it for anything that wasn't a Proxy host. The condition now also includes `"Hosted site"`; Redirect hosts are unaffected and correctly continue to show no upstream data, since they have none.
|
||||||
|
|
||||||
|
`v0.16.53` fixes the "Configure thresholds" popup on the Certificates page (added in v0.16.51) rendering with its first input floating oddly beside the title instead of below it. The cause: `.settings-form` is a shared two-column CSS grid, and the dialog's heading block was placed as a plain grid child instead of spanning both columns like every other full-width element in a settings form already does (`.dialog-actions`, error text, a `<label>` wrapping a textarea). That left the "Renewing-soon warning" field sitting in the grid's second column of row one, directly beside the heading text. `.settings-form>.dialog-heading` is now added to that same existing full-span rule, so the heading spans the full width and the three threshold fields lay out normally beneath it.
|
||||||
|
|
||||||
|
`v0.16.54` gives the Certificates table's Upstream column an accurate three-state color treatment instead of collapsing every non-numeric outcome into a single generic "no response." The underlying `upstreamHealth` data already distinguished four real states -- healthy (has a status code), unmonitored (monitoring intentionally turned off for that host), pending (no check has run yet), and a genuine failure (the check ran and errored or timed out) -- and the Hosted Site / Proxy host cards already labeled these correctly ("Monitoring paused", "Upstream check pending", etc.), but the new table only checked for a numeric status code and printed "no response" for everything else, including deliberately paused monitoring. The column now reads: green "running" dot with the status code for a healthy check, amber "idle" dot with "Monitoring paused" or "Check pending" for the two non-issue states, and a new red `.status-dot.bad` (added to styles.css, reusing the existing `--danger` token) with the actual error message for a real failure -- so a glance at the column now tells you whether something needs attention or is simply not being checked by design.
|
||||||
|
|
||||||
|
`v0.16.55` is a batch covering three items reported against the Certificates and Logs pages. First, the "Configure thresholds" link on the Certificates page moves from floating level with the page title down onto the same line as the descriptive text beneath it, using a dedicated `.section-heading-row` flex row instead of the whole heading block being one flex row. Second, the three-state Upstream coloring added to the Certificates table in v0.16.54 (healthy / monitoring paused or check pending / genuine failure) is now applied consistently everywhere else that shows the same `upstreamHealth` data -- the Hosted Site, Proxy Host, and Streaming Host cards' upstream text now also gets a matching amber `.upstream-copy.idle` state for "Monitoring paused" and "Upstream/Target check pending", instead of rendering identically green to a real healthy check the way it did before. Third, the Logs page's "Gateway events" panel is converted from a stacked list of `.event-row` cards into a sticky-header table (`Time | Severity | Category | Message`), matching the Access requests table directly above it on the same page; the Message column is left free to wrap rather than forced onto one line, since event messages are free-form and of varying length, unlike the fixed-format columns elsewhere.
|
||||||
|
|
||||||
|
`v0.16.56` adds the two background jobs from v0.16.45 and v0.16.48 (the SQLite integrity re-scan and the /data disk-usage walk, both moved off the request path and onto their own timers to fix the multi-second dashboard freezes reported at the time) to the Scheduled jobs list shown on the Dashboard and Administration -> System page. Both caches already tracked their own `checkedAt` timestamp internally, they just were never surfaced in the `jobs` array both pages already render from -- "Database integrity check" (every 30 minutes) and "Disk usage refresh" (every 60 seconds) now appear alongside Upstream checks, Scheduled backups, Log pruning, Access-log import, Public IP check, and Configuration drift check, with live last-run timestamps the same as every other entry in that list.
|
||||||
|
|
||||||
|
`v0.16.57` is a batch of four fixes/improvements against the Logs page and the System-runtime views reported after v0.16.55 and v0.16.56. First, the Gateway events table's Time/Severity/Category columns used percentage widths copied from the Performance table, leaving a lot of empty space around short values on wide screens; they're now fixed pixel widths (190px/110px/140px, same approach the Access requests table already uses), giving the free-form Message column the room it needs. Second, the Gateway events table wasn't visually joined to its heading/filter row above it the way Access requests and the Certificates table are -- the join CSS (`border-top:0`, bottom-only radius) that ties a heading box to the table below it existed for every other table on the app except this one, which is now fixed with the same one-line pattern. Third, the Runtime hero panel's Disk stat (shown on both the Dashboard and Administration -> System) now includes the real host free-space figure in its detail line alongside the assigned-quota percentage (e.g. "480 MB used of 2.0 GB assigned · 316 GB free on host") instead of only showing the quota view -- and the now-redundant standalone "Disk" tile in the System page's Disk usage breakdown panel (which showed the same host free/total figures with no quota context) has been removed, since that panel is otherwise scoped to what Site Gateway itself is storing (Sites, Backups, Certificates, Logs, Database). Fourth, the Runtime hero panel's CPU/Memory/Swap/Disk value text now gets the same amber/red coloring the progress bar underneath it already had at the existing 75%/90% thresholds -- previously only the thin bar changed color as a stat approached its limit, while the large percentage number stayed plain white regardless of severity.
|
||||||
|
|
||||||
|
`v0.16.58` fixes the Gateway events column-width fix from v0.16.57 not actually taking effect. The table carries two classes, `performance-table event-table`, and the generic `.performance-table th:nth-child(n+2){width:11.1%;text-align:center}` rule sits later in styles.css than the `.event-table` column rules added in v0.16.57 -- at equal CSS specificity, source order decides, so the later generic rule was silently winning and the fixed pixel widths never applied. The `.event-table` column selectors are now written as `table.event-table th:nth-child(n)`, adding the `table` type selector so they outrank `.performance-table`'s rules by specificity regardless of where either appears in the file; the Severity/Category text also moves from centered to left-aligned, matching the Access requests table and removing the awkward centered-in-a-wide-column look that was part of the same complaint.
|
||||||
|
|
||||||
|
`v0.16.59` is a release-readiness cleanup pass, prompted by an upcoming public release: a genuine CSS bug, and several stale claims in the in-app Documentation manual that had drifted from what the app actually does after the Certificates/Logs/Runtime work in v0.16.51 through v0.16.58. Fixed: the Documentation page's intro paragraph was rendering in the bright body-text color instead of muted gray, because the CSS rule targeting it used `p:last-child`, which stopped matching once the search box `<div>` was added after it as the real last child -- it now has its own dedicated class instead of relying on element position. In the manual itself: the Certificates overview article said Upstream health only applied to Proxy Hosts (true before v0.16.52, not since); the Certificates field-reference article said certificate detail came from "expanding" a row and that thresholds were set from a nonexistent "Administration -> Security & Health" location (they're on the Certificates page itself, both predating and unrelated to this session's changes); the Dashboard and System articles' Scheduled Jobs lists were missing the database-integrity and disk-usage-refresh jobs added in v0.16.56; the System article still claimed the Disk usage breakdown showed total/remaining capacity, which moved to the Runtime panel's Disk stat in v0.16.57; and neither the Dashboard nor System Runtime sections mentioned the value-text threshold coloring added in v0.16.57. The Logs article also picked up one clarifying sentence noting Gateway Events now has the same pinned column-header behavior as Access Logs. Outside the app: the README version badge and the ZimaOS App Store manifest's `version`/`update_at` fields were several releases stale (0.16.50 and 0.11.101 respectively) and are now current.
|
||||||
|
|
||||||
|
`v0.16.60` fixes the Documentation manual's intro header leaving a large block of empty space on wide screens, by capping the whole `.docs-intro` panel at 900px and centering it. Shipped, then reported back as visibly worse: centering the panel broke its alignment with the Contents sidebar and article panel in `.docs-layout` directly below it, which both remain full-width -- the intro now floats disconnected in the middle of the page instead of lining up with the rest of the manual.
|
||||||
|
|
||||||
|
`v0.16.61` reverts v0.16.60's panel-centering approach and fixes the same empty-space issue correctly. The actual cause was the lede paragraph's own `max-width:850px`, set inside a `.docs-intro` panel that already spans the full reading-column width (matching the Contents sidebar + article layout beneath it) -- the paragraph simply stopped well short of the panel's real width. Removing that max-width lets the paragraph flow to fill the panel it already sits in, while the panel itself stays full-width and aligned with the rest of the manual, exactly as it was before v0.16.60.
|
||||||
|
|
||||||
|
`v0.16.62` fixes two Dashboard issues found while reviewing a fresh screenshot for the marketing site. First, the Certificates tile's big number and its "X healthy · Y not detected" line disagreed with each other (e.g. showing "11" above "12 healthy") because they came from two different counts: the big number used `tlsDomains`, a route-count over enabled Hosted Sites and Proxy Hosts with TLS on, while the detail line used the certificate inventory's domain-level summary -- which also includes Redirect Hosts and expands any route with alias domains into one entry per domain. The big number now reads from that same certificate summary the detail line already used, so a route with an alias domain (the actual cause of the 11-vs-12 mismatch) is counted consistently in both places; the underlying route-count (`tlsDomains`) is untouched where it's used to gate the HTTPS/443 health probe, since that is a genuinely different question ("is TLS configured at all") from "how many certificates exist." Second, the Certificates tile's icon was a plain unicode square (`▣`) left over from before the tile had a real icon design -- it's now a small key glyph drawn as an inline SVG in the app's existing icon style (stroke-based, `currentColor`), matching the key icon used for Certificates on the marketing website and requested directly against a reference image.
|
||||||
|
|
||||||
|
`v0.16.63` converts the Backup history panel (Administration -> Backup & Restore) from a stacked-card timeline to the same pinned-header table used everywhere else records are logged -- Access Logs and Gateway Events. It was the one remaining place in the app presenting a log-like record as a list of `.activity-tile` cards (the same component the Dashboard's Recent Activity feed uses) instead of a table, and looked inconsistent sitting next to those two. The table has four columns -- Time, Action (Backup/Restored/Deleted/Imported), Type (Complete/Configuration/Safety), and Detail (size, a note that a safety backup was taken first, or the failure reason) -- reusing the exact `performance-table`/`event-table` CSS and column widths Gateway Events already established, plus the same green/red `status-dot` convention for success vs. failure. No backend or data changes; `/api/backups/history` already returned everything the new columns needed.
|
||||||
|
|
||||||
|
`v0.16.64` fixes two issues reported right after v0.16.63 shipped the new Backup history table. First, creating, deleting, or importing a backup updated the "Stored backups" list immediately but left Backup history showing stale data until something else triggered a full page refresh -- each of those three handlers called `renderBackups()` afterward but never `renderBackupHistory()`; only Restore happened to update it, incidentally, because it runs the app's full `refresh()` cycle afterward for unrelated reasons. All three now also call `renderBackupHistory()` directly, so every action that writes a history entry reflects it immediately, matching what Restore already did by accident. Second, the table looked lopsided -- all its content bunched on the left with a large empty gap on the right -- because it reused Gateway Events' column widths (`190px/110px/140px/auto`), sized for a Message column that's usually full of long text; Backup history's last column is normally a short phrase like "71 MB", so the table stretched to the full panel width with nothing to fill it. The table now has its own column widths (26%/18%/18%/auto) inside a capped max-width (880px) instead of stretching edge-to-edge, so it reads as one balanced block rather than left-aligned content trailing into empty space.
|
||||||
|
|
||||||
|
`v0.16.65` fixes three issues reported against the Stored backups card and the new Backup history table right after v0.16.63/v0.16.64. First, the "Configuration only" type chip used the same background color as the surrounding panel (`var(--panel2)` on `var(--panel2)`), making it functionally invisible next to the green "Complete" chip -- it now uses the app's purple accent, tinted the same way every other colored chip already is. Second, the "Site Gateway x.x.x" version line under each chip sat flush with the column's left edge while the chip's own text started a few pixels in from its padding, reading as slightly misaligned -- a `.chip+small{margin-left:var(--space-2)}` rule now lines the two up. Third, and most visible: Backup history's width cap from v0.16.64 (880px) was the wrong fix for the column-spacing complaint -- capping the table made it the one log-style table in the app that doesn't span the panel's full width like Access Logs and Gateway Events do, which is a bigger inconsistency than the spacing problem it was meant to solve. The cap is removed; the wider percentage-based columns introduced alongside it (26%/18%/18%/auto) stay, so the table is full-width again but its columns are no longer crowded into the first ~440px the way the borrowed Gateway Events pixel-widths had them.
|
||||||
|
|
||||||
|
`v0.16.66` replaces the generic "X updated" activity message every edit route wrote to Gateway Events -- Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts each logged one identical line regardless of what was actually changed in the save, so a rename, a domain change, a TLS toggle, and a health-check edit were all indistinguishable in the log. Each of the four `PATCH` routes now snapshots the relevant fields before applying the request body, diffs them against the saved result, and writes one comma-joined line naming exactly what changed in that save -- e.g. `Proxy host "Plex" updated — renamed from "Plex Media", target changed to 192.168.1.20:32400, TLS set to automatic.` Multiple fields changed in a single save produce one combined line, not one line per field; saving a form with no actual changes (an Edit dialog opened and immediately saved) now writes nothing at all, instead of the previous generic entry firing unconditionally. Primary fields (name, domain/aliases, target, TLS, HSTS, Access List, health-check on/off, and per-type fields like redirect code/path-preservation or streaming port/protocol) are called out individually; the long tail of advanced settings (custom headers, custom Caddy config, load-balancing upstreams/policy, upstream TLS overrides, and the fine-grained health-check parameters) are bucketed into a single "advanced settings updated" line to keep the summary readable rather than enumerating every possible field.
|
||||||
|
|
||||||
|
`v0.16.67` converts the Administration -> Users -> Audit log tab from a card-row list (`.event-row` divs with a checkmark/bang `.activity-mark`) to the same pinned-header table every other log-type page now uses -- Gateway Events, Access Logs, and Backup history (v0.16.63). It was the last page still on the older list pattern, noticed immediately after v0.16.66 added several new distinct audit entries and made the mismatch obvious side by side with Gateway Events. The table has four columns -- Time, User, Result, Action -- reusing the exact `performance-table`/`event-table` CSS and the standard green/red `status-dot` convention in place of the old custom `.activity-mark` dot. No backend or data changes; `/api/audit` already returned everything the columns needed. The now-orphaned `#audit-list` div styling (border, background, max-height, the bespoke activity-mark colors) is removed along with it, since the table's own wrapper classes already provide the equivalent scrolling/sticky-header container.
|
||||||
|
|
||||||
|
`v0.16.68` is a quick follow-on to v0.16.67: the Audit log's new table had no surrounding card, so it sat flush against the tab content instead of appearing inside the same bordered, elevated "tile" every other tab-hosted table uses -- Backup history's `dashboard-panel backup-history-section` wrapper on the Backups tab being the closest match. The Audit log's table markup is now wrapped in an equivalent `dashboard-panel audit-log-section` with an "Audit log" eyebrow above the existing heading, matching Backup history's structure exactly (search/filter row and table unchanged). Purely a wrapper/markup change -- no new CSS, no data or behavior changes.
|
||||||
|
|
||||||
|
`v0.16.69` closes a gap flagged after reviewing the ZimaOS compose file: `compose.yaml`, `compose.release.yaml`, and `compose.zimaos.yaml` all documented `DATA_DIR_LIMIT_GB` and the `mem_limit`/`cpus`/`cpuset` resource-panel options in README/install.html but never actually carried them as commented-optional entries the way the marketing site's install guide showed -- someone copying a real compose file instead of the docs page got none of that guidance. All three now include the same commented-out block (env-var style in the two static compose files, `${VAR}`-substituted and wired through `.env.example` in `compose.release.yaml`). This also folds in the marketing site's `install.html`, which had drifted out of sync with an already-updated draft and was missing the same options on the user's machine -- resynced so the published guide matches what ships in the repo.
|
||||||
|
|
||||||
|
`v0.16.70` fixes the Access Logs table (Administration > Logs, "Access requests"), the last page still on its own pre-unification CSS: `.log-table` pinned the Status and Duration columns with `position:sticky` and drew a divider `box-shadow` on each -- a horizontal-scroll affordance none of the other log-type pages use -- and left the Request column at a fixed width instead of stretching to fill the panel, so wide viewports showed an empty gap past Duration. Both are removed: the two trailing columns are back in normal table flow, and Request now takes `width:auto` to absorb the remaining space, matching how Gateway Events, Backup history, and the Audit log already size their last column. Purely a CSS change -- no markup, data, or behavior changes.
|
||||||
|
|
||||||
|
`v0.16.71` fixes a Gateway Events / Audit log message that slipped past v0.16.66: the Hosted Site file-upload route ("Replace files") still wrote the old, un-prefixed `Files replaced for "<name>".` line instead of the `Hosted site "<name>" ...` convention every other Hosted Site action uses (created, enabled/disabled, deleted, and the field-level update summaries). It lived in its own route separate from the four `PATCH` handlers v0.16.66 touched, so it was missed at the time and only noticed once a user pointed out the log entry gave no way to tell which route type it belonged to. Now reads `Hosted site "<name>" files replaced.`, matching the rest.
|
||||||
|
|
||||||
|
`v0.16.72` fixes the favicon not appearing in some Chromium-family browsers (reported: DuckDuckGo's browser showed the default globe icon while Safari showed the real one correctly). The app previously declared a single `<link rel="icon">` pointing straight at the 1018x1001, 396 KB source PNG with no `sizes` attribute and no `/favicon.ico` fallback -- Safari is forgiving about oversized, unsized favicons, but some Chromium-based browsers silently skip one that large rather than downscale it, and several also probe `/favicon.ico` directly regardless of what the `<link>` tag says. Added properly sized `favicon.ico` (16/32px, multi-size), standalone `favicon-16.png`/`favicon-32.png` with `sizes` attributes, and a 180x180 `apple-touch-icon.png`, all generated from the existing approved icon artwork and served automatically by the existing static file handler -- no server route or icon design changes.
|
||||||
|
|
||||||
|
`v0.16.73` makes the Dashboard's System panel top accent bar reflect resource state, instead of always showing green -- pointed out after a screenshot showed CPU pinned at 100% (its own stat correctly shown in red) while the panel's top bar stayed the hardcoded `var(--green)` it always had. `renderHeroPanel()` now tracks the worst tone across CPU/memory/swap/disk (the same "warning" at 75%+ / "critical" at 90%+ thresholds each stat's own value and fill bar already used) and applies a `tone-warning`/`tone-critical` class to the panel, which `.system-panel::before` now reads instead of a fixed color. Network, uptime, and throughput don't carry a tone and are excluded from the calculation, same as before. Applies to the Dashboard's System panel only -- the Administration > System tab's equivalent hero grid has no top accent bar to react.
|
||||||
|
|
||||||
|
`v0.16.74` fixes an inaccurate status label on Hosted Site, Proxy Host, and Streaming Host cards, and the Certificates table's Upstream column: a route with monitoring intentionally turned off via "Monitor this site/upstream" in Advanced options -- while the route itself stays enabled and running -- read "Monitoring paused", the same text used for a route that's fully disabled. "Paused" implies a temporary interruption; deliberately unchecking the monitor box is an ongoing, intentional setting. The backend already distinguished the two cases (`status: "disabled"` when the route itself is off vs. `status: "unmonitored"` when only health checks are off, in `checkProxy()`), the frontend just collapsed them into one string in four places. Disabled routes keep "Monitoring paused"; a running route with health checks off now reads "Monitoring disabled". No backend or status-logic changes, text only.
|
||||||
|
|
||||||
|
`v0.16.75` is a one-line smoke test to confirm the Gitea remote (`git.us2plus2.com/marvin/site-gateway`) actually receives pushes end-to-end after the GitHub-to-Gitea migration: the Dashboard heading text reads "Dashboard v2" instead of "Dashboard" (both the static HTML fallback and the JS that sets it on render), with no other functional change. Pushed to both `origin` (Gitea) and `github` remotes per the dual-push arrangement while GitHub CI still builds the published container image.
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ services:
|
|||||||
PUID: ${PUID:-1000}
|
PUID: ${PUID:-1000}
|
||||||
PGID: ${PGID:-1000}
|
PGID: ${PGID:-1000}
|
||||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||||
|
# Optional: display-only Disk allowance for the resource panel. Set
|
||||||
|
# DATA_DIR_LIMIT_GB in .env to use.
|
||||||
|
DATA_DIR_LIMIT_GB: ${DATA_DIR_LIMIT_GB:-}
|
||||||
|
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||||
|
# Administration > System tab's live resource panel can only show usage
|
||||||
|
# against the whole host. Uncomment and set in .env to give CPU/Memory a
|
||||||
|
# real, container-scoped denominator (MEM_LIMIT, e.g. "2g"; CPU_LIMIT,
|
||||||
|
# e.g. "2"; or CPUSET, e.g. "0,1", to pin cores instead of a count).
|
||||||
|
# mem_limit: ${MEM_LIMIT}
|
||||||
|
# cpus: ${CPU_LIMIT}
|
||||||
|
# cpuset: ${CPUSET}
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-80}:80"
|
- "${HTTP_PORT:-80}:80"
|
||||||
- "${HTTPS_PORT:-443}:443"
|
- "${HTTPS_PORT:-443}:443"
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ services:
|
|||||||
|
|
||||||
# Optional: certificate account email, passed to Caddy's ACME client.
|
# Optional: certificate account email, passed to Caddy's ACME client.
|
||||||
ACME_EMAIL: ""
|
ACME_EMAIL: ""
|
||||||
|
|
||||||
|
# Optional: display-only Disk allowance for the resource panel (e.g. a
|
||||||
|
# smaller dedicated share) -- usage/free space still come from the real
|
||||||
|
# volume, this just gives the panel a number to measure against.
|
||||||
|
# DATA_DIR_LIMIT_GB: 30
|
||||||
|
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||||
|
# Administration > System tab's live resource panel can only show usage
|
||||||
|
# against the whole host, which is rarely meaningful on a shared machine.
|
||||||
|
# Setting these gives CPU/Memory a real, container-scoped denominator.
|
||||||
|
# mem_limit: 2g
|
||||||
|
# cpus: "2"
|
||||||
|
# Pinning specific cores is also supported instead of (or alongside) a
|
||||||
|
# count -- the resource panel reads whichever one Docker actually applied.
|
||||||
|
# cpuset: "0,1"
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
+17
-2
@@ -30,6 +30,21 @@ services:
|
|||||||
PUID: 1000
|
PUID: 1000
|
||||||
PGID: 1000
|
PGID: 1000
|
||||||
ACME_EMAIL: ""
|
ACME_EMAIL: ""
|
||||||
|
|
||||||
|
# Optional: display-only Disk allowance for the resource panel (e.g. a
|
||||||
|
# smaller dedicated share) -- usage/free space still come from the real
|
||||||
|
# volume, this just gives the panel a number to measure against.
|
||||||
|
# DATA_DIR_LIMIT_GB: 30
|
||||||
|
# Optional but recommended: without a memory/CPU limit, the Dashboard and
|
||||||
|
# Administration > System tab's live resource panel can only show usage
|
||||||
|
# against the whole host, which is rarely meaningful on a shared ZimaOS
|
||||||
|
# box. Setting these gives CPU/Memory a real, container-scoped
|
||||||
|
# denominator instead.
|
||||||
|
# mem_limit: 2g
|
||||||
|
# cpus: "2"
|
||||||
|
# Pinning specific cores is also supported instead of (or alongside) a
|
||||||
|
# count -- the resource panel reads whichever one Docker actually applied.
|
||||||
|
# cpuset: "0,1"
|
||||||
ports:
|
ports:
|
||||||
- target: 8080
|
- target: 8080
|
||||||
published: "8080"
|
published: "8080"
|
||||||
@@ -76,8 +91,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.48",
|
"version": "0.16.75",
|
||||||
"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",
|
||||||
|
|||||||
+41
-24
@@ -181,7 +181,7 @@ function renderDashboard() {
|
|||||||
$("#dash-hosted-detail").textContent = healthCopy(data.hosted, "sites");
|
$("#dash-hosted-detail").textContent = healthCopy(data.hosted, "sites");
|
||||||
$("#dash-proxy-total").textContent = data.proxies.total;
|
$("#dash-proxy-total").textContent = data.proxies.total;
|
||||||
$("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes");
|
$("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes");
|
||||||
$("#dash-tls-total").textContent = data.tlsDomains;
|
$("#dash-tls-total").textContent = data.certificates.total;
|
||||||
$("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains";
|
$("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains";
|
||||||
$("#dash-redirect-total").textContent = state.redirects?.length || 0;
|
$("#dash-redirect-total").textContent = state.redirects?.length || 0;
|
||||||
$("#dash-stream-total").textContent = state.streams?.length || 0;
|
$("#dash-stream-total").textContent = state.streams?.length || 0;
|
||||||
@@ -241,20 +241,24 @@ 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 ? "Monitoring paused" : site.upstream?.status === "unmonitored" ? "Monitoring disabled" : !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";
|
||||||
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
|
const upstream = !proxy.enabled ? "Monitoring paused" : proxy.upstream?.status === "unmonitored" ? "Monitoring disabled" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
|
||||||
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 ? `<span class="status-dot idle"></span>Monitoring paused` : item.upstream.status === "unmonitored" ? `<span class="status-dot idle"></span>Monitoring disabled` : 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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +511,7 @@ function render() {
|
|||||||
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");
|
$("#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 v2";
|
||||||
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
return;
|
return;
|
||||||
@@ -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);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
+39
-18
File diff suppressed because one or more lines are too long
+32
-10
File diff suppressed because one or more lines are too long
+42
-24
@@ -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}
|
||||||
@@ -151,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}
|
||||||
@@ -221,7 +222,9 @@ header{align-items:flex-end}
|
|||||||
.live-dot.checking{background:var(--warning);animation-duration:.9s}
|
.live-dot.checking{background:var(--warning);animation-duration:.9s}
|
||||||
@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}}
|
@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}}
|
||||||
.system-panel{position:relative;overflow:hidden}
|
.system-panel{position:relative;overflow:hidden}
|
||||||
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--green);opacity:.85}
|
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--green);opacity:.85;transition:background .2s}
|
||||||
|
.system-panel.tone-warning::before{background:var(--warning)}
|
||||||
|
.system-panel.tone-critical::before{background:var(--danger)}
|
||||||
.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}
|
.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}
|
||||||
.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
|
.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
|
||||||
.system-grid dt{color:var(--muted);font-size:.72rem}
|
.system-grid dt{color:var(--muted);font-size:.72rem}
|
||||||
@@ -252,6 +255,18 @@ header{align-items:flex-end}
|
|||||||
@media(max-width:760px){.mobile-nav{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:0 0 30px;padding:var(--space-1);border:1px solid var(--line);border-radius:var(--radius-md);background:var(--panel)}.mobile-nav button{justify-content:center;text-align:center;padding:9px 5px;font-size:.72rem}.dashboard-view{margin-top:30px}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric-card{padding:var(--space-4)}.metric-card strong{font-size:1.65rem}.dashboard-columns{grid-template-columns:1fr}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-strip{grid-template-columns:1fr}.metric-chip{padding:11px 14px}}
|
@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}
|
||||||
|
table.backup-history-table th:nth-child(1),table.backup-history-table td:nth-child(1){width:26%}
|
||||||
|
table.backup-history-table th:nth-child(2),table.backup-history-table td:nth-child(2),table.backup-history-table th:nth-child(3),table.backup-history-table td:nth-child(3){width:18%}
|
||||||
|
table.backup-history-table th:nth-child(4),table.backup-history-table td:nth-child(4){width:auto}
|
||||||
.activity-mark.bad{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
|
.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)}
|
||||||
@@ -267,6 +282,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}
|
||||||
@@ -356,7 +374,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}
|
||||||
@@ -374,8 +392,9 @@ textarea:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(var(--green-r
|
|||||||
.redirect-card .card-footer{gap:var(--space-3)}
|
.redirect-card .card-footer{gap:var(--space-3)}
|
||||||
.redirect-card .button{padding:7px 9px;font-size:var(--font-size-xs)}
|
.redirect-card .button{padding:7px 9px;font-size:var(--font-size-xs)}
|
||||||
.chip{padding:5px var(--space-2);border-radius:var(--radius-full);background:var(--panel2);color:var(--muted);font-size:var(--font-size-xs)}
|
.chip{padding:5px var(--space-2);border-radius:var(--radius-full);background:var(--panel2);color:var(--muted);font-size:var(--font-size-xs)}
|
||||||
|
.chip+small{margin-left:var(--space-2)}
|
||||||
.chip.type-complete{background:rgba(var(--green-rgb),.15);color:var(--green)}
|
.chip.type-complete{background:rgba(var(--green-rgb),.15);color:var(--green)}
|
||||||
.chip.type-configuration{background:var(--panel2);color:var(--muted)}
|
.chip.type-configuration{background:color-mix(in srgb,var(--accent-purple) 15%,transparent);color:var(--accent-purple)}
|
||||||
.chip.type-encrypted{background:rgba(var(--chip-blue-rgb),.15);color:var(--blue)}
|
.chip.type-encrypted{background:rgba(var(--chip-blue-rgb),.15);color:var(--blue)}
|
||||||
.chip.type-unknown{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
|
.chip.type-unknown{background:rgba(var(--danger-rgb),.12);color:var(--danger)}
|
||||||
.backup-summary{display:block;margin-top:6px}
|
.backup-summary{display:block;margin-top:6px}
|
||||||
@@ -470,25 +489,21 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
|||||||
#app .upstream-diagnostics summary{padding:0;color:var(--blue);font-size:var(--font-size-xs);font-weight:700;border:0}
|
#app .upstream-diagnostics summary{padding:0;color:var(--blue);font-size:var(--font-size-xs);font-weight:700;border:0}
|
||||||
#app .upstream-diagnostics .upstream-detail{margin:6px 0 0}
|
#app .upstream-diagnostics .upstream-detail{margin:6px 0 0}
|
||||||
|
|
||||||
/* Log table layout & column widths */
|
/* Log table layout & column widths -- matches the .event-table/.performance-table
|
||||||
|
pattern used by Gateway Events, Backup history, and the Audit log: sticky header
|
||||||
|
only (no pinned/divided trailing columns), and the Request column absorbs the
|
||||||
|
remaining width instead of leaving a gap on wide panels. */
|
||||||
.log-table-wrap{max-height:min(52vh,620px);overflow:auto}
|
.log-table-wrap{max-height:min(52vh,620px);overflow:auto}
|
||||||
.log-table-wrap .log-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
|
.log-table-wrap .log-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}
|
||||||
.log-section-heading{margin:20px 0 10px}
|
.log-section-heading{margin:20px 0 10px}
|
||||||
.log-section-heading h2{margin:0;font-size:1.05rem}
|
.log-section-heading h2{margin:0;font-size:1.05rem}
|
||||||
.log-section-heading p{margin:var(--space-1) 0 0;font-size:.76rem}
|
.log-section-heading p{margin:var(--space-1) 0 0;font-size:.76rem}
|
||||||
@media(min-width:761px){#dashboard>aside{position:sticky;top:0;height:100vh;max-height:100vh;overflow-y:auto;align-self:start}}
|
@media(min-width:761px){#dashboard>aside{position:sticky;top:0;height:100vh;max-height:100vh;overflow-y:auto;align-self:start}}
|
||||||
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2),.log-table th:last-child,.log-table td:last-child{position:sticky;background:var(--panel);z-index:2}
|
|
||||||
.log-table th:nth-last-child(2),.log-table td:nth-last-child(2){right:104px;min-width:104px}
|
|
||||||
.log-table th:last-child,.log-table td:last-child{right:0;min-width:104px}
|
|
||||||
.log-table th:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
|
|
||||||
.log-table td:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}
|
|
||||||
.log-table-wrap{overflow-x:hidden;overflow-y:auto}
|
|
||||||
.log-table{table-layout:fixed}
|
.log-table{table-layout:fixed}
|
||||||
.log-table th:nth-child(1),.log-table td:nth-child(1){width:190px}
|
.log-table th:nth-child(1),.log-table td:nth-child(1){width:190px}
|
||||||
.log-table th:nth-child(2),.log-table td:nth-child(2){width:220px}
|
.log-table th:nth-child(2),.log-table td:nth-child(2){width:220px}
|
||||||
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){width:104px}
|
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){width:104px}
|
||||||
.log-table th:nth-child(3),.log-table td:nth-child(3){white-space:normal;overflow-wrap:anywhere}
|
.log-table th:nth-child(3),.log-table td:nth-child(3){width:auto;white-space:normal;overflow-wrap:anywhere}
|
||||||
.log-table th:last-child,.log-table td:last-child{box-shadow:-1px 0 0 var(--line)}
|
|
||||||
@media(max-width:900px){.log-table th:nth-child(1),.log-table td:nth-child(1){width:150px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:170px}}
|
@media(max-width:900px){.log-table th:nth-child(1),.log-table td:nth-child(1){width:150px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:170px}}
|
||||||
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){text-align:center}
|
.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){text-align:center}
|
||||||
#access-dialog .dialog-heading .close-dialog{display:none}
|
#access-dialog .dialog-heading .close-dialog{display:none}
|
||||||
@@ -675,7 +690,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);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}
|
||||||
@@ -809,10 +824,6 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
|||||||
.event-technical{margin-top:6px}
|
.event-technical{margin-top:6px}
|
||||||
.event-technical summary{padding:0;color:var(--blue);font-size:.7rem;cursor:pointer}
|
.event-technical summary{padding:0;color:var(--blue);font-size:.7rem;cursor:pointer}
|
||||||
.event-technical code{display:block;margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:var(--font-size-xs)}
|
.event-technical code{display:block;margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:var(--font-size-xs)}
|
||||||
#audit-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel);scrollbar-gutter:stable}
|
|
||||||
#audit-list .event-row{align-items:center;padding:18px 20px}
|
|
||||||
#audit-list .activity-mark{width:8px;height:8px;min-width:8px;border-radius:50%;padding:0;font-size:0;background:var(--green);box-shadow:0 0 0 4px rgba(var(--green-rgb),.1)}
|
|
||||||
#audit-list .activity-mark.bad{background:var(--danger);box-shadow:0 0 0 4px rgba(var(--danger-rgb),.09)}
|
|
||||||
|
|
||||||
/* Diagnostic section headings & readiness panels */
|
/* Diagnostic section headings & readiness panels */
|
||||||
.diagnostic-section-heading{margin:20px 0 10px;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)}
|
.diagnostic-section-heading{margin:20px 0 10px;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:var(--radius-2xl) 15px 0 0;background:var(--panel)}
|
||||||
@@ -821,11 +832,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}
|
||||||
@@ -970,12 +992,6 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
|||||||
.api-token-hide-revoked span{line-height:normal}
|
.api-token-hide-revoked span{line-height:normal}
|
||||||
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
|
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
|
||||||
|
|
||||||
/* Backup history timeline */
|
|
||||||
.backup-history-list{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4);max-height:min(46vh,520px);overflow:auto}
|
|
||||||
.backup-history-copy{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
||||||
.backup-history-copy strong{font-size:var(--font-size-md)}
|
|
||||||
.backup-history-copy small{color:var(--muted);font-size:var(--font-size-xs)}
|
|
||||||
.backup-history-copy small.failed{color:var(--danger)}
|
|
||||||
.check-control.is-disabled{opacity:.55}
|
.check-control.is-disabled{opacity:.55}
|
||||||
.check-control.is-disabled span{color:var(--muted)}
|
.check-control.is-disabled span{color:var(--muted)}
|
||||||
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
|
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
|
||||||
@@ -987,6 +1003,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)}
|
||||||
|
|||||||
+66
-6
@@ -91,6 +91,21 @@ function recordActivity(message, status = "ok") {
|
|||||||
try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); }
|
try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Edit-route change summaries: builds one human-readable, comma-joined line describing
|
||||||
|
// exactly what changed in a single PATCH save, instead of a generic "X updated" message
|
||||||
|
// that looks identical whether a rename, a domain change, or a TLS toggle happened.
|
||||||
|
function describeDomainsChange(previousDomains, nextDomains) {
|
||||||
|
if (previousDomains.join("|") === nextDomains.join("|")) return null;
|
||||||
|
if (!nextDomains.length) return "domain removed";
|
||||||
|
const shown = nextDomains.slice(0, 3).join(", ");
|
||||||
|
return `domain${nextDomains.length > 1 ? "s" : ""} changed to ${shown}${nextDomains.length > 3 ? ` (+${nextDomains.length - 3} more)` : ""}`;
|
||||||
|
}
|
||||||
|
function summarizeUpdate(kind, name, changes) {
|
||||||
|
const list = changes.filter(Boolean);
|
||||||
|
if (!list.length) return;
|
||||||
|
recordActivity(`${kind} “${name}” updated — ${list.join(", ")}.`);
|
||||||
|
}
|
||||||
|
|
||||||
async function directorySize(directory) {
|
async function directorySize(directory) {
|
||||||
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
|
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
|
||||||
const sizes = await Promise.all(entries.map(async entry => {
|
const sizes = await Promise.all(entries.map(async entry => {
|
||||||
@@ -964,7 +979,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 };
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1185,6 +1200,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
|
||||||
@@ -2175,7 +2192,7 @@ app.post("/api/sites/:id/files", upload.single("files"), async (req, res, next)
|
|||||||
if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." });
|
if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." });
|
||||||
await installUpload(site, req.file);
|
await installUpload(site, req.file);
|
||||||
await syncCaddy();
|
await syncCaddy();
|
||||||
recordActivity(`Files replaced for “${site.name}”.`);
|
recordActivity(`Hosted site “${site.name}” files replaced.`);
|
||||||
res.json(publicSite(site));
|
res.json(publicSite(site));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
@@ -2198,6 +2215,7 @@ app.patch("/api/sites/:id", async (req, res, next) => {
|
|||||||
const site = sites.find(item => item.id === req.params.id);
|
const site = sites.find(item => item.id === req.params.id);
|
||||||
if (!site) return res.status(404).json({ error: "Site not found." });
|
if (!site) return res.status(404).json({ error: "Site not found." });
|
||||||
const previousDomains = normalizeDomains(site.domain, site.domains);
|
const previousDomains = normalizeDomains(site.domain, site.domains);
|
||||||
|
const before = { name: site.name, tls: site.tls, hsts: site.hsts, accessListId: site.accessListId, healthEnabled: site.healthEnabled, healthDetail: JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]) };
|
||||||
const domain = normalizeDomain(req.body.domain);
|
const domain = normalizeDomain(req.body.domain);
|
||||||
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains);
|
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains);
|
||||||
const domainError = validateDomains(domains, site.id);
|
const domainError = validateDomains(domains, site.id);
|
||||||
@@ -2216,7 +2234,16 @@ app.patch("/api/sites/:id", async (req, res, next) => {
|
|||||||
await syncCaddy();
|
await syncCaddy();
|
||||||
await pruneOrphanedCertificates(previousDomains);
|
await pruneOrphanedCertificates(previousDomains);
|
||||||
await saveSites();
|
await saveSites();
|
||||||
recordActivity(`Gateway settings updated for “${site.name}”.`);
|
const afterHealthDetail = JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]);
|
||||||
|
summarizeUpdate("Hosted site", site.name, [
|
||||||
|
before.name !== site.name ? `renamed from “${before.name}”` : null,
|
||||||
|
describeDomainsChange(previousDomains, domains),
|
||||||
|
before.tls !== site.tls ? `TLS set to ${site.tls}` : null,
|
||||||
|
before.hsts !== site.hsts ? `HSTS ${site.hsts ? "enabled" : "disabled"}` : null,
|
||||||
|
before.accessListId !== site.accessListId ? (site.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||||
|
before.healthEnabled !== site.healthEnabled ? `health checks ${site.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||||
|
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
|
||||||
|
]);
|
||||||
res.json(publicSite(site));
|
res.json(publicSite(site));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
@@ -2255,6 +2282,7 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
|
|||||||
const proxy = proxies.find(item => item.id === req.params.id);
|
const proxy = proxies.find(item => item.id === req.params.id);
|
||||||
if (!proxy) return res.status(404).json({ error: "Proxy host not found." });
|
if (!proxy) return res.status(404).json({ error: "Proxy host not found." });
|
||||||
const previousDomains = normalizeDomains(proxy.domain, proxy.domains);
|
const previousDomains = normalizeDomains(proxy.domain, proxy.domains);
|
||||||
|
const before = { name: proxy.name, target: proxy.target, tls: proxy.tls, hsts: proxy.hsts, accessListId: proxy.accessListId, healthEnabled: proxy.healthEnabled, healthDetail: JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]) };
|
||||||
if (req.body.domain !== undefined) {
|
if (req.body.domain !== undefined) {
|
||||||
const domain = normalizeDomain(req.body.domain);
|
const domain = normalizeDomain(req.body.domain);
|
||||||
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains);
|
const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains);
|
||||||
@@ -2279,7 +2307,17 @@ app.patch("/api/proxies/:id", async (req, res, next) => {
|
|||||||
await syncCaddy();
|
await syncCaddy();
|
||||||
await pruneOrphanedCertificates(previousDomains);
|
await pruneOrphanedCertificates(previousDomains);
|
||||||
await saveProxies();
|
await saveProxies();
|
||||||
recordActivity(`Proxy host “${proxy.name}” updated.`);
|
const afterHealthDetail = JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]);
|
||||||
|
summarizeUpdate("Proxy host", proxy.name, [
|
||||||
|
before.name !== proxy.name ? `renamed from “${before.name}”` : null,
|
||||||
|
describeDomainsChange(previousDomains, normalizeDomains(proxy.domain, proxy.domains)),
|
||||||
|
before.target !== proxy.target ? `target changed to ${proxy.target}` : null,
|
||||||
|
before.tls !== proxy.tls ? `TLS set to ${proxy.tls}` : null,
|
||||||
|
before.hsts !== proxy.hsts ? `HSTS ${proxy.hsts ? "enabled" : "disabled"}` : null,
|
||||||
|
before.accessListId !== proxy.accessListId ? (proxy.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||||
|
before.healthEnabled !== proxy.healthEnabled ? `health checks ${proxy.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||||
|
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
|
||||||
|
]);
|
||||||
res.json(publicProxy(proxy));
|
res.json(publicProxy(proxy));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
@@ -2408,6 +2446,7 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." });
|
const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." });
|
||||||
const previousDomains = normalizeDomains(item.domain, item.domains);
|
const previousDomains = normalizeDomains(item.domain, item.domains);
|
||||||
|
const before = { name: item.name, target: item.target, accessListId: item.accessListId, enabled: item.enabled, code: item.code, preservePath: item.preservePath, tls: item.tls, hsts: item.hsts };
|
||||||
if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; }
|
if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; }
|
||||||
if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled);
|
if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled);
|
||||||
for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim();
|
for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim();
|
||||||
@@ -2416,7 +2455,19 @@ app.patch("/api/redirects/:id", async (req, res, next) => {
|
|||||||
if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath);
|
if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath);
|
||||||
if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls;
|
if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls;
|
||||||
if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts);
|
if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts);
|
||||||
await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item);
|
await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects();
|
||||||
|
summarizeUpdate("Redirect Host", item.name, [
|
||||||
|
before.name !== item.name ? `renamed from “${before.name}”` : null,
|
||||||
|
describeDomainsChange(previousDomains, normalizeDomains(item.domain, item.domains)),
|
||||||
|
before.target !== item.target ? `destination changed to ${item.target}` : null,
|
||||||
|
before.code !== item.code ? `redirect code changed to ${item.code}` : null,
|
||||||
|
before.preservePath !== item.preservePath ? `path preservation ${item.preservePath ? "enabled" : "disabled"}` : null,
|
||||||
|
before.tls !== item.tls ? `TLS set to ${item.tls}` : null,
|
||||||
|
before.hsts !== item.hsts ? `HSTS ${item.hsts ? "enabled" : "disabled"}` : null,
|
||||||
|
before.accessListId !== item.accessListId ? (item.accessListId ? "Access List assigned" : "Access List removed") : null,
|
||||||
|
before.enabled !== item.enabled ? (item.enabled ? "enabled" : "disabled") : null
|
||||||
|
]);
|
||||||
|
res.json(item);
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
app.delete("/api/redirects/:id", async (req, res, next) => {
|
app.delete("/api/redirects/:id", async (req, res, next) => {
|
||||||
@@ -2448,6 +2499,7 @@ app.post("/api/streams", async (req, res, next) => {
|
|||||||
app.patch("/api/streams/:id", async (req, res, next) => {
|
app.patch("/api/streams/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
|
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
|
||||||
|
const before = { name: stream.name, port: stream.port, target: stream.target, tcp: stream.tcp, udp: stream.udp, healthEnabled: stream.healthEnabled, enabled: stream.enabled };
|
||||||
const next_ = { ...stream };
|
const next_ = { ...stream };
|
||||||
if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; }
|
if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; }
|
||||||
if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; }
|
if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; }
|
||||||
@@ -2463,7 +2515,15 @@ app.patch("/api/streams/:id", async (req, res, next) => {
|
|||||||
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
|
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
|
||||||
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
|
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
|
||||||
await saveStreams();
|
await saveStreams();
|
||||||
recordActivity(`Streaming host “${stream.name}” updated.`);
|
const protocolLabel = (tcp, udp) => tcp && udp ? "TCP+UDP" : tcp ? "TCP" : "UDP";
|
||||||
|
summarizeUpdate("Streaming host", stream.name, [
|
||||||
|
before.name !== stream.name ? `renamed from “${before.name}”` : null,
|
||||||
|
before.port !== stream.port ? `port changed from ${before.port} to ${stream.port}` : null,
|
||||||
|
before.target !== stream.target ? `target changed to ${stream.target}` : null,
|
||||||
|
(before.tcp !== stream.tcp || before.udp !== stream.udp) ? `protocol changed to ${protocolLabel(stream.tcp, stream.udp)}` : null,
|
||||||
|
before.healthEnabled !== stream.healthEnabled ? `health checks ${stream.healthEnabled === false ? "disabled" : "enabled"}` : null,
|
||||||
|
before.enabled !== stream.enabled ? (stream.enabled ? "enabled" : "disabled") : null
|
||||||
|
]);
|
||||||
res.json(publicStream(stream));
|
res.json(publicStream(stream));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user