Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 557670a78b | |||
| 00ec6ff0d9 | |||
| 454d2dc8f7 | |||
| 7153729e9c | |||
| 28d19266e9 | |||
| dda6c517fc | |||
| 887cd2dfcc | |||
| 227823c534 | |||
| 216ce1b094 | |||
| 6821a30b65 | |||
| 4e4823b0f7 | |||
| 747c776a84 | |||
| 5b5ba9b3be |
@@ -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
|
||||||
|
|||||||
@@ -23,15 +23,37 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Sign in to Gitea Container Registry
|
||||||
|
if: ${{ github.server_url != 'https://github.com' }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.us2plus2.com
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Sign in to GitHub Container Registry
|
- name: Sign in to GitHub Container Registry
|
||||||
|
if: ${{ github.server_url == 'https://github.com' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Generate image tags
|
- name: Generate image tags (Gitea)
|
||||||
id: meta
|
id: meta-gitea
|
||||||
|
if: ${{ github.server_url != 'https://github.com' }}
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.us2plus2.com/marvin/site-gateway
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=alpha,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-alpha.') }}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Generate image tags (GitHub)
|
||||||
|
id: meta-github
|
||||||
|
if: ${{ github.server_url == 'https://github.com' }}
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}
|
images: ghcr.io/${{ github.repository }}
|
||||||
@@ -52,21 +74,38 @@ jobs:
|
|||||||
- name: Verify Node and built-in SQLite
|
- name: Verify Node and built-in SQLite
|
||||||
run: docker run --rm --entrypoint node site-gateway:smoke-test --input-type=module -e "import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE smoke (id INTEGER)'); db.close();"
|
run: docker run --rm --entrypoint node site-gateway:smoke-test --input-type=module -e "import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE smoke (id INTEGER)'); db.close();"
|
||||||
|
|
||||||
- name: Scan image for vulnerabilities
|
- name: Install Trivy
|
||||||
uses: aquasecurity/trivy-action@0.35.0
|
run: |
|
||||||
with:
|
set -e
|
||||||
image-ref: site-gateway:smoke-test
|
TRIVY_VERSION=0.74.0
|
||||||
severity: CRITICAL,HIGH
|
curl -sfL "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -o /tmp/trivy.tar.gz
|
||||||
exit-code: "0"
|
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
|
||||||
format: table
|
sudo mv /tmp/trivy /usr/local/bin/trivy
|
||||||
|
trivy --version
|
||||||
|
|
||||||
- name: Build and publish
|
- name: Scan image for vulnerabilities
|
||||||
|
run: trivy image --severity CRITICAL,HIGH --exit-code 0 --format table site-gateway:smoke-test
|
||||||
|
|
||||||
|
- name: Build and publish (Gitea)
|
||||||
|
if: ${{ github.server_url != 'https://github.com' }}
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta-gitea.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta-gitea.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build and publish (GitHub)
|
||||||
|
if: ${{ github.server_url == 'https://github.com' }}
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-github.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-github.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ At startup, the container creates the complete `/data` hierarchy, applies `PUID`
|
|||||||
|
|
||||||
## Unraid
|
## Unraid
|
||||||
|
|
||||||
1. Add the container from **Docker → Add Container** using the image `ghcr.io/mfwadejr/site-gateway2:latest`, or search Community Applications once a template is published.
|
1. Add the container from **Docker → Add Container** using the image `git.us2plus2.com/marvin/site-gateway:latest`, or search Community Applications once a template is published.
|
||||||
2. Map ports `80`, `443` (TCP+UDP), `8080`, and `9000-9099` as above, plus any Streaming Host ports you plan to use.
|
2. Map ports `80`, `443` (TCP+UDP), `8080`, and `9000-9099` as above, plus any Streaming Host ports you plan to use.
|
||||||
3. Map one path, e.g. `/mnt/user/appdata/site-gateway:/data`.
|
3. Map one path, e.g. `/mnt/user/appdata/site-gateway:/data`.
|
||||||
4. Set `PUID=99` and `PGID=100` so the container writes to `/data` as the `nobody`/`users` account Unraid expects.
|
4. Set `PUID=99` and `PGID=100` so the container writes to `/data` as the `nobody`/`users` account Unraid expects.
|
||||||
|
|||||||
+22
@@ -258,3 +258,25 @@ Roughly in priority order:
|
|||||||
`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.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.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.
|
||||||
|
|
||||||
|
`v0.16.76` fixes the Gitea Actions container-publish pipeline, which failed twice after the earlier GitHub-to-Gitea migration -- first at registry sign-in (missing `REGISTRY_USERNAME`/`REGISTRY_TOKEN` repo secrets, added directly in Gitea's Actions settings, no workflow change needed) and then at the "Scan image for vulnerabilities" step, which used `aquasecurity/trivy-action@0.35.0`. That action installs Trivy at runtime via a `git clone`-based installer script against GitHub, a dependency separate from the registry sign-in fix and one that doesn't reliably resolve from a Gitea Actions runner. Replaced with two plain shell steps: a pinned `curl` of Trivy 0.56.2's release tarball directly from GitHub's release CDN (a single static HTTPS download, not a git checkout) followed by `trivy image` run directly, keeping the same severity/exit-code/format settings. Scanning behavior is unchanged; only the installation mechanism moved off the flaky git-based installer.
|
||||||
|
|
||||||
|
`v0.16.77` fixes a follow-on to v0.16.76's Trivy-installer replacement: the pinned version, `0.56.2`, no longer exists on GitHub's release list (current is `v0.74.0`), so the `curl -sfL` request 404'd and failed fast with exit code 22 instead of installing anything. `TRIVY_VERSION` is now `0.74.0`, verified against the actual release assets before pushing. No other change to the install/scan steps from v0.16.76.
|
||||||
|
|
||||||
|
`v0.16.78` restores GitHub's half of the dual-publish pipeline without touching Gitea's: the earlier migration commits had rewritten `container.yml`'s registry login, tag, and publish steps to target only `git.us2plus2.com` using Gitea-only secrets, so every push to GitHub since then failed in ~30 seconds at "Sign in to Gitea Container Registry" with "Username and password required" -- GitHub's repo never had `REGISTRY_USERNAME`/`REGISTRY_TOKEN`. The login, tag-generation, and publish steps are now duplicated, one set per registry, each gated with `if: github.server_url == 'https://github.com'` (or `!=`) so the workflow self-selects which registry to sign into and push to depending on which host is actually running it -- Gitea Actions keeps using the existing `REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets against `git.us2plus2.com` exactly as before, GitHub Actions goes back to `ghcr.io` using `github.actor`/`GITHUB_TOKEN` as it did pre-migration. The shared build/scan steps (smoke-test image, SQLite check, Trivy install/scan) are unconditional and run identically on both. Also reverts the v0.16.75 smoke-test change: the Dashboard heading text is back to "Dashboard" now that the Gitea pipeline is confirmed working end to end.
|
||||||
|
|||||||
+12
-1
@@ -5,7 +5,7 @@
|
|||||||
# docker compose -f compose.release.yaml up -d
|
# docker compose -f compose.release.yaml up -d
|
||||||
services:
|
services:
|
||||||
site-gateway:
|
site-gateway:
|
||||||
image: ${SITE_GATEWAY_IMAGE:-ghcr.io/mfwadejr/site-gateway2:latest}
|
image: ${SITE_GATEWAY_IMAGE:-git.us2plus2.com/marvin/site-gateway:latest}
|
||||||
container_name: site-gateway
|
container_name: site-gateway
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -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"
|
||||||
|
|||||||
+20
-5
@@ -13,7 +13,7 @@ name: site-gateway
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
site-gateway:
|
site-gateway:
|
||||||
image: ghcr.io/mfwadejr/site-gateway2:latest
|
image: git.us2plus2.com/marvin/site-gateway:latest
|
||||||
container_name: site-gateway
|
container_name: site-gateway
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -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"
|
||||||
@@ -62,7 +77,7 @@ x-casaos:
|
|||||||
index: /
|
index: /
|
||||||
port_map: "8080"
|
port_map: "8080"
|
||||||
scheme: http
|
scheme: http
|
||||||
icon: https://raw.githubusercontent.com/mfwadejr/site-gateway2/main/src/public/site-gateway-icon-approved.png
|
icon: https://git.us2plus2.com/marvin/site-gateway/raw/branch/main/src/public/site-gateway-icon-approved.png
|
||||||
title:
|
title:
|
||||||
en_US: Site Gateway
|
en_US: Site Gateway
|
||||||
tagline:
|
tagline:
|
||||||
@@ -78,6 +93,6 @@ x-casaos:
|
|||||||
architectures: ["amd64", "arm64"]
|
architectures: ["amd64", "arm64"]
|
||||||
version: "0.16.58"
|
version: "0.16.58"
|
||||||
update_at: "2026-09-20"
|
update_at: "2026-09-20"
|
||||||
website: https://github.com/mfwadejr/site-gateway2
|
website: https://git.us2plus2.com/marvin/site-gateway
|
||||||
repo: https://github.com/mfwadejr/site-gateway2
|
repo: https://git.us2plus2.com/marvin/site-gateway
|
||||||
support: https://github.com/mfwadejr/site-gateway2/issues
|
support: https://git.us2plus2.com/marvin/site-gateway/issues
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "site-gateway",
|
"name": "site-gateway",
|
||||||
"version": "0.16.67",
|
"version": "0.16.78",
|
||||||
"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",
|
||||||
|
|||||||
+3
-3
@@ -247,14 +247,14 @@ function upstreamStateClass(enabled, upstream) {
|
|||||||
}
|
}
|
||||||
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 ${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>`;
|
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";
|
||||||
@@ -277,7 +277,7 @@ function renderCertificates() {
|
|||||||
const tlsOk = item ? ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status) : null;
|
const tlsOk = item ? ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status) : null;
|
||||||
const dnsCell = item ? `<span class="status-dot ${dnsOk ? "running" : "error"}"></span>${dnsOk ? "Resolved" : "Failed"}` : `<span class="status-dot idle"></span>—`;
|
const dnsCell = item ? `<span class="status-dot ${dnsOk ? "running" : "error"}"></span>${dnsOk ? "Resolved" : "Failed"}` : `<span class="status-dot idle"></span>—`;
|
||||||
const tlsCell = item ? `<span class="status-dot ${tlsOk ? "running" : "error"}"></span>${escapeHtml(item.tls.status.replaceAll("-", " "))}` : `<span class="status-dot idle"></span>—`;
|
const tlsCell = item ? `<span class="status-dot ${tlsOk ? "running" : "error"}"></span>${escapeHtml(item.tls.status.replaceAll("-", " "))}` : `<span class="status-dot idle"></span>—`;
|
||||||
const upstreamCell = !item ? `<span class="status-dot idle"></span>—` : !item.upstream || item.upstream.status === "unmonitored" ? `<span class="status-dot idle"></span>Monitoring paused` : item.upstream.status === "pending" ? `<span class="status-dot idle"></span>Check pending` : item.upstream.status === "healthy" ? `<span class="status-dot running"></span>${item.upstream.httpStatus}` : `<span class="status-dot bad"></span>${escapeHtml(item.upstream.error || "Unavailable")}`;
|
const 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")}`;
|
||||||
const statusLabel = cert.status === "mismatch" ? "Domain mismatch" : cert.status.charAt(0).toUpperCase() + cert.status.slice(1);
|
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>`;
|
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>';
|
}).join("") : '<tr><td colspan="7" class="quiet-state">No HTTPS domains are configured.</td></tr>';
|
||||||
|
|||||||
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 |
+18
-5
@@ -22,7 +22,7 @@ function renderStreams() {
|
|||||||
empty.classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
empty.classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||||
list.innerHTML = state.streams.map(item => {
|
list.innerHTML = state.streams.map(item => {
|
||||||
const status = item.status === "running" ? "running" : item.status === "error" ? "error" : "disabled";
|
const status = item.status === "running" ? "running" : item.status === "error" ? "error" : "disabled";
|
||||||
const upstream = item.enabled === false || item.upstream?.status === "unmonitored" ? "Monitoring paused" : !item.upstream || item.upstream.status === "pending" ? "Target check pending" : item.upstream.status === "healthy" ? `Target reachable · ${item.upstream.responseMs} ms` : `Target unreachable · ${extendedEscape(item.upstream.error || "check failed")}`;
|
const upstream = item.enabled === false ? "Monitoring paused" : item.upstream?.status === "unmonitored" ? "Monitoring disabled" : !item.upstream || item.upstream.status === "pending" ? "Target check pending" : item.upstream.status === "healthy" ? `Target reachable · ${item.upstream.responseMs} ms` : `Target unreachable · ${extendedEscape(item.upstream.error || "check failed")}`;
|
||||||
const protocols = [item.tcp !== false ? "TCP" : null, item.udp ? "UDP" : null].filter(Boolean).map(value => `<span class="chip">${value}</span>`).join("");
|
const protocols = [item.tcp !== false ? "TCP" : null, item.udp ? "UDP" : null].filter(Boolean).map(value => `<span class="chip">${value}</span>`).join("");
|
||||||
const toggle = `<button class="toggle ${item.enabled === false ? "" : "on"}" data-stream-action="toggle" aria-label="${item.enabled === false ? "Enable" : "Disable"} ${extendedEscape(item.name)}"><span></span></button>`;
|
const toggle = `<button class="toggle ${item.enabled === false ? "" : "on"}" data-stream-action="toggle" aria-label="${item.enabled === false ? "Enable" : "Disable"} ${extendedEscape(item.name)}"><span></span></button>`;
|
||||||
return `<article class="site-card stream-card" data-stream-id="${item.id}" data-kind="stream"><div class="card-top"><div class="site-icon">${featureIcon(item,"SH")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Streaming host options" aria-expanded="false">•••</button><div class="menu"><button data-stream-action="edit">Edit streaming host</button><button data-stream-action="icon">Change icon</button><button data-stream-action="delete" class="danger-text">Delete streaming host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">Port ${item.port}</p><p class="gateway-address">→ ${extendedEscape(item.target)}</p><p class="upstream-copy ${item.enabled === false || item.upstream?.status === "unmonitored" || !item.upstream || item.upstream.status === "pending" ? "idle" : item.upstream.status === "healthy" ? "" : "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}${protocols}</div></div></article>`;
|
return `<article class="site-card stream-card" data-stream-id="${item.id}" data-kind="stream"><div class="card-top"><div class="site-icon">${featureIcon(item,"SH")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Streaming host options" aria-expanded="false">•••</button><div class="menu"><button data-stream-action="edit">Edit streaming host</button><button data-stream-action="icon">Change icon</button><button data-stream-action="delete" class="danger-text">Delete streaming host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">Port ${item.port}</p><p class="gateway-address">→ ${extendedEscape(item.target)}</p><p class="upstream-copy ${item.enabled === false || item.upstream?.status === "unmonitored" || !item.upstream || item.upstream.status === "pending" ? "idle" : item.upstream.status === "healthy" ? "" : "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}${protocols}</div></div></article>`;
|
||||||
@@ -140,7 +140,7 @@ function renderHealthSettings() {
|
|||||||
|
|
||||||
// --- Misc admin panel decorators, called after every refresh -------------------
|
// --- Misc admin panel decorators, called after every refresh -------------------
|
||||||
function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); }
|
function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); }
|
||||||
function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="audit"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="audit"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users.nextElementSibling); } if (panel.dataset.ready) return; panel.dataset.ready = "1"; panel.innerHTML = '<div class="panel-heading"><div><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div class="table-wrap performance-table-wrap event-table-wrap"><table class="performance-table event-table"><thead><tr><th>Time</th><th>User</th><th>Result</th><th>Action</th></tr></thead><tbody id="audit-list"><tr><td colspan="4" class="quiet-state">Open this tab to load audit records.</td></tr></tbody></table></div>'; const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => { const failed = item.status === "error"; return `<tr><td>${extendedEscape(formatTime(item.created_at))}</td><td>${extendedEscape(item.actor || "System")}</td><td><span class="status-dot ${failed ? "error" : "running"}"></span>${failed ? "Failed" : "Success"}</td><td>${extendedEscape(item.action)}</td></tr>`; }).join("") : '<tr><td colspan="4" class="quiet-state">No matching audit records.</td></tr>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); }
|
function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="audit"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="audit"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users.nextElementSibling); } if (panel.dataset.ready) return; panel.dataset.ready = "1"; panel.innerHTML = '<div class="dashboard-panel audit-log-section"><div class="panel-heading"><div><p class="eyebrow">Audit log</p><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div class="table-wrap performance-table-wrap event-table-wrap"><table class="performance-table event-table"><thead><tr><th>Time</th><th>User</th><th>Result</th><th>Action</th></tr></thead><tbody id="audit-list"><tr><td colspan="4" class="quiet-state">Open this tab to load audit records.</td></tr></tbody></table></div></div>'; const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => { const failed = item.status === "error"; return `<tr><td>${extendedEscape(formatTime(item.created_at))}</td><td>${extendedEscape(item.actor || "System")}</td><td><span class="status-dot ${failed ? "error" : "running"}"></span>${failed ? "Failed" : "Success"}</td><td>${extendedEscape(item.action)}</td></tr>`; }).join("") : '<tr><td colspan="4" class="quiet-state">No matching audit records.</td></tr>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); }
|
||||||
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
|
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
|
||||||
function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `<div class="panel-heading"><div><h2>Logs & retention</h2><p class="muted">Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.</p></div></div><form class="settings-form retention-form"><label class="check-control"><input name="pruningEnabled" type="checkbox" ${policy.pruningEnabled ? "checked" : ""}><span>Enable automatic pruning</span></label><label>Access logs<input name="accessDays" type="number" min="7" max="3650" value="${policy.accessDays}"><small>High-volume request records.</small></label><label>Gateway activity<input name="activityDays" type="number" min="7" max="3650" value="${policy.activityDays}"><small>Operational and configuration events.</small></label><label>Audit logs<input name="auditDays" type="number" min="7" max="3650" value="${policy.auditDays}"><small>Administrative accountability records.</small></label><label>Certificate events<input name="certificateDays" type="number" min="7" max="3650" value="${policy.certificateDays}"></label><label>Security events<input name="securityDays" type="number" min="7" max="3650" value="${policy.securityDays}"></label><div class="dialog-actions"><button class="button primary">Save retention policy</button></div></form>`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); }
|
function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `<div class="panel-heading"><div><h2>Logs & retention</h2><p class="muted">Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.</p></div></div><form class="settings-form retention-form"><label class="check-control"><input name="pruningEnabled" type="checkbox" ${policy.pruningEnabled ? "checked" : ""}><span>Enable automatic pruning</span></label><label>Access logs<input name="accessDays" type="number" min="7" max="3650" value="${policy.accessDays}"><small>High-volume request records.</small></label><label>Gateway activity<input name="activityDays" type="number" min="7" max="3650" value="${policy.activityDays}"><small>Operational and configuration events.</small></label><label>Audit logs<input name="auditDays" type="number" min="7" max="3650" value="${policy.auditDays}"><small>Administrative accountability records.</small></label><label>Certificate events<input name="certificateDays" type="number" min="7" max="3650" value="${policy.certificateDays}"></label><label>Security events<input name="securityDays" type="number" min="7" max="3650" value="${policy.securityDays}"></label><div class="dialog-actions"><button class="button primary">Save retention policy</button></div></form>`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); }
|
||||||
// The single entry point app.js calls after every refresh() to re-render every
|
// The single entry point app.js calls after every refresh() to re-render every
|
||||||
@@ -622,30 +622,43 @@ function renderHeroPanel(prefix, health, { sixthSlot = "throughput" } = {}) {
|
|||||||
if (!document.querySelector(`#${prefix}-${keys[0]}-value`)) return;
|
if (!document.querySelector(`#${prefix}-${keys[0]}-value`)) return;
|
||||||
if (!health) { keys.forEach(key => setHeroStat(prefix, key, { value: "\u2014", detail: "Unavailable" })); return; }
|
if (!health) { keys.forEach(key => setHeroStat(prefix, key, { value: "\u2014", detail: "Unavailable" })); return; }
|
||||||
const tone = percent => percent >= 90 ? "critical" : percent >= 75 ? "warning" : "";
|
const tone = percent => percent >= 90 ? "critical" : percent >= 75 ? "warning" : "";
|
||||||
|
// Tracks the worst tone across the resource stats (not network/uptime/throughput, which don't
|
||||||
|
// carry one) so the panel's own top accent bar can reflect it too, instead of always showing
|
||||||
|
// green regardless of whether CPU/memory/swap/disk are actually in a warning/critical state.
|
||||||
|
const toneRank = { "": 0, warning: 1, critical: 2 };
|
||||||
|
let worstTone = "";
|
||||||
|
const trackTone = value => { if (toneRank[value] > toneRank[worstTone]) worstTone = value; };
|
||||||
if (health.cpu) {
|
if (health.cpu) {
|
||||||
const quotaLabel = health.cpu.quotaSource === "quota" ? `Of ${health.cpu.quotaCpus} allocated CPU${health.cpu.quotaCpus === 1 ? "" : "s"}` : health.cpu.quotaSource === "pinned" ? `Of ${health.cpu.quotaCpus} pinned core${health.cpu.quotaCpus === 1 ? "" : "s"}` : `Of host\u2019s ${health.cpu.quotaCpus} core${health.cpu.quotaCpus === 1 ? "" : "s"} \u2014 no limit set`;
|
const quotaLabel = health.cpu.quotaSource === "quota" ? `Of ${health.cpu.quotaCpus} allocated CPU${health.cpu.quotaCpus === 1 ? "" : "s"}` : health.cpu.quotaSource === "pinned" ? `Of ${health.cpu.quotaCpus} pinned core${health.cpu.quotaCpus === 1 ? "" : "s"}` : `Of host\u2019s ${health.cpu.quotaCpus} core${health.cpu.quotaCpus === 1 ? "" : "s"} \u2014 no limit set`;
|
||||||
|
trackTone(tone(health.cpu.percent));
|
||||||
setHeroStat(prefix, "cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: quotaLabel });
|
setHeroStat(prefix, "cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: quotaLabel });
|
||||||
}
|
}
|
||||||
else setHeroStat(prefix, "cpu", { value: "\u2014", detail: "cgroup CPU stats unavailable" });
|
else setHeroStat(prefix, "cpu", { value: "\u2014", detail: "cgroup CPU stats unavailable" });
|
||||||
if (health.memory) setHeroStat(prefix, "memory", { value: `${health.memory.percent.toFixed(1)}%`, percent: health.memory.percent, tone: tone(health.memory.percent), detail: `${formatBytes(health.memory.usedBytes)} / ${formatBytes(health.memory.limitBytes)}` });
|
if (health.memory) { trackTone(tone(health.memory.percent)); setHeroStat(prefix, "memory", { value: `${health.memory.percent.toFixed(1)}%`, percent: health.memory.percent, tone: tone(health.memory.percent), detail: `${formatBytes(health.memory.usedBytes)} / ${formatBytes(health.memory.limitBytes)}` }); }
|
||||||
else setHeroStat(prefix, "memory", { value: "\u2014", detail: "cgroup memory stats unavailable" });
|
else setHeroStat(prefix, "memory", { value: "\u2014", detail: "cgroup memory stats unavailable" });
|
||||||
// Swap only gets a real percentage when the container has an actual --memory-swap limit set
|
// Swap only gets a real percentage when the container has an actual --memory-swap limit set
|
||||||
// (memory.swap.max is a real number). Without one it's unbounded and shares the host's swap,
|
// (memory.swap.max is a real number). Without one it's unbounded and shares the host's swap,
|
||||||
// so a raw "0 B" would read like a hard cap that doesn't exist -- say so instead.
|
// so a raw "0 B" would read like a hard cap that doesn't exist -- say so instead.
|
||||||
if (health.swap && health.swap.configured === false) setHeroStat(prefix, "swap", { value: "Off", percent: 0, detail: "Swap is not configured for this container" });
|
if (health.swap && health.swap.configured === false) setHeroStat(prefix, "swap", { value: "Off", percent: 0, detail: "Swap is not configured for this container" });
|
||||||
else if (health.swap && health.swap.limitBytes) setHeroStat(prefix, "swap", { value: `${health.swap.percent.toFixed(1)}%`, percent: health.swap.percent, tone: tone(health.swap.percent), detail: `${formatBytes(health.swap.usedBytes)} / ${formatBytes(health.swap.limitBytes)}` });
|
else if (health.swap && health.swap.limitBytes) { trackTone(tone(health.swap.percent)); setHeroStat(prefix, "swap", { value: `${health.swap.percent.toFixed(1)}%`, percent: health.swap.percent, tone: tone(health.swap.percent), detail: `${formatBytes(health.swap.usedBytes)} / ${formatBytes(health.swap.limitBytes)}` }); }
|
||||||
else if (health.swap) setHeroStat(prefix, "swap", { value: formatBytes(health.swap.usedBytes), percent: 0, detail: "Unlimited \u2014 shares host swap" });
|
else if (health.swap) setHeroStat(prefix, "swap", { value: formatBytes(health.swap.usedBytes), percent: 0, detail: "Unlimited \u2014 shares host swap" });
|
||||||
else setHeroStat(prefix, "swap", { value: "\u2014", detail: "cgroup swap stats unavailable" });
|
else setHeroStat(prefix, "swap", { value: "\u2014", detail: "cgroup swap stats unavailable" });
|
||||||
if (health.disk) {
|
if (health.disk) {
|
||||||
const overAssigned = health.disk.assignedLimitBytes && health.disk.percent > 100;
|
const overAssigned = health.disk.assignedLimitBytes && health.disk.percent > 100;
|
||||||
const diskDetail = health.disk.assignedLimitBytes ? `${formatBytes(health.disk.usedBytes)} used of ${formatBytes(health.disk.assignedLimitBytes)} assigned \u00b7 ${formatBytes(health.disk.availableBytes)} free on host` : `${formatBytes(health.disk.usedBytes)} used \u00b7 ${formatBytes(health.disk.availableBytes)} free`;
|
const diskDetail = health.disk.assignedLimitBytes ? `${formatBytes(health.disk.usedBytes)} used of ${formatBytes(health.disk.assignedLimitBytes)} assigned \u00b7 ${formatBytes(health.disk.availableBytes)} free on host` : `${formatBytes(health.disk.usedBytes)} used \u00b7 ${formatBytes(health.disk.availableBytes)} free`;
|
||||||
setHeroStat(prefix, "disk", { value: `${health.disk.percent.toFixed(1)}%`, percent: Math.min(100, health.disk.percent), tone: overAssigned ? "critical" : tone(health.disk.percent), detail: diskDetail });
|
const diskTone = overAssigned ? "critical" : tone(health.disk.percent);
|
||||||
|
trackTone(diskTone);
|
||||||
|
setHeroStat(prefix, "disk", { value: `${health.disk.percent.toFixed(1)}%`, percent: Math.min(100, health.disk.percent), tone: diskTone, detail: diskDetail });
|
||||||
}
|
}
|
||||||
else setHeroStat(prefix, "disk", { value: "\u2014", detail: "Disk stats unavailable" });
|
else setHeroStat(prefix, "disk", { value: "\u2014", detail: "Disk stats unavailable" });
|
||||||
if (health.network) setHeroStat(prefix, "network", { value: formatRate(health.network.rxBytesPerSec + health.network.txBytesPerSec), percent: 0, detail: `\u2193 ${formatRate(health.network.rxBytesPerSec)} \u00b7 \u2191 ${formatRate(health.network.txBytesPerSec)}` });
|
if (health.network) setHeroStat(prefix, "network", { value: formatRate(health.network.rxBytesPerSec + health.network.txBytesPerSec), percent: 0, detail: `\u2193 ${formatRate(health.network.rxBytesPerSec)} \u00b7 \u2191 ${formatRate(health.network.txBytesPerSec)}` });
|
||||||
else setHeroStat(prefix, "network", { value: "\u2014", detail: "Sampling\u2026" });
|
else setHeroStat(prefix, "network", { value: "\u2014", detail: "Sampling\u2026" });
|
||||||
if (sixthSlot === "throughput") setHeroStat(prefix, "throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" });
|
if (sixthSlot === "throughput") setHeroStat(prefix, "throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" });
|
||||||
else if (sixthSlot === "uptime") setHeroStat(prefix, "uptime", Number.isFinite(health.uptimeSeconds) ? { value: formatDuration(health.uptimeSeconds), detail: "Since last restart" } : { value: "\u2014", detail: "Unavailable" });
|
else if (sixthSlot === "uptime") setHeroStat(prefix, "uptime", Number.isFinite(health.uptimeSeconds) ? { value: formatDuration(health.uptimeSeconds), detail: "Since last restart" } : { value: "\u2014", detail: "Unavailable" });
|
||||||
|
// Reflect the worst CPU/memory/swap/disk tone on the panel's own top accent bar -- previously
|
||||||
|
// hardcoded green regardless of what the stats inside it were actually showing.
|
||||||
|
const heroPanel = document.querySelector(`#${prefix}-grid`)?.closest(".system-panel");
|
||||||
|
if (heroPanel) { heroPanel.classList.remove("tone-warning", "tone-critical"); if (worstTone) heroPanel.classList.add(`tone-${worstTone}`); }
|
||||||
}
|
}
|
||||||
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
|
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
|
||||||
function renderSystemPanel() {
|
function renderSystemPanel() {
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<title>Site Gateway</title>
|
<title>Site Gateway</title>
|
||||||
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
|
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
|
||||||
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
<link rel="stylesheet" href="/styles.css?v=0.16.67">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||||
|
<link rel="stylesheet" href="/styles.css?v=0.16.78">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
@@ -451,6 +454,6 @@
|
|||||||
<div id="toast" class="toast" role="status"></div>
|
<div id="toast" class="toast" role="status"></div>
|
||||||
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
|
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
|
||||||
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
|
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
|
||||||
<script src="/app.js?v=0.16.67" defer></script><script src="/features.js?v=0.16.67" defer></script><script src="/select-enhance.js?v=0.16.67" defer></script>
|
<script src="/app.js?v=0.16.78" defer></script><script src="/features.js?v=0.16.78" defer></script><script src="/select-enhance.js?v=0.16.78" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+8
-10
@@ -222,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}
|
||||||
@@ -487,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}
|
||||||
|
|||||||
+1
-1
@@ -2192,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); }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user