Compare commits

...
Author SHA1 Message Date
Hermes a39602afe5 P7 spec rev5: DC-150 ticket X25519-only (remove ECDSA fallback contradiction) 2026-10-03 21:54:40 -07:00
Hermes a4f961d609 P7 spec rev4: pin provider-contract unblock wording, PKCS8-prefix X25519 private-key extraction, DC-143 authorization-vs-key-expiry distinction 2026-10-03 21:31:08 -07:00
Hermes 5f47e12f18 P7 spec rev3: X25519-only browser keygen (no ECDSA fallback), unblock row + honest semantics everywhere, three-zone WG firewall model 2026-10-03 21:25:30 -07:00
Hermes 2e6d535242 P7 spec rev2: honest unblock semantics, WG addressing/keygen model (browser-side keygen, CIDR AllowedIPs), ticket-set fixes 2026-10-03 21:14:30 -07:00
Hermes 420fa2ba2a P7 Private Network lane: product spec (Tailscale v1 + WireGuard self-hosted) + backlog tickets DC-140..DC-152 2026-10-03 21:06:44 -07:00
Hermes 7557b49b5b [grade=B urn:ump:zluxbji5tepzdqybrctqfyjdvbfin2o66atw6rwykg6vdwkzhfsa] backlog v3: P6 Shipdeck era (DC-109-119) cross-platform + barrier-removal lane
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- DC-109-112: native runtime, installer remote mode, self-distribution,
  updater v2 (Shipdeck engine; v0 DoD-verified)
- DC-113-115: first-run doctor, auth-gate helper, update nudge
- DC-116-119: Podman runtime, service-control abstraction, Mac signing
  (electron-builder, 5 acceptance gates), 3-OS CI matrix
- DC-P6-EVIDENCE.md: verbatim live-state evidence (E1-E6) backing the claims
- DC-102 status corrected (mismatched DiskSpaceMonitor annotation)
2026-09-14 05:22:16 -07:00
DashCaddy Polish Loop 65a447e27e DC-130 rev3: log.error on unexpected bridge status in /status + test asserting the log call (13 tests green)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-09-14 04:19:55 -07:00
DashCaddy Polish Loop 121caef488 DC-130 rev2: fail-fast bridge bindings (host), /status gateway-error mapping (401/403/unreachable -> 502, probe-failure stays ok:false) + 3 new tests (13 total)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-09-14 04:11:30 -07:00
DashCaddy Polish Loop 0ab1dfe6e5 DC-130: start.sh passes SHIPDECK_BRIDGE_URL/TOKEN_FILE to the container (deploys panel bridge wiring)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-09-14 03:49:35 -07:00
DashCaddy Polish Loop c90851f25f DC-130: deploys panel frontend — modal with services/deploy/journal tabs, rollback + status actions (bundle + cache tag rebuilt) 2026-09-14 03:49:35 -07:00
DashCaddy Polish Loop 98d25ac41b DC-130: deploys panel frontend — modal with services/deploy/journal tabs, rollback + status actions, wired into features bundle + top bar 2026-09-14 03:49:19 -07:00
DashCaddy Polish Loop 1fc61c3fdb DC-130: shipdeck deploys panel — bridge-proxied source deploys (routes + tests, opt-in via SHIPDECK_BRIDGE_URL) 2026-09-14 03:33:31 -07:00
DashCaddy Polish Loop e33bc91438 fix(publish): prune build artifacts (build-output, out, dist_electron, data, release) from release tarball — v1.16.0 tarball was 817MB with gitignored Electron build-output; now 21MB
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-09-13 05:45:40 -07:00
DashCaddy Polish Loop 70e252c8a5 [grade=A urn:ump:seyasvbntxsjibq5jiaqmfowc6xgchwe4jvcc54jd6355ujgm75q] DC-122: self-updater hardening + auto-update on + docker disk discipline (v1.16.0)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- _isNewer: same-version releases are never 'newer' (commit labels are
  opaque stamps) — kills the same-version auto-apply regression loop
- _autoCheckAndApply: identical version@sha256 never re-applied
- dashcaddy-update.sh: truthful rollback verdicts (failed rebuild/health
  = exit 1 + failure result), exact frontend snapshot/restore (incl.
  update-introduced owned subtrees), contents-copy cp fallbacks with
  manifest reconciliation, JSON-encoded results/meta/stamp, prune on
  every exit path
- self-updater: frontend-only Linux releases fail loudly (no more silent
  no-op success stuck in 'applying')
- start.sh: DASHCADDY_UPDATE_ENABLED=true (Sami 2026-09-13), json-file
  log caps 10M x3, source->webroot sync via update-stamp contract
- tests: _isNewer regression suite, functional cycle + json/escape suites
  (scripts/test-frontend-cycle.sh, scripts/test-json-escape.sh)

15 judge rounds: C,C,D,D,C,C,D,C,C,C,D,C,A
2026-09-13 05:32:14 -07:00
DashCaddy Polish Loop 939fdbb68b fix(services): commit deployed sec.sami card addition (staged during last night's deploy, missed the commit) 2026-09-13 05:32:13 -07:00
DashCaddy Polish Loop fad51c81b2 [grade=A] Register sec.sami dashboard in DashCaddy login-page map
Add 'sec' to the buildLoginPage pages map in sso-gate.js. Without it,
/api/v1/auth/login-page?service=sec returned 404 'Unknown service' — the
TOTP login page is built from a hardcoded per-service map, so adding sec
to services.json (runtime config, gitignored) alone was not enough.

sec needs no app-token (plain web app behind the TOTP gate): after
check-session verifies the session it redirects to /?direct=1, bypassing
the Caddy @needsAutoLogin loop.

Codex grade A (urn:ump:p3jkreyxpqikwt54uxgpl7sybkbsnklbs5kdonq5ejfqyh27udla).
2026-09-13 05:32:13 -07:00
23 changed files with 2301 additions and 213 deletions

No files matched your search

+134
View File
@@ -433,3 +433,137 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
- **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring. - **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring.
- **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes` — `health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`. - **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes` — `health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`.
- **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change). - **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change).
# P7 — Private Network lane (DC-140 … DC-152)
> Spec: `specs/PRIVATE-NETWORK-SPEC.md`. Parent feature: DashCaddy Private Network
> (Tailscale mode v1 + WireGuard self-hosted mode). Ordered by dependency, not by
> value. All commits judge-gated (min B).
### DC-140: Network provider abstraction (`src/network/`)
- **status:** todo
- **owner:** (fleet)
- **details:** `provider.js` (contract + JSDoc), `registry.js` (active provider +
capabilities), `errors.js` (ProviderError with code/retryable). No feature code
may import a provider directly — registry only. Provider contract methods:
getStatus, listDevices, listPeople, createInvite, revokeInvite, blockPerson,
unblockPerson, removePerson, revokeDevice, syncAccess, whois, configureDns.
Tailscale provider wraps existing tailscale-coord.js + tailscale-manager.js.
Registry defaults to inactive until the wizard connects one. Tests: registry
resolution, capability flags, error mapping. Files: `src/network/*.js`,
`__tests__/network-registry.test.js`, `__tests__/network-provider-tailscale.test.js`.
### DC-141: People store + routes (`routes/network-people.js`)
- **status:** todo
- **details:** people.json atomic store (invite-store pattern), CRUD routes under
`/api/v1/network/people`, admin-protection (`protectedAt`), last-admin guard
`assertNotLastAdmin()` exported for reuse. Sync endpoint merging provider users
into people. Tests: store CRUD, route contract, guard (cannot block/remove
last admin), sync merge idempotency.
### DC-142: Tailscale invites (link-first) via provider
- **status:** todo
- **details:** POST/GET/DELETE `/api/v1/network/invites` calling provider
createInvite/revokeInvite. Tailscale: user-invites API, email optional —
inviteUrl is the artifact (share-text pre-formed sentence, copy button,
navigator.share, opt-in email checkbox; never log raw invite URLs to console).
Reuse DC-085 share pattern. Tests: route contract with mocked provider,
no-raw-token-in-logs assertion.
### DC-143: Block / unblock / remove person + revoke device
- **status:** todo
- **details:** POST `/network/people/:id/block|unblock|remove`,
`/network/devices/:id/revoke`. Orchestrates: provider calls, session kill
(existing auth store), access-map purge/restore, audit log entries. Block
snapshots pre-block grants; unblock restores the AUTHORIZATION state (grants,
status, sessions) — Tailscale device keys, once expired, are irreversible and
require re-authentication (reauthRequired=true + user note); WireGuard peers
restore immediately. Remove deletes devices + person record. Tests: orchestration order (mocked provider), honest unblock
semantics (Tailscale: reauthRequired true + note; WireGuard: immediate
restore), last-admin guard integration, audit entries written.
**Note (not a ticket):** the access map module itself is DC-145.
### DC-144: Groups (DashCaddy-side) + resources
- **status:** todo
- **details:** groups in people-store (array on person), group CRUD routes,
resource model (services registry projection + manual entries), per-resource
`sensitive` flag. No Tailscale-group mapping — ever (free plan caps 3; compiled
rules are per-user). Tests: group CRUD, resource projection, sensitive flag
validation.
### DC-145: Access map store + grid API
- **status:** todo
- **details:** `network-access.json` grants store, bulk GET/PUT
`/network/access` (whole-grid save, atomic), effective-access computation
(group grants + person overrides), change events for compiler subscription.
This file is the single source of truth. Tests: store, effective access
resolution (group + override precedence), atomic whole-grid save.
### DC-146: Technitium integration
- **status:** todo
- **details:** Technitium HTTP API client (credential via credential-manager),
per-resource/per-device record upserts (idempotent), split-DNS configure when
provider capability present, verify-by-resolve after write, wizard check step.
Tests: API client with mocked HTTP, idempotent upsert, failure paths.
### DC-147: CA naming + name constraint + install page hardening
- **status:** todo
- issued from existing DashCA (routes/ca.js): operator-named root (setup-time,
rename = guided re-trust), critical nameConstraints (permitted DNS subtree =
private TLD), leaf regression tests (SAN + serverAuth EKU + ≤398d), branded
install page with per-OS steps incl. iOS enable-trust second step + fingerprint
+ Android native-app caveat, encrypted root-key backup step (passphrase +
checksum). Tests: parse issued cert and assert constraint/SAN/EKU/validity;
install-page content checks.
### DC-148: Tailscale ACL compiler + lockout guard
- **status:** todo
- **details:** access map → per-user HuJSON policy compiler. Hard rules:
last-active-admin must keep access to the DashCaddy host resource; host device
keeps egress; dry-run (ACL preview/test) before every push; last-known-good
policy retained for rollback; post-push hash stored, pre-push hash compare →
drift banner on mismatch. Provider.syncAccess(tailscale) uses this. Tests:
golden-policy cases, lockout adversarial cases (each rule), drift detection,
preview-failure refusal to push.
### DC-149: WireGuard provider — hub management
- **status:** todo
- **details:** `providers/wireguard.js`: wg-quick iface `dcnet0` at 10.53.0.1/16
(client pool 10.53.0.0/16, one /32 per device, stable assignment), HUB keypair
generated host-side, peer store (pubkeys only), systemd unit if missing,
net.ipv4.ip_forward=1 for hub-and-spoke, no NAT on dcnet0. All execFile-mocked
in tests. Tests: pool assignment stability, peer add/remove, unit generation,
error paths (missing wg binary).
### DC-150: WireGuard invites (QR) + client config
- **status:** todo
- **details:** one-time token (7d TTL, single-use) → redemption page. Client
keypair generated IN THE BROWSER via WebCrypto X25519 ONLY — NO fallback
algorithm; browsers lacking X25519 get an explicit unsupported-browser
failure. Server receives the public key only, registers the peer /32,
returns config
(Address, DNS=10.53.0.1, hub PublicKey, AllowedIPs=10.53.0.0/16 — a CIDR,
never a DNS zone). Config + QR rendered client-side; private key never
transits or rests on the server. Tests: token lifecycle, single-use
enforcement, expiry, server-rejects-private-key-posts, config template
correctness.
### DC-151: WireGuard firewall matrix (nftables)
- **status:** todo
- **details:** per-resource nft sets (net_dc_res_<name>), peer IPs per grant,
transactional apply (build temp ruleset → `nft -c` → `nft -f` → verify →
rollback file), three-zone baseline: external-interface UDP listen-port accept
(NOT on dcnet0); dcnet0 local input = ICMP + admin ports from admin set only +
resource ports; dcnet0 forwarding = per-resource sets, peer-to-peer default
drop. Tests: ruleset
golden text, transactional failure injection (nft -c fails → no apply),
verify-step failure, admin-port isolation.
### DC-152: Setup wizard + status page
- **status:** todo
- **details:** mode pick → credentials/endpoint → private domain pick
(.internal default, home.arpa, custom+warning) → provider connect + sync →
Technitium check → CA naming (locked-in warning) → first admin person.
Status page: who's online, recent connections (provider status + DashCaddy
connection log). Reuse monitoring widget patterns. Tests: wizard step
contracts, status aggregation with mocked providers.
+201
View File
@@ -0,0 +1,201 @@
# Evidence file for DC-PRODUCTION-GRADE-BACKLOG.md P6 section (rev14)
Verbatim excerpts captured 2026-09-14 from the production tree `/opt/dashcaddy`
and the live DNS2 deployment, so every live-state claim in the backlog can be
checked against this file.
## E1. Self-updater contract — dashcaddy-api/src/docker/self-updater.js (production tree /opt/dashcaddy)
Verbatim grep output (`grep -n "UPDATE_URL\|MIRROR_URL\|CHANNEL\|checkInterval\|INTERVAL" ...`):
```
24: CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
25: UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
26: MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
35: CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
46: checkInterval: parseInt(options.checkInterval || DEFAULTS.CHECK_INTERVAL, 10),
47: updateUrl: options.updateUrl || DEFAULTS.UPDATE_URL,
48: mirrorUrl: options.mirrorUrl || DEFAULTS.MIRROR_URL,
64: channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
```
Revoked kill switch, verbatim (same file, lines 531/535):
```js
if (remote?.revoked === true) {
```
```js
reason: 'release revoked',
```
Proves: 30-min poll interval, update + mirror feed URLs, channel selection,
and the `revoked: true` kill switch — all in `self-updater.js` of the
production tree.
## E2. Shipdeck v0 DoD — shipdeck/docs/SPEC.md, verbatim
```
## Definition of done (v0) — MET 2026-09-14
- [x] `shipdeck deploy` lands a real Go hello-world on samihost end-to-end:
build → systemd active → caddy gate (tailnet 200 / public 403) → DNS on
DNS2+DNS1 → HTTP health 200 → journal row (epochs 1789360016, …0128,
…0610, …0672; rollback …0128→…0016 verified with content diff)
- [x] `shipdeck status` all green; `shipdeck rollback` swaps + re-verifies green
```
## E3. Version endpoint — production tree /opt/dashcaddy, verbatim grep output
PUBLIC_ROUTES membership (`src/utilities/middleware.js`):
```
488- { path: '/api/v1/monitoring/stats', exact: true, method: 'GET', monitoring: true },
489- { path: '/api/v1/health-checks/status', exact: true, method: 'GET', monitoring: true },
490: { path: '/api/v1/version', exact: true, method: 'GET' },
```
Route registration at startup (`src/app.js`, verbatim `sed -n '519,523p'`):
```js
appName = versionRoute.getName();
// Pre-build the version router once at startup and reuse it.
const versionRouter = versionRoute.buildRouter();
apiRouter.use(versionRouter);
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
```
(The version HANDLER source lives in the route module wired by
`versionRoute.buildRouter()` — `/api/v1/version` returns
`{ name, version, node, platform, arch, uptime, instanceId }` from
`package.json` read at startup, per the E5 live response below.)
## E4. Installer build targets — dashcaddy-installer/BUILD_GUIDE.md, verbatim
```
# Windows (creates portable .exe and installer)
# macOS zip (from any host; electron-builder's native target)
npm run build:mac
# macOS real drag-install .dmg — built ON LINUX, no Mac needed
# (one-time toolchain: see scripts/build-dmg-linux.sh header)
# Linux (creates AppImage and .deb)
npm run build:linux
```
Output artifacts (same file): portable + NSIS .exe (Windows), mac .zip +
Linux-built .dmg (macOS, unsigned), AppImage + .deb (Linux).
## E5. Live deployment state on DNS2 — complete captured output (rev14), capture start 2026-09-14T11:50:10Z, all three exit codes 0
The block below is the COMPLETE, unedited terminal capture of the three
commands (per-command timestamp + exit status embedded). The first command's
stdout is the full version.json — no ellipses; the long changelog string is
part of the real response.
```
=== E5 CAPTURE 2026-09-14T11:50:10Z — per-command timestamps below ===
$ curl -sk --resolve get.dashcaddy.net:443:127.0.0.1 https://get.dashcaddy.net/release/version.json
{
"version": "1.16.0",
"commit": "70e252c",
"channel": "stable",
"url": "https://get.dashcaddy.net/release/latest.tar.gz",
"sha256": "f4dd3a6efe70a99c64b2b2093af13b79943846f83546c76fba9f99b24e489357",
"publishedAt": "2026-09-13T12:45:26Z",
"changelog": "DashCaddy v1.16.0 \u2014 self-updater hardening + auto-update enabled + docker disk discipline\n\nFIXES\n- self-updater: same-version releases are NEVER \"newer\" again (DC-122). Commit\n labels are opaque build stamps; only a semver bump counts. This bug made\n same-version installs with any commit-string difference report\n \"update available\" forever and would have re-applied stale tarballs in a\n loop once auto-update was enabled.\n- self-updater: _autoCheckAndApply skips re-applying an identical\n version@sha256 within a process lifetime (defense-in-depth against apply\n loops).\n- dashcaddy-update.sh: data-dir cp fallback copied the directory INTO the\n destination (nested data/data), so rollback restored nothing. Now copies\n contents (\"dir/.\") \u2014 backup AND restore paths fixed.\n- dashcaddy-update.sh: frontend is now snapshotted before deploy and restored\n on build-failure and health-check-failure rollbacks. Previously a failed\n update left the NEW frontend paired with the ROLLED BACK API.\n- start.sh: bundle sync is newer-source-only. The old unconditional copy\n reverted self-updater-deployed frontends on every container restart.\n- dashcaddy-update.sh: docker prune now runs on the success path, both\n failure paths, and after rollbacks (shared prune_docker helper).\n\nCHANGES\n- start.sh: DASHCADDY_UPDATE_ENABLED=true \u2014 auto-update is ON (Sami 2026-09-13).\n- start.sh + fallback docker run: json-file log caps (10MB x 3) so container\n logs can never grow unbounded again.\n\nTESTS\n- __tests__/self-updater-isnewer.test.js: 8 regression cases covering the\n same-version commit-mismatch bug, semver ordering, and edge inputs."
}curl_exit=0 at 2026-09-14T11:50:10Z
$ curl -s -m 6 http://127.0.0.1:3001/api/v1/version
{"success":true,"name":"dashcaddy-api","version":"1.16.0","node":"v20.11.1","platform":"linux","arch":"x64","uptime":1768.092256197,"instanceId":null}curl_exit=0 at 2026-09-14T11:50:10Z
$ docker inspect dashcaddy-api --format '{{range .Config.Env}}{{println .}}{{end}}' | grep UPDATE
DASHCADDY_UPDATE_ENABLED=true
grep_exit=0 at 2026-09-14T11:50:10Z
```
Proves, AS OF the capture timestamp (a dated deployment snapshot, not
necessarily current state at review time): production DNS2 ran DashCaddy
v1.16.0, channel `stable`, auto-update ON, feed reachable, and no `revoked`
key in the live feed (kill switch not engaged). Re-run the three commands
above to refresh.
## E6. Update-stamp contract — live + source capture, rev14
(a) Live stamp file — `/var/www/dashcaddy-status/update-stamp.json` (verbatim `cat`):
```json
{"version":"1.16.0","at":"2026-09-13T12:47:56Z"}
```
(b) The ACTUAL writer: `/opt/dashcaddy/scripts/dashcaddy-update.sh` (host-side
helper executed by the self-updater's update flow; the Node self-updater
orchestrates, this script writes the stamp — see excerpt, verbatim `sed -n '714,725p'`):
```bash
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
fi
# DC-122: host-side deployment stamp — start.sh treats a stamped, newer
# deployment as authoritative and skips its source-bundle sync (this is
# the only writer that can reach the web root with real host paths).
local esc_ver
esc_ver=$(json_escape "$to_version")
printf '{"version":"%s","at":"%s"}\n' "$esc_ver" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> "$frontend_target_dir/update-stamp.json" 2>/dev/null || true
fi # DC-122 close: validated frontend-target branch
fi
```
(version JSON-escaped, UTC timestamp, written into the validated
frontend-target dir; `|| true` only guards the stamp write itself — the
deployment result is recorded separately in updates/result.json.)
(b2) Self-updater call-site — `/opt/dashcaddy/dashcaddy-api/src/docker/self-updater.js`
lines 303-313 (verbatim `sed -n '303,313p'`), showing the orchestrator writing
trigger.json and deferring stamp writing to the host-side helper:
```js
await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'),
JSON.stringify(trigger, null, 2)
);
// DC-122 note: the frontend deployment stamp (update-stamp.json) is
// written by the HOST-side dashcaddy-update.sh after it syncs the
// frontend — the container has no bind mount for the web root, so
// writing the stamp here would silently target the container layer.
// The host-side systemd service will handle the rest.
```
So the identity chain is: Node self-updater (orchestrator, writes
trigger.json) → host-side `dashcaddy-update.sh` (executed via the systemd path
unit; writes update-stamp.json into the web root). The word "writer" in (b)
refers to dashcaddy-update.sh specifically. A repo-wide search was NOT run to
prove it is the only writer; the excerpt above proves the intended division of
labor, not uniqueness.
(c) Sync-gating side: `/opt/dashcaddy/start.sh` (verbatim `sed -n '150,170p'`) —
start.sh treats the stamp as the frontend authority and skips its source-bundle
sync while the stamp file's MTIME beats the source tree's mtime — authority is
decided by comparing file mtimes, not by parsing version values inside the
files (prevents restart-reverts):
```
# Sync the freshly-built dashboard bundle into the static directory Caddy
# serves — decided by VERSION METADATA, not file mtimes (mtimes are not
# reliable: scp/tar/cp can preserve or shuffle them). DC-122 contract:
# - The self-updater writes a STAMP (update-stamp.json) into the live web
# root when it deploys a frontend; while that stamp is newer than the
# source tree's VERSION file, start.sh must NOT touch the live bundle.
# - Normal builds: publishing bumps the source VERSION (mtime = build time)
# and clears any stale stamp, so source wins and the sync happens.
echo "[start.sh] Syncing dashboard bundle into static dir (metadata-driven)..."
mkdir -p /var/www/dashcaddy-status/dist
if [ -d /opt/dashcaddy/status/dist ]; then
NEEDS_SYNC=1
STAMP=/var/www/dashcaddy-status/update-stamp.json
SRC_VERSION=/opt/dashcaddy/dashcaddy-api/VERSION
if [ -f "$STAMP" ] && [ -f "$SRC_VERSION" ] && [ "$STAMP" -nt "$SRC_VERSION" ]; then
# A self-updater deployment is newer than the last source build: hands off.
echo "[start.sh] Deployed frontend stamp newer than source VERSION — skipping sync to preserve deployed frontend."
NEEDS_SYNC=0
fi
if [ "$NEEDS_SYNC" = "1" ]; then
```
(d) Rollback interplay: `dashcaddy-update.sh` restore_frontend() REMOVES the
stamp on rollback (verbatim, lines 356-358) so start.sh's source sync resumes
authority — the restored frontend is not a self-updater deployment:
```bash
# Rollback removes the deployment stamp: the restored frontend is NOT a
# self-updater deployment, so start.sh's source sync must resume authority.
rm -f "${target:?}/update-stamp.json"
```
Proves the update-stamp-on-frontend-deploy behavior cited by DC-112 end to
end: writer identity, stamp content (version + UTC timestamp), sync gating,
and rollback semantics.
+117 -5
View File
@@ -1,8 +1,10 @@
# DashCaddy Production-Grade Backlog (v2) # DashCaddy Production-Grade Backlog (v3)
> Generated 2026-08-12 from a full codebase audit. > Generated 2026-08-12 from a full codebase audit; last revised 2026-09-14 (P6 added).
> v1 items (P0-1 through P2-7) are ALL DONE. > v1 items (P0-1 through P2-7) are ALL DONE.
> Current state: 1539 tests, 86.55% statement coverage, 0 ESLint errors, 173 warnings. > NOTE: the "Current Health Snapshot" below is HISTORICAL (2026-08-12 audit snapshot),
> kept for trend reference — re-run the audit before quoting these numbers.
> Snapshot values (not current): 1539 tests, 86.55% statement coverage, 0 ESLint errors, 173 warnings.
## Current Health Snapshot ## Current Health Snapshot
- **Tests:** 1539 passing across 63 suites - **Tests:** 1539 passing across 63 suites
@@ -257,7 +259,7 @@
- **impact:** Users set a disk budget (e.g., "DashCaddy gets 20GB") and the system auto-manages cleanup. The #1 reason people abandon self-hosting is disk filling up silently. This solves it. - **impact:** Users set a disk budget (e.g., "DashCaddy gets 20GB") and the system auto-manages cleanup. The #1 reason people abandon self-hosting is disk filling up silently. This solves it.
### DC-102: One-click deploy should auto-generate Caddyfile entry + DNS record ### DC-102: One-click deploy should auto-generate Caddyfile entry + DNS record
- **status:** already done (DiskSpaceMonitor) - **status:** pending (prior "already done (DiskSpaceMonitor)" annotation was a mismatched status note — DiskSpaceMonitor is a DC-101 disk-budget component, not a deploy-chain implementation; the deploy chain itself is not wired)
- **details:** When a user deploys an app from the catalog, DashCaddy should automatically: (1) Create the Docker container, (2) Add a Caddyfile reverse_proxy block with TLS for `appname.tld`, (3) Create a DNS record pointing to the host, (4) Reload Caddy, (5) Add the service to the dashboard with health check. Currently steps 2-4 are manual. Fix: add a `deployApp(serviceId, options)` function that orchestrates the full chain. The Caddyfile generation can use the admin API (POST to :2019) so no file editing needed. DNS record creation uses the existing Technitium/Cloudflare DNS provider integration. Effort: ~4 hr. - **details:** When a user deploys an app from the catalog, DashCaddy should automatically: (1) Create the Docker container, (2) Add a Caddyfile reverse_proxy block with TLS for `appname.tld`, (3) Create a DNS record pointing to the host, (4) Reload Caddy, (5) Add the service to the dashboard with health check. Currently steps 2-4 are manual. Fix: add a `deployApp(serviceId, options)` function that orchestrates the full chain. The Caddyfile generation can use the admin API (POST to :2019) so no file editing needed. DNS record creation uses the existing Technitium/Cloudflare DNS provider integration. Effort: ~4 hr.
- **impact:** This is THE core value proposition. Without this, DashCaddy is just Portainer with extra steps. With this, it's a self-hosting platform. - **impact:** This is THE core value proposition. Without this, DashCaddy is just Portainer with extra steps. With this, it's a self-hosting platform.
@@ -293,6 +295,115 @@
--- ---
## P6 — Shipdeck era: cross-platform & barrier removal (added 2026-09-14, Sami directive)
> Source: "further improve DashCaddy and remove barriers to quality usage of it
> on all platforms including PC, Mac and Linux" — planned alongside the Shipdeck
> v0 landing (`shipdeck/docs/SPEC.md` in the shipdeck repo, "Definition of done
> (v0) — MET 2026-09-14": hello-world on samihost, build → systemd active →
> caddy gate (tailnet 200 / public 403) → DNS on DNS2+DNS1 → HTTP health 200 →
> journal row, verified rollback). The Shipdeck SPEC names DashCaddy-family use:
> "the
> deploy engine under the DashCaddy family (DashCaddy panel drives the CLI)";
> these items wire that in. Build order: Lane A deployment (DC-109–112) →
> Lane B onboarding (DC-113–115) → Lane C platform parity (DC-116–119).
>
> **Lane acceptance criteria (lane-level outcomes; a lane is done when these hold):**
> - Lane A: on a Linux/systemd host where Docker is absent or unused, a real
> service completes a fresh shipdeck deploy, one in-place update, and one
> rollback — each verified green. Docker regression criteria (must all hold
> after each lane item): existing Docker services still deploy/update/roll
> back through the unchanged Docker path; existing services.json configs
> load without migration errors; the full Jest suite passes at or above the
> current gate. Secrets are never logged. This lane owns the
> rollback-failure requirement: DC-112 must auto-rollback and alert on
> failed post-update health.
> - Lane B: on a fresh environment (new browser profile / clean VM), the
> doctor detects each seeded environment fault, the auth helper names the
> failed hop, and the update nudge stays silent when current.
> - Lane C: every parity claim is CI-proven per OS (build + boot + smoke),
> not grep-audited.
### DC-109: Native (Docker-free) service runtime — services gain `runtime: docker|native`
- **status:** pending
- **details:** Extend the service model with a runtime field. `docker` = today's behavior, unchanged. `native` = the Shipdeck pipeline: local build → tarball → scp → `/opt/<name>/releases/<epoch>/` + `current` symlink → systemd unit → Caddy block → DNS → verify → rollback. All implemented and DoD-verified in Shipdeck v0 (`shipdeck/docs/SPEC.md` — "Definition of done (v0) — MET 2026-09-14": hello-world on samihost, tailnet 200 / public 403, DNS on DNS2+DNS1, journal row, verified rollback). A host running only native services needs NO Docker. **Scope note: DC-109–112 initially target remote Linux/systemd hosts; local macOS/Windows native service parity arrives via DC-117.** Effort: ~6 hr (services.json schema + deploy routing + status surfacing).
- **impact:** Removes the largest install dependency identified in this lane's analysis (local Docker Desktop) for PC/Mac users deploying to a remote server; native rollback is one command instead of `docker build` on the VPS.
### DC-110: Installer "remote server" mode — SSH target, zero local dependencies
- **status:** pending
- **details:** The Electron installer asks: this machine (classic 5-step wizard, Docker mode) or remote server (SSH host + key, Shipdeck mode). Remote mode skips the Docker/Caddy dependency checks entirely — it configures the remote host and prints the dashboard URL. This is the Mac unlock: no Docker Desktop account, no local engine, works from any laptop. Single-host seed of DC-108 (fleet). Open design points to settle at build time: whether remote mode also provisions remote Caddy/DNS or guides the operator through them, SSH host-key verification policy, and secret-handling (keys never logged, never stored plaintext beyond the user's chosen location). Effort: ~5 hr.
- **impact:** Turns "install DashCaddy" from a multi-step dependency hunt into a short wizard; removes the largest single install dependency (local Docker) for non-Linux users.
### DC-111: DashCaddy dogfoods its own distribution via Shipdeck (native self-host path)
- **status:** pending
- **details:** Publish a native (non-Docker) DashCaddy flavor: release tarball + generated systemd unit + `current` symlink swap, deployed by Shipdeck. `start.sh` stays for the Docker flavor; the native flavor makes updates a symlink swap instead of `docker build` on the user's VPS. Same release feed (version.json + `revoked` kill switch). Effort: ~4 hr.
- **impact:** Installs DashCaddy on hosts without Docker; dogfoods the native path we are selling.
### DC-112: Updater v2 — release-dir + symlink-swap updates for the native engine
- **status:** pending
- **details:** Generalize the self-updater shipped in DashCaddy v1.16.0 to the native flavor. Verifiable source of the existing contract: `dashcaddy-api/src/docker/self-updater.js:531` relative to the production-tree root `/opt/dashcaddy` — 30-min release-feed poll (`CHECK_INTERVAL: 30 * 60 * 1000` at line 24), channel selection (`CHANNEL` at line 35), `"revoked": true` kill switch (that line), update-stamp on frontend deploys — written by the host-side `scripts/dashcaddy-update.sh` helper which the self-updater orchestrates (verbatim excerpts, live stamp file, and sync-gate capture in `DC-P6-EVIDENCE.md`, E1/E6); the deploy/rollback flow is also documented in the `dashcaddy-ops` skill, "Self-updater (v1.16.0+, DC-122)" section). Native flavor: download tarball → new release dir → swap `current` → restart → health check → auto-rollback on failed health. Docker path untouched. Effort: ~5 hr.
- **impact:** Native installs get the same hands-off updates and rollback safety Docker installs already have.
### DC-113: First-run doctor — preflight checks + one-click fixes
- **status:** pending
- **details:** One screen at first run (and from Help): Docker reachable? Caddy binary + admin port? ports 80/443 free? DNS resolvable? disk space? Each check shows fix instructions or a one-click fix where safe. Kills the "blank page on gated service" support class (documented failure mode). Effort: ~4 hr.
- **impact:** Support experience to date (DashCaddy sessions 2026-05→09) has been dominated by environment/config issues rather than code bugs; the doctor turns that class into self-service.
### DC-114: "Why am I seeing this?" helper on the TOTP/auth gate
- **status:** pending
- **details:** The auth gate is a recurring confusion point (documented in the dashcaddy skill reference `totp-session-ip-key-inconsistency.md`, including the operator report "I keep providing a code and it doesn't work"). Login page gets inline diagnostics: which hop failed, cookie status, IP-consistency note, retry guidance. Server returns structured reason codes instead of bare 401. Effort: ~3 hr.
- **impact:** Converts the documented auth-gate drop-off case into a guided flow.
### DC-115: Update nudge in the dashboard
- **status:** pending
- **details:** Footer badge comparing running version vs latest release feed; links to the update flow; dismissible; silent when current. The public version endpoint is verified in the live tree: `/api/v1/version` is in `PUBLIC_ROUTES` (`src/utilities/middleware.js`) and wired at startup (`src/app.js`). Latest-version source = the same release feed the self-updater polls (v1.16.0 contract). Effort: ~2 hr.
- **impact:** Users running months-old builds file phantom bugs; the nudge keeps fleets current.
### DC-116: Podman as a supported container runtime
- **status:** pending
- **details:** Podman speaks Docker's socket API, so most DashCaddy container paths work against it with runtime detection + docs + a CI smoke test (rootless/quadlet notes included). Positions DashCaddy for orgs that cannot run Docker Desktop (licensing) and answers the "Docker vs open-source alternatives" wave with support instead of migration. Effort: ~4 hr.
- **impact:** Business-friendly runtime choice; removes licensing objections in the sellable tier.
### DC-117: Service-control abstraction (systemd/launchd/Windows service) + per-OS static Pylon
- **status:** pending
- **details:** One service-control API over systemctl / launchd / sc.exe; Pylon ships as a single static agent per OS (no Node runtime required on managed hosts). `platform-paths.js` stays the single source of truth for paths (v1.12.0 lesson). Effort: ~8 hr.
- **impact:** True cross-platform management, not Linux-with-caveats.
### DC-118: Mac Gatekeeper trust path — electron-builder native signing + notarization
- **status:** pending
- **details:** The Linux-built .dmg/.zip are unsigned → macOS Gatekeeper blocks/scare-warns on first open, and NO lightweight measure removes that friction: **ad-hoc signing does not establish developer identity and does not satisfy Gatekeeper distribution trust** (needs ~$99/yr Apple Developer Program). The fix: **electron-builder's built-in mac signing + notarization** — it signs nested frameworks/helpers with entitlements before the outer app (never hand-rolled `codesign --deep`) and submits notarization itself. Requires `electron-builder >= 24` (verify the version pinned in `dashcaddy-installer/package.json` at implementation time).
Implementation shape (config, not a hand-written script — the script gets written and exercised IN this item's PR where a `macos-latest` runner can prove it):
```js
// electron-builder.config.js (mac section) — shape valid for electron-builder 24.x–26.x.
// PIN the actual major against dashcaddy-installer/package.json at implementation time;
// if the pinned major is >= 27, migrate per its mac.sign/notarize schema change before use.
mac: {
identity: "Developer ID Application: <name> (${TEAMID})",
hardenedRuntime: true,
gatekeeperAssess: true,
entitlements: "build/entitlements.mac.plist",
notarize: true, // auto notarize + staple on CI (24.x–26.x shape)
forceCodeSigning: true, // FATAL on missing credentials — no silent unsigned artifacts
}
```
CI env (Actions secrets only): `CSC_LINK` (the base64-decoded .p12 file path) + `CSC_KEY_PASSWORD` (the .p12's password) — with a CSC_LINK p12, electron-builder imports it into its OWN temporary keychain internally, so the PR must NOT hand-roll keychain lifecycle (no custom create/unlock/partition-list code); notarization uses `APPLE_ID` + `APPLE_APP_SPECIFIC_PASSWORD` + `APPLE_TEAM_ID`.
**Acceptance criteria (this item is done only when all pass on a real `macos-latest` run):**
1. `electron-builder --mac` exits 0 with signing + notarization enabled.
2. Produced `.dmg`: `xcrun stapler validate <dmg>` passes and `spctl -a -t open --context context:primary-signature-id -v <dmg>` reports accepted.
3. Produced `.zip`: extract it, then run `spctl -a -t exec -v <extracted .app>` on the contained app — must report accepted. Stapling is per-bundle: the `.app` inside the ZIP carries electron-builder's staple; the ZIP container itself cannot be stapled, and Gatekeeper on macOS 12+ re-queries Apple's notarization service online at first open to assess the signed `.app` inside.
4. Notary submission id logged; on failure, `xcrun notarytool log <id> …` output is attached before any retry.
5. First-open gate on a clean macOS VM must exercise the real download path with quarantine applied: download via a browser, OR confirm/add the xattr explicitly after any non-browser transfer (`xattr -w com.apple.quarantine "0081;00000000;Safari;" <file>` — do NOT rely on plain `curl`/`scp` to set it; macOS may not apply quarantine to CLI-downloaded files). First open then shows no Gatekeeper block.
Drafting-review pitfalls from the 2026-09-14 adversarial rounds are HISTORICAL and inapplicable to this electron-builder approach (they applied to an earlier hand-rolled shell-script draft: keychain passwords, `mktemp -u`, search-list restore, filename gating — all now handled by electron-builder's internal keychain management). The version-validation rule stands: at implementation, confirm the pinned electron-builder major's actual `mac.sign`, `notarize`, and `forceCodeSigning` schema against its docs — do not trust the broad 24.x–26.x shape above without checking.
The Linux .dmg build this slots into is documented in `dashcaddy-installer/BUILD_GUIDE.md` (libguestfs HFS+ volume + libdmg-hfsplus UDZO). Effort: ~2 hr once enrolled (+$99/yr Apple Developer Program).
- **impact:** Today the first Mac impression is a security warning; the trust path must be fixed before paid acquisition, and only real signing + notarization does it.
### DC-119: Cross-platform CI matrix — boot it on all 3 OSes per release
- **status:** pending
- **details:** Per release: build API + installer on Windows/macOS/Linux runners, boot the API, run smoke probes (version + health), run the installer's dependency-checker in report mode. Replaces grep-audits with runtime proof — matches the reproducibility principle (Sami 2026-06: "other people can do the same things and expect reproducibility"). Effort: ~6 hr.
- **impact:** Platform-parity claims become tested facts, not hopes.
---
## Summary by Priority ## Summary by Priority
| Priority | Count | Effort | Theme | | Priority | Count | Effort | Theme |
@@ -305,4 +416,5 @@
| P3.5 | 9 (DC-086–094) | ~14.5 hr | Operational maturity | | P3.5 | 9 (DC-086–094) | ~14.5 hr | Operational maturity |
| P4 | 6 (DC-095–100) | ~16.5 hr | Advanced features | | P4 | 6 (DC-095–100) | ~16.5 hr | Advanced features |
| P5 | 8 (DC-101–108) | ~29 hr | Product vision: self-hosting platform | | P5 | 8 (DC-101–108) | ~29 hr | Product vision: self-hosting platform |
| **Total** | **47** | **~110.5 hr** | | | P6 | 11 (DC-109–119) | ~49 hr | Shipdeck era: cross-platform & barrier removal |
| **Total** | **58** | **~159.5 hr** | |
+1 -1
View File
@@ -1 +1 @@
20260722-065235-cookie-only-session-653478a 70e252c
@@ -0,0 +1,268 @@
/**
* Routes tests for /api/v1/deploys (DC-130 — shipdeck bridge proxy).
*
* Pattern: build the router with stub deps, hit it via a tiny express app,
* assert response shapes and the exact proxy interactions with fetchT.
* The feature gate (SHIPDECK_BRIDGE_URL) is exercised in both states.
*/
const express = require('express');
const FIXTURE_REPOS = { ok: true, repos: [{ dir: '/root/helloworld', name: 'helloworld' }] };
const FIXTURE_SERVICES = {
ok: true,
services: [{ name: 'helloworld', host: null, last_action: 'deploy', last_time: '2026-09-14T05:00:00Z', last_epoch: 1789360000 }],
};
const FIXTURE_ROWS = {
ok: true,
rows: [{ time: '2026-09-14T05:00:00Z', service: 'helloworld', action: 'deploy', epoch: 1789360000, duration: '30.0s' }],
};
function buildApp(fetchT, env = {}) {
return buildAppWithLogCapture(fetchT, env, { info: jest.fn(), warn: jest.fn(), error: jest.fn() });
}
function buildAppWithLogCapture(fetchT, env = {}, log) {
process.env.SHIPDECK_BRIDGE_URL = env.url !== undefined ? env.url : 'http://172.17.0.1:8977';
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = env.tokenFile !== undefined ? env.tokenFile : '';
jest.resetModules();
const mod = require('../../routes/deploys');
const router = mod({
asyncHandler: (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
},
log,
auditLogger: { log: jest.fn(async () => {}) },
fetchT,
});
const app = express();
app.use(express.json());
app.use('/api/v1/deploys', router);
// express error middleware → json shape like production
app.use((err, req, res, next) => {
res.status(500).json({ success: false, error: err.message });
});
return app;
}
// expose log on the fetcher wrapper for log-capture assertions
function jsonFetcherWithLog(responses) {
const calls = [];
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
return { calls, log, fetchT: null };
}
function jsonFetcher(responses) {
// responses: map of "METHOD path" -> {status, body}
const calls = [];
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
const wrapper = {
calls,
log,
fetchT: jest.fn(async (url, opts) => {
const key = `${(opts && opts.method) || 'GET'} ${url.replace(/^https?:\/\/[^/]+/, '')}`;
calls.push({ key, opts });
const r = responses[key] || { status: 404, body: { ok: false, error: 'no fixture' } };
return {
status: r.status,
json: async () => r.body,
};
}),
};
return wrapper;
}
describe('routes/deploys — feature gate', () => {
test('501 with clear message when SHIPDECK_BRIDGE_URL unset', async () => {
const app = buildApp(jsonFetcher({}).fetchT, { url: '' });
const res = await app.inject ? null : null; // supertest absent; use fetch via server
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/repos`);
const body = await r.json();
server.close();
expect(r.status).toBe(501);
expect(body.success).toBe(false);
expect(body.error).toMatch(/SHIPDECK_BRIDGE_URL/);
});
});
describe('routes/deploys — proxied endpoints', () => {
test('GET /repos proxies and unwraps bridge payload', async () => {
const f = jsonFetcher({ 'GET /api/repos': { status: 200, body: FIXTURE_REPOS } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/repos`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.repos).toHaveLength(1);
expect(body.repos[0].name).toBe('helloworld');
expect(f.calls[0].opts.headers['X-Shipdeck-Token']).toBeDefined();
});
test('GET /services proxies inventory', async () => {
const f = jsonFetcher({ 'GET /api/services': { status: 200, body: FIXTURE_SERVICES } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/services`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.services[0].name).toBe('helloworld');
});
test('GET /journal rejects invalid service names (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/journal?service=..%2Fetc`);
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('GET /journal passes valid service filter', async () => {
const f = jsonFetcher({ 'GET /api/journal?service=helloworld': { status: 200, body: FIXTURE_ROWS } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/journal?service=helloworld`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.rows[0].action).toBe('deploy');
});
test('GET /status returns ok:false with output when probe fails (no throw)', async () => {
const f = jsonFetcher({
'GET /api/status?service=helloworld': { status: 500, body: { ok: false, output: '[FAIL] http-tailnet' } },
});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.ok).toBe(false);
expect(body.output).toContain('[FAIL]');
});
test('GET /status maps bridge auth failure (401) to 502, not a probe result', async () => {
const f = jsonFetcher({
'GET /api/status?service=helloworld': { status: 401, body: { ok: false, error: 'invalid token' } },
});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
const body = await r.json();
server.close();
expect(r.status).toBe(502);
expect(body.success).toBe(false);
expect(body.error).toMatch(/bridge/i);
});
test('GET /status maps unexpected bridge statuses to 502 with error logging', async () => {
const f = jsonFetcher({
'GET /api/status?service=helloworld': { status: 404, body: { ok: false, error: 'not found' } },
});
const app = buildAppWithLogCapture(f.fetchT, {}, f.log);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
server.close();
expect(r.status).toBe(502);
expect(f.log.error).toHaveBeenCalledWith(
'deploys',
'status probe: unexpected bridge response',
expect.objectContaining({ service: 'helloworld', status: 404 }),
);
});
test('GET /status maps bridge connection failure to 502', async () => {
const fetchT = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
const app = buildApp(fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
const body = await r.json();
server.close();
expect(r.status).toBe(502);
expect(body.error).toMatch(/unreachable/);
});
test('POST /deploy proxies dir and audits', async () => {
const f = jsonFetcher({
'POST /api/deploy': { status: 200, body: { ok: true, exit: 0, output: 'DEPLOYED helloworld in 30.0s' } },
});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dir: '/root/helloworld' }),
});
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.output).toContain('DEPLOYED');
expect(JSON.parse(f.calls[0].opts.body).dir).toBe('/root/helloworld');
});
test('POST /deploy rejects missing dir (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('POST /rollback rejects invalid service name', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'bad name; rm' }),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('bridge 500 surfaces as 502 with output excerpt', async () => {
const f = jsonFetcher({
'POST /api/deploy': { status: 500, body: { ok: false, exit: 8, output: 'service did not listen' } },
});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dir: '/root/helloworld' }),
});
const body = await r.json();
server.close();
expect(r.status).toBe(502);
expect(body.success).toBe(false);
});
});
@@ -0,0 +1,109 @@
/**
* DC-122 regression tests — SelfUpdater._isNewer() must never treat a
* same-version/different-commit release as "newer".
*
* Loader works in BOTH layouts:
* - repo layout: requires ../src/docker/self-updater.js directly;
* - flattened judge worktree (deps missing): extracts the _isNewer +
* _compareVersions method sources from the implementation file and
* evaluates just those two pure functions — the test then exercises
* the exact shipped logic without needing platform-paths/logging.
*/
const path = require('path');
const fs = require('fs');
function findImplementationFile() {
const candidates = [
path.join(__dirname, '..', 'src', 'docker', 'self-updater.js'),
path.join(__dirname, 'self-updater.js'),
path.join(__dirname, '0_self-updater.js'),
];
for (const c of candidates) if (fs.existsSync(c)) return c;
throw new Error('self-updater.js not found relative to test file');
}
// Pull a single method out of the class source text by brace matching.
function extractMethod(src, name, argNames) {
const marker = `${name}(${argNames}) {`;
const at = src.indexOf(marker);
if (at === -1) throw new Error(`method ${name}(${argNames}) not found in source`);
const bodyStart = at + marker.length;
let depth = 1;
let i = bodyStart;
while (depth > 0 && i < src.length) {
const ch = src[i++];
if (ch === '{') depth++;
else if (ch === '}') depth--;
}
const body = src.slice(bodyStart, i - 1);
const args = argNames.split(',').map((s) => s.trim());
return new Function(...args, body);
}
function loadIsNewer() {
const implPath = findImplementationFile();
const src = fs.readFileSync(implPath, 'utf8');
// DC-122 (judge rev11): never construct a real SelfUpdater here — its
// constructor writes instance-id / notify-secret files to production
// default paths, making a unit test stateful. Always evaluate the two
// pure methods from source; this is the exact shipped logic either way.
const impl = {
_compareVersions: extractMethod(src, '_compareVersions', 'a, b'),
_isNewer: extractMethod(src, '_isNewer', 'local, remote'),
};
return impl._isNewer.bind(impl);
}
describe('SelfUpdater._isNewer() — DC-122 same-version auto-apply regression', () => {
let isNewer;
beforeAll(() => {
isNewer = loadIsNewer();
});
test('same version + different commit labels ⇒ NOT newer (the DC-122 bug)', () => {
expect(isNewer(
{ version: '1.16.0', commit: '20260722-065235-cookie-only-session-653478a' },
{ version: '1.16.0', commit: '321334c' }
)).toBe(false);
});
test('same version + reversed commit labels ⇒ NOT newer', () => {
expect(isNewer(
{ version: '1.16.0', commit: '321334c' },
{ version: '1.16.0', commit: '20260722-065235-cookie-only-session-653478a' }
)).toBe(false);
});
test('identical version+commit ⇒ NOT newer', () => {
expect(isNewer(
{ version: '1.16.0', commit: 'abc1234' },
{ version: '1.16.0', commit: 'abc1234' }
)).toBe(false);
});
test('higher remote semver ⇒ newer', () => {
expect(isNewer(
{ version: '1.15.0', commit: 'abc1234' },
{ version: '1.16.0', commit: 'def5678' }
)).toBe(true);
});
test('lower remote semver ⇒ NOT newer (downgrade refused)', () => {
expect(isNewer(
{ version: '1.16.0', commit: 'abc1234' },
{ version: '1.15.0', commit: 'def5678' }
)).toBe(false);
});
test('remote without version ⇒ NOT newer', () => {
expect(isNewer({ version: '1.16.0', commit: 'abc1234' }, {})).toBe(false);
expect(isNewer({ version: '1.16.0' }, null)).toBe(false);
});
test('multi-component semver compares numerically (10.0.0 > 9.5.1)', () => {
expect(isNewer({ version: '9.5.1' }, { version: '10.0.0' })).toBe(true);
expect(isNewer({ version: '10.0.0' }, { version: '9.5.1' })).toBe(false);
});
});
+8
View File
@@ -105,5 +105,13 @@
"containerId": null, "containerId": null,
"appTemplate": "sami-files", "appTemplate": "sami-files",
"deployedAt": "2026-06-19T00:00:00.000Z" "deployedAt": "2026-06-19T00:00:00.000Z"
},
{
"id": "sec",
"name": "Security",
"logo": "/assets/chat.png",
"url": "https://sec.sami",
"ip": "localhost",
"tailscaleOnly": true
} }
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.15.0", "version": "1.16.0",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js", "main": "server.js",
"engines": { "engines": {
+10
View File
@@ -388,6 +388,16 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j)) fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})` }).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
}, },
sec: {
// sec.sami — fleet security dashboard. Unlike media apps it needs NO
// app-specific token: it's a plain web app behind the TOTP gate. The
// SHELL above has already verified check-session returned authenticated
// (otherwise it redirected to status.sami), so here we simply enter the
// dashboard. ?direct=1 bypasses the Caddy @needsAutoLogin redirect that
// sends bare "/" back to /dashcaddy-login, avoiding a loop.
title: 'Signing in to Security...', bg: '#070b10', accent: '#58a6ff',
body: `d.textContent='Session verified, opening dashboard...';go('/?direct=1');`
},
}; };
const cfg = pages[service]; const cfg = pages[service];
+201
View File
@@ -0,0 +1,201 @@
/**
* Deploys route factory — shipdeck-backed source deploys (DC-130).
*
* Bridge architecture: the shipdeck CLI (SSH keys, fleet-dns creds, root)
* lives on the DNS2 HOST. A token-gated shipdeck-bridge daemon
* (/opt/shipdeck-bridge, systemd shipdeck-bridge.service, 127.0.0.1:8977 +
* docker bridge 172.17.0.1:8977) wraps the CLI. This route proxies to it —
* the container never touches SSH or DNS credentials.
*
* Endpoints (all under /api/v1/deploys, standard dashboard auth):
* GET /repos -> deployable repos (dirs with Shipdeckfile)
* GET /services -> deployed services (journal-derived)
* GET /journal?service=N -> journal rows
* GET /status?service=N -> live re-probe
* POST /deploy {dir} -> run a deploy (long; up to SHIPDECK_DEPLOY_TIMEOUT)
* POST /rollback {service} -> roll back to the previous release
*
* Opt-in: when SHIPDECK_BRIDGE_URL is unset every endpoint returns 501 with a
* clear message (the DC-048 opt-in pattern: the feature does not exist until
* the operator configures it).
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const SHIPDECK_BRIDGE_URL = process.env.SHIPDECK_BRIDGE_URL || '';
const SHIPDECK_BRIDGE_TOKEN_FILE = process.env.SHIPDECK_BRIDGE_TOKEN_FILE || '';
const SHIPDECK_PROBE_TIMEOUT = Number(process.env.SHIPDECK_PROBE_TIMEOUT || 15000);
const SHIPDECK_DEPLOY_TIMEOUT = Number(process.env.SHIPDECK_DEPLOY_TIMEOUT || 620000);
const SERVICE_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
function readBridgeToken() {
if (!SHIPDECK_BRIDGE_TOKEN_FILE) return '';
const fs = require('fs');
try {
return fs.readFileSync(SHIPDECK_BRIDGE_TOKEN_FILE, 'utf8').trim();
} catch (e) {
return '';
}
}
module.exports = function ({ asyncHandler, log, auditLogger, fetchT }) {
const router = express.Router();
function featureEnabled() {
return SHIPDECK_BRIDGE_URL !== '';
}
function notConfigured(res) {
return errorResponse(res, 501, 'Deploys feature not configured: set SHIPDECK_BRIDGE_URL (and SHIPDECK_BRIDGE_TOKEN_FILE) to enable');
}
/**
* Proxy to the bridge. Returns {status, body}.
* bodyStream: pass a longer timeout for deploy/rollback.
*/
async function bridge(method, path, body, timeoutMs) {
const token = readBridgeToken();
const headers = { 'X-Shipdeck-Token': token };
let payload;
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetchT(SHIPDECK_BRIDGE_URL + path, {
method,
headers,
body: payload,
}, timeoutMs || SHIPDECK_PROBE_TIMEOUT);
let parsed;
try {
parsed = await res.json();
} catch (e) {
parsed = { ok: false, error: 'bridge returned non-JSON response' };
}
return { status: res.status, body: parsed };
}
// ---- feature gate for every endpoint ----
router.use((req, res, next) => {
if (!featureEnabled()) return notConfigured(res);
next();
});
router.get('/repos', asyncHandler(async (req, res) => {
const { status, body } = await bridge('GET', '/api/repos');
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
}
return ok(res, { repos: body.repos });
}));
router.get('/services', asyncHandler(async (req, res) => {
const { status, body } = await bridge('GET', '/api/services');
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
}
return ok(res, { services: body.services });
}));
router.get('/journal', asyncHandler(async (req, res) => {
const service = String(req.query.service || '');
if (service && !SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
const qs = service ? `?service=${encodeURIComponent(service)}` : '';
const { status, body } = await bridge('GET', '/api/journal' + qs);
if (status !== 200 && status !== 500) {
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
}
return ok(res, { rows: body.rows || [], ok: body.ok });
}));
router.get('/status', asyncHandler(async (req, res) => {
const service = String(req.query.service || '');
if (!SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
let status, body;
try {
({ status, body } = await bridge('GET', `/api/status?service=${encodeURIComponent(service)}`));
} catch (e) {
log.error('deploys', 'status probe: bridge unreachable', { service, error: e.message });
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
if (status === 401 || status === 403) {
// bridge auth/protocol failure = infrastructure problem, NOT a probe result
log.error('deploys', 'status probe: bridge auth failed', { service, status });
return errorResponse(res, 502, 'shipdeck bridge rejected the request (auth/config error)');
}
if (status >= 500 && body && body.ok === false && body.output) {
// shipdeck status exits non-zero when checks fail — that's an EXPECTED
// probe result (failing checks), surface as ok:false with the output.
return ok(res, { ok: false, output: body.output || '' });
}
if (status !== 200) {
// malformed upstream route or other unexpected bridge failure
log.error('deploys', 'status probe: unexpected bridge response', {
service,
status,
bridgeError: body && body.error ? String(body.error).slice(0, 200) : 'none',
});
return errorResponse(res, 502, 'shipdeck bridge protocol error (unexpected response, status ' + status + ')');
}
return ok(res, { ok: body.ok === true, output: body.output || '' });
}));
router.post('/deploy', asyncHandler(async (req, res) => {
const dir = req.body && req.body.dir;
if (typeof dir !== 'string' || !dir.trim()) {
return errorResponse(res, 400, 'dir is required');
}
try {
const { status, body } = await bridge('POST', '/api/deploy', { dir }, SHIPDECK_DEPLOY_TIMEOUT);
if (auditLogger) {
auditLogger.log({
action: 'deploy.shipdeck',
resource: dir,
details: { dir, exit: body.exit },
outcome: body.ok ? 'success' : 'failure',
}).catch(() => {});
}
if (status !== 200 || !body.ok) {
log.warn('deploys', 'shipdeck deploy failed', { dir, exit: body.exit });
return errorResponse(res, status === 401 ? 502 : status === 500 ? 502 : status, body.error || 'deploy failed', { output: (body.output || '').slice(-4000) });
}
log.info('deploys', 'shipdeck deploy completed', { dir });
return ok(res, { exit: body.exit, output: body.output });
} catch (e) {
log.error('deploys', 'bridge unreachable', { error: e.message });
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
router.post('/rollback', asyncHandler(async (req, res) => {
const service = req.body && req.body.service;
if (typeof service !== 'string' || !SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
try {
const { status, body } = await bridge('POST', '/api/rollback', { service }, SHIPDECK_DEPLOY_TIMEOUT);
if (auditLogger) {
auditLogger.log({
action: 'deploy.rollback',
resource: service,
details: { service, exit: body.exit },
outcome: body.ok ? 'success' : 'failure',
}).catch(() => {});
}
if (status !== 200 || !body.ok) {
return errorResponse(res, 502, body.error || 'rollback failed', { output: (body.output || '').slice(-4000) });
}
return ok(res, { exit: body.exit, output: body.output });
} catch (e) {
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
return router;
};
+10
View File
@@ -99,6 +99,7 @@ const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows'); const workflowsRoutes = require('../routes/workflows');
const dependenciesRoutes = require('../routes/dependencies'); const dependenciesRoutes = require('../routes/dependencies');
const securityRoutes = require('../routes/security'); const securityRoutes = require('../routes/security');
const deploysRoutes = require('../routes/deploys');
const diskSettingsRoutes = require('../routes/disk-settings'); const diskSettingsRoutes = require('../routes/disk-settings');
const aiIntentRoutes = require('../routes/ai-intent'); const aiIntentRoutes = require('../routes/ai-intent');
const logInsightsRoutes = require('../routes/log-insights'); const logInsightsRoutes = require('../routes/log-insights');
@@ -775,6 +776,15 @@ async function createApp() {
log: ctx.log, log: ctx.log,
})); }));
// DC-130: shipdeck deploys — source deploys via the host-side bridge.
// Feature-flagged: 501 unless SHIPDECK_BRIDGE_URL is set (opt-in pattern).
apiRouter.use('/deploys', deploysRoutes({
asyncHandler: ctx.asyncHandler,
log: ctx.log,
auditLogger: ctx.auditLogger,
fetchT: ctx.fetchT,
}));
// Log Insights — plain English activity summary + safe log disposal // Log Insights — plain English activity summary + safe log disposal
apiRouter.use('/disk-settings', diskSettingsRoutes); apiRouter.use('/disk-settings', diskSettingsRoutes);
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler })); apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler }));
+40 -7
View File
@@ -305,6 +305,11 @@ class SelfUpdater extends EventEmitter {
JSON.stringify(trigger, null, 2) JSON.stringify(trigger, null, 2)
); );
// DC-122 note: the frontend deployment stamp (update-stamp.json) is
// written by the HOST-side dashcaddy-update.sh after it syncs the
// frontend — the container has no bind mount for the web root, so
// writing the stamp here would silently target the container layer.
// The host-side systemd service will handle the rest. // The host-side systemd service will handle the rest.
// After container restart, checkPostUpdateResult() reads the result. // After container restart, checkPostUpdateResult() reads the result.
this._addToHistory({ this._addToHistory({
@@ -317,6 +322,13 @@ class SelfUpdater extends EventEmitter {
channel: this.config.channel, channel: this.config.channel,
instanceId: this.instanceId, instanceId: this.instanceId,
}); });
} else if (frontendSrc && this.config.hostFrontendDir && !isWindows) {
// DC-122: frontend-only release on Linux with deferred host deploy —
// there is no API component to trigger, and the container cannot
// reach the web root itself. Fail loudly instead of silently
// succeeding while deploying nothing. The enclosing catch records
// the single 'failed' history entry and resets status.
throw new Error('Frontend-only release cannot be applied on this install: no API component to trigger the host-side deploy. Publish a full release (dashcaddy-api + status).');
} else if (isWindows) { } else if (isWindows) {
// Windows: frontend updated, API needs manual restart // Windows: frontend updated, API needs manual restart
this._addToHistory({ this._addToHistory({
@@ -383,7 +395,18 @@ class SelfUpdater extends EventEmitter {
if (historyIndex !== -1) { if (historyIndex !== -1) {
const pending = history[historyIndex]; const pending = history[historyIndex];
pending.status = result.success ? 'success' : 'rolled-back'; // DC-122: truthful status. success:true → 'success'
// success:false + error mentions rollback → 'rolled-back'
// any other failure → 'failed'
// (an explicit rollback whose rebuild/health also failed is NOT a
// successful rollback — it must not be recorded as one)
if (result.success) {
pending.status = 'success';
} else if (typeof result.error === 'string' && /rolled back/i.test(result.error)) {
pending.status = 'rolled-back';
} else {
pending.status = 'failed';
}
pending.duration = result.duration; pending.duration = result.duration;
if (result.error) pending.error = result.error; if (result.error) pending.error = result.error;
if (result.version) pending.version = result.version; if (result.version) pending.version = result.version;
@@ -469,8 +492,18 @@ class SelfUpdater extends EventEmitter {
try { try {
const result = await this.checkForUpdate(); const result = await this.checkForUpdate();
if (result.available && result.remote) { if (result.available && result.remote) {
// DC-122 defense-in-depth: never re-apply an identical
// version@sha256 within this process lifetime, even if version
// stamping after an apply fails and the next check still reports
// "newer". Prevents repeated rebuild loops when auto-update is on.
const ref = `${result.remote.version}@${result.remote.sha256 || ''}`;
if (ref === this._lastAppliedRef) {
log.info('updater', 'Skipping auto-apply: identical release already applied', { ref });
return;
}
log.info('updater', 'Update available', { localVersion: result.local.version, remoteVersion: result.remote.version }); log.info('updater', 'Update available', { localVersion: result.local.version, remoteVersion: result.remote.version });
await this.applyUpdate(result.remote); await this.applyUpdate(result.remote);
this._lastAppliedRef = ref;
} }
} catch (e) { } catch (e) {
log.error('updater', e, { phase: 'autoUpdate' }); log.error('updater', e, { phase: 'autoUpdate' });
@@ -561,12 +594,12 @@ class SelfUpdater extends EventEmitter {
_isNewer(local, remote) { _isNewer(local, remote) {
if (!remote || !remote.version) return false; if (!remote || !remote.version) return false;
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version); // DC-122: same version ⇒ NOT newer, period. Commit hashes are opaque
if (versionCompare < 0) return true; // build labels (pipelines stamp different formats — short SHA vs
if (versionCompare > 0) return false; // timestamp-prefixed), so any inequality would read as "newer" and made
// Same version — check commit hash // same-version installs re-apply stale tarballs in a loop once
if (remote.commit && local.commit && remote.commit !== local.commit) return true; // DASHCADDY_UPDATE_ENABLED=true. A real release must bump semver.
return false; return this._compareVersions(local.version || '0.0.0', remote.version) < 0;
} }
_compareVersions(a, b) { _compareVersions(a, b) {
+291 -25
View File
@@ -72,30 +72,79 @@ channel_allowed() {
esac esac
} }
# ── JSON string escaping (DC-122) ─────────────────────────────────────────────
# Complete JSON string encoder for the rare no-python fallback path: mandatory
# escapes (quote, backslash) plus ALL control bytes U+0000–U+001F as \uXXXX or
# their short forms.
json_escape() {
local s="$1" out="" ch i hex
for (( i=0; i<${#s}; i++ )); do
ch="${s:i:1}"
case "$ch" in
\\) out+='\\' ;;
\") out+='\"' ;;
$'\b') out+='\b' ;;
$'\f') out+='\f' ;;
$'\n') out+='\n' ;;
$'\r') out+='\r' ;;
$'\t') out+='\t' ;;
*)
if [[ "$ch" < ' ' || "$ch" == $'\x7f' ]]; then
printf -v hex '%02x' "'$ch"
out+="\u00${hex}"
else
out+="$ch"
fi
;;
esac
done
printf '%s' "$out"
}
write_result() { write_result() {
local success="$1" version="$2" duration="$3" local success="$1" version="$2" duration="$3"
shift 3 shift 3
local error="${1:-}" local error="${1:-}"
if [[ "$success" == "true" ]]; then if command -v python3 >/dev/null 2>&1; then
cat > "$RESULT_FILE" <<EOF python3 - "$success" "$version" "$duration" "$error" > "$RESULT_FILE" <<'PY'
import json, sys, datetime
success, version, duration, error = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
obj = {
"success": success == "true",
"version": version,
"duration": int(duration) if duration.isdigit() else 0,
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
if error:
obj["error"] = error
print(json.dumps(obj, indent=2))
PY
else
# Safe fallback: escaped interpolation (no raw quote/backslash leakage).
local esc_version esc_error
esc_version=$(json_escape "$version")
esc_error=$(json_escape "$error")
if [[ "$success" == "true" ]]; then
cat > "$RESULT_FILE" <<EOF
{ {
"success": true, "success": true,
"version": "${version}", "version": "${esc_version}",
"duration": ${duration}, "duration": ${duration},
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} }
EOF EOF
else else
cat > "$RESULT_FILE" <<EOF cat > "$RESULT_FILE" <<EOF
{ {
"success": false, "success": false,
"version": "${version}", "version": "${esc_version}",
"duration": ${duration}, "duration": ${duration},
"error": "${error}", "error": "${esc_error}",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} }
EOF EOF
fi
fi fi
} }
@@ -116,8 +165,15 @@ backup_data_dir() {
if [[ -d "$DATA_SOURCE_DIR" ]]; then if [[ -d "$DATA_SOURCE_DIR" ]]; then
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/" log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}" mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
# DC-122: fallback must copy CONTENTS ("dir/.") — a bare "cp -a dir dest"
# nests a data/ level inside the existing destination dir, which then made
# restore_data_dir restore data/data/... (rollback restoring nothing).
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \ rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}" || { log "rsync unavailable — cp fallback (contents copy)"; cp -a "$DATA_SOURCE_DIR/." "${backup_dir}/${DATA_BACKUP_PREFIX}/"; }
# Manifest of backed-up files — lets the no-rsync restore path mirror
# rsync --delete semantics (remove live files that the backup lacks).
( cd "${backup_dir}/${DATA_BACKUP_PREFIX}" && find . -type f -printf '%P\n' | sort ) \
> "${backup_dir}/data.manifest" 2>/dev/null || true
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))" log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
else else
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup" log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
@@ -163,15 +219,157 @@ restore_data_dir() {
local backup_dir="$1" local backup_dir="$1"
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}" local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
if [[ -d "$data_backup" ]]; then if [[ -d "$data_backup" ]]; then
log "Restoring data/ from backup..." # DC-122: contents copy on the cp fallback — see backup_data_dir note.
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \ if rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null; then
|| cp -a "$data_backup" "$DATA_SOURCE_DIR" log "Data restored successfully (rsync --delete)"
log "Data restored successfully" else
log "rsync unavailable — cp fallback + manifest reconciliation"
cp -a "$data_backup/." "$DATA_SOURCE_DIR/"
# Mirror deletion semantics without rsync: remove live files that the
# backup manifest says did not exist at backup time.
local manifest="${backup_dir}/data.manifest"
if [[ -f "$manifest" ]]; then
local live_list deleted=0
live_list="$( cd "$DATA_SOURCE_DIR" && find . -type f -printf '%P\n' | sort )"
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
# defense against path traversal in a corrupted manifest
[[ "$rel" == ..* || "$rel" == */..* || "$rel" == *../* ]] && continue
rm -f "$DATA_SOURCE_DIR/$rel" && deleted=$(( deleted + 1 ))
done < <(comm -13 "$manifest" <(printf '%s\n' "$live_list"))
# prune directories that became empty
find "$DATA_SOURCE_DIR" -mindepth 1 -type d -empty -delete 2>/dev/null || true
log "Manifest reconciliation: removed ${deleted} post-backup file(s)"
fi
log "Data restored successfully (cp fallback)"
fi
else else
log "WARNING: No data backup found at ${data_backup} — data/ not restored" log "WARNING: No data backup found at ${data_backup} — data/ not restored"
fi fi
} }
# ── Frontend backup/restore (DC-122) ──────────────────────────────────────────
# The frontend is synced to the live web root BEFORE the API rebuild + health
# check. If the update then fails, the new frontend would pair with the rolled
# back API. Snapshot exactly what we touch so rollback can restore it.
FRONTEND_SUBDIRS="dist css vendor js assets"
# Snapshot = files + a manifest + metadata, so restore is EXACT:
# frontend.meta : JSON with target dir + whether index.html/sw.js existed
# frontend.manifest : every file that existed at snapshot time (relative)
# Restore deletes live files that the snapshot manifest does not know about
# (an update-introduced asset dies with the update) and recreates absent
# snapshot files.
backup_frontend_dir() {
local backup_dir="$1" target="$2"
# DC-122: reset any stale snapshot from a previous update with the same
# version key before writing this one.
rm -rf "${backup_dir}/frontend" "${backup_dir}/frontend.manifest" "${backup_dir}/frontend.meta"
mkdir -p "${backup_dir}/frontend"
[[ -f "$target/index.html" ]] && cp -f "$target/index.html" "${backup_dir}/frontend/index.html"
[[ -f "$target/sw.js" ]] && cp -f "$target/sw.js" "${backup_dir}/frontend/sw.js"
for sub in $FRONTEND_SUBDIRS; do
[[ -d "$target/$sub" ]] && cp -rf "$target/$sub" "${backup_dir}/frontend/$sub"
done
( cd "${backup_dir}/frontend" && find . -type f -printf '%P\n' | sort ) \
> "${backup_dir}/frontend.manifest" 2>/dev/null || true
# DC-122: use a validated target path for frontend.meta — json-escaped.
local esc_target
esc_target=$(json_escape "$target")
printf '{"target":"%s","indexExisted":%s,"swExisted":%s}\n' \
"$esc_target" \
"$( [[ -f "$target/index.html" ]] && echo true || echo false )" \
"$( [[ -f "$target/sw.js" ]] && echo true || echo false )" \
> "${backup_dir}/frontend.meta"
log "Frontend snapshot saved to ${backup_dir}/frontend ($(wc -l < "${backup_dir}/frontend.manifest" 2>/dev/null || echo 0) files)"
}
# ── Frontend target validation (DC-122) ───────────────────────────────────────
# A custom DASHCADDY_HOST_FRONTEND_DIR is supported, but ONLY if it validates:
# absolute, exists, is a directory, no '..' components, not the filesystem root.
# The SAME validator gates deploy and restore, so anything we deploy to is
# something we can roll back, and nothing else is ever touched.
validate_frontend_target() {
local t="$1"
[[ -n "$t" ]] || return 1
[[ "$t" = /* ]] || return 1
[[ "$t" != "/" ]] || return 1
[[ "$t" != *..* ]] || return 1
[[ -d "$t" ]] || return 1
return 0
}
restore_frontend_dir() {
local backup_dir="$1"
local snap="${backup_dir}/frontend"
[[ -d "$snap" ]] || { log "No frontend snapshot in this backup — skipping frontend restore"; return 0; }
# DC-122: resolve the target FIRST, validate BEFORE any delete/copy this
# function performs. A recorded custom target is honored — it was validated
# by this same function's rules before deployment, and recorded in meta.
local target=""
if [[ -f "${backup_dir}/frontend.meta" ]]; then
target=$(python3 -c "import json;print(json.load(open('${backup_dir}/frontend.meta')).get('target',''))" 2>/dev/null)
# DC-122 (hardened after live-fire test caught it): if the recorded meta
# target is present but INVALID, refuse outright. Never fall through to
# discovery with an untrusted/invalid target — that could point the
# restore at a directory the snapshot was never taken from.
if [[ -n "$target" ]] && ! validate_frontend_target "$target"; then
log "REFUSING frontend restore: recorded meta target is invalid: '${target}'"
return 1
fi
validate_frontend_target "$target" || target=""
fi
if [[ -z "$target" ]]; then
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
[[ -d "$candidate" ]] && target="$candidate" && break
done
fi
validate_frontend_target "$target" || { log "Refusing frontend restore on unexpected/unknown target: '${target:-}'"; return 1; }
log "Restoring frontend to ${target} (exact state, updater-owned scope)..."
# DC-122: structural exact restore of the owned subtrees:
# - snapshot has the subtree → rm -rf live + copy snapshot (exact)
# - snapshot lacks the subtree but live has it → the update introduced it
# ⇒ remove it. (Fixes rollback leaving behind e.g. a css/ dir the update
# created when none existed before.)
# index.html / sw.js are governed by frontend.meta existence flags.
# Files outside FRONTEND_SUBDIRS are NEVER touched.
for sub in $FRONTEND_SUBDIRS; do
if [[ -d "$snap/$sub" ]]; then
rm -rf "${target:?}/${sub:?}"
cp -rf "$snap/$sub" "${target:?}/$sub"
elif [[ -d "${target:?}/${sub}" ]]; then
rm -rf "${target:?}/${sub:?}"
log "Removed update-introduced subtree: ${sub}/"
fi
done
# index.html / sw.js per frontend.meta existence flags (single pass).
if grep -q '"indexExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/index.html" ]]; then
cp -f "$snap/index.html" "${target:?}/index.html"
elif grep -q '"indexExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then
rm -f "${target:?}/index.html"
fi
if grep -q '"swExisted":true' "${backup_dir}/frontend.meta" 2>/dev/null && [[ -f "$snap/sw.js" ]]; then
cp -f "$snap/sw.js" "${target:?}/sw.js"
elif grep -q '"swExisted":false' "${backup_dir}/frontend.meta" 2>/dev/null; then
rm -f "${target:?}/sw.js"
fi
# Rollback removes the deployment stamp: the restored frontend is NOT a
# self-updater deployment, so start.sh's source sync must resume authority.
rm -f "${target:?}/update-stamp.json"
# Empty dirs left behind by the removal pass.
find "${target:?}/dist" "${target:?}/css" "${target:?}/js" "${target:?}/vendor" "${target:?}/assets" \
-mindepth 1 -type d -empty -delete 2>/dev/null || true
log "Frontend restored"
}
# ── Docker space reclaim (DC-122) ─────────────────────────────────────────────
# Every rebuild leaves the previous image dangling (~250MB); prune it so a
# churn of auto-updates doesn't seize disk. Safe to call at any exit point.
prune_docker() {
docker image prune -f --filter "dangling=true" >/dev/null 2>&1 || true
docker builder prune -f --keep-storage 500m >/dev/null 2>&1 || true
}
wait_for_health() { wait_for_health() {
local port="${1:-3001}" local port="${1:-3001}"
local timeout="$HEALTH_TIMEOUT" local timeout="$HEALTH_TIMEOUT"
@@ -211,6 +409,7 @@ rollback_restore() {
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers" cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
fi fi
restore_data_dir "$backup_dir" restore_data_dir "$backup_dir"
restore_frontend_dir "$backup_dir"
} }
# ── Deployment mode ─────────────────────────────────────────────────────────── # ── Deployment mode ───────────────────────────────────────────────────────────
@@ -257,6 +456,7 @@ restart_container() {
log "Recreating container via minimal docker run (fallback)..." log "Recreating container via minimal docker run (fallback)..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \ docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
--log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \
-p 127.0.0.1:3001:3001 \ -p 127.0.0.1:3001:3001 \
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \ -v /opt/dashcaddy/dashcaddy-api/data:/app/data \
-e SERVICES_FILE=/app/data/services.json \ -e SERVICES_FILE=/app/data/services.json \
@@ -327,6 +527,16 @@ main() {
exit 1 exit 1
fi fi
# DC-122: validate the frontend target BEFORE any backup/deploy mutation.
# An invalid trigger-provided target is a hard failure of the whole update
# (the release cannot be applied faithfully), not a silent frontend skip.
if [[ "$action" != "rollback" && -n "$frontend_target_dir" ]] && ! validate_frontend_target "$frontend_target_dir"; then
log "ERROR: frontend target '${frontend_target_dir}' failed validation — refusing update"
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Invalid frontendTargetDir in trigger: ${frontend_target_dir}"
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
# ── Handle rollback ──────────────────────────────────────────────────────── # ── Handle rollback ────────────────────────────────────────────────────────
if [[ "$action" == "rollback" ]]; then if [[ "$action" == "rollback" ]]; then
local backup_dir="${BACKUPS_DIR}/${version}" local backup_dir="${BACKUPS_DIR}/${version}"
@@ -340,17 +550,29 @@ main() {
log "Performing rollback to v${version}..." log "Performing rollback to v${version}..."
rollback_restore "$backup_dir" rollback_restore "$backup_dir"
# Rebuild old code # Rebuild old code — DC-122: a rollback that cannot rebuild or cannot
# recover health is a FAILED rollback and must be reported as such.
log "Rebuilding container..." log "Rebuilding container..."
build_image 2>&1 | tail -3 || true local rebuild_ok=false health_ok=false
if build_image; then rebuild_ok=true; fi
restart_container restart_container
wait_for_health || log "WARNING: Health check failed after rollback" if wait_for_health; then health_ok=true; fi
write_result "true" "$version" "$(( $(date +%s) - start_time ))" if [[ "$rebuild_ok" == "true" && "$health_ok" == "true" ]]; then
rm -f "${TRIGGER_FILE}.processing" write_result "true" "$version" "$(( $(date +%s) - start_time ))"
log "=== Rollback complete ===" log "=== Rollback complete ==="
exit 0 prune_docker
rm -f "${TRIGGER_FILE}.processing"
exit 0
else
write_result "false" "$version" "$(( $(date +%s) - start_time ))" \
"Rollback incomplete (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED"
log "=== Rollback FAILED (rebuild_ok=$rebuild_ok health_ok=$health_ok) — INTERVENTION REQUIRED ==="
prune_docker
rm -f "${TRIGGER_FILE}.processing"
exit 1
fi
fi fi
# ── Handle update ─────────────────────────────────────────────────────────── # ── Handle update ───────────────────────────────────────────────────────────
@@ -375,6 +597,18 @@ main() {
# Backup data/ directory (services.json, config.json, credentials, etc.) # Backup data/ directory (services.json, config.json, credentials, etc.)
backup_data_dir "$backup_dir" backup_data_dir "$backup_dir"
# DC-122: snapshot the live frontend so a failed update can roll it back
# (frontend is synced to the web root before build+health check).
# Use the TRIGGER-provided target when set (custom installs) — the snapshot
# must cover exactly the dir the deploy will touch — else discover.
local fe_target="${frontend_target_dir}"
if [[ -z "$fe_target" ]]; then
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
[[ -d "$candidate" ]] && fe_target="$candidate" && break
done
fi
[[ -n "$fe_target" && -d "$fe_target" ]] && backup_frontend_dir "$backup_dir" "$fe_target"
# Backup updater state (trigger.json.processing + result.json) so post-mortem # Backup updater state (trigger.json.processing + result.json) so post-mortem
# has a forensic trail tied to this exact version's backup. # has a forensic trail tied to this exact version's backup.
backup_update_state "$backup_dir" backup_update_state "$backup_dir"
@@ -460,6 +694,11 @@ main() {
done done
fi fi
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
# DC-122: same validator gates deployment — if the trigger-provided custom
# target doesn't validate, refuse before mutating anything.
if ! validate_frontend_target "$frontend_target_dir"; then
log "ERROR: frontend target '${frontend_target_dir}' failed validation — skipping frontend sync (update continues for API only)"
else
log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir" log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir"
mkdir -p "$frontend_target_dir" mkdir -p "$frontend_target_dir"
[[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html" [[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html"
@@ -474,6 +713,14 @@ main() {
mkdir -p "$frontend_target_dir/assets" mkdir -p "$frontend_target_dir/assets"
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
fi fi
# DC-122: host-side deployment stamp — start.sh treats a stamped, newer
# deployment as authoritative and skips its source-bundle sync (this is
# the only writer that can reach the web root with real host paths).
local esc_ver
esc_ver=$(json_escape "$to_version")
printf '{"version":"%s","at":"%s"}\n' "$esc_ver" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> "$frontend_target_dir/update-stamp.json" 2>/dev/null || true
fi # DC-122 close: validated frontend-target branch
fi fi
# 4. Rebuild container # 4. Rebuild container
@@ -486,10 +733,18 @@ main() {
if [[ "$build_ok" != "true" ]]; then if [[ "$build_ok" != "true" ]]; then
log "ERROR: Docker build failed — rolling back code + data" log "ERROR: Docker build failed — rolling back code + data"
code_restore "$backup_dir" code_restore "$backup_dir"
build_image 2>&1 | tail -3 || true restore_frontend_dir "$backup_dir"
local rb_rebuild_ok=false rb_health_ok=false
if build_image; then rb_rebuild_ok=true; fi
restart_container restart_container
wait_for_health || true if wait_for_health; then rb_health_ok=true; fi
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed" prune_docker
if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed — rolled back cleanly"
else
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" \
"Docker build failed AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED"
fi
rm -f "${TRIGGER_FILE}.processing" rm -f "${TRIGGER_FILE}.processing"
exit 1 exit 1
fi fi
@@ -506,16 +761,27 @@ main() {
local duration=$(( $(date +%s) - start_time )) local duration=$(( $(date +%s) - start_time ))
log "ERROR: Health check failed after update — rolling back code + data" log "ERROR: Health check failed after update — rolling back code + data"
rollback_restore "$backup_dir" rollback_restore "$backup_dir"
build_image 2>&1 | tail -3 || true local rb_rebuild_ok=false rb_health_ok=false
if build_image; then rb_rebuild_ok=true; fi
restart_container restart_container
wait_for_health || log "WARNING: Rollback health check also failed" if wait_for_health; then rb_health_ok=true; fi
write_result "false" "$to_version" "$duration" "Health check failed after update" prune_docker
if [[ "$rb_rebuild_ok" == "true" && "$rb_health_ok" == "true" ]]; then
write_result "false" "$to_version" "$duration" "Health check failed after update — rolled back cleanly"
else
write_result "false" "$to_version" "$duration" \
"Health check failed after update AND rollback incomplete (rebuild_ok=$rb_rebuild_ok health_ok=$rb_health_ok) — INTERVENTION REQUIRED"
fi
fi fi
# 7. Cleanup # 7. Cleanup
rm -f "${TRIGGER_FILE}.processing" rm -f "${TRIGGER_FILE}.processing"
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
# 8. DC-122: reclaim docker space after every self-update rebuild (old
# dangling image layers otherwise accumulate ~250MB per apply).
prune_docker
log "=== Update process complete ===" log "=== Update process complete ==="
} }
+3 -2
View File
@@ -42,8 +42,9 @@ cp -a "${ROOT_DIR}/ca" "$STAGE_DIR/ca" 2>/dev/null || true
cp -a "${ROOT_DIR}/dashcaddy-installer" "$STAGE_DIR/dashcaddy-installer" 2>/dev/null || true cp -a "${ROOT_DIR}/dashcaddy-installer" "$STAGE_DIR/dashcaddy-installer" 2>/dev/null || true
cp -a "${ROOT_DIR}/README.md" "$STAGE_DIR/README.md" 2>/dev/null || true cp -a "${ROOT_DIR}/README.md" "$STAGE_DIR/README.md" 2>/dev/null || true
find "$STAGE_DIR" \( -name node_modules -o -name .git -o -name coverage -o -name .nyc_output \) -prune -exec rm -rf {} + find "$STAGE_DIR" \( -name node_modules -o -name .git -o -name coverage -o -name .nyc_output \
find "$STAGE_DIR" -type f \( -name '*.test.js' -o -name '*.spec.js' \) -delete -o -name build-output -o -name out -o -name dist_electron -o -name data -o -name release \) -prune -exec rm -rf {} +
find "$STAGE_DIR" -type f \( -name '*.test.js' -o -name '*.spec.js' -o -name '*.bak' -o -name '*.tar.gz' \) -delete
log "Creating tarball..." log "Creating tarball..."
tar -czf "${OUT_DIR}/${TARBALL_NAME}" -C "$TMP_DIR" dashcaddy tar -czf "${OUT_DIR}/${TARBALL_NAME}" -C "$TMP_DIR" dashcaddy
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# DC-122 full-cycle functional test: backup → deploy → rollback on a CUSTOM
# frontend target, proving (a) rollback restores the custom target, (b) an
# unrelated sibling directory is never touched, (c) invalid targets are
# refused. Sources the real functions from the real script.
set -euo pipefail
# Updater script path: $1 overrides; default = adjacent to this test file,
# falling back to the installed location (works in repo, worktree, and prod).
SCRIPT="${1:-}"
if [[ -z "$SCRIPT" ]]; then
# Worktree layouts prefix files with numbers (2_dashcaddy-update.sh);
# repo layout is unprefixed. Resolve whichever exists, adjacent first.
local_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for cand in "$local_dir/dashcaddy-update.sh" \
"$local_dir"/*dashcaddy-update.sh \
/opt/dashcaddy/scripts/dashcaddy-update.sh; do
[[ -f "$cand" ]] && SCRIPT="$cand" && break
done
fi
[[ -f "$SCRIPT" ]] || { echo "updater script not found"; exit 1; }
# Trap-covered workspace for ALL temp artifacts (collision-safe).
WORK=$(mktemp -d) || { echo "mktemp failed"; exit 1; }
trap 'rm -rf "$WORK"' EXIT
# Extract just the functions + constants we need (no main execution).
sed -n '/^FRONTEND_SUBDIRS=/p;/^json_escape()/,/^}/p;/^backup_frontend_dir()/,/^}/p;/^restore_frontend_dir()/,/^}/p;/^validate_frontend_target()/,/^}/p' "$SCRIPT" > "$WORK/dcfuncs.sh"
log() { echo "[t] $*"; }
source "$WORK/dcfuncs.sh"
T="$WORK/tree"
mkdir -p "$T"
CUSTOM_TARGET="$T/custom-webroot"
VICTIM="$T/custom-webroot-sibling"
mkdir -p "$CUSTOM_TARGET/dist" "$CUSTOM_TARGET/js" "$VICTIM"
# Pre-update state
echo v1 > "$CUSTOM_TARGET/index.html"
echo v1 > "$CUSTOM_TARGET/sw.js"
echo v1 > "$CUSTOM_TARGET/dist/core.js"
echo v1 > "$CUSTOM_TARGET/js/app.js"
echo secret > "$VICTIM/precious.txt"
BACKUP="$T/backup/v1.15.0"
mkdir -p "$BACKUP"
# 1. backup (as the update flow does, with the custom target)
backup_frontend_dir "$BACKUP" "$CUSTOM_TARGET"
[[ -f "$BACKUP/frontend.meta" ]] && [[ -f "$BACKUP/frontend.manifest" ]] || { echo "FAIL: snapshot incomplete"; exit 1; }
grep -q "$CUSTOM_TARGET" "$BACKUP/frontend.meta" || { echo "FAIL: meta lost target"; exit 1; }
echo "PASS backup: meta + manifest written, target recorded"
# 2. "deploy" a new version (simulates what the update does)
echo v2 > "$CUSTOM_TARGET/index.html"
echo v2 > "$CUSTOM_TARGET/dist/core.js"
echo new > "$CUSTOM_TARGET/dist/newfile.js" # update-introduced file
mkdir -p "$CUSTOM_TARGET/dist/newdir" && echo x > "$CUSTOM_TARGET/dist/newdir/f.js"
printf '{"version":"v2"}' > "$CUSTOM_TARGET/update-stamp.json"
# 3. rollback
restore_frontend_dir "$BACKUP" || { echo "FAIL: restore errored"; exit 1; }
[[ "$(cat "$CUSTOM_TARGET/index.html")" == v1 ]] && echo "PASS: index.html rolled back" || { echo "FAIL: index.html"; exit 1; }
[[ "$(cat "$CUSTOM_TARGET/dist/core.js")" == v1 ]] && echo "PASS: dist/core.js rolled back" || { echo "FAIL: core.js"; exit 1; }
[[ ! -e "$CUSTOM_TARGET/dist/newfile.js" ]] && echo "PASS: update-introduced file removed" || { echo "FAIL: newfile.js survived"; exit 1; }
[[ ! -e "$CUSTOM_TARGET/dist/newdir" ]] && echo "PASS: update-introduced dir removed" || { echo "FAIL: newdir survived"; exit 1; }
[[ ! -f "$CUSTOM_TARGET/update-stamp.json" ]] && echo "PASS: stamp cleared on rollback" || { echo "FAIL: stamp survived"; exit 1; }
[[ "$(cat "$VICTIM/precious.txt")" == secret ]] && echo "PASS: sibling directory untouched" || { echo "FAIL: VICTIM MODIFIED"; exit 1; }
# 4. invalid targets are refused outright (relative path = traversal risk class)
BACKUP2="$T/backup2"; mkdir -p "$BACKUP2/frontend"
printf '{"target":"relative/not-absolute","indexExisted":false,"swExisted":false}\n' > "$BACKUP2/frontend.meta"
( cd "$BACKUP2/frontend" && find . -type f -printf '%P\n' | sort ) > "$BACKUP2/frontend.manifest"
if restore_frontend_dir "$BACKUP2" 2>/dev/null; then
echo "FAIL: relative target was accepted"; exit 1
fi
echo "PASS: invalid (relative) target refused"
# 5. a validated custom target round-trips: snapshot→destroy→restore
CUSTOM2="$T/custom2-webroot"; mkdir -p "$CUSTOM2/js"
echo orig > "$CUSTOM2/js/only.js"
BACKUP3="$T/backup3"; mkdir -p "$BACKUP3"
backup_frontend_dir "$BACKUP3" "$CUSTOM2"
rm -rf "$CUSTOM2/js" && mkdir -p "$CUSTOM2/js" && echo clobbered > "$CUSTOM2/js/only.js"
restore_frontend_dir "$BACKUP3" >/dev/null 2>&1 || { echo "FAIL: custom-target restore errored"; exit 1; }
[[ "$(cat "$CUSTOM2/js/only.js")" == orig ]] && echo "PASS: validated custom target restored" || { echo "FAIL: custom restore"; exit 1; }
# 6. update-introduced OWNED subtree (css/ absent at backup, created by
# "deploy") must be REMOVED by rollback — not left behind.
CUSTOM3="$T/custom3-webroot"; mkdir -p "$CUSTOM3/dist" # note: NO css/ yet
echo v1 > "$CUSTOM3/dist/core.js"
BACKUP4="$T/backup4"; mkdir -p "$BACKUP4"
backup_frontend_dir "$BACKUP4" "$CUSTOM3"
mkdir -p "$CUSTOM3/css" && echo new > "$CUSTOM3/css/introduced.css" # update creates css/
restore_frontend_dir "$BACKUP4" >/dev/null 2>&1 || { echo "FAIL: introduced-subtree restore errored"; exit 1; }
[[ ! -e "$CUSTOM3/css" ]] && echo "PASS: update-introduced owned subtree removed" || { echo "FAIL: css/ survived rollback"; exit 1; }
[[ "$(cat "$CUSTOM3/dist/core.js")" == v1 ]] && echo "PASS: pre-existing subtree intact" || { echo "FAIL: dist broken"; exit 1; }
echo "=== ALL FULL-CYCLE TESTS PASSED ==="
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Functional test of json_escape + restore guard (DC-122 verification)
set -euo pipefail
# Updater script path: $1 overrides; default = adjacent to this test file
# (numbered worktree prefixes included), falling back to installed location.
SCRIPT="${1:-}"
if [[ -z "$SCRIPT" ]]; then
local_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for cand in "$local_dir/dashcaddy-update.sh" \
"$local_dir"/*dashcaddy-update.sh \
/opt/dashcaddy/scripts/dashcaddy-update.sh; do
[[ -f "$cand" ]] && SCRIPT="$cand" && break
done
fi
[[ -f "$SCRIPT" ]] || { echo "updater script not found"; exit 1; }
# Exactly ONE validated, trap-covered workspace for ALL temp artifacts.
WORK=$(mktemp -d) || { echo "mktemp failed"; exit 1; }
trap 'rm -rf "$WORK"' EXIT
source <(sed -n '/^json_escape()/,/^}/p' "$SCRIPT")
# Test 1: quotes + backslash + newline + tab round-trip through python json
r=$(json_escape 'a"b\c
d e')
python3 - "$r" <<'PY'
import json, sys
v = json.loads('"' + sys.argv[1] + '"')
assert v == 'a"b\\c\nd\te', f"round-trip mismatch: {v!r}"
print("TEST1 OK: quotes/backslash/newline/tab round-trip valid JSON")
PY
# Test 2: control chars (backspace, form feed, x01) become \uXXXX short forms
r2=$(json_escape "$(printf 'x\by\fz\001w')")
python3 - "$r2" <<'PY'
import json, sys
v = json.loads('"' + sys.argv[1] + '"')
assert v == "x\by\fz\x01w", f"control round-trip mismatch: {v!r}"
print("TEST2 OK: control bytes escaped and parse back")
PY
# Test 3: restore_frontend_dir must REFUSE an invalid recorded target —
# invoke it for real and assert (a) nonzero return, (b) zero mutations.
sed -n '/^validate_frontend_target()/,/^}/p' "$SCRIPT" > "$WORK/rfd-parts.sh"
sed -n '/^restore_frontend_dir()/,/^}/p' "$SCRIPT" >> "$WORK/rfd-parts.sh"
log() { echo "[t] $*"; }
source "$WORK/rfd-parts.sh"
WORK2="$WORK/fixtures"
mkdir -p "$WORK2/victim/dist" "$WORK2/backup/frontend"
echo keep > "$WORK2/victim/dist/keep.js"
cat > "$WORK2/backup/frontend.meta" <<EOF
{"target":"relative/not-absolute","indexExisted":true,"swExisted":true}
EOF
( cd "$WORK2/backup/frontend" && find . -type f -printf '%P\n' | sort ) > "$WORK2/backup/frontend.manifest"
# Full-tree fingerprint BEFORE any restore attempt: prove ZERO mutations
# anywhere in the victim tree across the refused restore.
fp_before=$(find "$WORK2/victim" -type f -exec sha256sum {} + | sort)
rc=0
restore_frontend_dir "$WORK2/backup" 2>/dev/null || rc=$?
fp_after=$(find "$WORK2/victim" -type f -exec sha256sum {} + | sort)
if [[ $rc -ne 0 ]]; then echo "TEST3a OK: restore returned nonzero ($rc) for invalid target"; else echo "TEST3 FAIL: restore returned 0"; exit 1; fi
if [[ "$fp_before" == "$fp_after" ]]; then
echo "TEST3b OK: victim untouched (fingerprint identical across refused restore)"
else
echo "TEST3 FAIL: victim modified"; exit 1
fi
echo "ALL JSON/GUARD TESTS DONE"
+257
View File
@@ -0,0 +1,257 @@
# DashCaddy Private Network — Product Spec (v1)
> Source: feature brief draft v4 (Sami, 2026-10-03) + technical review (same thread).
> Status: **approved for build** — "Do it and then build the non-tailscale version as well."
> Tickets: DC-140 … DC-152 (see BACKLOG.md "P7 — Private Network lane").
> Judge gate: every commit in this lane is graded via codex-as-judge, min B.
## 1. What we're building
DashCaddy turns a tailnet (or a self-hosted WireGuard hub) into a **managed private
network**: people, devices, per-app access, private DNS names, and a personal
certificate authority — all from one panel.
Two network modes, one product:
| | Tailscale mode (v1 default) | WireGuard mode (self-hosted) |
|---|---|---|
| Data plane | Tailscale (direct, NAT traversal) | WireGuard hub run by DashCaddy on the host |
| Control plane | api.tailscale.com (OAuth client preferred; API key fallback) | DashCaddy itself (host wg-quick + key store) |
| Invites | Tailscale user-invites API, link-first (no email required) | DashCaddy invite link + QR config |
| Block/remove | API device expire/delete + ACL deny | Remove peer from hub + firewall rule |
| Unblock | Re-allow at ACL level; expired device keys must re-authenticate (see §5) | Re-add peer from stored record (immediate) |
| Rules | Tailnet policy ACLs compiled by DashCaddy | Hub firewall (nftables set-based) compiled by DashCaddy |
| Outside dependency | Yes | None |
**Everything above the provider is shared**: people, groups, resources, the access
map, forward_auth enforcement, Technitium names, the CA, the UI. This is enforced
by the provider abstraction (DC-140) — no feature module may call a provider
directly.
## 2. Core concepts and stores
- **Person** — a human. `people.json` (atomic JSON store, same pattern as
invite-store/user-store). Fields: `id`, `displayName`, `providerUserId`
(Tailscale user id or WG pubkey identity), `groups[]`, `status:
active|blocked|removed`, `devices[]`, `createdAt`, `protectedAt` (admin guard).
- **Device** — one machine. Belongs to a person. `keyId` (TS device id / WG peer
pubkey), `name`, `ip` (tailnet IP), `online`, `lastSeen`, `os`.
- **Group** — named bundle of people, DashCaddy-side only (UI concept). Free
Tailscale plans cap ACL groups at 3 — therefore **DashCaddy groups never map
to Tailscale groups**; compiled ACLs reference users directly.
- **Resource** — an app or device reachable by name. `plex.internal`, `nas.internal`.
Derived from the existing services registry + manual entries.
- **Access map** — groups/people × resources grid. Single source of truth.
`network-access.json`: `{ grants: [{ principal: {type:'person'|'group', id}, resource: 'plex', allowed: bool }] }`.
This file, not Tailscale state, is authoritative.
## 3. Provider abstraction (DC-140)
`src/network/` package:
```
src/network/provider.js — NetworkProvider interface (documented contract)
src/network/registry.js — active provider resolution + capability flags
src/network/errors.js — ProviderError { code, retryable }
src/network/providers/tailscale.js
src/network/providers/wireguard.js
```
Contract (all async, all return plain JSON, all validated):
| Method | Tailscale impl | WireGuard impl |
|---|---|---|
| `id()`, `capabilities()` | `'tailscale'` | `'wireguard'` |
| `getStatus()` | tailscale status --json (cached, existing manager) | wg show dump + iface up/down |
| `listDevices()` | API + local status merge | peer store read |
| `listPeople()` | API users → people projection | people.json (self-owned) |
| `createInvite({role, email?})` | POST user-invites (email optional → returns inviteUrl) | mint one-time invite token in store |
| `revokeInvite(id)` | DELETE user-invite | delete token |
| `blockPerson(userId)` | expire all device keys + ACL deny | drop peer + firewall deny set |
| `unblockPerson(userId)` | re-allow at ACL level; expired device keys stay expired (re-auth required, §5) | re-add stored peer (immediate) |
| `removePerson(userId)` | DELETE devices, revoke invites | remove peer + person record |
| `revokeDevice(deviceId)` | expire/delete device key | remove single peer |
| `syncAccess(map, resources)` | compile ACLs → dry-run → push (hash-guarded) | compile nftables → apply → verify |
| `whois(ip)` | local tailscale whois | peer table lookup |
| `configureDns({zone, server})` | split-DNS API | not supported (Technitium direct) |
Capabilities: `{ invites, splitDnsApi, perPeerFirewall, selfHosted }`. Feature
modules check capabilities and degrade cleanly (e.g. WireGuard mode skips the
split-DNS API step and writes Technitium directly).
**Existing routes untouched.** `routes/tailscale.js` / `tailscale-admin.js` keep
working as-is; the new `routes/network.js` + `src/network/` are additive.
## 4. Enforcement — two layers
**Layer 1: network.** The access map compiles to provider rules.
- Tailscale: HuJSON policy with per-user src entries (never groups), `dst` as
`host:port` per resource. DashCaddy host always keeps an explicit allow for
the admin. **Lockout guard** (hard rule, tested): refuse to push any policy
where (a) the last active admin person would lose access to the DashCaddy
host resource, or (b) the DashCaddy host device would lose egress. Use the
ACL preview/dry-run endpoint before every push; keep the last-known-good
policy body for one-click rollback. Store the policy hash after each push and
compare before the next — outside edits ⇒ drift banner, not silent overwrite.
- WireGuard: hub-and-spoke means everyone can reach the hub; per-resource
enforcement is a hub nftables matrix. One nft set per resource
(`net_dc_res_plex`), peer IPs added per grant. **Apply is transactional**:
build the full ruleset to a temp file, `nft -c` (check), swap atomically via
`nft -f`, verify with `nft list set`, roll back file kept.
**Layer 2: app (forward_auth).** Shared across providers.
- Whois path: forward_auth asks DashCaddy which person owns the connecting
device (provider.whois), then checks the access map. Tailnet users get in
without a separate login. **Identity follows the device** — a shared device
grants the owner's access; documented in the UI.
- Step-up: resources flagged `sensitive` additionally require the existing
DashCaddy login + TOTP (reuse sso-gate).
## 5. People lifecycle (DC-141, DC-144)
- Invite = link-first (DC-085 pattern): inviteUrl is the artifact, email is an
opt-in checkbox, never logged raw to console.
- Block: network-layer deny (ACL / firewall) + expire Tailscale device keys + kill
DashCaddy sessions + mark person blocked. **Unblock honestly:** the deny, the
status, and the session policy reverse — but expired Tailscale device keys are
NOT un-expired; those devices must re-authenticate with Tailscale on next
connect. The UI says "blocked devices will need to sign in again" rather than
pretending instant reversal. WireGuard peers restore immediately on re-add
(peer config is re-generated server-side from the stored pubkey).
- Remove: block semantics + delete provider devices + delete person record +
purge from access map. The last active admin can never be blocked/removed
(same guard as the ACL compiler — one shared `assertNotLastAdmin()`).
## 6. Names — Technitium (DC-146)
- Per-resource + per-device A/AAAA records → tailnet IPs, written via
Technitium HTTP API (credential in credential-manager, existing pattern).
- Split DNS: Tailscale mode uses the API when available; check + repair wizard
step. WireGuard mode: devices get Technitium's hub IP as their DNS entry in
the generated client config.
- Record writes are idempotent (upsert by name+type) and verified by resolve.
## 7. Certificate authority (DC-147)
Builds on the existing DashCA pipeline (routes/ca.js, DC-076). Changes:
- Root CN = operator-chosen name ("Ahmed Family Certificate Authority"),
captured at first setup, baked in; rename ⇒ guided re-trust flow (warn at
naming time, not after).
- Root gets a **critical nameConstraints extension** (permittedSubtrees
DNS:<private-tld>). Mark critical so non-understanding clients fail closed.
- Leaves ≤ 398 days, SANs always set, serverAuth EKU (iOS 13+ requirements) —
the existing openssl leaf path already complies; add regression tests that
parse the issued cert and assert all three.
- Branded install page at ca.<tld>: per-OS steps including **iOS's second
step** (enable trust in Settings → General → About → Certificate Trust
Settings), SHA-256 fingerprint for verification, and an Android note that
native apps ignore user CAs (browsers fine).
- Root key never leaves the host except through an explicit wizard step:
encrypted backup download ( passphrase-wrapped ) + checksum.
## 8. WireGuard provider (DC-149–151)
### 8.1 Addressing and routing (exact prefixes)
- Hub interface `dcnet0`: 10.53.0.1/16, listen port chosen at setup (default 51820,
UDP). Endpoint = host's reachable address (setup captures it; wizard warns if
it's behind NAT without a forwarded port).
- Client address pool: 10.53.0.0/16, one /32 per device (10.53.x.y/32 assigned
on device add, stable, stored in people.json devices[]).
- **AllowedIPs = 10.53.0.0/16 only** (a CIDR, never a DNS zone). Split tunnel:
client's other traffic is untouched. Resource names resolve via Technitium
(8.3) to IPs inside 10.53.0.0/16, which routes through the tunnel.
- Hub forwarding: `net.ipv4.ip_forward=1` is required for hub-and-spoke
peer→peer traffic; peer→hub-resource traffic is local. No NAT/masquerade on
dcnet0 — all resource access is to hub-local services on 10.53.0.1 or to
other peers' /32s, so addresses stay routable and firewall rules match peers.
- Firewall baseline — three distinct zones, never conflated:
(a) **External input**: the WireGuard UDP listen port arrives on the EXTERNAL
interface (e.g. eth0), not dcnet0 — its accept rule is scoped to the external
interface name (captured at setup).
(b) **dcnet0 local input** (iifname dcnet0): ICMP from 10.53.0.0/16, admin
ports (API/SSH on 10.53.0.1) from admin peers only, per-resource ports to
10.53.0.1; drop other new inbound to 10.53.0.1.
(c) **dcnet0 forwarding** (ip_forward=1): peer→resource gated by per-resource
sets; peer→peer default DROP (both iifname and oifname dcnet0 tails) unless a
grant implies it.
### 8.2 Key lifecycle (no server-side private-key storage)
- **Client keypairs are generated in the invitee's browser** using WebCrypto
**X25519** — WireGuard requires Curve25519 key material; there is NO fallback
algorithm (a browser lacking X25519 support gets an explicit "browser not
supported" error, not a degraded key). Serialization (browser-export
reality): `exportKey('raw', publicKey)` yields the raw 32-byte public key →
base64. `exportKey('pkcs8', privateKey)` yields a PKCS#8 wrapper — the page
strips the fixed 16-byte PKCS#8 X25519 prefix (`302e020100300506032b656e0422
0420`) to extract the raw 32-byte private key → base64 (`wg genkey`-format;
the extraction is pure byte-slicing of a fixed-prefix wrapper, verified by
length/constant-prefix assertions before use). The server receives the base64
public key (44 chars) and validates length/charset before use.
- The redemption page posts ONLY the public key to the server; the server
registers it as a WireGuard peer (10.53.x.y/32) and returns the client
config (Address, DNS, PublicKey of hub, AllowedIPs = 10.53.0.0/16).
- The private key NEVER transits or rests on the server: it lives in the
browser page for the session, rendered into the config + QR client-side.
- Server stores: peer public keys, assigned /32s, names — nothing else.
- Revocation = remove peer (pubkey) from the hub + firewall-set update; the
client's private key becomes useless without hub acceptance.
### 8.3 DNS
- Technitium serves the private zone on the hub IP (10.53.0.1). Client config
DNS = 10.53.0.1. Per-resource/per-device records resolve to 10.53.x.y /32s
(per DC-146). In Tailscale mode, the same records hold tailnet IPs instead.
### 8.4 Hub management
- `wg-quick` interface `dcnet0` managed by DashCaddy (systemd unit
`dcnet-hub.service` if absent), keygen for the HUB keypair on the host
(hub key is host-local and backed up with it).
### 8.5 Invites
- Invite redemption: one-time token (7-day TTL, single-use) → redemption page
(browser keygen per 8.2) → config + QR rendered client-side, page
auto-expires after redemption.
- Firewall: nftables matrix (section 4). Server resources listen on the hub IP;
per-resource sets gate peer access. The host's own admin ports are NOT
exposed on dcnet0 except to admins.
WireGuard mode dependency note: "no third party" still depends on host
networking — a reachable UDP endpoint, wireguard-tools, nftables, and Technitium
on the DashCaddy host.
## 9. Setup wizard (DC-152)
Single flow, mode pick first:
1. Mode: Tailscale (paste OAuth client secret or API key — surface expiry
warnings for API keys, ≤90-day; recommend OAuth client `tskey-client-`) or
WireGuard (port, endpoint hostname/IP).
2. Private domain pick (default `.internal`, `home.arpa` offered, custom with
"could become real later" warning).
3. Provider connect + health check + first sync.
4. Technitium zone + split-DNS check.
5. CA naming (locked-in warning) + root generation + install page link.
6. First person (the admin — protected).
## 10. Non-goals (explicitly out of scope for this lane)
- Headscale support. Subnet routers / LAN access. Time-limited access.
- Multi-hub HA. Client auto-updaters. Migration between modes with data
carry-over (documented manual path only — switching re-runs the wizard).
## 11. Test strategy
- Every new module: Jest unit tests with the same mock rigor as
tailscale-manager.test.js (no live network in tests).
- ACL compiler: golden-policy tests (map JSON → expected HuJSON), lockout-guard
adversarial cases (last-admin removal, host egress cut, drift detection).
- WG provider: execFile-mocked `wg`/`nft` binaries; ruleset built to temp and
compared as text; transactional-apply failure injection.
- forward_auth: route tests against the running supertest app (existing
__tests__/routes pattern), including shared-device semantics.
- After merge + deploy to the dev rig: live-curl every new route (the DC-048
discipline — hermetic green is not production proof).
+27 -13
View File
@@ -149,26 +149,38 @@ fi
echo "[start.sh] Creating container with full config..." echo "[start.sh] Creating container with full config..."
# Sync the freshly-built dashboard bundle into the static directory Caddy # Sync the freshly-built dashboard bundle into the static directory Caddy
# serves. The Docker image bakes dist/ from the source tree at build time, # serves — decided by VERSION METADATA, not file mtimes (mtimes are not
# but DNS2 also serves /var/www/dashcaddy-status/dist/ (the original # reliable: scp/tar/cp can preserve or shuffle them). DC-122 contract:
# Windows installer mirror path). If we don't sync after every build, the # - The self-updater writes a STAMP (update-stamp.json) into the live web
# served bundle keeps the OLD hash while the API responds with new code, # root when it deploys a frontend; while that stamp is newer than the
# which shows up in the dashboard as "version unavailable" + "no data" # source tree's VERSION file, start.sh must NOT touch the live bundle.
# widgets because the new API surface doesn't match the old widget code. # - Normal builds: publishing bumps the source VERSION (mtime = build time)
# This step is idempotent and ~50ms — always safe to run. # and clears any stale stamp, so source wins and the sync happens.
echo "[start.sh] Syncing dashboard bundle into static dir..." echo "[start.sh] Syncing dashboard bundle into static dir (metadata-driven)..."
mkdir -p /var/www/dashcaddy-status/dist mkdir -p /var/www/dashcaddy-status/dist
if [ -d /opt/dashcaddy/status/dist ]; then if [ -d /opt/dashcaddy/status/dist ]; then
cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/ 2>/dev/null || true NEEDS_SYNC=1
cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/ 2>/dev/null || true STAMP=/var/www/dashcaddy-status/update-stamp.json
cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/ 2>/dev/null || true SRC_VERSION=/opt/dashcaddy/dashcaddy-api/VERSION
echo "[start.sh] Bundle synced ($(ls /opt/dashcaddy/status/dist/*.js 2>/dev/null | wc -l) bundle files + sw.js + index.html)." if [ -f "$STAMP" ] && [ -f "$SRC_VERSION" ] && [ "$STAMP" -nt "$SRC_VERSION" ]; then
# A self-updater deployment is newer than the last source build: hands off.
echo "[start.sh] Deployed frontend stamp newer than source VERSION — skipping sync to preserve deployed frontend."
NEEDS_SYNC=0
fi
if [ "$NEEDS_SYNC" = "1" ]; then
cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/ 2>/dev/null || true
cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/ 2>/dev/null || true
cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/ 2>/dev/null || true
rm -f "$STAMP"
echo "[start.sh] Bundle synced ($(ls /opt/dashcaddy/status/dist/*.js 2>/dev/null | wc -l) bundle files + sw.js + index.html)."
fi
else else
echo "[start.sh] WARN: /opt/dashcaddy/status/dist missing — skipping sync (frontend will be stale)." echo "[start.sh] WARN: /opt/dashcaddy/status/dist missing — skipping sync (frontend will be stale)."
fi fi
docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
--memory=1g --memory-swap=2g --cpus=2 \ --memory=1g --memory-swap=2g --cpus=2 \
--log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \
--add-host=get.dashcaddy.net:194.233.88.206 \ --add-host=get.dashcaddy.net:194.233.88.206 \
--add-host=get2.dashcaddy.net:194.233.88.206 \ --add-host=get2.dashcaddy.net:194.233.88.206 \
--dns ${DNS_PRIMARY} \ --dns ${DNS_PRIMARY} \
@@ -204,6 +216,8 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
-e DASHCADDY_SELF_IPS="${SELF_IPS}" \ -e DASHCADDY_SELF_IPS="${SELF_IPS}" \
-e ASSETS_DIR=/app/assets \ -e ASSETS_DIR=/app/assets \
-e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \ -e DASHCADDY_API_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api \
-e DASHCADDY_UPDATE_ENABLED=false \ -e DASHCADDY_UPDATE_ENABLED=true \
-e SHIPDECK_BRIDGE_URL=http://172.17.0.1:8977 \
-e SHIPDECK_BRIDGE_TOKEN_FILE=/app/data/shipdeck-bridge-token \
-e CA_CERT_PATH=/etc/ssl/sami-ca/root.crt \ -e CA_CERT_PATH=/etc/ssl/sami-ca/root.crt \
${IMAGE} ${IMAGE}
+1
View File
@@ -78,6 +78,7 @@ const bundles = {
JS('container-exec.js'), JS('container-exec.js'),
JS('audit-log.js'), JS('audit-log.js'),
JS('security-center.js'), JS('security-center.js'),
JS('deploys.js'),
JS('weather.js'), JS('weather.js'),
JS('clock.js'), JS('clock.js'),
JS('card-badges.js'), JS('card-badges.js'),
+210 -158
View File
@@ -90,44 +90,44 @@
<button id="logo-save" class="btn-accent">Save</button> <button id="logo-save" class="btn-accent">Save</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),z=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),b=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),h=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),S=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),f=DC.NAME;let E=null,O=null,L=null,j="left",N=f;z?.addEventListener("change",()=>{z.checked?(P.style.display="none",M.style.display="",E=null,O=null):(P.style.display="flex",M.style.display="none",L=null)});function R(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{R(t.target.files[0],e=>{E=e,B.src=e,I.textContent="New dark logo ready to save"})}),b?.addEventListener("change",t=>{R(t.target.files[0],e=>{O=e,A.src=e,I.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{R(t.target.files[0],e=>{L=e,B.src=e,A.src=e,I.textContent="New logo ready to save (both themes)"})});function u(t){S.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function v(t){N=t||f,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function w(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(h.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(h.src=e.customLogo,H.src=e.customLogo,B.src=e.customLogo,A.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(j=e.position,u(e.position)),e.dashboardTitle&&v(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{j=t.dataset.pos,u(j)})}),document.getElementById("brand")?.addEventListener("click",()=>{E=null,O=null,L=null,D&&(D.value=""),b&&(b.value=""),T&&(T.value=""),z&&(z.checked=!1),P.style.display="flex",M.style.display="none",B.src=h.src,A.src=H.src;const t=h.src.includes("custom-logo")||H.src.includes("custom-logo");I.textContent=t?"Using custom logo":"Using default logos",u(j),$.value=N,k.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||f,e={position:j,dashboardTitle:t};z?.checked&&L?(e.dataDark=L,e.dataLight=L):(E&&(e.dataDark=E),O&&(e.dataLight=O));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(h.src=o.pathDark+i,B.src=o.pathDark+i),o.pathLight&&(H.src=o.pathLight+i,A.src=o.pathLight+i),u(j),v(t),k.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? </div>`);const b=document.getElementById("logo-modal"),C=document.getElementById("logo-preview-dark"),j=document.getElementById("logo-preview-light"),k=document.getElementById("logo-status"),z=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),H=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),x=document.getElementById("logo-upload-light"),B=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),L=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),u=DC.NAME;let v=null,D=null,I=null,R="left",N=u;z?.addEventListener("change",()=>{z.checked?(P.style.display="none",H.style.display="",v=null,D=null):(P.style.display="flex",H.style.display="none",I=null)});function O(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}A?.addEventListener("change",t=>{O(t.target.files[0],e=>{v=e,C.src=e,k.textContent="New dark logo ready to save"})}),x?.addEventListener("change",t=>{O(t.target.files[0],e=>{D=e,j.src=e,k.textContent="New light logo ready to save"})}),B?.addEventListener("change",t=>{O(t.target.files[0],e=>{I=e,C.src=e,j.src=e,k.textContent="New logo ready to save (both themes)"})});function m(t){L.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function y(t){N=t||u,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function S(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,C.src=e.customLogoDark),e.customLogoLight&&(M.src=e.customLogoLight,j.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,M.src=e.customLogo,C.src=e.customLogo,j.src=e.customLogo),e.isDefault||(k.textContent="Using custom logo"),e.position&&(R=e.position,m(e.position)),e.dashboardTitle&&y(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{R=t.dataset.pos,m(R)})}),document.getElementById("brand")?.addEventListener("click",()=>{v=null,D=null,I=null,A&&(A.value=""),x&&(x.value=""),B&&(B.value=""),z&&(z.checked=!1),P.style.display="flex",H.style.display="none",C.src=w.src,j.src=M.src;const t=w.src.includes("custom-logo")||M.src.includes("custom-logo");k.textContent=t?"Using custom logo":"Using default logos",m(R),$.value=N,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||u,e={position:R,dashboardTitle:t};z?.checked&&I?(e.dataDark=I,e.dataLight=I):(v&&(e.dataDark=v),D&&(e.dataLight=D));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(w.src=o.pathDark+i,C.src=o.pathDark+i),o.pathLight&&(M.src=o.pathLight+i,j.src=o.pathLight+i),m(R),y(t),b.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(h.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",E=null,O=null,L=null,$.value=f,v(f),j="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(k,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),x=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function m(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(p.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),x.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const a=new FileReader;a.onload=o=>{l=o.target.result,g.src=l,x.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();p.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),x.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),m(),w();const c=document.getElementById("settings-timezone");c&&(new MutationObserver(()=>{k.classList.contains("show")&&c.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(c,e)})()}).observe(k,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=c.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(k,B){const A=Intl.supportedValuesOf("timeZone"),I=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";k.innerHTML="";for(const z of A){const P=document.createElement("option");P.value=z,P.textContent=z.replace(/_/g," "),z===I&&(P.selected=!0),k.appendChild(P)}},(function(){let k="homelab",B=null;async function A(){try{const v=await fetch("/api/v1/config");if(v.ok&&(B=await v.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(v){console.warn("Could not fetch server config, checking localStorage fallback:",v.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function z(u){document.querySelectorAll(".setup-step").forEach(w=>{w.style.display="none"});const v=document.getElementById(u);v&&(v.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let v='<div style="display: grid; gap: 20px;">';if(k==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",x=document.getElementById("setup-ca-name")?.value?.trim()||"",s=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;v+=` This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",C.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",k.textContent="Using default logos",v=null,D=null,I=null,$.value=u,y(u),R="left",m("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const f=document.getElementById("favicon-preview"),E=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function g(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(p.href=e.customFavicon+"?t="+Date.now(),f.src=e.customFavicon+"?t="+Date.now(),E.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const a=new FileReader;a.onload=o=>{l=o.target.result,f.src=l,E.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();p.href=e.path+"?t="+Date.now(),f.src=e.path+"?t="+Date.now(),E.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),g(),S();const c=document.getElementById("settings-timezone");c&&(new MutationObserver(()=>{b.classList.contains("show")&&c.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(c,e)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=c.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(b,C){const j=Intl.supportedValuesOf("timeZone"),k=C||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const z of j){const P=document.createElement("option");P.value=z,P.textContent=z.replace(/_/g," "),z===k&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",C=null;async function j(){try{const y=await fetch("/api/v1/config");if(y.ok&&(C=await y.json(),C&&C.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(y){console.warn("Could not fetch server config, checking localStorage fallback:",y.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}j();const k=document.getElementById("setup-timezone");k&&window.populateTimezoneSelect(k);function z(m){document.querySelectorAll(".setup-step").forEach(S=>{S.style.display="none"});const y=document.getElementById(m);y&&(y.style.display="block")}function P(){const m=document.getElementById("setup-summary-content");if(!m)return;let y='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const f=document.getElementById("setup-tld")?.value?.trim()||".home",E=document.getElementById("setup-ca-name")?.value?.trim()||"",s=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;y+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${g}</div> <div><strong>TLD:</strong> ${f}</div>
<div><strong>Certificate Authority:</strong> ${x}</div> <div><strong>Certificate Authority:</strong> ${E}</div>
<div><strong>DNS Server:</strong> ${s}:${p}</div> <div><strong>DNS Server:</strong> ${s}:${p}</div>
<div><strong>Example URLs:</strong> https://uptime${g}, https://nextcloud${g}</div> <div><strong>Example URLs:</strong> https://uptime${f}, https://nextcloud${f}</div>
</div> </div>
</div> </div>
`}else if(k==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";v+=` `}else if(b==="simple"){const f=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";y+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Access Method:</strong> IP:Port only</div> <div><strong>Access Method:</strong> IP:Port only</div>
<div><strong>Default IP:</strong> ${g}</div> <div><strong>Default IP:</strong> ${f}</div>
<div><strong>SSL:</strong> None (HTTP only)</div> <div><strong>SSL:</strong> None (HTTP only)</div>
<div><strong>Example URLs:</strong> http://${g}:8080, http://${g}:3000</div> <div><strong>Example URLs:</strong> http://${f}:8080, http://${f}:3000</div>
</div> </div>
</div> </div>
`}else if(k==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",x=document.getElementById("setup-public-email")?.value?.trim()||"",s=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=s==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;v+=` `}else if(b==="public"){const f=document.getElementById("setup-public-domain")?.value?.trim()||"",E=document.getElementById("setup-public-email")?.value?.trim()||"",s=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=s==="subdirectory"?`https://${f}/sonarr, https://${f}/grafana`:`https://sonarr.${f}, https://grafana.${f}`;y+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${g}</div> <div><strong>Domain:</strong> ${f}</div>
<div><strong>SSL:</strong> Let's Encrypt</div> <div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${x}</div> <div><strong>Email:</strong> ${E}</div>
<div><strong>Routing:</strong> ${s==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div> <div><strong>Routing:</strong> ${s==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${p}</div> <div><strong>Example URLs:</strong> ${p}</div>
</div> </div>
</div> </div>
`}const w=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";v+=` `}const S=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";y+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);"> <div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${w.replace(/_/g," ")}</div> <div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${S.replace(/_/g," ")}</div>
</div> </div>
`,v+="</div>",u.innerHTML=v,z("setup-step-summary")}async function M(u){try{const v=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return v.ok?(await v.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${v.status}`),{function:"saveConfigToServer"}),!1)}catch(v){return errorHandler.logError("[SetupWizard] Save Config",v,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:k,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(k==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const x=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:x,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else k==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):k==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const v=await M(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const w=k==="homelab"?"Professional Home Lab":k==="simple"?"Simple Setup":"Public Server",g=v?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${w}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const b=document.getElementById("setup-step-1-next");b&&(b.onclick=function(u){u.preventDefault();const v=document.querySelector('input[name="config-type"]:checked');v&&(k=v.value),z(k==="homelab"?"setup-step-homelab":k==="simple"?"setup-step-simple":k==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await M({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const h=document.getElementById("setup-tld");h&&(h.oninput=function(u){const v=u.target.value||".home",w=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");w&&(w.textContent=v),g&&(g.textContent=v)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(u){u.preventDefault(),z("setup-step-1")});const S=document.getElementById("setup-homelab-next");S&&(S.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-tld")?.value?.trim()||"",w=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!v||!v.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!w){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(u){u.preventDefault(),z("setup-step-1")});const f=document.getElementById("setup-simple-next");f&&(f.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var v=document.getElementById("dns-requirement-note");v&&(v.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const E=document.getElementById("setup-public-back");E&&(E.onclick=function(u){u.preventDefault(),z("setup-step-1")});const O=document.getElementById("setup-public-next");O&&(O.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-public-domain")?.value?.trim()||"",w=document.getElementById("setup-public-email")?.value?.trim()||"";if(!v){showNotification("Please enter your domain name","warning");return}if(!w||!w.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const L=document.getElementById("setup-summary-back");L&&(L.onclick=function(u){u.preventDefault(),k==="homelab"?z("setup-step-homela Line truncated `,y+="</div>",m.innerHTML=y,z("setup-step-summary")}async function H(m){try{const y=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});return y.ok?(await y.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${y.status}`),{function:"saveConfigToServer"}),!1)}catch(y){return errorHandler.logError("[SetupWizard] Save Config",y,{function:"saveConfigToServer"}),!1}}async function A(){const m={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(b==="homelab"){m.tld=document.getElementById("setup-tld")?.value?.trim()||".home",m.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const E=document.getElementById("setup-dns-provider")?.value||"technitium";m.dns={provider:E,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},m.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else b==="simple"?(m.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",m.defaults={dnsType:"none",sslType:"none",targetIP:m.defaultIP}):b==="public"&&(m.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",m.email=document.getElementById("setup-public-email")?.value?.trim()||"",m.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m.defaults={dnsType:m.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const y=await H(m);safeSet("dashcaddy-config",JSON.stringify(m)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const S=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",f=y?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${S}. Settings saved to: ${f}`,"success",5e3),setTimeout(()=>location.reload(),500)}const x=document.getElementById("setup-step-1-next");x&&(x.onclick=function(m){m.preventDefault();const y=document.querySelector('input[name="config-type"]:checked');y&&(b=y.value),z(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const B=document.getElementById("setup-skip");B&&(B.onclick=async function(m){m.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await H({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(m){const y=m.target.value||".home",S=document.getElementById("tld-preview"),f=document.getElementById("tld-preview-2");S&&(S.textContent=y),f&&(f.textContent=y)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(m){m.preventDefault(),z("setup-step-1")});const L=document.getElementById("setup-homelab-next");L&&(L.onclick=function(m){m.preventDefault();const y=document.getElementById("setup-tld")?.value?.trim()||"",S=document.getElementById("setup-ca-name")?.value?.trim()||"",f=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!y||!y.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!S){showNotification("Please enter a Certificate Authority name","warning");return}if(!f){showNotification("Please enter your DNS server IP address","warning");return}P()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(m){m.preventDefault(),z("setup-step-1")});const u=document.getElementById("setup-simple-next");u&&(u.onclick=function(m){m.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(m){m.onchange=function(){var y=document.getElementById("dns-requirement-note");y&&(y.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const v=document.getElementById("setup-public-back");v&&(v.onclick=function(m){m.preventDefault(),z("setup-step-1")});const D=document.getElementById("setup-public-next");D&&(D.onclick=function(m){m.preventDefault();const y=document.getElementById("setup-public-domain")?.value?.trim()||"",S=document.getElementById("setup-public-email")?.value?.trim()||"";if(!y){showNotification("Please enter your domain name","warning");return}if(!S||!S.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const I=document.getElementById("setup-summary-back");I&&(I.onclick=function(m){m.preventDefault(),b==="homelab"?z("setup-step-homela Line truncated
<div class="app-selector-content"> <div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2> <h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div> <div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button> </button>
</div> </div>
</div> </div>
</div>`);const B="custom-apps";let A=null,I=null;const z=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function M(){try{const p=await(await fetch("/api/v1/apps/templates")).json();if(p.success)return A=p.templates,I=p.categories,!0}catch(s){k.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function D(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(p){return k.logError("[AppSelector] Check Port",p,{function:"checkPortAvailability"}),{available:!0}}}async function b(s){try{const l=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(l.success)return l.suggestedPort}catch(p){k.logError("[AppSelector] Get Suggested Port",p,{function:"getSuggestedPort"})}return s}async function T(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await M()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const s={};for(const[l,m]of Object.entries(A)){const c=m.category||"Other";s[c]||(s[c]=[]),s[c].push({id:l,...m})}const p=I?Object.keys(I):Object.keys(s).sort();for(const l of p){const m=s[l];if(!m||m.length===0)continue;m.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const c=document.createElement("div");c.className="app-category-header";const t=I?.[l]||{};c.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(l)}`,t.color&&(c.style.borderBottomColor=t.color),P.appendChild(c),m.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",r=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=` </div>`);const C="custom-apps";let j=null,k=null;const z=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function H(){try{const p=await(await fetch("/api/v1/apps/templates")).json();if(p.success)return j=p.templates,k=p.categories,!0}catch(s){b.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function A(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(p){return b.logError("[AppSelector] Check Port",p,{function:"checkPortAvailability"}),{available:!0}}}async function x(s){try{const l=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(l.success)return l.suggestedPort}catch(p){b.logError("[AppSelector] Get Suggested Port",p,{function:"getSuggestedPort"})}return s}async function B(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!await H()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const s={};for(const[l,g]of Object.entries(j)){const c=g.category||"Other";s[c]||(s[c]=[]),s[c].push({id:l,...g})}const p=k?Object.keys(k):Object.keys(s).sort();for(const l of p){const g=s[l];if(!g||g.length===0)continue;g.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const c=document.createElement("div");c.className="app-category-header";const t=k?.[l]||{};c.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(l)}`,t.color&&(c.style.borderBottomColor=t.color),P.appendChild(c),g.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",r=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div> <div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div> <div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div> <div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${n}${r} ${n}${r}
`,o?a.onclick=()=>h(e,a):a.onclick=()=>H(e),P.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function h(s,p){const l="widget-"+s.id+"-enabled",c=!(safeGet(l)!=="false");safeSet(l,String(c));const t=s.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=c?"":"none")}const e=p.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=c?"ON":"OFF",e.style.background=c?"#2ecc7130":"#e74c3c30",e.style.color=c?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${c?"enabled":"disabled"}`,"success",2e3)}async function H(s){const p=document.getElementById("app-deploy-modal"),l=document.getElementById("app-deploy-title"),m=document.getElementById("deploy-subdomain"),c=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${s.name} container: `,o?a.onclick=()=>w(e,a):a.onclick=()=>M(e),P.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function w(s,p){const l="widget-"+s.id+"-enabled",c=!(safeGet(l)!=="false");safeSet(l,String(c));const t=s.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=c?"":"none")}const e=p.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=c?"ON":"OFF",e.style.background=c?"#2ecc7130":"#e74c3c30",e.style.color=c?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${c?"enabled":"disabled"}`,"success",2e3)}async function M(s){const p=document.getElementById("app-deploy-modal"),l=document.getElementById("app-deploy-title"),g=document.getElementById("deploy-subdomain"),c=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${s.name} container:
Container: ${V.name} Container: ${V.name}
Status: ${V.status} Status: ${V.status}
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
Would you like to use this existing container? Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container. Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}l.textContent=`Deploy ${s.name}`;const i=s.subdomain||s.id.replace(/-/g,"");m.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const W=s.subpathSupport||"strip";W==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">&#9432; '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const r=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),d=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),y=document.querySelector(`input[name="dns-type"][value="${r}"]`),C=document.querySelector(`input[name="ssl-type"][value="${d}"]`);y?y.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,C?C.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),F=q?.querySelector("div");if(q&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const _=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){_.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else _.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const W=s.mediaMount?.containerPath,G=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=` Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}l.textContent=`Deploy ${s.name}`;const i=s.subdomain||s.id.replace(/-/g,"");g.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const W=s.subpathSupport||"strip";W==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">&#9432; '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const r=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),d=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),h=document.querySelector(`input[name="dns-type"][value="${r}"]`),T=document.querySelector(`input[name="ssl-type"][value="${d}"]`);h?h.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,T?T.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),F=q?.querySelector("div");if(q&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const _=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){_.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else _.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const W=s.mediaMount?.containerPath,G=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}" <input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" /> style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span> <span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button> <button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await D(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await b(se);Y.innerHTML=` `,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await A(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await x(se);Y.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span> <span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span>
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?o.innerHTML=` `;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?o.innerHTML=`
<span style="color: #4caf50;">Connected</span> <span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span> <span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span> <span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=m.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}c.textContent=K}m.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),z.classList.remove("show"),p.classList.add("show"),p.dataset.appTemplate=JSON.stringify(s)}async function S(s){const p=s.appTemplate,l=safeGetJSON(B,[]),m=p._useExisting&&p._existingContainer,c=l.find(t=>t.id===s.subdomain);if(!(c&&!m&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(c){const t=l.indexOf(c);l.splice(t,1),safeSet(B,JSON.stringify(l))}if(m)s.port=p._existingContainer.primaryPort;else{const t=s.port||p.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await b(p.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}. `:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=g.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}c.textContent=K}g.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),z.classList.remove("show"),p.classList.add("show"),p.dataset.appTemplate=JSON.stringify(s)}async function L(s){const p=s.appTemplate,l=safeGetJSON(C,[]),g=p._useExisting&&p._existingContainer,c=l.find(t=>t.id===s.subdomain);if(!(c&&!g&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(c){const t=l.indexOf(c);l.splice(t,1),safeSet(C,JSON.stringify(l))}if(g)s.port=p._existingContainer.primaryPort;else{const t=s.port||p.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await A(t);if(!e.available){const a=await x(p.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
Would you like to use port ${a} instead?`))s.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(m?`Configuring ${p.name} with existing container...`:`Deploying ${p.name}...`,"info",0);try{const t={appId:p.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||p.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};m&&(t.config.useExisting=!0,t.config.existingContainerId=p._existingContainer.id,t.config.existingPort=p._existingContainer.primaryPort,!s.port&&p._existingContainer.primaryPort&&(t.config.port=p._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:s.subdomain,name:p.name,logo:`/assets/${p.id}.png`,containerId:a.containerId,url:a.url,ip:s.ip,appTemplate:p.id,tailscaleOnly:s.tailscaleOnly||!1};l.push(o),safeSet(B,JSON.stringify(l)),window.APPS&&!window.APPS.some(n=>n.id===p.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${p.name} configured with existing container! Would you like to use port ${a} instead?`))s.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(g?`Configuring ${p.name} with existing container...`:`Deploying ${p.name}...`,"info",0);try{const t={appId:p.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||p.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};g&&(t.config.useExisting=!0,t.config.existingContainerId=p._existingContainer.id,t.config.existingPort=p._existingContainer.primaryPort,!s.port&&p._existingContainer.primaryPort&&(t.config.port=p._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:s.subdomain,name:p.name,logo:`/assets/${p.id}.png`,containerId:a.containerId,url:a.url,ip:s.ip,appTemplate:p.id,tailscaleOnly:s.tailscaleOnly||!1};l.push(o),safeSet(C,JSON.stringify(l)),window.APPS&&!window.APPS.some(n=>n.id===p.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${p.name} configured with existing container!
URL: ${a.url}`:`${p.name} deployed successfully! URL: ${a.url}`:`${p.name} deployed successfully!
URL: ${a.url}`;a.warning&&(i+=` URL: ${a.url}`;a.warning&&(i+=`
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete p._useExisting,delete p._existingContainer,a.url&&a.url.startsWith("https://")&&$(a.url,p.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(` \u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete p._useExisting,delete p._existingContainer,a.url&&a.url.startsWith("https://")&&$(a.url,p.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${p.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){k.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${p.name}: ${t.message}`,"error",8e3)}}}async function $(s,p){showNotification(`\u23F3 Generating SSL certificate for ${p}...`,"warning",6e4);let l=0;const m=12,c=async()=>{l++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${p} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<m?setTimeout(c,5e3):showNotification(`\u26A0\uFE0F ${p} deployed but SSL certificate may still be generating. `);showNotification(`Setup Instructions for ${p.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){b.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${p.name}: ${t.message}`,"error",8e3)}}}async function $(s,p){showNotification(`\u23F3 Generating SSL certificate for ${p}...`,"warning",6e4);let l=0;const g=12,c=async()=>{l++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${p} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<g?setTimeout(c,5e3):showNotification(`\u26A0\uFE0F ${p} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(c,3e3)}function f(){safeGetJSON(B,[]).forEach(p=>{window.APPS.some(l=>l.id===p.id)||window.APPS.push(p)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),z.classList.add("show")}),wireModal(z,document.getElementById("app-selector-cancel"));const E=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{E.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(E.dataset.appTemplate),p=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(c=>{l.push({hostPath:c.value.trim(),containerPath:c.dataset.containerPath})});const m={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:p||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!m.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!p){showNotification("Please enter a media library path for this application","warning");return}E.classList.remove("show"),S(m)}),wireModal(E);const O=document.getElementById("folder-browser-modal"),L=document.getElementById("folder-browser-path"),j=document.getElementById("folder-browser-list"),N=document.getElementById("folder-browser-selected"),R=document.getElementById("folder-browser-selected-list");let u="",v=[],w=null;window.openFolderBrowser=function(s){w=s,v=s.value.split(",").map(p=>p.trim()).filter(p=>p),u="",x(),g(""),O.classList.add("show")};async function g(s){L.textContent=s||"Select a drive...",j.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!l.success){j.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}u=l.path||"",L.textContent=u||"Select a drive...";let m="";l.parent&&l.parent!==l.path&&(m+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;"> Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(c,3e3)}function u(){safeGetJSON(C,[]).forEach(p=>{window.APPS.some(l=>l.id===p.id)||window.APPS.push(p)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{B(),z.classList.add("show")}),wireModal(z,document.getElementById("app-selector-cancel"));const v=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{v.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(v.dataset.appTemplate),p=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(c=>{l.push({hostPath:c.value.trim(),containerPath:c.dataset.containerPath})});const g={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:p||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!g.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!p){showNotification("Please enter a media library path for this application","warning");return}v.classList.remove("show"),L(g)}),wireModal(v);const D=document.getElementById("folder-browser-modal"),I=document.getElementById("folder-browser-path"),R=document.getElementById("folder-browser-list"),N=document.getElementById("folder-browser-selected"),O=document.getElementById("folder-browser-selected-list");let m="",y=[],S=null;window.openFolderBrowser=function(s){S=s,y=s.value.split(",").map(p=>p.trim()).filter(p=>p),m="",E(),f(""),D.classList.add("show")};async function f(s){I.textContent=s||"Select a drive...",R.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!l.success){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}m=l.path||"",I.textContent=m||"Select a drive...";let g="";l.parent&&l.parent!==l.path&&(g+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span> <span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span> <span style="color: var(--muted);">.. Parent Directory</span>
</div>`),l.items.length===0&&!l.parent?m+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?m+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(c=>{const t=c.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=v.includes(c.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";m+=`<div class="folder-item" data-path="${escapeHtml(c.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}"> </div>`),l.items.length===0&&!l.parent?g+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?g+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(c=>{const t=c.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=y.includes(c.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";g+=`<div class="folder-item" data-path="${escapeHtml(c.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
<span style="font-size: 1.2rem;">${t}</span> <span style="font-size: 1.2rem;">${t}</span>
<span style="flex: 1;">${escapeHtml(c.name)}</span> <span style="flex: 1;">${escapeHtml(c.name)}</span>
${e?'<span style="color: var(--success);">\u2713</span>':""} ${e?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),j.innerHTML=m,j.querySelectorAll(".folder-item").forEach(c=>{c.addEventListener("click",()=>{g(c.dataset.path)}),c.addEventListener("mouseenter",()=>{c.style.background="var(--card-bg)"}),c.addEventListener("mouseleave",()=>{const t=v.includes(c.dataset.path);c.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(p){j.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(p.message)}</div>`}}function x(){if(v.length===0){N.style.display="none";return}N.style.display="block",R.innerHTML=v.map(s=>` </div>`}),R.innerHTML=g,R.querySelectorAll(".folder-item").forEach(c=>{c.addEventListener("click",()=>{f(c.dataset.path)}),c.addEventListener("mouseenter",()=>{c.style.background="var(--card-bg)"}),c.addEventListener("mouseleave",()=>{const t=y.includes(c.dataset.path);c.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(p){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(p.message)}</div>`}}function E(){if(y.length===0){N.style.display="none";return}N.style.display="block",O.innerHTML=y.map(s=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;"> <span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
${escapeHtml(s)} ${escapeHtml(s)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button> <button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span> </span>
`).join("")}window.removeSelectedFolder=function(s){v=v.filter(p=>p!==s),x(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!v.includes(u)&&(v.push(u),x(),g(u))}),wireModal(O,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{w&&(w.value=v.join(", ")),O.classList.remove("show")}),f()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal"> `).join("")}window.removeSelectedFolder=function(s){y=y.filter(p=>p!==s),E(),f(m)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{m&&!y.includes(m)&&(y.push(m),E(),f(m))}),wireModal(D,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{S&&(S.value=y.join(", ")),D.classList.remove("show")}),u()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;"> <div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3> <h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="recipe-next" class="btn-accent-solid">Next</button> <button id="recipe-next" class="btn-accent-solid">Next</button>
</div> </div>
</div> </div>
</div>`);let k=null,B=null,A=null,I=1,z=!1;const P=document.getElementById("recipe-deploy-modal"),M=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),b=document.getElementById("recipe-next");wireModal(P,M);async function T(){try{const u=await fetch("/api/v1/recipes/templates"),v=await u.json();if(v.success)return k=v.templates,B=v.categories,!0;if(u.status===403)return z=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function h(){try{z=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{z=!1}return z}window.renderRecipeCards=async function(u){await h();let v;if(z&&k?v=k:v=H(),!v||v.length===0)return;const w=document.createElement("div");w.className="app-category-header",w.innerHTML="\u{1F9EA} Recipes",w.style.borderBottomColor="#8e44ad",u.appendChild(w);const g=Array.isArray(v)?v:Object.values(v);g.sort((x,s)=>(s.popularity||0)-(x.popularity||0));for(const x of g){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const p=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${x.componentCount||x.components?.length||"?"} apps</div>`,l=z?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=` </div>`);let b=null,C=null,j=null,k=1,z=!1;const P=document.getElementById("recipe-deploy-modal"),H=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),x=document.getElementById("recipe-next");wireModal(P,H);async function B(){try{const m=await fetch("/api/v1/recipes/templates"),y=await m.json();if(y.success)return b=y.templates,C=y.categories,!0;if(m.status===403)return z=!1,!1}catch(m){console.warn("Failed to fetch recipe templates:",m.message)}return!1}async function w(){try{z=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{z=!1}return z}window.renderRecipeCards=async function(m){await w();let y;if(z&&b?y=b:y=M(),!y||y.length===0)return;const S=document.createElement("div");S.className="app-category-header",S.innerHTML="\u{1F9EA} Recipes",S.style.borderBottomColor="#8e44ad",m.appendChild(S);const f=Array.isArray(y)?y:Object.values(y);f.sort((E,s)=>(s.popularity||0)-(E.popularity||0));for(const E of f){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const p=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${E.componentCount||E.components?.length||"?"} apps</div>`,l=z?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=`
${l} ${l}
<div class="app-option-icon">${escapeHtml(x.icon||"\u{1F9EA}")}</div> <div class="app-option-icon">${escapeHtml(E.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(x.name)}</div> <div class="app-option-name">${escapeHtml(E.name)}</div>
<div class="app-option-desc">${escapeHtml(x.description||"")}</div> <div class="app-option-desc">${escapeHtml(E.description||"")}</div>
${p} ${p}
`,s.onclick=()=>{if(!z){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}S(x)},u.appendChild(s)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function S(u){A=u,I=1;const v=document.getElementById("app-selector-modal");v&&v.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,$(),f(),P.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const v=parseInt(u.dataset.step);u.classList.toggle("active",v===I),u.classList.toggle("completed",v<I)});for(let u=1;u<=4;u++){const v=document.getElementById(`recipe-step-${u}`);v&&(v.style.display=u===I?"":"none")}D.style.display=I>1&&I<4?"":"none",I===4?(b.style.display="none",M.textContent="Close"):I===3?(b.textContent="\u{1F680} Deploy",b.style.display="",M.textContent="Cancel"):(b.textContent="Next",b.style.display="",M.textContent="Cancel")}function f(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const v=A.components||[];for(const w of v){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const x=w.required,s=w.internal;g.innerHTML=` `,s.onclick=()=>{if(!z){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}L(E)},m.appendChild(s)}};function M(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function L(m){j=m,k=1;const y=document.getElementById("app-selector-modal");y&&y.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${m.name}`,$(),u(),P.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(m=>{const y=parseInt(m.dataset.step);m.classList.toggle("active",y===k),m.classList.toggle("completed",y<k)});for(let m=1;m<=4;m++){const y=document.getElementById(`recipe-step-${m}`);y&&(y.style.display=m===k?"":"none")}A.style.display=k>1&&k<4?"":"none",k===4?(x.style.display="none",H.textContent="Close"):k===3?(x.textContent="\u{1F680} Deploy",x.style.display="",H.textContent="Cancel"):(x.textContent="Next",x.style.display="",H.textContent="Cancel")}function u(){const m=document.getElementById("recipe-component-list");m.innerHTML="";const y=j.components||[];for(const S of y){const f=document.createElement("div");f.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const E=S.required,s=S.internal;f.innerHTML=`
<input type="checkbox" ${x?"checked disabled":"checked"} data-component-id="${w.id}" <input type="checkbox" ${E?"checked disabled":"checked"} data-component-id="${S.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" /> style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(w.role||w.id)}</div> <div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(S.role||S.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);"> <div style="font-size: 0.78rem; color: var(--muted);">
${w.templateRef?escapeHtml(w.templateRef):"Built-in"} ${S.templateRef?escapeHtml(S.templateRef):"Built-in"}
${x?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'} ${E?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""} ${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</div> </div>
${w.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(w.note)}</div>`:""} ${S.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(S.note)}</div>`:""}
</div> </div>
`,u.appendChild(g)}}function E(){const u=document.getElementById("recipe-volumes-section"),v=document.getElementById("recipe-volume-list"),w=A.sharedVolumes;if(w&&Object.keys(w).length>0){u.style.display="",v.innerHTML="";for(const[g,x]of Object.entries(w)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=` `,m.appendChild(f)}}function v(){const m=document.getElementById("recipe-volumes-section"),y=document.getElementById("recipe-volume-list"),S=j.sharedVolumes;if(S&&Object.keys(S).length>0){m.style.display="",y.innerHTML="";for(const[f,E]of Object.entries(S)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(x.label||g)}</label> <label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(E.label||f)}</label>
<input type="text" data-volume-key="${g}" value="${escapeHtml(x.defaultPath||"")}" <input type="text" data-volume-key="${f}" value="${escapeHtml(E.defaultPath||"")}"
placeholder="${escapeHtml(x.defaultPath||"/path")}" placeholder="${escapeHtml(E.defaultPath||"/path")}"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" /> style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
<div class="form-hint-sm">${escapeHtml(x.description||"")}</div> <div class="form-hint-sm">${escapeHtml(E.description||"")}</div>
`,v.appendChild(s)}}else u.style.display="none"}function O(){const u=document.getElementById("recipe-review-content"),v=L(),w=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),g={};w.forEach(l=>{g[l.dataset.volumeKey]=l.value});const x=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",p=document.getElementById("recipe-tailscale").checked;u.innerHTML=` `,y.appendChild(s)}}else m.style.display="none"}function D(){const m=document.getElementById("recipe-review-content"),y=I(),S=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),f={};S.forEach(l=>{f[l.dataset.volumeKey]=l.value});const E=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",p=document.getElementById("recipe-tailscale").checked;m.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(A.name)}</div> <div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(j.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(A.description||"")}</div> <div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(j.description||"")}</div>
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<strong>Components (${v.length}):</strong> <strong>Components (${y.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;"> <div style="display: grid; gap: 4px; margin-top: 6px;">
${v.map(l=>`<div style="padding: 4px 0; font-size: 0.85rem;"> ${y.map(l=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(l.role||l.id)}</strong> ${l.internal?'<span style="color:var(--muted)">(internal)</span>':""} \u2022 <strong>${escapeHtml(l.role||l.id)}</strong> ${l.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")} </div>`).join("")}
</div> </div>
</div> </div>
${Object.keys(g).length>0?`<div style="margin-bottom: 12px;"> ${Object.keys(f).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong> <strong>Volumes:</strong>
${Object.entries(g).map(([l,m])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${l}: ${escapeHtml(m)}</div>`).join("")} ${Object.entries(f).map(([l,g])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${l}: ${escapeHtml(g)}</div>`).join("")}
</div>`:""} </div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);"> <div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(x)} &bull; IP: ${escapeHtml(s)} ${p?"&bull; Tailscale only":""} Timezone: ${escapeHtml(E)} &bull; IP: ${escapeHtml(s)} ${p?"&bull; Tailscale only":""}
</div> </div>
${A.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(A.network.name)}</code></div>`:""} ${j.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(j.network.name)}</code></div>`:""}
`}function L(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),v=new Set;u.forEach(g=>{g.checked&&v.add(g.dataset.componentId)});const w=A.components||[];return w.filter(g=>g.required).forEach(g=>v.add(g.id)),w.filter(g=>v.has(g.id))}async function j(){const u=document.getElementById("recipe-progress-list"),v=document.getElementById("recipe-deploy-result");v.style.display="none",u.innerHTML="";const w=L();for(const p of w){const l=document.createElement("div");l.id=`recipe-progress-${p.id}`,l.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",l.innerHTML=` `}function I(){const m=document.querySelectorAll("#recipe-component-list input[data-component-id]"),y=new Set;m.forEach(f=>{f.checked&&y.add(f.dataset.componentId)});const S=j.components||[];return S.filter(f=>f.required).forEach(f=>y.add(f.id)),S.filter(f=>y.has(f.id))}async function R(){const m=document.getElementById("recipe-progress-list"),y=document.getElementById("recipe-deploy-result");y.style.display="none",m.innerHTML="";const S=I();for(const p of S){const l=document.createElement("div");l.id=`recipe-progress-${p.id}`,l.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",l.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span> <span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(p.role||p.id)}</span> <span style="flex:1; font-weight: 500;">${escapeHtml(p.role||p.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span> <span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,u.appendChild(l)}const g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),x={};g.forEach(p=>{x[p.dataset.volumeKey]=p.value});const s={selectedComponents:w.map(p=>p.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:x},componentOverrides:{}};for(const p of w)N(p.id,"deploying","Deploying...");try{const l=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:s})})).json();if(l.success){for(const m of l.deployed||[])N(m.id,"success",m.url?`Running \u2192 ${m.url}`:"Running");for(const m of l.errors||[])N(m.componentId,"error",m.error);v.style.display="",v.innerHTML=` `,m.appendChild(l)}const f=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),E={};f.forEach(p=>{E[p.dataset.volumeKey]=p.value});const s={selectedComponents:S.map(p=>p.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:E},componentOverrides:{}};for(const p of S)N(p.id,"deploying","Deploying...");try{const l=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:j.id,config:s})})).json();if(l.success){for(const g of l.deployed||[])N(g.id,"success",g.url?`Running \u2192 ${g.url}`:"Running");for(const g of l.errors||[])N(g.componentId,"error",g.error);y.style.display="",y.innerHTML=`
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);"> <div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(l.message||"Deployed!")}</div> <div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(l.message||"Deployed!")}</div>
${l.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;"> ${l.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong> <strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${l.setupInstructions.map(m=>`<li>${escapeHtml(m)}</li>`).join("")}</ul> <ul style="margin: 4px 0 0 16px; padding: 0;">${l.setupInstructions.map(g=>`<li>${escapeHtml(g)}</li>`).join("")}</ul>
</div>`:""} </div>`:""}
</div> </div>
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else v.style.display="",v.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);"> `,showNotification(`${j.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else y.style.display="",y.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(l.error||"Unknown error")} <strong>Deployment failed:</strong> ${escapeHtml(l.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(p){v.style.display="",v.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);"> </div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(p){y.style.display="",y.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(p.message)} <strong>Network error:</strong> ${escapeHtml(p.message)}
</div>`}}function N(u,v,w){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const x=g.querySelector(".recipe-progress-icon"),s=g.querySelector(".recipe-progress-status");v==="deploying"?(x.textContent="\u23F3",s.style.color="var(--accent)"):v==="success"?(x.textContent="\u2705",s.style.color="var(--ok-fg)"):v==="error"&&(x.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=w}b.addEventListener("click",()=>{if(I===3){I=4,$(),j();return}I<3&&(I++,$(),I===2&&E(),I===3&&O())}),D.addEventListener("click",()=>{I>1&&I<4&&(I--,$())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const v={};u.forEach(w=>{const g=w.dataset.recipeId;v[g]||(v[g]=[]),v[g].push(w)});for(const[w,g]of Object.entries(v))g.length<2||g.forEach((x,s)=>{if(x.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let p=x.querySelector(".recipe-group-label");p||(p=document.createElement("div"),p.className="recipe-group-label",p.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",p.textContent=w.replace(/-/g," "),x.style.position="relative",x.appendChild(p))}})},window.manageRecipe=async function(u,v){const w=`/api/v1/recipes/${u}/${v}`,g=v==="remove"?"DELETE":"POST",x=v==="remove"?`/api/v1/recipes/${u}`:w;if(!(v==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const p=await(await secureFetch(x,{method:g})).json();p.success?(showNotification(`Recipe ${v}: ${p.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${v} failed: ${p.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const R=document.createElement("style");R.textContent=` </div>`}}function N(m,y,S){const f=document.getElementById(`recipe-progress-${m}`);if(!f)return;const E=f.querySelector(".recipe-progress-icon"),s=f.querySelector(".recipe-progress-status");y==="deploying"?(E.textContent="\u23F3",s.style.color="var(--accent)"):y==="success"?(E.textContent="\u2705",s.style.color="var(--ok-fg)"):y==="error"&&(E.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=S}x.addEventListener("click",()=>{if(k===3){k=4,$(),R();return}k<3&&(k++,$(),k===2&&v(),k===3&&D())}),A.addEventListener("click",()=>{k>1&&k<4&&(k--,$())}),window.groupRecipeCards=function(){const m=document.querySelectorAll(".service-card[data-recipe-id]");if(m.length===0)return;const y={};m.forEach(S=>{const f=S.dataset.recipeId;y[f]||(y[f]=[]),y[f].push(S)});for(const[S,f]of Object.entries(y))f.length<2||f.forEach((E,s)=>{if(E.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let p=E.querySelector(".recipe-group-label");p||(p=document.createElement("div"),p.className="recipe-group-label",p.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",p.textContent=S.replace(/-/g," "),E.style.position="relative",E.appendChild(p))}})},window.manageRecipe=async function(m,y){const S=`/api/v1/recipes/${m}/${y}`,f=y==="remove"?"DELETE":"POST",E=y==="remove"?`/api/v1/recipes/${m}`:S;if(!(y==="remove"&&!confirm(`Remove the entire ${m} recipe? This will delete all containers and configuration.`)))try{const p=await(await secureFetch(E,{method:f})).json();p.success?(showNotification(`Recipe ${y}: ${p.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${y} failed: ${p.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const O=document.createElement("style");O.textContent=`
.recipe-step { .recipe-step {
flex: 1; flex: 1;
text-align: center; text-align: center;
@@ -550,7 +550,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel { .recipe-step-panel {
min-height: 180px; min-height: 180px;
} }
`,document.head.appendChild(R),h()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const k=document.getElementById("reload-caddy-top"),B=k.textContent;try{k.textContent="\u23F3 Reloading...",k.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await A.json();if(A.ok&&I.success)k.textContent="\u2705 Reloaded!",setTimeout(()=>{k.textContent=B,k.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(A){k.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{k.textContent=B,k.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal"> `,document.head.appendChild(O),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),C=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const j=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),k=await j.json();if(j.ok&&k.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=C,b.disabled=!1},2e3);else throw new Error(k.error||"Reload failed")}catch(j){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${j.message}`,"error"),setTimeout(()=>{b.textContent=C,b.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;"> <div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>\u{1F4CB} Error Logs</h3> <h3>\u{1F4CB} Error Logs</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -597,8 +597,8 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="error-log-close">Close</button> <button id="error-log-close">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("error-log-modal"),B=document.getElementById("view-error-logs"),A=document.getElementById("error-log-refresh"),I=document.getElementById("error-log-clear"),z=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),M=document.getElementById("error-log-context"),D=document.getElementById("error-log-search"),b=document.getElementById("error-log-since"),T=document.getElementById("error-log-until"),h=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),S=document.getElementById("error-log-total"),$=50;let f=0,E=null,O=0,L=[];function j(x){if(!x)return null;const s=new Date(x);return isNaN(s.getTime())?null:s.toISOString()}async function N(){try{const x=await fetch("/api/v1/error-logs/contexts");if(!x.ok)return;const s=await x.json();if(!s.success||!Array.isArray(s.contexts))return;L=s.contexts;const p=M.value;M.innerHTML='<option value="">All</option>';for(const l of s.contexts){const m=document.createElement("option");m.value=l.name,m.textContent=`${l.name} (${l.count})`,M.appendChild(m)}p&&s.contexts.some(l=>l.name===p)&&(M.value=p)}catch{}}function R(){const x=new URLSearchParams;x.set("limit",String($)),x.set("offset",String(f)),P.value&&x.set("level",P.value),M.value&&x.set("context",M.value);const s=j(b.value),p=j(T.value);s&&x.set("since",s),p&&x.set("until",p);const l=(D.value||"").trim();return l&&x.set("search",l),x}async function u(x){try{x?(E&&E.abort(),E=new AbortController):(E&&E.abort(),E=new AbortController,f=0,O++,h.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const s=O,p=R(),l=await fetch("/api/v1/error-logs?"+p.toString(),{signal:E.signal});if(!l.ok){h.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,H.style.display="none",S.textContent="";return}const m=await l.json();if(!m.success){h.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.error||"unknown")}</div>`,H.style.display="none",S.textContent="";return}if(!x&&s!==O)return;const c=Array.isArray(m.logs)?m.logs:[];if(c.length===0&&!x){const e=m.filters&&(m.filters.level||m.filters.context||m.filters.search||m.filters.since||m.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";h.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,H.style.display="none",S.textContent=m.total?`${m.total} total`:"";return}let t="";x||(t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',t+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',t+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',t+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',t+='<th style="padding: 6px; text-align: left;">Message</th>',t+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',t+="</tr>");for(const e of c){const a=(e.level||"?").toUpperCase(),o=a==="ERR"?"var(--bad-fg)":a==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",i=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",n=e.context||"\u2014",r=(e.error||"").split(` </div>`);const b=document.getElementById("error-log-modal"),C=document.getElementById("view-error-logs"),j=document.getElementById("error-log-refresh"),k=document.getElementById("error-log-clear"),z=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),H=document.getElementById("error-log-context"),A=document.getElementById("error-log-search"),x=document.getElementById("error-log-since"),B=document.getElementById("error-log-until"),w=document.getElementById("error-log-container"),M=document.getElementById("error-log-load-more"),L=document.getElementById("error-log-total"),$=50;let u=0,v=null,D=0,I=[];function R(E){if(!E)return null;const s=new Date(E);return isNaN(s.getTime())?null:s.toISOString()}async function N(){try{const E=await fetch("/api/v1/error-logs/contexts");if(!E.ok)return;const s=await E.json();if(!s.success||!Array.isArray(s.contexts))return;I=s.contexts;const p=H.value;H.innerHTML='<option value="">All</option>';for(const l of s.contexts){const g=document.createElement("option");g.value=l.name,g.textContent=`${l.name} (${l.count})`,H.appendChild(g)}p&&s.contexts.some(l=>l.name===p)&&(H.value=p)}catch{}}function O(){const E=new URLSearchParams;E.set("limit",String($)),E.set("offset",String(u)),P.value&&E.set("level",P.value),H.value&&E.set("context",H.value);const s=R(x.value),p=R(B.value);s&&E.set("since",s),p&&E.set("until",p);const l=(A.value||"").trim();return l&&E.set("search",l),E}async function m(E){try{E?(v&&v.abort(),v=new AbortController):(v&&v.abort(),v=new AbortController,u=0,D++,w.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const s=D,p=O(),l=await fetch("/api/v1/error-logs?"+p.toString(),{signal:v.signal});if(!l.ok){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,M.style.display="none",L.textContent="";return}const g=await l.json();if(!g.success){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.error||"unknown")}</div>`,M.style.display="none",L.textContent="";return}if(!E&&s!==D)return;const c=Array.isArray(g.logs)?g.logs:[];if(c.length===0&&!E){const e=g.filters&&(g.filters.level||g.filters.context||g.filters.search||g.filters.since||g.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";w.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,M.style.display="none",L.textContent=g.total?`${g.total} total`:"";return}let t="";E||(t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',t+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',t+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',t+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',t+='<th style="padding: 6px; text-align: left;">Message</th>',t+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',t+="</tr>");for(const e of c){const a=(e.level||"?").toUpperCase(),o=a==="ERR"?"var(--bad-fg)":a==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",i=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",n=e.context||"\u2014",r=(e.error||"").split(`
`)[0],d=e.request&&e.request.ip||"";t+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',t+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(i)}</td>`,t+=`<td style="padding: 6px;"><span style="color: ${o}; font-weight: 600;">${escapeHtml(a)}</span></td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(n)}</td>`,t+=`<td style="padding: 6px;">${escapeHtml(r)}</td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(d)}</td>`,t+="</tr>",e.detail&&(t+=`<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!x)t+="</table>",h.innerHTML=t;else{const e=h.querySelector("table");e&&e.insertAdjacentHTML("beforeend",t)}f+=c.length,H.style.display=m.hasMore?"":"none",S.textContent=`${m.total} total${m.hasMore?" (showing "+f+")":""}`,h.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const a=e.nextElementSibling;a&&a.classList.contains("error-log-detail")&&(a.style.display=a.style.display==="none"?"":"none")}))})}catch(s){if(s&&s.name==="AbortError")return;h.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`,S.textContent=""}}async function v(){if(confirm("Clear the entire error log? This cannot be undone."))try{const s=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();s.success?(await N(),u(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(s.error||"Clear failed"),"error",4e3)}catch(x){showNotification("\u274C "+x.message,"error",4e3)}}let w;function g(){P?.addEventListener("change",()=>u(!1)),M?.addEventListener("change",()=>u(!1)),D?.addEventListener("input",()=>{clearTimeout(w),w=setTimeout(()=>u(!1),250)});let x;[b,T].forEach(s=>{s?.addEventListener("change",()=>{clearTimeout(x),x=setTimeout(()=>u(!1),250)})}),A?.addEventListener("click",()=>u(!1)),H?.addEventListener("click",()=>u(!0)),I?.addEventListener("click",v),wireModal(k,z)}B?.addEventListener("click",async()=>{k?.classList.add("show"),await N(),u(!1)}),g()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;"> `)[0],d=e.request&&e.request.ip||"";t+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',t+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(i)}</td>`,t+=`<td style="padding: 6px;"><span style="color: ${o}; font-weight: 600;">${escapeHtml(a)}</span></td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(n)}</td>`,t+=`<td style="padding: 6px;">${escapeHtml(r)}</td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(d)}</td>`,t+="</tr>",e.detail&&(t+=`<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!E)t+="</table>",w.innerHTML=t;else{const e=w.querySelector("table");e&&e.insertAdjacentHTML("beforeend",t)}u+=c.length,M.style.display=g.hasMore?"":"none",L.textContent=`${g.total} total${g.hasMore?" (showing "+u+")":""}`,w.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const a=e.nextElementSibling;a&&a.classList.contains("error-log-detail")&&(a.style.display=a.style.display==="none"?"":"none")}))})}catch(s){if(s&&s.name==="AbortError")return;w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`,L.textContent=""}}async function y(){if(confirm("Clear the entire error log? This cannot be undone."))try{const s=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();s.success?(await N(),m(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(s.error||"Clear failed"),"error",4e3)}catch(E){showNotification("\u274C "+E.message,"error",4e3)}}let S;function f(){P?.addEventListener("change",()=>m(!1)),H?.addEventListener("change",()=>m(!1)),A?.addEventListener("input",()=>{clearTimeout(S),S=setTimeout(()=>m(!1),250)});let E;[x,B].forEach(s=>{s?.addEventListener("change",()=>{clearTimeout(E),E=setTimeout(()=>m(!1),250)})}),j?.addEventListener("click",()=>m(!1)),M?.addEventListener("click",()=>m(!0)),k?.addEventListener("click",y),wireModal(b,z)}C?.addEventListener("click",async()=>{b?.classList.add("show"),await N(),m(!1)}),f()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;"> <div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;"> <div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div> <div>
@@ -648,14 +648,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="cl-close-btn" class="btn-secondary">Close</button> <button id="cl-close-btn" class="btn-secondary">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("container-logs-modal"),B=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),I=document.getElementById("cl-log-search"),z=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),M=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),b=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),h=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),S=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),f=document.getElementById("cl-line-count"),E=document.getElementById("cl-filter-count"),O=document.getElementById("cl-image"),L=document.getElementById("cl-status"),j=document.getElementById("cl-created");let N=null,R=[],u=[],v=null,w=!1,g=null;function x(r){if(!r)return"-";const d=new Date(r);return isNaN(d.getTime())?r:d.toLocaleString()}function s(r){if(!r)return"";const d=document.createElement("div");return d.textContent=r,d.innerHTML}function p(r,d){const y=r.stream==="stderr"?"log-stderr":"log-stdout",C=r.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return` </div>`);const b=document.getElementById("container-logs-modal"),C=document.getElementById("cl-container-select"),j=document.getElementById("cl-log-content"),k=document.getElementById("cl-log-search"),z=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),H=document.getElementById("cl-stream"),A=document.getElementById("cl-download"),x=document.getElementById("cl-clear-search"),B=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),L=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),u=document.getElementById("cl-line-count"),v=document.getElementById("cl-filter-count"),D=document.getElementById("cl-image"),I=document.getElementById("cl-status"),R=document.getElementById("cl-created");let N=null,O=[],m=[],y=null,S=!1,f=null;function E(r){if(!r)return"-";const d=new Date(r);return isNaN(d.getTime())?r:d.toLocaleString()}function s(r){if(!r)return"";const d=document.createElement("div");return d.textContent=r,d.innerHTML}function p(r,d){const h=r.stream==="stderr"?"log-stderr":"log-stdout",T=r.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${y}" data-index="${d}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;"> <div class="log-entry ${h}" data-index="${d}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${d+1}</span> <span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${d+1}</span>
<span class="log-stream-icon" style="color: ${r.stream==="stderr"?"#f59e0b":"#22c55e"};">${C}</span> <span class="log-stream-icon" style="color: ${r.stream==="stderr"?"#f59e0b":"#22c55e"};">${T}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(r.text)}</span> <span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(r.text)}</span>
</div> </div>
`}function l(r,d=""){if(!r||r.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',f.textContent="0 lines",E.textContent="0 filtered";return}if(R=r,u=d?r.filter(y=>y.text&&y.text.toLowerCase().includes(d.toLowerCase())):r,f.textContent=`${r.length} lines`,E.textContent=d?`${u.length} of ${r.length} shown`:`${r.length} shown`,u.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(d)}"</div>`;return}A.innerHTML=u.map((y,C)=>p(y,C)).join(""),A.scrollTop=A.scrollHeight}async function m(){try{const d=(await getJSON("/api/v1/logs/containers")).containers||[],y=B.value;B.innerHTML='<option value="">Select a container...</option>',d.forEach(C=>{const U=document.createElement("option");U.value=C.id,U.textContent=`${C.name} (${C.image.split(":")[0]}) - ${C.status}`,U.dataset.name=C.name,U.dataset.image=C.image,U.dataset.status=C.status,U.dataset.created=C.created,B.appendChild(U)}),y&&B.querySelector(`option[value="${y}"]`)&&(B.value=y,c(y))}catch(r){console.error("Failed to load containers:",r)}}function c(r){const d=B.querySelector(`option[value="${r}"]`);d&&(O.textContent=d.dataset.image||"-",L.textContent=d.dataset.status||"-",L.style.color=d.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",j.textContent=x(d.dataset.created))}async function t(){const r=B.value;if(!r){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),N=r,c(r);const d=z.value,y=I.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const C=`/api/v1/logs/container/${r}${d!=="all"?`?tail=${d}`:""}`,U=await getJSON(C);U.logs&&U.logs.length>0?l(U.logs,y):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',f.textContent="0 lines",E.textContent="0 filtered")}catch(C){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(C.message)}</div>`}}function e(){const r=B.value;if(!r)return;a(),w=!0,M.textContent="\u23F9 Stop",H.style.display="flex",S.textContent="\u{1F7E2}",$.textContent="Connecting...";const d=`/api/v1/logs/stream/${r}`;v=new EventSource(d),v.onopen=()=>{S.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},v.onmessage=y=>{try{const C=JSON.parse(y.data);if(C.error){S.textContent="\u{1F534}",$.textContent=`Error: ${C.error}`;return}R.push(C),u.push(C),f.textContent=`${R.length} lines`,E.textContent=`${u.length} shown`;const U=I.value.trim();if(!U||C.text&&C.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=p(C,u.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(C){console.error("Error parsing log:",C)}},v.onerror=()=>{S.textContent="\u{1F534}",$.textContent="Disconnected",w=!1,M.textContent="\u25B6 Stream"},k._eventSource=v}function a(){v&&(v.close(),v=null),k._eventSource&&(k._eventSource.close(),k._eventSource=null),w=!1,M.textContent="\u25B6 Stream",H.style.display="none"}function o(){if(!R||R.length===0){showNotification("No logs to download","error");return}const r=B.querySelector(`option[value="${N}"]`)?.dataset.name||N,d=new Date().toISOString().replace(/[:.]/g,"-"),y=`${r}-logs-${d}.txt`,C=R.map(_=>{const J=_.timestamp||"",X=_.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${_.text}`}).join(` `}function l(r,d=""){if(!r||r.length===0){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',u.textContent="0 lines",v.textContent="0 filtered";return}if(O=r,m=d?r.filter(h=>h.text&&h.text.toLowerCase().includes(d.toLowerCase())):r,u.textContent=`${r.length} lines`,v.textContent=d?`${m.length} of ${r.length} shown`:`${r.length} shown`,m.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(d)}"</div>`;return}j.innerHTML=m.map((h,T)=>p(h,T)).join(""),j.scrollTop=j.scrollHeight}async function g(){try{const d=(await getJSON("/api/v1/logs/containers")).containers||[],h=C.value;C.innerHTML='<option value="">Select a container...</option>',d.forEach(T=>{const U=document.createElement("option");U.value=T.id,U.textContent=`${T.name} (${T.image.split(":")[0]}) - ${T.status}`,U.dataset.name=T.name,U.dataset.image=T.image,U.dataset.status=T.status,U.dataset.created=T.created,C.appendChild(U)}),h&&C.querySelector(`option[value="${h}"]`)&&(C.value=h,c(h))}catch(r){console.error("Failed to load containers:",r)}}function c(r){const d=C.querySelector(`option[value="${r}"]`);d&&(D.textContent=d.dataset.image||"-",I.textContent=d.dataset.status||"-",I.style.color=d.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",R.textContent=E(d.dataset.created))}async function t(){const r=C.value;if(!r){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),N=r,c(r);const d=z.value,h=k.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const T=`/api/v1/logs/container/${r}${d!=="all"?`?tail=${d}`:""}`,U=await getJSON(T);U.logs&&U.logs.length>0?l(U.logs,h):(j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',u.textContent="0 lines",v.textContent="0 filtered")}catch(T){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(T.message)}</div>`}}function e(){const r=C.value;if(!r)return;a(),S=!0,H.textContent="\u23F9 Stop",M.style.display="flex",L.textContent="\u{1F7E2}",$.textContent="Connecting...";const d=`/api/v1/logs/stream/${r}`;y=new EventSource(d),y.onopen=()=>{L.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},y.onmessage=h=>{try{const T=JSON.parse(h.data);if(T.error){L.textContent="\u{1F534}",$.textContent=`Error: ${T.error}`;return}O.push(T),m.push(T),u.textContent=`${O.length} lines`,v.textContent=`${m.length} shown`;const U=k.value.trim();if(!U||T.text&&T.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=p(T,m.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",j.appendChild(F),j.scrollTop=j.scrollHeight}}catch(T){console.error("Error parsing log:",T)}},y.onerror=()=>{L.textContent="\u{1F534}",$.textContent="Disconnected",S=!1,H.textContent="\u25B6 Stream"},b._eventSource=y}function a(){y&&(y.close(),y=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),S=!1,H.textContent="\u25B6 Stream",M.style.display="none"}function o(){if(!O||O.length===0){showNotification("No logs to download","error");return}const r=C.querySelector(`option[value="${N}"]`)?.dataset.name||N,d=new Date().toISOString().replace(/[:.]/g,"-"),h=`${r}-logs-${d}.txt`,T=O.map(_=>{const J=_.timestamp||"",X=_.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${_.text}`}).join(`
`),U=new Blob([C],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=y,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${R.length} log lines`,"success")}B?.addEventListener("change",()=>{t()}),z?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),M?.addEventListener("click",()=>{w?a():e()}),D?.addEventListener("click",()=>{o()}),b?.addEventListener("click",()=>{I.value="",l(R,"")}),I?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{l(R,I.value.trim())},300)}),I?.addEventListener("keydown",r=>{r.key==="Escape"&&(I.value="",l(R,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{k.classList.add("show"),m()});function n(){a(),k.classList.remove("show")}T?.addEventListener("click",n),h?.addEventListener("click",n),document.addEventListener("keydown",r=>{r.key==="Escape"&&k.classList.contains("show")&&n()}),k.addEventListener("click",r=>{r.target===k&&n()}),window.openContainerLogsModal=function(r,d){k.classList.add("show"),m().then(()=>{const y=Array.from(B.options).find(C=>C.value===r||C.dataset.name===d);y?(B.value=y.value,c(y.value),t()):r?(N=r,O.textContent=d||r,L.textContent="-",j.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const k=[{unit:"caddy",label:"Caddy (reverse proxy)"},{unit:"dashcaddy-api",label:"DashCaddy API (host systemd unit, not this container)"},{unit:"docker",label:"Docker daemon"},{unit:"ssh",label:"SSH server"},{unit:"systemd-journald",label:"systemd-journald"},{unit:"tailscaled",label:"Tailscale"},{unit:"networkd-dispatcher",label:"Networkd dispatcher"}];injectModal("journald-modal",` `),U=new Blob([T],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=h,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${O.length} log lines`,"success")}C?.addEventListener("change",()=>{t()}),z?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),H?.addEventListener("click",()=>{S?a():e()}),A?.addEventListener("click",()=>{o()}),x?.addEventListener("click",()=>{k.value="",l(O,"")}),k?.addEventListener("input",()=>{clearTimeout(f),f=setTimeout(()=>{l(O,k.value.trim())},300)}),k?.addEventListener("keydown",r=>{r.key==="Escape"&&(k.value="",l(O,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),g()});function n(){a(),b.classList.remove("show")}B?.addEventListener("click",n),w?.addEventListener("click",n),document.addEventListener("keydown",r=>{r.key==="Escape"&&b.classList.contains("show")&&n()}),b.addEventListener("click",r=>{r.target===b&&n()}),window.openContainerLogsModal=function(r,d){b.classList.add("show"),g().then(()=>{const h=Array.from(C.options).find(T=>T.value===r||T.dataset.name===d);h?(C.value=h.value,c(h.value),t()):r?(N=r,D.textContent=d||r,I.textContent="-",R.textContent="-",t()):j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const b=[{unit:"caddy",label:"Caddy (reverse proxy)"},{unit:"dashcaddy-api",label:"DashCaddy API (host systemd unit, not this container)"},{unit:"docker",label:"Docker daemon"},{unit:"ssh",label:"SSH server"},{unit:"systemd-journald",label:"systemd-journald"},{unit:"tailscaled",label:"Tailscale"},{unit:"networkd-dispatcher",label:"Networkd dispatcher"}];injectModal("journald-modal",`
<div id="journald-modal" class="weather-modal" style="z-index: 1002;"> <div id="journald-modal" class="weather-modal" style="z-index: 1002;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;"> <div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;"> <div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
@@ -698,7 +698,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div> </div>
</div> </div>
</div> </div>
`);const B=document.getElementById("journald-modal"),A=document.getElementById("jd-unit-select"),I=document.getElementById("jd-search"),z=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),M=document.getElementById("jd-stream"),D=document.getElementById("jd-clear-search"),b=document.getElementById("jd-close"),T=document.getElementById("jd-close-btn"),h=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),S=document.getElementById("jd-filter-count"),$=document.getElementById("jd-overflow"),f=document.getElementById("jd-unit-display"),E=document.getElementById("jd-stream-state");let O=!1,L=[],j=!1,N=null,R=null;function u(c){const t=document.createElement("div");return t.textContent=String(c),t.innerHTML}function v(c){O=c,A.innerHTML="",k.forEach(t=>{const e=document.createElement("option");e.value=t.unit,e.textContent=t.label+" ("+t.unit+")",A.appendChild(e)}),A.disabled=!c,c?(P.disabled=!1,M.disabled=!1):(h.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>',P.disabled=!0,M.disabled=!0)}async function w(){try{const c=await fetch("/api/v1/logs/journal/units");if(!c.ok){v(!1);return}const t=await c.json();v(!!t.available)}catch{v(!1)}}function g(){const c=(I.value||"").trim().toLowerCase(),t=c?L.filter(e=>(e.textContent||"").toLowerCase().includes(c)):L;if(t.length===0)h.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(c?` matching &quot;${u(c)}&quot;`:"")+"</div>";else{const e=t.map(o=>{const i=o.timestamp?u(o.timestamp):"\u2014",n=u(o.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${i}</span>${n}</div>`}).join("");h.innerHTML=e,h.scrollHeight-h.scrollTop-h.clientHeight<80&&(h.scrollTop=h.scrollHeight)}H.textContent=`${L.length} entries`,S.textContent=c?`${t.length} of ${L.length} shown`:`${L.length} shown`}async function x(){if(!O)return;p();const c=A.value;if(!c)return;const t=Math.max(1,Math.min(5e3,Number(z.value)||200)),e=(I.value||"").trim();h.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const a=new URL("/api/v1/logs/journal",window.location.origin);a.searchParams.set("unit",c),a.searchParams.set("tail",String(t)),e&&a.searchParams.set("search",e);const o=await fetch(a.toString()),i=await o.json();if(!o.ok||!i.success){h.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+u(i&&i.error||"HTTP "+o.status)+"</div>";return}f.textContent=c,L=(i.entries||[]).map(n=>({timestamp:n.timestamp,unit:n.unit,textContent:n.text||""})),$.style.display="none",g()}catch(a){h.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+u(a.message)+"</div>"}}function s(){if(!O)return;p();const c=A.value;if(!c)return;const t=(I.value||"").trim();f.textContent=c,M.textContent="\u23F8 Stop",M.classList.add("streaming"),E.textContent="streaming",E.style.color="var(--ok-fg, #4ade80)",L=[],g(),$.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",c),t&&e.searchParams.set("search",t),N=new EventSource(e.toString()),N.onmessage=a=>{try{const o=JSON.parse(a.data);if(o.error){/stream (exceeded|line cap)/.test(o.error)&&($.style.display="",p()),h.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+u(o.error)+"</div>",h.scrollTop=h.scrollHeight;return}L.push({timestamp:o.timestamp,unit:o.unit||c,textContent:o.text||""}),L.length>5e3&&(L=L.slice(L.length-5e3),$.style.display=""),g()}catch{}},N.onerror=()=>{},j=!0}function p(){if(j=!1,N){try{N.close()}catch{}N=null}M.textContent="\u25B6 Stream",M.classList.remove("streaming"),E.textContent="disconnected",E.style.color="var(--muted)"}function l(){p(),B.classList.remove("show")}P.addEventListener("click",x),M.addEventListener("click",()=>j?p():s()),D.addEventListener("click",()=>{I.value="",g()}),I.addEventListener("input",()=>{clearTimeout(R),R=setTimeout(g,200)}),I.addEventListener("keydown",c=>{c.key==="Escape"&&(I.value="",g())}),b.addEventListener("click",l),T.addEventListener("click",l),B.addEventListener("click",c=>{c.target===B&&l()}),document.addEventListener("keydown",c=>{c.key==="Escape"&&B.classList.contains("show")&&l()}),A.addEventListener("change",()=>{L.length>0&&x()});function m(){B.classList.add("show"),w()}window.openJournaldModal=m,document.getElementById("view-journald-logs")?.addEventListener("click",m)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal"> `);const C=document.getElementById("journald-modal"),j=document.getElementById("jd-unit-select"),k=document.getElementById("jd-search"),z=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),H=document.getElementById("jd-stream"),A=document.getElementById("jd-clear-search"),x=document.getElementById("jd-close"),B=document.getElementById("jd-close-btn"),w=document.getElementById("jd-content"),M=document.getElementById("jd-line-count"),L=document.getElementById("jd-filter-count"),$=document.getElementById("jd-overflow"),u=document.getElementById("jd-unit-display"),v=document.getElementById("jd-stream-state");let D=!1,I=[],R=!1,N=null,O=null;function m(c){const t=document.createElement("div");return t.textContent=String(c),t.innerHTML}function y(c){D=c,j.innerHTML="",b.forEach(t=>{const e=document.createElement("option");e.value=t.unit,e.textContent=t.label+" ("+t.unit+")",j.appendChild(e)}),j.disabled=!c,c?(P.disabled=!1,H.disabled=!1):(w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>',P.disabled=!0,H.disabled=!0)}async function S(){try{const c=await fetch("/api/v1/logs/journal/units");if(!c.ok){y(!1);return}const t=await c.json();y(!!t.available)}catch{y(!1)}}function f(){const c=(k.value||"").trim().toLowerCase(),t=c?I.filter(e=>(e.textContent||"").toLowerCase().includes(c)):I;if(t.length===0)w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(c?` matching &quot;${m(c)}&quot;`:"")+"</div>";else{const e=t.map(o=>{const i=o.timestamp?m(o.timestamp):"\u2014",n=m(o.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${i}</span>${n}</div>`}).join("");w.innerHTML=e,w.scrollHeight-w.scrollTop-w.clientHeight<80&&(w.scrollTop=w.scrollHeight)}M.textContent=`${I.length} entries`,L.textContent=c?`${t.length} of ${I.length} shown`:`${I.length} shown`}async function E(){if(!D)return;p();const c=j.value;if(!c)return;const t=Math.max(1,Math.min(5e3,Number(z.value)||200)),e=(k.value||"").trim();w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const a=new URL("/api/v1/logs/journal",window.location.origin);a.searchParams.set("unit",c),a.searchParams.set("tail",String(t)),e&&a.searchParams.set("search",e);const o=await fetch(a.toString()),i=await o.json();if(!o.ok||!i.success){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+m(i&&i.error||"HTTP "+o.status)+"</div>";return}u.textContent=c,I=(i.entries||[]).map(n=>({timestamp:n.timestamp,unit:n.unit,textContent:n.text||""})),$.style.display="none",f()}catch(a){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+m(a.message)+"</div>"}}function s(){if(!D)return;p();const c=j.value;if(!c)return;const t=(k.value||"").trim();u.textContent=c,H.textContent="\u23F8 Stop",H.classList.add("streaming"),v.textContent="streaming",v.style.color="var(--ok-fg, #4ade80)",I=[],f(),$.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",c),t&&e.searchParams.set("search",t),N=new EventSource(e.toString()),N.onmessage=a=>{try{const o=JSON.parse(a.data);if(o.error){/stream (exceeded|line cap)/.test(o.error)&&($.style.display="",p()),w.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+m(o.error)+"</div>",w.scrollTop=w.scrollHeight;return}I.push({timestamp:o.timestamp,unit:o.unit||c,textContent:o.text||""}),I.length>5e3&&(I=I.slice(I.length-5e3),$.style.display=""),f()}catch{}},N.onerror=()=>{},R=!0}function p(){if(R=!1,N){try{N.close()}catch{}N=null}H.textContent="\u25B6 Stream",H.classList.remove("streaming"),v.textContent="disconnected",v.style.color="var(--muted)"}function l(){p(),C.classList.remove("show")}P.addEventListener("click",E),H.addEventListener("click",()=>R?p():s()),A.addEventListener("click",()=>{k.value="",f()}),k.addEventListener("input",()=>{clearTimeout(O),O=setTimeout(f,200)}),k.addEventListener("keydown",c=>{c.key==="Escape"&&(k.value="",f())}),x.addEventListener("click",l),B.addEventListener("click",l),C.addEventListener("click",c=>{c.target===C&&l()}),document.addEventListener("keydown",c=>{c.key==="Escape"&&C.classList.contains("show")&&l()}),j.addEventListener("change",()=>{I.length>0&&E()});function g(){C.classList.add("show"),S()}window.openJournaldModal=g,document.getElementById("view-journald-logs")?.addEventListener("click",g)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;"> <div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>\u{1F4BE} Container Snapshots</h3> <h3>\u{1F4BE} Container Snapshots</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -752,7 +752,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="snapshot-close">Close</button> <button id="snapshot-close">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("snapshot-modal"),B=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),I=document.getElementById("snapshot-container-select"),z=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),M=document.getElementById("snapshot-create-status");let D=null;async function b(){try{const f=await(await fetch("/api/v1/containers")).json();if(!f.success||!f.containers)return;I.innerHTML='<option value="">-- Select a container --</option>';for(const E of f.containers){const O=document.createElement("option");O.value=E.id,O.textContent=`${E.name||E.id} (${E.image||"unknown"})`,O.dataset.name=E.name,O.dataset.image=E.image,O.dataset.status=E.status,O.dataset.created=E.created,I.appendChild(O)}}catch($){console.error("Failed to load containers:",$)}}function T($){if(!$||!$.value){z.style.display="none",D=null;return}D=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),z.style.display=""}async function h(){if(!D){M.textContent="Please select a container first",M.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){M.textContent="Please enter a snapshot name",M.style.color="var(--bad-fg)";return}const f=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",M.textContent="";try{const O=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:f})})).json();O.success?(M.textContent=`\u2713 Snapshot "${$}" created successfully`,M.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(M.textContent=`\u2717 Failed: ${O.error||"Unknown error"}`,M.style.color="var(--bad-fg)")}catch(E){M.textContent=`\u2717 Error: ${E.message}`,M.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function H(){k.classList.add("show"),b()}function S(){k.classList.remove("show"),z.style.display="none",D=null,I.selectedIndex=0}B?.addEventListener("click",H),A?.addEventListener("click",S),wireModal(k,A),I?.addEventListener("change",$=>{const f=I.options[I.selectedIndex];T(f)}),P?.addEventListener("click",h),k?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{k.querySelectorAll(".panel-tab").forEach(f=>f.classList.remove("active")),k.querySelectorAll(".panel-section").forEach(f=>f.classList.remove("active")),$.classList.add("active"),k.querySelector(`#${$.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal"> </div>`);const b=document.getElementById("snapshot-modal"),C=document.getElementById("snapshot-btn"),j=document.getElementById("snapshot-close"),k=document.getElementById("snapshot-container-select"),z=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),H=document.getElementById("snapshot-create-status");let A=null;async function x(){try{const u=await(await fetch("/api/v1/containers")).json();if(!u.success||!u.containers)return;k.innerHTML='<option value="">-- Select a container --</option>';for(const v of u.containers){const D=document.createElement("option");D.value=v.id,D.textContent=`${v.name||v.id} (${v.image||"unknown"})`,D.dataset.name=v.name,D.dataset.image=v.image,D.dataset.status=v.status,D.dataset.created=v.created,k.appendChild(D)}}catch($){console.error("Failed to load containers:",$)}}function B($){if(!$||!$.value){z.style.display="none",A=null;return}A=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),z.style.display=""}async function w(){if(!A){H.textContent="Please select a container first",H.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){H.textContent="Please enter a snapshot name",H.style.color="var(--bad-fg)";return}const u=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",H.textContent="";try{const D=await(await fetch(`/api/v1/containers/${encodeURIComponent(A)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:u})})).json();D.success?(H.textContent=`\u2713 Snapshot "${$}" created successfully`,H.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(H.textContent=`\u2717 Failed: ${D.error||"Unknown error"}`,H.style.color="var(--bad-fg)")}catch(v){H.textContent=`\u2717 Error: ${v.message}`,H.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function M(){b.classList.add("show"),x()}function L(){b.classList.remove("show"),z.style.display="none",A=null,k.selectedIndex=0}C?.addEventListener("click",M),j?.addEventListener("click",L),wireModal(b,j),k?.addEventListener("change",$=>{const u=k.options[k.selectedIndex];B(u)}),P?.addEventListener("click",w),b?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(u=>u.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(u=>u.classList.remove("active")),$.classList.add("active"),b.querySelector(`#${$.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;"> <div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3> <h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;"> <p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -831,19 +831,19 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button> <button id="arr-setup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("arr-setup-modal"),B=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),I=document.getElementById("smart-connect-btn"),z=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),M=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),b=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),h=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),S=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let f=null;const E={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},O={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function L(g){z.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",M.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function j(g){const x={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},s=x[g]||x.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function N(){L("detect"),b.style.display="none";try{if(f=await(await fetch("/api/v1/arr/smart-detect")).json(),!f.success){b.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(f.error)}</div>`,b.style.display="block";return}let x='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[p,l]of Object.entries(f.services)){const m=E[p]||"\u{1F4E6}",c=O[p]||p,t=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",a=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";x+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`);const b=document.getElementById("arr-setup-modal"),C=document.getElementById("arr-setup-btn"),j=document.getElementById("arr-setup-cancel"),k=document.getElementById("smart-connect-btn"),z=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),H=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),x=document.getElementById("smart-detect-results"),B=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),L=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let u=null;const v={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},D={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function I(f){z.style.display=f==="detect"?"block":"none",P.style.display=f==="credentials"?"block":"none",H.style.display=f==="progress"?"block":"none",A.style.display=f==="results"?"block":"none"}function R(f){const E={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},s=E[f]||E.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function N(){I("detect"),x.style.display="none";try{if(u=await(await fetch("/api/v1/arr/smart-detect")).json(),!u.success){x.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(u.error)}</div>`,x.style.display="block";return}let E='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[p,l]of Object.entries(u.services)){const g=v[p]||"\u{1F4E6}",c=D[p]||p,t=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",a=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";E+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${m}</span> <span style="font-size: 1.1rem;">${g}</span>
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${c}</div> <div style="font-weight: 500; font-size: 0.9rem;">${c}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;"> <div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${t} ${e} ${a} ${t} ${e} ${a}
</div> </div>
</div> </div>
${j(l.status)} ${R(l.status)}
</div>`}x+="</div>";const s=f.summary;x+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;"> </div>`}E+="</div>";const s=u.summary;E+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected &middot; ${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected &middot;
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` &middot; <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""} ${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` &middot; <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""}
</div>`,b.innerHTML=x,b.style.display="block",R(f),setTimeout(()=>{L("credentials")},800)}catch(g){b.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,b.style.display="block"}}function R(g){let x="";const s=g.services,p=["radarr","sonarr","prowlarr"];for(const c of p){const t=s[c];if(!t||t.status==="not_found"&&!t.url)continue;const e=E[c],a=O[c],o=t.status==="connected";x+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};"> </div>`,x.innerHTML=E,x.style.display="block",O(u),setTimeout(()=>{I("credentials")},800)}catch(f){x.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(f.message)}</div>`,x.style.display="block"}}function O(f){let E="";const s=f.services,p=["radarr","sonarr","prowlarr"];for(const c of p){const t=s[c];if(!t||t.status==="not_found"&&!t.url)continue;const e=v[c],a=D[c],o=t.status==="connected";E+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${e}</span> <span style="font-size: 1.1rem;">${e}</span>
<span style="font-weight: 500;">${a}</span> <span style="font-weight: 500;">${a}</span>
@@ -864,40 +864,40 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
</div> </div>
</div> </div>
<button onclick="smartTestConnection('${c}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button> <button onclick="smartTestConnection('${c}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const l=s.plex;if(l){const c=l.status==="connected";x+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;"> </div>`}const l=s.plex;if(l){const c=l.status==="connected";E+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span> <span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span> <span style="font-weight: 500;">Plex</span>
${j(l.status)} ${R(l.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(l.source||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(l.source||"")}</span>
</div> </div>
</div>`}const m=s.seerr;if(m){const c=m.status==="connected";let t="";if(m.configuredServices){const e=m.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;"> </div>`}const g=s.seerr;if(g){const c=g.status==="connected";let t="";if(g.configuredServices){const e=g.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot; Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot; ${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${e.plex?"&#10003; Plex":"&#10007; Plex"} ${e.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}x+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"};"> </div>`}E+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span> <span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span> <span style="font-weight: 500;">Seerr</span>
${j(m.status)} ${R(g.status)}
</div> </div>
${t} ${t}
</div>`}T.innerHTML=x}window.smartTestConnection=async function(g){const x=document.getElementById(`smart-${g}-url`),s=document.getElementById(`smart-${g}-key`),p=document.getElementById(`smart-${g}-status`),l=x?.value.trim(),m=s?.value.trim();if(!l||!m){p.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}p.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,url:l,apiKey:m})})).json();t.success?p.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(c){p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(c.message)}</span>`}};async function u(){L("progress"),h.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const g={};for(const s of["radarr","sonarr","prowlarr"]){const p=document.getElementById(`smart-${s}-url`)?.value.trim(),l=document.getElementById(`smart-${s}-key`)?.value.trim();l&&p?g[s]={apiKey:l,url:p}:l&&(g[s]={apiKey:l})}const x={services:Object.keys(g).length>0?g:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const p=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();let l="";for(const m of p.steps||[]){const c=m.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=m.status==="success"?"var(--muted)":"var(--bad-fg)";l+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;"> </div>`}B.innerHTML=E}window.smartTestConnection=async function(f){const E=document.getElementById(`smart-${f}-url`),s=document.getElementById(`smart-${f}-key`),p=document.getElementById(`smart-${f}-status`),l=E?.value.trim(),g=s?.value.trim();if(!l||!g){p.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}p.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:f,url:l,apiKey:g})})).json();t.success?p.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(c){p.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(c.message)}</span>`}};async function m(){I("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const f={};for(const s of["radarr","sonarr","prowlarr"]){const p=document.getElementById(`smart-${s}-url`)?.value.trim(),l=document.getElementById(`smart-${s}-key`)?.value.trim();l&&p?f[s]={apiKey:l,url:p}:l&&(f[s]={apiKey:l})}const E={services:Object.keys(f).length>0?f:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const p=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();let l="";for(const g of p.steps||[]){const c=g.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=g.status==="success"?"var(--muted)":"var(--bad-fg)";l+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${c} ${c}
<span>${escapeHtml(m.step)}</span> <span>${escapeHtml(g.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(m.details||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(g.details||"")}</span>
</div>`}h.innerHTML=l,setTimeout(()=>v(p),500)}catch(s){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function v(g){L("results");const x=g.summary||{},s=x.failed===0&&x.succeeded>0,p=s?"var(--ok-fg)":"#f39c12",l=s?"&#10003;":"&#9888;",m=s?"All Connected!":`${escapeHtml(String(x.succeeded))}/${escapeHtml(String(x.totalSteps))} Steps Succeeded`;let c=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${p} 12%, transparent); border-radius: 10px; border: 1px solid ${p}; margin-bottom: 12px;"> </div>`}w.innerHTML=l,setTimeout(()=>y(p),500)}catch(s){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function y(f){I("results");const E=f.summary||{},s=E.failed===0&&E.succeeded>0,p=s?"var(--ok-fg)":"#f39c12",l=s?"&#10003;":"&#9888;",g=s?"All Connected!":`${escapeHtml(String(E.succeeded))}/${escapeHtml(String(E.totalSteps))} Steps Succeeded`;let c=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${p} 12%, transparent); border-radius: 10px; border: 1px solid ${p}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${p};">${l}</div> <div style="font-size: 1.5rem; color: ${p};">${l}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${p};">${m}</div> <div style="font-size: 1.1rem; font-weight: 600; color: ${p};">${g}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(x.succeeded))} succeeded, ${escapeHtml(String(x.failed))} failed</div> <div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(E.succeeded))} succeeded, ${escapeHtml(String(E.failed))} failed</div>
</div>`;c+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of g.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';c+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;"> </div>`;c+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of f.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';c+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span> ${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
</div>`}c+="</div>",H.innerHTML=c,$.style.display=x.failed>0?"block":"none",g.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&w()}async function w(){try{const x=await(await fetch("/api/v1/plex/libraries")).json();if(x.success&&x.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}c+="</div>",M.innerHTML=c,$.style.display=E.failed>0?"block":"none",f.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&S()}async function S(){try{const E=await(await fetch("/api/v1/plex/libraries")).json();if(E.success&&E.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(x.serverName)} Libraries</h4> <h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(E.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const p of x.libraries){const l=p.type==="movie"?"\u{1F3AC}":p.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;"> <div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const p of E.libraries){const l=p.type==="movie"?"\u{1F3AC}":p.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${l} <strong>${escapeHtml(p.title)}</strong> ${l} <strong>${escapeHtml(p.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(p.count))} items</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(p.count))} items</span>
</div>`}s+="</div></div>",S.innerHTML=s,S.style.display="block"}}catch{}}B?.addEventListener("click",()=>{k.classList.add("show"),S.style.display="none",N()}),wireModal(k,A),I?.addEventListener("click",u),$?.addEventListener("click",u)})(),(function(){const k=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal"> </div>`}s+="</div></div>",L.innerHTML=s,L.style.display="block"}}catch{}}C?.addEventListener("click",()=>{b.classList.add("show"),L.style.display="none",N()}),wireModal(b,j),k?.addEventListener("click",m),$?.addEventListener("click",m)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3> <h3>\u{1F514} Notification Settings</h3>
@@ -1109,15 +1109,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="notifications-save" class="btn-accent">Save Settings</button> <button id="notifications-save" class="btn-accent">Save Settings</button>
</div> </div>
</div> </div>
</div>`);const B=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),I=document.getElementById("notifications-save"),z=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(S=>{const $=document.getElementById(`${S}-enabled`),f=document.getElementById(`${S}-config`);$?.addEventListener("change",()=>{f.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),M=document.getElementById("health-check-config");P?.addEventListener("change",()=>{M.style.opacity=P.checked?"1":"0.5"});async function D(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const f=$.config;document.getElementById("notifications-enabled").checked=f.enabled,document.getElementById("discord-enabled").checked=f.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=f.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=f.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=f.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=f.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=f.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=f.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=f.providers?.email?.enabled?"block":"none",f.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=f.providers.ntfy.serverUrl),f.providers?.email?.host&&(document.getElementById("email-host").value=f.providers.email.host),f.providers?.email?.from&&(document.getElementById("email-from").value=f.providers.email.from),f.providers?.email?.to&&(document.getElementById("email-to").value=f.providers.email.to),f.providers?.email?.port&&(document.getElementById("email-port").value=f.providers.email.port),f.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=f.providers.email.secure===!0),f.providers?.email?.username&&(document.getElementById("email-user").value=f.providers.email.username);const E=document.getElementById("email-pass");f.providers?.email?.hasPassword?(E.value="",E.placeholder="saved \u2014 leave blank to keep"):E.placeholder="app password",document.getElementById("health-check-enabled").checked=f.healthCheck?.enabled||!1,f.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=f.healthCheck.intervalMinutes),f.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(f.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=f.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=f.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=f.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=f.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=f.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=f.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=f.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=f.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=f.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=f.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=f.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=f.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=f.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=f.events?.alert!==!1}}catch(S){k.logError("[Notifications] Load Config",S,{function:"loadConfig"})}}async function b(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),f=document.getElementById("notification-history");$.success&&$.history?.length>0?f.innerHTML=$.history.map(E=>{const O=new Date(E.timestamp).toLocaleString();return` </div>`);const C=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),k=document.getElementById("notifications-save"),z=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(L=>{const $=document.getElementById(`${L}-enabled`),u=document.getElementById(`${L}-config`);$?.addEventListener("change",()=>{u.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),H=document.getElementById("health-check-config");P?.addEventListener("change",()=>{H.style.opacity=P.checked?"1":"0.5"});async function A(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const u=$.config;document.getElementById("notifications-enabled").checked=u.enabled,document.getElementById("discord-enabled").checked=u.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=u.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=u.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=u.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=u.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=u.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=u.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=u.providers?.email?.enabled?"block":"none",u.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=u.providers.ntfy.serverUrl),u.providers?.email?.host&&(document.getElementById("email-host").value=u.providers.email.host),u.providers?.email?.from&&(document.getElementById("email-from").value=u.providers.email.from),u.providers?.email?.to&&(document.getElementById("email-to").value=u.providers.email.to),u.providers?.email?.port&&(document.getElementById("email-port").value=u.providers.email.port),u.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=u.providers.email.secure===!0),u.providers?.email?.username&&(document.getElementById("email-user").value=u.providers.email.username);const v=document.getElementById("email-pass");u.providers?.email?.hasPassword?(v.value="",v.placeholder="saved \u2014 leave blank to keep"):v.placeholder="app password",document.getElementById("health-check-enabled").checked=u.healthCheck?.enabled||!1,u.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=u.healthCheck.intervalMinutes),u.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(u.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=u.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=u.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=u.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=u.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=u.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=u.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=u.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=u.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=u.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=u.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=u.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=u.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=u.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=u.events?.alert!==!1}}catch(L){b.logError("[Notifications] Load Config",L,{function:"loadConfig"})}}async function x(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),u=document.getElementById("notification-history");$.success&&$.history?.length>0?u.innerHTML=$.history.map(v=>{const D=new Date(v.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;"> <div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[E.type]||"var(--muted)"}">${E.type==="success"?"\u2713":E.type==="error"?"\u2717":"\u2139"}</span> <span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[v.type]||"var(--muted)"}">${v.type==="success"?"\u2713":v.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(E.title)}</div> <div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(v.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${O}</div> <div style="font-size: 0.7rem; color: var(--muted);">${D}</div>
</div> </div>
</div> </div>
`}).join(""):f.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(S){k.logError("[Notifications] Load History",S,{function:"loadHistory"})}}async function T(){try{const S={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},f=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)})).json();f.success?(showNotification("Notification settings saved","success",3e3),B.classList.remove("show")):showNotification(`Failed to save: ${f.error}`,"error",3e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}}async function h(S){try{const f=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:S})})).json();f.success?showNotification(`Test ${S} notification sent!`,"success",3e3):showNotification(`Test failed: ${f.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>h("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>h("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>h("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>h("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{B.classList.add("show"),D(),b()}),I?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const S=document.getElementById("notifications-send-test"),$=S.textContent;S.textContent="Sending...",S.disabled=!0;try{const E=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();E.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${E.results?.map(O=>`${O.provider}: ${O.error||"ok"}`).join(", ")}`,"error",5e3)}catch(f){showNotification(`Error: ${f.message}`,"error",3e3)}finally{S.textContent=$,S.disabled=!1}});async function H(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){ Line truncated `}).join(""):u.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(L){b.logError("[Notifications] Load History",L,{function:"loadHistory"})}}async function B(){try{const L={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},u=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(L)})).json();u.success?(showNotification("Notification settings saved","success",3e3),C.classList.remove("show")):showNotification(`Failed to save: ${u.error}`,"error",3e3)}catch(L){showNotification(`Error: ${L.message}`,"error",3e3)}}async function w(L){try{const u=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:L})})).json();u.success?showNotification(`Test ${L} notification sent!`,"success",3e3):showNotification(`Test failed: ${u.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(L){showNotification(`Error: ${L.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{C.classList.add("show"),A(),x()}),k?.addEventListener("click",B),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const L=document.getElementById("notifications-send-test"),$=L.textContent;L.textContent="Sending...",L.disabled=!0;try{const v=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();v.success?(showNotification("Test notification sent!","success",3e3),M()):showNotification(`Test failed: ${v.results?.map(D=>`${D.provider}: ${D.error||"ok"}`).join(", ")}`,"error",5e3)}catch(u){showNotification(`Error: ${u.message}`,"error",3e3)}finally{L.textContent=$,L.disabled=!1}});async function M(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){ Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3> <h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1223,7 +1223,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button> <button id="backup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);var P=document.getElementById("backup-modal"),M=document.getElementById("backup-restore-btn"),D=document.getElementById("backup-cancel"),b=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),h=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),S=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),f=document.getElementById("backup-do-restore-btn"),E=document.getElementById("backup-result"),O=document.getElementById("backup-schedules-container"),L=document.getElementById("backup-history-container"),j=document.getElementById("backup-disk-container"),N=document.getElementById("pointintime-container"),R=null;M?.addEventListener("click",function(){P.classList.add("show"),E&&(E.style.display="none"),S&&(S.style.display="none"),H&&(H.style.display="none"),R=null}),wireModal(P,D),b?.addEventListener("click",async function(){b.disabled=!0,b.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=B();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var r=Object.keys(a.browserState).length,d=a.themes?Object.keys(a.themes).length:0;E.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(d?" + "+d+" themes":""),E.style.display="block",E.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",E.style.border="1px solid var(--ok-fg)"}catch(y){E.innerHTML="\u274C Export failed: "+escapeHtml(y.message),E.style.display="block",E.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",E.style.border="1px solid var(--bad-fg)"}b.disabled=!1,b.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){h.click()}),h?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){H.textContent="\u{1F4C4} "+a.name,H.style.display="block",E.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(I(i)){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",$.innerHTML=n,S.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),d=await r.json();if(d.success){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var y in d.preview.files){var C=d.preview.files[y],U=C.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(C.description)+"</span>"}n+="</div>",d.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=n,S.style.display="block"}else E.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),E.style.display="block",E.style.background="color-mix(in srgb, #f39c12 15%, transparent)",E.style.border="1px solid #f39c12",S.style.display="none"}catch(q){E.innerHTML="\u274CLine truncated </div>`);var P=document.getElementById("backup-modal"),H=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),x=document.getElementById("backup-export-btn"),B=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),L=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),u=document.getElementById("backup-do-restore-btn"),v=document.getElementById("backup-result"),D=document.getElementById("backup-schedules-container"),I=document.getElementById("backup-history-container"),R=document.getElementById("backup-disk-container"),N=document.getElementById("pointintime-container"),O=null;H?.addEventListener("click",function(){P.classList.add("show"),v&&(v.style.display="none"),L&&(L.style.display="none"),M&&(M.style.display="none"),O=null}),wireModal(P,A),x?.addEventListener("click",async function(){x.disabled=!0,x.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=C();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var r=Object.keys(a.browserState).length,d=a.themes?Object.keys(a.themes).length:0;v.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(d?" + "+d+" themes":""),v.style.display="block",v.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",v.style.border="1px solid var(--ok-fg)"}catch(h){v.innerHTML="\u274C Export failed: "+escapeHtml(h.message),v.style.display="block",v.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",v.style.border="1px solid var(--bad-fg)"}x.disabled=!1,x.innerHTML="\u2B07\uFE0F Download Full Backup"}),B?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){M.textContent="\u{1F4C4} "+a.name,M.style.display="block",v.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(k(i)){O=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",$.innerHTML=n,L.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),d=await r.json();if(d.success){O=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var h in d.preview.files){var T=d.preview.files[h],U=T.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(T.description)+"</span>"}n+="</div>",d.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=n,L.style.display="block"}else v.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),v.style.display="block",v.style.background="color-mix(in srgb, #f39c12 15%, transparent)",v.style.border="1px solid #f39c12",L.style.display="none"}catch(q){v.innerHTML="\u274CLine truncated
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(a),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),i=await o.json();i.success?(showNotification(e+" restored to "+a,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(i.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal"> This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(a),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),i=await o.json();i.success?(showNotification(e+" restored to "+a,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(i.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
@@ -1305,7 +1305,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button> <button id="stats-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("stats-modal"),B=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),z=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),M=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),b=document.getElementById("stats-last-update");let T=null,h=null;function H(l){if(l===0||!l)return"0 B";const m=1024,c=["B","KB","MB","GB"],t=Math.floor(Math.log(l)/Math.log(m));return parseFloat((l/Math.pow(m,t)).toFixed(1))+" "+c[t]}function S(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function $(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function f(){try{let l=null,m=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,m=!0,h=e.stats)}catch{}if(!m){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const a of e.stats)l[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};h=l}}if(!l||Object.keys(l).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(l)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=S(o),r=$(i),d=a.memory?.usage||a.memory?.used||0,y=a.memory?.limit||0,C=a.network?.rxBytes||a.network?.rx||0,U=a.network?.txBytes||a.network?.tx||0,q=e.aggregated;c+=` </div>`);const b=document.getElementById("stats-modal"),C=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),k=document.getElementById("stats-refresh-btn"),z=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),H=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),x=document.getElementById("stats-last-update");let B=null,w=null;function M(l){if(l===0||!l)return"0 B";const g=1024,c=["B","KB","MB","GB"],t=Math.floor(Math.log(l)/Math.log(g));return parseFloat((l/Math.pow(g,t)).toFixed(1))+" "+c[t]}function L(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function $(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function u(){try{let l=null,g=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,g=!0,w=e.stats)}catch{}if(!g){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const a of e.stats)l[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};w=l}}if(!l||Object.keys(l).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(l)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=L(o),r=$(i),d=a.memory?.usage||a.memory?.used||0,h=a.memory?.limit||0,T=a.network?.rxBytes||a.network?.rx||0,U=a.network?.txBytes||a.network?.tx||0,q=e.aggregated;c+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> <div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${e.name||t}</span> <span style="font-weight: 600; flex: 1;">${e.name||t}</span>
@@ -1330,18 +1330,18 @@ This will replace current configuration, credentials, and data. Containers will
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${r}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${r}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
</div> </div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(d)} / ${H(y)}</div> <div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${M(d)} / ${M(h)}</div>
</div> </div>
<div> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;"> <div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${H(C)}</span> <span style="color: #3498db;">\u2193 ${M(T)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span> <span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${H(U)}</span> <span style="color: #e74c3c;">\u2191 ${M(U)}</span>
</div> </div>
</div> </div>
</div> </div>
</div>`}c+="</div>",P.innerHTML=c,b.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function E(){if(!M)return;const l=h;if(!l||Object.keys(l).length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let m='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,t]of Object.entries(l)){const e=t.aggregated;e&&(m+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}c+="</div>",P.innerHTML=c,x.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function v(){if(!H)return;const l=w;if(!l||Object.keys(l).length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let g='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,t]of Object.entries(l)){const e=t.aggregated;e&&(g+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||c}</div> <div style="font-weight: 600; margin-bottom: 10px;">${t.name||c}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;"> <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div> <div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
@@ -1350,13 +1350,13 @@ This will replace current configuration, credentials, and data. Containers will
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div> <div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div> </div>
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""} ${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
</div>`)}m+="</div>",M.innerHTML=m}async function O(){if(!D)return;D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const l=h;if(!l||Object.keys(l).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let m=!1;try{m=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{m=!1}let c=[];try{const r=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();r.success&&(c=r.history||[])}catch{}let t={};try{const r=await(await fetch("/api/v1/monitoring/alerts/config")).json();r.success&&(t=r.configs||{})}catch{}const a=Object.entries(l).map(([n,r])=>{const d=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return` </div>`)}g+="</div>",H.innerHTML=g}async function D(){if(!A)return;A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const l=w;if(!l||Object.keys(l).length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let g=!1;try{g=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{g=!1}let c=[];try{const r=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();r.success&&(c=r.history||[])}catch{}let t={};try{const r=await(await fetch("/api/v1/monitoring/alerts/config")).json();r.success&&(t=r.configs||{})}catch{}const a=Object.entries(l).map(([n,r])=>{const d=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
<tr data-container="${n}"> <tr data-container="${n}">
<td style="font-weight: 600; padding: 8px;">${r.name||n}</td> <td style="font-weight: 600; padding: 8px;">${r.name||n}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${d.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${m?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${d.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${d.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${m?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${d.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${d.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${m?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${d.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${d.autoRestart?"checked":""} ${m?"":"disabled"} /></td> <td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${d.autoRestart?"checked":""} ${g?"":"disabled"} /></td>
<td style="padding: 4px 8px;"> <td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${r.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button> <button class="alert-test-btn btn-xs" data-container="${n}" data-name="${r.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
</td> </td>
@@ -1370,7 +1370,7 @@ This will replace current configuration, credentials, and data. Containers will
<td style="padding: 6px 8px; text-align: center;">${d}</td> <td style="padding: 6px 8px; text-align: center;">${d}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td> <td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td>
</tr> </tr>
`}).join(""),i=m?` `}).join(""),i=g?`
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4> <h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
@@ -1401,7 +1401,7 @@ This will replace current configuration, credentials, and data. Containers will
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p> <p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button> <button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
</div> </div>
`;D.innerHTML=` `;A.innerHTML=`
${i} ${i}
<div> <div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4> <h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
@@ -1423,7 +1423,7 @@ This will replace current configuration, credentials, and data. Containers will
</div> </div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'} `:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div> </div>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(r=>{const d=r.dataset.container;n[d]={cpuThreshold:parseInt(r.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(r.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(r.querySelector(".alert-disk")?.value)||50,autoRestart:!!r.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const d=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),y=document.getElementById("save-all-alerts");y.textContent=d.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{y.textContent="Save All"},2e3)}catch{const d=document.getElementById("save-all-alerts");d.textContent="\u274C Error",setTimeout(()=>{d.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),k.classList.remove("show"),j(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=r},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{k.classList.remove("show"),j(),typeof openLicenseModal=="function"&&openLicenseModal()})}function L(){T&&clearInterval(T),z?.checked&&(T=setInterval(f,DC.POLL.STATS))}function j(){T&&(clearInterval(T),T=null)}B?.addEventListener("click",()=>{k.classList.add("show"),f(),L()}),A?.addEventListener("click",()=>{k.classList.remove("show"),j()}),k?.addEventListener("click",l=>{l.target===k&&(k.classList.remove("show"),j())}),I?.addEventListener("click",f),z?.addEventListener("change",()=>{z.checked?L():j()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",E),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",O);const N=document.getElementById("stats-history-container"),R=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let v="1h";function w(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function x(l,m,c,t,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=l.map(m).filter(_=>_!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,r=600,d=80,y=4,C=(r-y*2)/Math.max(a.length-1,1),U=a.map((_,J)=>{const X=y+J*C,Q=d-y-(_-i)/n*(d-y*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=a[a.length-1],F=a.reduce((_,J)=>_+J,0)/a.length;return` `,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(r=>{const d=r.dataset.container;n[d]={cpuThreshold:parseInt(r.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(r.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(r.querySelector(".alert-disk")?.value)||50,autoRestart:!!r.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const d=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),h=document.getElementById("save-all-alerts");h.textContent=d.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{h.textContent="Save All"},2e3)}catch{const d=document.getElementById("save-all-alerts");d.textContent="\u274C Error",setTimeout(()=>{d.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),b.classList.remove("show"),R(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=r},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{b.classList.remove("show"),R(),typeof openLicenseModal=="function"&&openLicenseModal()})}function I(){B&&clearInterval(B),z?.checked&&(B=setInterval(u,DC.POLL.STATS))}function R(){B&&(clearInterval(B),B=null)}C?.addEventListener("click",()=>{b.classList.add("show"),u(),I()}),j?.addEventListener("click",()=>{b.classList.remove("show"),R()}),b?.addEventListener("click",l=>{l.target===b&&(b.classList.remove("show"),R())}),k?.addEventListener("click",u),z?.addEventListener("change",()=>{z.checked?I():R()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",v),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",D);const N=document.getElementById("stats-history-container"),O=document.getElementById("stats-history-container-area"),m=document.querySelectorAll(".stats-range-btn");let y="1h";function S(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function f(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function E(l,g,c,t,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=l.map(g).filter(_=>_!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,r=600,d=80,h=4,T=(r-h*2)/Math.max(a.length-1,1),U=a.map((_,J)=>{const X=h+J*T,Q=d-h-(_-i)/n*(d-h*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=a[a.length-1],F=a.reduce((_,J)=>_+J,0)/a.length;return`
<div style="margin-bottom: 14px;"> <div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;"> <div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span> <span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
@@ -1433,11 +1433,11 @@ This will replace current configuration, credentials, and data. Containers will
<polyline fill="none" stroke="${c}" stroke-width="1.5" points="${U}" /> <polyline fill="none" stroke="${c}" stroke-width="1.5" points="${U}" />
</svg> </svg>
</div> </div>
`}function s(){if(!N)return;const l=h||{},m=N.value,c=Object.entries(l);if(c.length===0){N.innerHTML='<option value="">No containers</option>';return}N.innerHTML=c.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),m&&l[m]&&(N.value=m)}async function p(){if(!R||!N)return;const l=N.value;if(!l){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const m=Date.now(),c=m-w(v);R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(l)}?startTime=${c}&endTime=${m}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){R.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${v}. Tier: ${g(o)}.</div>`;return}const i=o==="raw",n=i?U=>U.cpu?.percent:U=>U.cpu?.avg,r=i?U=>U.memory?.percent:U=>U.memory?.avgPercent,d=i?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,y=i?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let C=` `}function s(){if(!N)return;const l=w||{},g=N.value,c=Object.entries(l);if(c.length===0){N.innerHTML='<option value="">No containers</option>';return}N.innerHTML=c.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),g&&l[g]&&(N.value=g)}async function p(){if(!O||!N)return;const l=N.value;if(!l){O.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const g=Date.now(),c=g-S(y);O.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(l)}?startTime=${c}&endTime=${g}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${y}. Tier: ${f(o)}.</div>`;return}const i=o==="raw",n=i?U=>U.cpu?.percent:U=>U.cpu?.avg,r=i?U=>U.memory?.percent:U=>U.memory?.avgPercent,d=i?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,h=i?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let T=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;"> <div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${a.length} samples \xB7 ${escapeHtml(g(o))} \xB7 ${new Date(c).toLocaleString()} \u2192 ${new Date(m).toLocaleString()} ${a.length} samples \xB7 ${escapeHtml(f(o))} \xB7 ${new Date(c).toLocaleString()} \u2192 ${new Date(g).toLocaleString()}
</div> </div>
`;C+=x(a,n,"#2ecc71","CPU","%"),C+=x(a,r,"#3498db","Memory","%"),C+=x(a,d,"#9b59b6","Network RX"," MB"),C+=x(a,y,"#e67e22","Network TX"," MB"),R.innerHTML=C}catch(t){R.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}u.forEach(l=>{l.addEventListener("click",()=>{u.forEach(m=>m.classList.remove("active")),l.classList.add("active"),v=l.dataset.range,p()})}),N?.addEventListener("change",p),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),p()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal"> `;T+=E(a,n,"#2ecc71","CPU","%"),T+=E(a,r,"#3498db","Memory","%"),T+=E(a,d,"#9b59b6","Network RX"," MB"),T+=E(a,h,"#e67e22","Network TX"," MB"),O.innerHTML=T}catch(t){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}m.forEach(l=>{l.addEventListener("click",()=>{m.forEach(g=>g.classList.remove("active")),l.classList.add("active"),y=l.dataset.range,p()})}),N?.addEventListener("change",p),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),p()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3> <h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1561,7 +1561,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button> <button id="health-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("health-modal"),B=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),I=document.getElementById("health-refresh-btn"),z=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),M=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),b=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),h=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),S=document.getElementById("health-form-save"),$="dashcaddy-health-settings",f={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},E=document.getElementById("health-global-save"),O=document.getElementById("health-global-reset"),L=document.getElementById("health-global-status"),j=document.getElementById("health-setting-retention"),N=document.getElementById("health-setting-interval"),R=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),v=document.getElementById("health-setting-disk-threshold");function w(){try{const i=safeGet($),n=i?JSON.parse(i):{};return Object.assign({},f,n)}catch{return Object.assign({},f)}}function g(){const i=w();j&&(j.value=i.retentionDays),N&&(N.value=i.pollingInterval),R&&(R.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),v&&(v.value=i.diskUsageThreshold)}function x(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(j?.value)||f.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(N?.value)||f.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(R?.value)||f.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||f.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(v?.value)||f.diskUsageThreshold))};try{safeSet($,JSON.stringify(i)),g(),L&&(L.textContent="Saved \u2713",L.style.color="var(--ok-fg)",setTimeout(()=>{L&&(L.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){L&&(L.textContent="Save failed",L.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function s(){try{safeSet($,JSON.stringify(f))}catch{}g(),L&&(L.textContent="Reset to defaults \u2713",L.style.color="var(--ok-fg)",setTimeout(()=>{L&&(L.textContent="")},2500))}g(),E?.addEventListener("click",x),O?.addEventListener("click",s);let p=null;function l(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function m(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function c(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const r=Object.values(n.status);let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',d+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',d+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',d+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const y of r){const C=y.status==="up",U=C?"var(--dot-ok)":"var(--dot-bad)",q=y.uptime?.["24h"]??"-",F=y.uptime?.["7d"]??"-",_=y.avgResponseTime!=null?Math.round(y.avgResponseTime)+"ms":"-",J=y.timestamp?timeAgo(y.timestamp):"-";d+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(y.serviceId)}">`,d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${C?"Up":"Down"}</td>`,d+=`<td style="padding: 8px; color: ${typeof q=="number"?l(q):"var(--muted)"};">${typeof q=="number"?q.toFixed(1)+"%":q}</td>`,d+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,d+=`<td style="padding: 8px;">${_}</td>`,d+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,d+="</tr>",d+=`<tr id="health-detail-${escapeHtml(y.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated </div>`);const b=document.getElementById("health-modal"),C=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),k=document.getElementById("health-refresh-btn"),z=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),H=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),x=document.getElementById("health-add-btn"),B=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),L=document.getElementById("health-form-save"),$="dashcaddy-health-settings",u={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},v=document.getElementById("health-global-save"),D=document.getElementById("health-global-reset"),I=document.getElementById("health-global-status"),R=document.getElementById("health-setting-retention"),N=document.getElementById("health-setting-interval"),O=document.getElementById("health-setting-stats-interval"),m=document.getElementById("health-setting-max-entries"),y=document.getElementById("health-setting-disk-threshold");function S(){try{const i=safeGet($),n=i?JSON.parse(i):{};return Object.assign({},u,n)}catch{return Object.assign({},u)}}function f(){const i=S();R&&(R.value=i.retentionDays),N&&(N.value=i.pollingInterval),O&&(O.value=i.statsPollingInterval),m&&(m.value=i.maxEntriesPerService),y&&(y.value=i.diskUsageThreshold)}function E(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(R?.value)||u.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(N?.value)||u.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(O?.value)||u.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(m?.value)||u.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(y?.value)||u.diskUsageThreshold))};try{safeSet($,JSON.stringify(i)),f(),I&&(I.textContent="Saved \u2713",I.style.color="var(--ok-fg)",setTimeout(()=>{I&&(I.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){I&&(I.textContent="Save failed",I.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function s(){try{safeSet($,JSON.stringify(u))}catch{}f(),I&&(I.textContent="Reset to defaults \u2713",I.style.color="var(--ok-fg)",setTimeout(()=>{I&&(I.textContent="")},2500))}f(),v?.addEventListener("click",E),D?.addEventListener("click",s);let p=null;function l(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function g(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function c(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const r=Object.values(n.status);let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',d+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',d+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',d+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const h of r){const T=h.status==="up",U=T?"var(--dot-ok)":"var(--dot-bad)",q=h.uptime?.["24h"]??"-",F=h.uptime?.["7d"]??"-",_=h.avgResponseTime!=null?Math.round(h.avgResponseTime)+"ms":"-",J=h.timestamp?timeAgo(h.timestamp):"-";d+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(h.serviceId)}">`,d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(h.name||h.serviceId)}</td>`,d+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${T?"Up":"Down"}</td>`,d+=`<td style="padding: 8px; color: ${typeof q=="number"?l(q):"var(--muted)"};">${typeof q=="number"?q.toFixed(1)+"%":q}</td>`,d+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,d+=`<td style="padding: 8px;">${_}</td>`,d+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,d+="</tr>",d+=`<tr id="health-detail-${escapeHtml(h.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;"> <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${_.totalChecks||0}</strong></div> <div><span style="color: var(--muted);">Total Checks</span><br><strong>${_.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${l(_.uptime||0)};">${(_.uptime||0).toFixed(2)}%</strong></div> <div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${l(_.uptime||0)};">${(_.uptime||0).toFixed(2)}%</strong></div>
@@ -1571,14 +1571,14 @@ This will replace current configuration, credentials, and data. Containers will
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(J.max||0)}ms</strong></div> <div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(J.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${_.upChecks||0}</strong></div> <div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${_.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${_.downChecks||0}</strong></div> <div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${_.downChecks||0}</strong></div>
</div>`}else U.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(q){U.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(q.message)}</div>`}}})})}catch(i){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),r=await i.json(),d=await n.json();let y="";const C=r.success&&r.incidents?r.incidents:[];if(C.length>0){y+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+C.length+")</h4>";for(const q of C)y+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);"> </div>`}else U.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(q){U.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(q.message)}</div>`}}})})}catch(i){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),r=await i.json(),d=await n.json();let h="";const T=r.success&&r.incidents?r.incidents:[];if(T.length>0){h+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+T.length+")</h4>";for(const q of T)h+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(q.serviceId)}</span> <span style="font-weight: 500;">${escapeHtml(q.serviceId)}</span>
<span>${m(q.severity)}</span> <span>${g(q.severity)}</span>
</div> </div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(q.message)}</div> <div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(q.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(q.createdAt)} \xB7 ${q.occurrences||1} occurrence(s)</div> <div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(q.createdAt)} \xB7 ${q.occurrences||1} occurrence(s)</div>
</div>`;y+="</div>"}else y+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=d.success&&d.history?d.history:[];if(U.length>0){y+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',y+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',y+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const q of U){const F=q.status==="resolved",_=F&&q.duration?q.duration<6e4?Math.round(q.duration/1e3)+"s":Math.round(q.duration/6e4)+"m":"-";y+='<tr style="border-bottom: 1px solid var(--border);">',y+=`<td style="padding: 6px;">${escapeHtml(q.serviceId)}</td>`,y+=`<td style="padding: 6px;">${escapeHtml(q.type)}</td>`,y+=`<td style="padding: 6px;">${m(q.severity)}</td>`,y+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${q.status}</span></td>`,y+=`<td style="padding: 6px;">${_}</td>`,y+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(q.createdAt)}</td>`,y+="</tr>"}y+="</table>"}P.innerHTML=y||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),r=n.success&&n.status?Object.values(n.status):[];if(r.length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const y of r){const C=y.status==="up";d+='<tr style="border-bottom: 1px solid var(--border);">',d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px; color: ${C?"var(--ok-fg)":"var(--bad-fg)"};">${C?"Up":"Down"}</td>`,d+=`<td style="padding: 8px;">${y.sla?.target?y.sla.target+"%":"-"}</td>`,d+='<td style="padding: 8px; text-align: right;">',d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,d+="</td></tr>"}d+="</table>",M.innerHTML=d}catch(i){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,r,d,y,C,U){p=i||null,h.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=r||"",document.getElementById("health-form-timeout").value=d||1e4,document.getElementById("health-form-codes").value=y||"200",document.getElementById("health-form-sla").value=C||99.9,document.getElementById("health-form-slow").value=U||5e3,T.style.display="",b.style.display="none"}function o(){T.style.display="none",b.style.display="",p=null}b?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",o),S?.addEventListener("click",async()=>{const i=p||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const r=document.getElementById("health-form-codes").value.split(",").map(y=>parseInt(y.trim())).filter(Boolean),d={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:r.length?r:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated </div>`;h+="</div>"}else h+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=d.success&&d.history?d.history:[];if(U.length>0){h+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',h+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',h+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const q of U){const F=q.status==="resolved",_=F&&q.duration?q.duration<6e4?Math.round(q.duration/1e3)+"s":Math.round(q.duration/6e4)+"m":"-";h+='<tr style="border-bottom: 1px solid var(--border);">',h+=`<td style="padding: 6px;">${escapeHtml(q.serviceId)}</td>`,h+=`<td style="padding: 6px;">${escapeHtml(q.type)}</td>`,h+=`<td style="padding: 6px;">${g(q.severity)}</td>`,h+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${q.status}</span></td>`,h+=`<td style="padding: 6px;">${_}</td>`,h+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(q.createdAt)}</td>`,h+="</tr>"}h+="</table>"}P.innerHTML=h||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),r=n.success&&n.status?Object.values(n.status):[];if(r.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const h of r){const T=h.status==="up";d+='<tr style="border-bottom: 1px solid var(--border);">',d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(h.name||h.serviceId)}</td>`,d+=`<td style="padding: 8px; color: ${T?"var(--ok-fg)":"var(--bad-fg)"};">${T?"Up":"Down"}</td>`,d+=`<td style="padding: 8px;">${h.sla?.target?h.sla.target+"%":"-"}</td>`,d+='<td style="padding: 8px; text-align: right;">',d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(h.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(h.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,d+="</td></tr>"}d+="</table>",H.innerHTML=d}catch(i){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,r,d,h,T,U){p=i||null,w.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=r||"",document.getElementById("health-form-timeout").value=d||1e4,document.getElementById("health-form-codes").value=h||"200",document.getElementById("health-form-sla").value=T||99.9,document.getElementById("health-form-slow").value=U||5e3,B.style.display="",x.style.display="none"}function o(){B.style.display="none",x.style.display="",p=null}x?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",o),L?.addEventListener("click",async()=>{const i=p||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const r=document.getElementById("health-form-codes").value.split(",").map(h=>parseInt(h.trim())).filter(Boolean),d={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:r.length?r:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3> <h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1652,17 +1652,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button> <button id="updates-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("updates-modal"),B=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),z=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function b(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const r=(()=>{const d=window.APPS||[];for(const y of d)if(y.containerId===n.containerId||y.name===n.containerName||y.id===n.containerName)return y.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(r)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",z.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,z.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Update "${d}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const C=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(C.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>b(),2e3);else throw new Error(C.error||"Update failed")}catch(y){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+y.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),z.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Rollback "${d}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const C=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(r)}`,{method:"POST"})).json();if(C.success)n.textContent="Rolled back!",setTimeout(()=>b(),2e3);else throw new Error(C.error||"Rollback failed")}catch(y){n.textContent="Failed",showNotification("Rollback error: "+y.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(c){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function T(){const c=window._pendingUpdates||[];if(!c.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${c.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of c)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated </div>`);const b=document.getElementById("updates-modal"),C=document.getElementById("updates-btn"),j=document.getElementById("updates-cancel"),k=document.getElementById("updates-check-btn"),z=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),H=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function x(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',A.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const r=(()=>{const d=window.APPS||[];for(const h of d)if(h.containerId===n.containerId||h.name===n.containerName||h.id===n.containerName)return h.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(r)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",z.innerHTML=a,A.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,z.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Update "${d}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const T=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(T.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>x(),2e3);else throw new Error(T.error||"Update failed")}catch(h){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+h.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),z.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Rollback "${d}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const T=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(r)}`,{method:"POST"})).json();if(T.success)n.textContent="Rolled back!",setTimeout(()=>x(),2e3);else throw new Error(T.error||"Rollback failed")}catch(h){n.textContent="Failed",showNotification("Rollback error: "+h.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(c){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function B(){const c=window._pendingUpdates||[];if(!c.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${c.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of c)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(y)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;"> <select class="auto-schedule" data-id="${escapeHtml(h)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${U?"":" selected"}>Disabled</option> <option value=""${U?"":" selected"}>Disabled</option>
<option value="daily"${U==="daily"?" selected":""}>Daily</option> <option value="daily"${U==="daily"?" selected":""}>Daily</option>
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option> <option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option> <option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(y)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(y)}"${q?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${_}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(y)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",M.innerHTML=n,M.querySelectorAll(".save-auto-btn").forEach(r=>{r.addEventListener("click",async()=>{const d=r.dataset.id,y=r.closest("tr"),C=y.querySelector(".auto-schedule").value,U=y.querySelector(".auto-rollback").checked,q=y.querySelector(".auto-window").value.trim();r.textContent="Saving...",r.disabled=!0;try{const _=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!C,schedule:C||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(_.success)r.textContent="\u2713 Saved";else throw new Error(_.error)}catch(F){r.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{r.textContent="Save",r.disabled=!1},2e3)})})}catch(c){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),f=document.getElementById("dashcaddy-update-badge"),E=document.getElementById("dashcaddy-update-details"),O=document.getElementById("dashcaddy-new-version"),L=document.getElementById("dashcaddy-changelog"),j=document.getElementById("dashcaddy-apply-btn"),N=document.getElementById("dashcaddy-check-btn"),R=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),v=document.getElementById("dashcaddy-history-container");let w=null;function g(c,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=c)}async function x(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(c){c||(N.textContent="Checking...",N.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(w=e,e.success&&e.available&&e.remote){f.style.display="",E.style.display="",O.textContent="v"+e.remote.version,L.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else f.style.display="none",E.style.display="none",await x(),c||g("You are running the latest version.","success");c||(N.textContent="Check for Updates",N.disabled=!1)}catch(t){c||(g("Failed to check: "+t.message,"error"),N.textContent="Check for Updates",N.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;j.textContent="Updating...",j.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),j.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(c){throw g("Update failed: "+c.message,"error"),j.textContent="Update Now",j.disabled=!1,c}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){v.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated </select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(h)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(h)}"${q?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${_}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(h)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",H.innerHTML=n,H.querySelectorAll(".save-auto-btn").forEach(r=>{r.addEventListener("click",async()=>{const d=r.dataset.id,h=r.closest("tr"),T=h.querySelector(".auto-schedule").value,U=h.querySelector(".auto-rollback").checked,q=h.querySelector(".auto-window").value.trim();r.textContent="Saving...",r.disabled=!0;try{const _=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!T,schedule:T||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(_.success)r.textContent="\u2713 Saved";else throw new Error(_.error)}catch(F){r.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{r.textContent="Save",r.disabled=!1},2e3)})})}catch(c){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),u=document.getElementById("dashcaddy-update-badge"),v=document.getElementById("dashcaddy-update-details"),D=document.getElementById("dashcaddy-new-version"),I=document.getElementById("dashcaddy-changelog"),R=document.getElementById("dashcaddy-apply-btn"),N=document.getElementById("dashcaddy-check-btn"),O=document.getElementById("dashcaddy-rollback-btn"),m=document.getElementById("dashcaddy-status-bar"),y=document.getElementById("dashcaddy-history-container");let S=null;function f(c,t){m&&(m.style.display="block",m.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",m.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",m.textContent=c)}async function E(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(c){c||(N.textContent="Checking...",N.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(S=e,e.success&&e.available&&e.remote){u.style.display="",v.style.display="",D.textContent="v"+e.remote.version,I.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else u.style.display="none",v.style.display="none",await E(),c||f("You are running the latest version.","success");c||(N.textContent="Check for Updates",N.disabled=!1)}catch(t){c||(f("Failed to check: "+t.message,"error"),N.textContent="Check for Updates",N.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;R.textContent="Updating...",R.disabled=!0,f("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return f("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),R.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(c){throw f("Update failed: "+c.message,"error"),R.textContent="Update Now",R.disabled=!1,c}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){y.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+e.join(` `+e.join(`
`)+` `)+`
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(c){g("Rollback failed: "+c.message,"error")}}N?.addEventListener("click",()=>s(!1)),j?.addEventListener("click",()=>p().catch(()=>{})),R?.addEventListener("click",m),I?.addEventListener("click",h),B?.addEventListener("click",()=>{k?.classList.add("show"),b()}),wireModal(k,A),window.openUpdateModal=function(c){k?.classList.add("show"),b().then(()=>{if(!c)return;const t=z.querySelector(`[data-app-id="${c}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",S),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{x(),l(),w||s(!0)}),window.dcApplyUpdate=p,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal"> Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;f("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)f("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(c){f("Rollback failed: "+c.message,"error")}}N?.addEventListener("click",()=>s(!1)),R?.addEventListener("click",()=>p().catch(()=>{})),O?.addEventListener("click",g),k?.addEventListener("click",w),C?.addEventListener("click",()=>{b?.classList.add("show"),x()}),wireModal(b,j),window.openUpdateModal=function(c){b?.classList.add("show"),x().then(()=>{if(!c)return;const t=z.querySelector(`[data-app-id="${c}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",M),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",L),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{E(),l(),S||s(!0)}),window.dcApplyUpdate=p,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3> <h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p> <p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1711,7 +1711,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="dr-close">Close</button> <button id="dr-close">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("docker-resources-modal"),B=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function I(D){if(!D||D===0)return"0 B";const b=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,T)).toFixed(1)+" "+b[T]}async function z(){const D=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let h='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';h+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const S=H.name==="buildkit"||H.name.length===64;h+='<tr style="border-bottom: 1px solid var(--border);">',h+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(H.name)}">${escapeHtml(H.name.length>40?H.name.substring(0,37)+"...":H.name)}</td>`,h+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,h+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,h+='<td style="padding: 6px; text-align: right;">',S||(h+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),h+="</td></tr>"}h+="</table>",D.innerHTML=h,D.querySelectorAll(".dr-vol-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete volume "${H.dataset.name}"? Data will be lost.`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(H.dataset.name)}?force=true`),z()}catch(S){showNotification("Delete failed: "+S.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(b){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(b.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),b=D.value.trim();if(!b){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:b}),D.value="",showNotification(`Volume "${b}" created`,"success"),z()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let h='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';h+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const S=["bridge","host","none"].includes(H.name);h+='<tr style="border-bottom: 1px solid var(--border);">',h+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,h+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,h+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,h+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,h+='<td style="padding: 6px; text-align: right;">',S||(h+=`<button class="dr-net-del" data-id="${escapeHtml(H.id)}" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),h+="</td></tr>"}h+="</table>",D.innerHTML=h,D.querySelectorAll(".dr-net-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete network "${H.dataset.name}"?`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(H.dataset.id)}`),P()}catch(S){showNotification("Delete failed: "+S.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(b){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(b.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),b=document.getElementById("dr-net-driver"),T=D.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:b.value}),D.value="",showNotification(`Network "${T}" created`,"success"),P()}catch(h){showNotification("Create failed: "+h.message,"error")}});async fuLine truncated </div>`);const b=document.getElementById("docker-resources-modal"),C=document.getElementById("docker-resources-btn"),j=document.getElementById("dr-close");function k(A){if(!A||A===0)return"0 B";const x=["B","KB","MB","GB","TB"],B=Math.floor(Math.log(Math.abs(A))/Math.log(1024));return(A/Math.pow(1024,B)).toFixed(1)+" "+x[B]}async function z(){const A=document.getElementById("dr-vol-list");try{const B=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(B.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of B){const L=M.name==="buildkit"||M.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(M.name)}">${escapeHtml(M.name.length>40?M.name.substring(0,37)+"...":M.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',L||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",A.innerHTML=w,A.querySelectorAll(".dr-vol-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete volume "${M.dataset.name}"? Data will be lost.`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(M.dataset.name)}?force=true`),z()}catch(L){showNotification("Delete failed: "+L.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(x){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(x.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-vol-name"),x=A.value.trim();if(!x){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:x}),A.value="",showNotification(`Volume "${x}" created`,"success"),z()}catch(B){showNotification("Create failed: "+B.message,"error")}});async function P(){const A=document.getElementById("dr-net-list");try{const B=(await getJSON("/api/v1/docker/networks")).networks||[];if(B.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of B){const L=["bridge","host","none"].includes(M.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(M.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${M.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',L||(w+=`<button class="dr-net-del" data-id="${escapeHtml(M.id)}" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",A.innerHTML=w,A.querySelectorAll(".dr-net-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete network "${M.dataset.name}"?`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(M.dataset.id)}`),P()}catch(L){showNotification("Delete failed: "+L.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(x){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(x.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-net-name"),x=document.getElementById("dr-net-driver"),B=A.value.trim();if(!B){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:B,driver:x.value}),A.value="",showNotification(`Network "${B}" created`,"success"),P()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;"> <div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3> <h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p> <p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1752,8 +1752,8 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<div id="compose-progress-content"></div> <div id="compose-progress-content"></div>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("compose-import-modal"),B=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(k,A);let I=null;function z(M){document.getElementById("compose-step-paste").style.display=M==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=M==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=M==="progress"?"":"none"}B?.addEventListener("click",()=>{z("paste"),I=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",k?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",M=>{const D=M.target.files[0];if(!D)return;const b=new FileReader;b.onload=()=>{document.getElementById("compose-yaml").value=b.result},b.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const M=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!M){showNotification("Paste a docker-compose.yml","warning");return}const b=document.getElementById("compose-parse-btn"),T=b.textContent;b.textContent="Parsing...",b.disabled=!0;try{const h=await postJSON("/api/v1/apps/import-compose",{yaml:M,stackName:D});I=h,I.stackName=D,P(h),z("preview")}catch(h){showNotification("Parse failed: "+h.message,"error")}finally{b.textContent=T,b.disabled=!1}});function P(M){const D=document.getElementById("compose-preview-content");let b="";M.networks&&M.networks.length>0&&(b+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${M.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),M.volumes&&M.volumes.length>0&&(b+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${M.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),b+=`<div style="font-weight: 600; margin-bottom: 8px;">${M.services.length} service(s)</div>`,b+='<div class="scroll-container" style="max-height: 350px;">';for(const T of M.services){const h=T.skip?"var(--bad-fg)":"var(--border)";if(b+=`<div style="padding: 10px 14px; border: 1px solid ${h}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,b+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(b+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),b+="</div>",!T.skip&&(b+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(b+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),T.volumes?.length&&(b+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(b+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(b+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(T.envFileWarning)}</div>`),T.resources?.cpus||T.resources?.memory)){const H=[];T.resources.cpus&&H.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&H.push(`Mem: ${T.resources.memory}MB`),b+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}b+="</div>"}b+="</div>",D.innerHTML=b}document.getElementById("compose-back-btn")?.addEventListener("click",()=>z("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!I)return;const M=document.getElementById("compose-deploy-btn");M.textContent="Deploying...",M.disabled=!0,z("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const b=await postJSON("/api/v1/apps/deploy-compose",{services:I.services,networks:I.networks,stackName:I.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(b.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const h of b.results){const H=h.status==="deployed"||h.status==="created"?"\u2705":h.status==="exists"?"\u26A1":h.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${H} <strong>${escapeHtml(h.name)}</strong> (${h.type}) \u2014 ${escapeHtml(h.status)}`,h.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(h.error)}</span>`),h.subdomain&&(T+=` \u2192 <code>${escapeHtml(h.subdomain)}</code>`),h.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(h.reason)})</span>`),T+="</div>"}T+="</div>",T+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated </div>`);const b=document.getElementById("compose-import-modal"),C=document.getElementById("compose-import-btn"),j=document.getElementById("compose-cancel");wireModal(b,j);let k=null;function z(H){document.getElementById("compose-step-paste").style.display=H==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=H==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=H==="progress"?"":"none"}C?.addEventListener("click",()=>{z("paste"),k=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",H=>{const A=H.target.files[0];if(!A)return;const x=new FileReader;x.onload=()=>{document.getElementById("compose-yaml").value=x.result},x.readAsText(A)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const H=document.getElementById("compose-yaml").value.trim(),A=document.getElementById("compose-stack-name").value.trim()||"stack";if(!H){showNotification("Paste a docker-compose.yml","warning");return}const x=document.getElementById("compose-parse-btn"),B=x.textContent;x.textContent="Parsing...",x.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:H,stackName:A});k=w,k.stackName=A,P(w),z("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{x.textContent=B,x.disabled=!1}});function P(H){const A=document.getElementById("compose-preview-content");let x="";H.networks&&H.networks.length>0&&(x+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${H.networks.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),H.volumes&&H.volumes.length>0&&(x+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${H.volumes.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),x+=`<div style="font-weight: 600; margin-bottom: 8px;">${H.services.length} service(s)</div>`,x+='<div class="scroll-container" style="max-height: 350px;">';for(const B of H.services){const w=B.skip?"var(--bad-fg)":"var(--border)";if(x+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,x+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(B.name)}`,B.skip&&(x+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(B.reason)}</span>`),x+="</div>",!B.skip&&(x+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(B.image)}</code></div>`,B.ports?.length&&(x+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${B.ports.map(M=>`${M.host}:${M.container}`).join(", ")}</div>`),B.volumes?.length&&(x+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${B.volumes.length}</div>`),Object.keys(B.environment||{}).length&&(x+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(B.environment).length}</div>`),B.envFileWarning&&(x+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(B.envFileWarning)}</div>`),B.resources?.cpus||B.resources?.memory)){const M=[];B.resources.cpus&&M.push(`CPU: ${B.resources.cpus}`),B.resources.memory&&M.push(`Mem: ${B.resources.memory}MB`),x+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${M.join(", ")}</div>`}x+="</div>"}x+="</div>",A.innerHTML=x}document.getElementById("compose-back-btn")?.addEventListener("click",()=>z("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!k)return;const H=document.getElementById("compose-deploy-btn");H.textContent="Deploying...",H.disabled=!0,z("progress");const A=document.getElementById("compose-progress-content");A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const x=await postJSON("/api/v1/apps/deploy-compose",{services:k.services,networks:k.networks,stackName:k.stackName});let B=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(x.stackName)}" \u2014 Deployment Complete</div>`;B+='<div class="scroll-container" style="max-height: 350px;">';for(const w of x.results){const M=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";B+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',B+=`${M} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(B+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(B+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(B+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),B+="</div>"}B+="</div>",B+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>z("paste"))}finally{M.textContent="Deploy All",M.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal"> <div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>z("paste"))}finally{H.textContent="Deploy All",H.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;"> <div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3> <h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div> <div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
@@ -1761,11 +1761,11 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="exec-close">Close</button> <button id="exec-close">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("exec-modal"),B=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let I=null,z=null,P=null;function M(){if(z){try{z.close()}catch{}z=null}if(I){try{I.dispose()}catch{}I=null}P=null,B.innerHTML=""}function D(b,T){if(M(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||b}`,k?.classList.add("show"),typeof Terminal>"u"){B.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}I=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,I.loadAddon(P)),I.open(B),P&&setTimeout(()=>P.fit(),50);const h=location.protocol==="https:"?"wss:":"ws:";z=new WebSocket(`${h}//${location.host}/ws/exec/${encodeURIComponent(b)}`),z.binaryType="arraybuffer",z.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),P){const S=P.proposeDimensions();S&&z.send(JSON.stringify({type:"resize",cols:S.cols,rows:S.rows}))}},z.onmessage=S=>{if(typeof S.data=="string"){try{const $=JSON.parse(S.data);if($.type==="connected"){I.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r </div>`);const b=document.getElementById("exec-modal"),C=document.getElementById("exec-terminal"),j=document.getElementById("exec-close");let k=null,z=null,P=null;function H(){if(z){try{z.close()}catch{}z=null}if(k){try{k.dispose()}catch{}k=null}P=null,C.innerHTML=""}function A(x,B){if(H(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${B||x}`,b?.classList.add("show"),typeof Terminal>"u"){C.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}k=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,k.loadAddon(P)),k.open(C),P&&setTimeout(()=>P.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";z=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(x)}`),z.binaryType="arraybuffer",z.onopen=()=>{if(k.writeln("\x1B[32mConnecting...\x1B[0m"),P){const L=P.proposeDimensions();L&&z.send(JSON.stringify({type:"resize",cols:L.cols,rows:L.rows}))}},z.onmessage=L=>{if(typeof L.data=="string"){try{const $=JSON.parse(L.data);if($.type==="connected"){k.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r
`);return}if($.type==="error"){I.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){I.writeln(`\r `);return}if($.type==="error"){k.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){k.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(S.data)}else I.write(new Uint8Array(S.data))},z.onclose=()=>{I&&I.writeln(`\r \x1B[33mSession ended.\x1B[0m`);return}}catch{}k.write(L.data)}else k.write(new Uint8Array(L.data))},z.onclose=()=>{k&&k.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},z.onerror=()=>{I&&I.writeln(`\r \x1B[33mDisconnected.\x1B[0m`)},z.onerror=()=>{k&&k.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},I.onData(S=>{z&&z.readyState===WebSocket.OPEN&&z.send(S)}),I.onResize(({cols:S,rows:$})=>{z&&z.readyState===WebSocket.OPEN&&z.send(JSON.stringify({type:"resize",cols:S,rows:$}))});const H=()=>{P&&P.fit()};window.addEventListener("resize",H),k._resizeHandler=H}A?.addEventListener("click",()=>{M(),k._resizeHandler&&window.removeEventListener("resize",k._resizeHandler),k?.classList.remove("show")}),k?.addEventListener("click",b=>{b.target===k&&(M(),k._resizeHandler&&window.removeEventListener("resize",k._resizeHandler),k?.classList.remove("show"))}),window.openExecModal=D})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal"> \x1B[31mConnection error.\x1B[0m`)},k.onData(L=>{z&&z.readyState===WebSocket.OPEN&&z.send(L)}),k.onResize(({cols:L,rows:$})=>{z&&z.readyState===WebSocket.OPEN&&z.send(JSON.stringify({type:"resize",cols:L,rows:$}))});const M=()=>{P&&P.fit()};window.addEventListener("resize",M),b._resizeHandler=M}j?.addEventListener("click",()=>{H(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",x=>{x.target===b&&(H(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=A})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;"> <div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
<h3>\u{1F4DC} Audit Log</h3> <h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1811,7 +1811,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="audit-cancel">Close</button> <button id="audit-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("audit-modal"),B=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),I=document.getElementById("audit-refresh-btn"),z=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),M=document.getElementById("audit-outcome-filter"),D=document.getElementById("audit-since"),b=document.getElementById("audit-until"),T=document.getElementById("audit-log-container"),h=document.getElementById("audit-load-more");let H=0,S=null,$=0;const f=50;function E(N){if(!N)return null;const R=new Date(N);return isNaN(R.getTime())?null:R.toISOString()}async function O(N){try{N?(S&&S.abort(),S=new AbortController):(S&&S.abort(),S=new AbortController,H=0,$++,T.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const R=$,u=new URLSearchParams;u.set("limit",String(f)),u.set("offset",String(H));const v=P.value,w=M.value,g=E(D.value),x=E(b.value);v&&u.set("action",v),w&&u.set("outcome",w),g&&u.set("since",g),x&&u.set("until",x);const s=await fetch("/api/v1/audit-logs?"+u.toString(),{signal:S.signal});if(!s.ok){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${s.status}</div>`,h.style.display="none";return}const p=await s.json();if(!p.success){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.error||"unknown")}</div>`,h.style.display="none";return}if(!N&&R!==$)return;const l=Array.isArray(p.entries)?p.entries:[];if(l.length===0&&!N){const c=p.filters&&(p.filters.action||p.filters.outcome||p.filters.since||p.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(c)}</div>`,h.style.display="none";return}let m="";N||(m='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',m+='<th style="padding: 6px; text-align: left;">When</th>',m+='<th style="padding: 6px; text-align: left;">Actor</th>',m+='<th style="padding: 6px; text-align: left;">IP</th>',m+='<th style="padding: 6px; text-align: left;">Action</th>',m+='<th style="padding: 6px; text-align: left;">Resource</th>',m+='<th style="padding: 6px; text-align: left;">Result</th>',m+="</tr>");for(const c of l){const t=c.outcome==="success",e=L(c);m+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',m+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(c.timestamp||"")}">${timeAgo(c.timestamp)}</td>`,m+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,m+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c.ip||"-")}</td>`,m+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(c.action||"-")}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(c.resource||"-")}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")}</span></td>`,m+="</tr>",c.details&&Object.keys(c.details).length>0&&(m+=`<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(c.details,null,2))}</pre></td></tr>`)}if(!N)m+="</table>",T.innerHTML=m;else{const c=T.querySelector("table");c&&c.insertAdjacentHTML("beforeend",m)}H+=l.length,h.style.display=p.hasMore?"":"none",T.querySelectorAll(".audit-row").forEach(c=>{c.dataset.wired||(c.dataset.wired="true",c.addEventListener("click",()=>{const t=c.nextElementSibling;t&&t.classList.contains("audit-detail")&&(t.style.display=t.style.display==="none"?"":"none")}))})}catch(R){if(R&&R.name==="AbortError")return;T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(R.message)}</div>`}}function L(N){const R=N.details||{},u=R.userEmail,v=R.userId,w=R.userRole,g=R.viaProvider;if(u){const x=w?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(w)}${g?"/"+escapeHtml(g):""}]</span>`:"";return`${escapeHtml(u)}${x}`}return v?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(v)}</span>`:N.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}B?.addEventListener("click",()=>{k?.classList.add("show"),O(!1)}),wireModal(k,A),I?.addEventListener("click",()=>O(!1)),P?.addEventListener("change",()=>O(!1)),M?.addEventListener("change",()=>O(!1));let j;[D,b].forEach(N=>{N?.addEventListener("change",()=>{clearTimeout(j),j=setTimeout(()=>O(!1),250)})}),h?.addEventListener("click",()=>O(!0)),z?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const R=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated </div>`);const b=document.getElementById("audit-modal"),C=document.getElementById("audit-log-btn"),j=document.getElementById("audit-cancel"),k=document.getElementById("audit-refresh-btn"),z=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),H=document.getElementById("audit-outcome-filter"),A=document.getElementById("audit-since"),x=document.getElementById("audit-until"),B=document.getElementById("audit-log-container"),w=document.getElementById("audit-load-more");let M=0,L=null,$=0;const u=50;function v(N){if(!N)return null;const O=new Date(N);return isNaN(O.getTime())?null:O.toISOString()}async function D(N){try{N?(L&&L.abort(),L=new AbortController):(L&&L.abort(),L=new AbortController,M=0,$++,B.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const O=$,m=new URLSearchParams;m.set("limit",String(u)),m.set("offset",String(M));const y=P.value,S=H.value,f=v(A.value),E=v(x.value);y&&m.set("action",y),S&&m.set("outcome",S),f&&m.set("since",f),E&&m.set("until",E);const s=await fetch("/api/v1/audit-logs?"+m.toString(),{signal:L.signal});if(!s.ok){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${s.status}</div>`,w.style.display="none";return}const p=await s.json();if(!p.success){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.error||"unknown")}</div>`,w.style.display="none";return}if(!N&&O!==$)return;const l=Array.isArray(p.entries)?p.entries:[];if(l.length===0&&!N){const c=p.filters&&(p.filters.action||p.filters.outcome||p.filters.since||p.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";B.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(c)}</div>`,w.style.display="none";return}let g="";N||(g='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',g+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',g+='<th style="padding: 6px; text-align: left;">When</th>',g+='<th style="padding: 6px; text-align: left;">Actor</th>',g+='<th style="padding: 6px; text-align: left;">IP</th>',g+='<th style="padding: 6px; text-align: left;">Action</th>',g+='<th style="padding: 6px; text-align: left;">Resource</th>',g+='<th style="padding: 6px; text-align: left;">Result</th>',g+="</tr>");for(const c of l){const t=c.outcome==="success",e=I(c);g+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',g+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(c.timestamp||"")}">${timeAgo(c.timestamp)}</td>`,g+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,g+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c.ip||"-")}</td>`,g+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(c.action||"-")}</td>`,g+=`<td style="padding: 6px;">${escapeHtml(c.resource||"-")}</td>`,g+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")}</span></td>`,g+="</tr>",c.details&&Object.keys(c.details).length>0&&(g+=`<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(c.details,null,2))}</pre></td></tr>`)}if(!N)g+="</table>",B.innerHTML=g;else{const c=B.querySelector("table");c&&c.insertAdjacentHTML("beforeend",g)}M+=l.length,w.style.display=p.hasMore?"":"none",B.querySelectorAll(".audit-row").forEach(c=>{c.dataset.wired||(c.dataset.wired="true",c.addEventListener("click",()=>{const t=c.nextElementSibling;t&&t.classList.contains("audit-detail")&&(t.style.display=t.style.display==="none"?"":"none")}))})}catch(O){if(O&&O.name==="AbortError")return;B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(O.message)}</div>`}}function I(N){const O=N.details||{},m=O.userEmail,y=O.userId,S=O.userRole,f=O.viaProvider;if(m){const E=S?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(S)}${f?"/"+escapeHtml(f):""}]</span>`:"";return`${escapeHtml(m)}${E}`}return y?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(y)}</span>`:N.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}C?.addEventListener("click",()=>{b?.classList.add("show"),D(!1)}),wireModal(b,j),k?.addEventListener("click",()=>D(!1)),P?.addEventListener("change",()=>D(!1)),H?.addEventListener("change",()=>D(!1));let R;[A,x].forEach(N=>{N?.addEventListener("change",()=>{clearTimeout(R),R=setTimeout(()=>D(!1),250)})}),w?.addEventListener("click",()=>D(!0)),z?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const O=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;"> <div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
<h3>\u{1F6E1}\uFE0F Security Center</h3> <h3>\u{1F6E1}\uFE0F Security Center</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1890,28 +1890,28 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="sec-cancel">Close</button> <button id="sec-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("security-modal"),B=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),I=k.querySelectorAll(".sec-tab"),z=k.querySelectorAll(".sec-panel");let P=[],M=[],D=null;I.forEach(s=>{s.addEventListener("click",()=>{I.forEach(p=>p.classList.toggle("active",p===s)),z.forEach(p=>p.style.display=p.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&H(),s.dataset.tab==="events"&&j(),s.dataset.tab==="hosts"&&v()})}),B&&B.addEventListener("click",()=>{k.classList.add("show"),H(),T()}),A.addEventListener("click",b),k.addEventListener("click",s=>{s.target===k&&b()});function b(){k.classList.remove("show"),h()}function T(){if(h(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",s=>{try{P=JSON.parse(s.data).events||[],N()}catch{}}),D.addEventListener("security",s=>{try{const p=JSON.parse(s.data);P.unshift(p),P.length>500&&(P.length=500);const l=k.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?N():l==="overview"&&H()}catch{}}),D.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function h(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{k.classList.contains("show")&&T()});async function H(){try{const s=new Date(Date.now()-864e5).toISOString(),[p,l,m]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),c=(await p.json()).data||{},t=(await l.json()).data?.hosts||[],e=(await m.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${c.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${c.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${c.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,S("sec-top-actors",c.top_actors||[]),S("sec-top-targets",c.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function S(s,p){const l=document.getElementById(s);if(!p.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+p.map(m=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(m.key))}</td><td style="text-align:right;color:var(--muted);">${m.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),f=document.getElementById("sec-filter-severity"),E=document.getElementById("sec-filter-host"),O=document.getElementById("sec-filter-actor"),L=document.getElementById("sec-refresh-btn");[$,f,E].forEach(s=>s.addEventListener("change",j)),O.addEventListener("input",x(j,250)),L.addEventListener("click",j);async function j(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),f.value&&s.set("severity",f.value),E.value&&s.set("source_host",E.value),O.value&&s.set("actor_prefix",O.value),P=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],N(),(!E.options.length||E.options.length===1)&&await u()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(s.message)+"</div>"}}function N(){const s=document.getElementById("sec-events-container");if(!P.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=P.slice(0,200).map(R).join("")}function R(s){const p=s.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[p]||"#7f8c8d",m=s.ts?new Date(s.ts).toLocaleTimeString():"",c=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",a=s.action||"",o=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;"> </div>`);const b=document.getElementById("security-modal"),C=document.getElementById("security-center-btn"),j=document.getElementById("sec-cancel"),k=b.querySelectorAll(".sec-tab"),z=b.querySelectorAll(".sec-panel");let P=[],H=[],A=null;k.forEach(s=>{s.addEventListener("click",()=>{k.forEach(p=>p.classList.toggle("active",p===s)),z.forEach(p=>p.style.display=p.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&M(),s.dataset.tab==="events"&&R(),s.dataset.tab==="hosts"&&y()})}),C&&C.addEventListener("click",()=>{b.classList.add("show"),M(),B()}),j.addEventListener("click",x),b.addEventListener("click",s=>{s.target===b&&x()});function x(){b.classList.remove("show"),w()}function B(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{A=new EventSource("/api/v1/security/events/stream"),A.addEventListener("init",s=>{try{P=JSON.parse(s.data).events||[],N()}catch{}}),A.addEventListener("security",s=>{try{const p=JSON.parse(s.data);P.unshift(p),P.length>500&&(P.length=500);const l=b.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?N():l==="overview"&&M()}catch{}}),A.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function w(){if(A){try{A.close()}catch{}A=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{b.classList.contains("show")&&B()});async function M(){try{const s=new Date(Date.now()-864e5).toISOString(),[p,l,g]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),c=(await p.json()).data||{},t=(await l.json()).data?.hosts||[],e=(await g.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${c.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${c.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${c.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,L("sec-top-actors",c.top_actors||[]),L("sec-top-targets",c.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function L(s,p){const l=document.getElementById(s);if(!p.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+p.map(g=>`<tr><td style="padding:3px 0;word-break:break-all;">${f(String(g.key))}</td><td style="text-align:right;color:var(--muted);">${g.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),u=document.getElementById("sec-filter-severity"),v=document.getElementById("sec-filter-host"),D=document.getElementById("sec-filter-actor"),I=document.getElementById("sec-refresh-btn");[$,u,v].forEach(s=>s.addEventListener("change",R)),D.addEventListener("input",E(R,250)),I.addEventListener("click",R);async function R(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),u.value&&s.set("severity",u.value),v.value&&s.set("source_host",v.value),D.value&&s.set("actor_prefix",D.value),P=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],N(),(!v.options.length||v.options.length===1)&&await m()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+f(s.message)+"</div>"}}function N(){const s=document.getElementById("sec-events-container");if(!P.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=P.slice(0,200).map(O).join("")}function O(s){const p=s.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[p]||"#7f8c8d",g=s.ts?new Date(s.ts).toLocaleTimeString():"",c=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",a=s.action||"",o=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${l};font-weight:600;">${g(p)}</span> <span style="color:${l};font-weight:600;">${f(p)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(c)}</span> <span style="color:var(--muted);font-size:0.75rem;">${f(c)}</span>
<span title="${g(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(t)}</span> <span title="${f(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${f(t)}</span>
<span title="${g(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(a)} ${g(e)}</span> <span title="${f(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${f(a)} ${f(e)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(o)}</span> <span style="color:var(--muted);font-size:0.75rem;">${f(o)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(m)}</span> <span style="color:var(--muted);font-size:0.75rem;text-align:right;">${f(g)}</span>
</div>`}async function u(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=E.value;E.innerHTML='<option value="">All hosts</option>'+p.map(m=>`<option value="${g(m.id)}">${g(m.label||m.id)}</option>`).join(""),l&&(E.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",w),document.getElementById("sec-hosts-refresh").addEventListener("click",v);async function v(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];M=p;const l=document.getElementById("sec-hosts-container");if(!p.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=p.map(m=>{const c=m.enabled?m.last_seen_at?Date.now()-Date.parse(m.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;"> </div>`}async function m(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=v.value;v.innerHTML='<option value="">All hosts</option>'+p.map(g=>`<option value="${f(g.id)}">${f(g.label||g.id)}</option>`).join(""),l&&(v.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",S),document.getElementById("sec-hosts-refresh").addEventListener("click",y);async function y(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];H=p;const l=document.getElementById("sec-hosts-container");if(!p.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=p.map(g=>{const c=g.enabled?g.last_seen_at?Date.now()-Date.parse(g.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<div> <div>
<strong>${g(m.label||m.id)}</strong> <strong>${f(g.label||g.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(m.type)}</span> <span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${f(g.type)}</span>
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;"> <div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
id: ${g(m.id)} \xB7 id: ${f(g.id)} \xB7
registered ${new Date(m.registered_at).toLocaleDateString()} \xB7 registered ${new Date(g.registered_at).toLocaleDateString()} \xB7
last seen ${m.last_seen_at?new Date(m.last_seen_at).toLocaleString():"never"} last seen ${g.last_seen_at?new Date(g.last_seen_at).toLocaleString():"never"}
</div> </div>
</div> </div>
<div> <div>
<span style="font-size:0.85rem;margin-right:12px;">${c}</span> <span style="font-size:0.85rem;margin-right:12px;">${c}</span>
${m.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${g(m.id)}" style="color:var(--bad-fg);">Remove</button>`} ${g.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${f(g.id)}" style="color:var(--bad-fg);">Remove</button>`}
</div> </div>
</div>`}).join(""),l.querySelectorAll(".sec-host-del").forEach(m=>{m.addEventListener("click",async()=>{confirm(`Remove host ${m.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(m.dataset.id)}`,{method:"DELETE"}),v())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+g(s.message)+"</div>"}}async function w(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const p=prompt("Display label:",s)||s,l=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const m=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:p,type:l})}),c=await m.json();if(!m.ok){alert("Failed: "+(c?.error?.message||m.statusText));return}alert(`\u2705 Host registered! </div>`}).join(""),l.querySelectorAll(".sec-host-del").forEach(g=>{g.addEventListener("click",async()=>{confirm(`Remove host ${g.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(g.dataset.id)}`,{method:"DELETE"}),y())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+f(s.message)+"</div>"}}async function S(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const p=prompt("Display label:",s)||s,l=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const g=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:p,type:l})}),c=await g.json();if(!g.ok){alert("Failed: "+(c?.error?.message||g.statusText));return}alert(`\u2705 Host registered!
id: ${c.data.host.id} id: ${c.data.host.id}
label: ${c.data.host.label} label: ${c.data.host.label}
@@ -1922,7 +1922,59 @@ type: ${c.data.host.type}
${c.data.api_key} ${c.data.api_key}
Send this key as: Authorization: Bearer <api_key> Send this key as: Authorization: Bearer <api_key>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m){alert("Failed: "+m.message)}}function g(s){return String(s).replace(/[&<>"']/g,p=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[p])}function x(s,p){let l;return function(){clearTimeout(l),l=setTimeout(()=>s.apply(this,arguments),p)}}})(),(function(){const k=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3> To endpoint: POST /api/v1/security/events/ingest or /events/batch`),y()}catch(g){alert("Failed: "+g.message)}}function f(s){return String(s).replace(/[&<>"']/g,p=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[p])}function E(s,p){let l;return function(){clearTimeout(l),l=setTimeout(()=>s.apply(this,arguments),p)}}})(),(function(){injectModal("deploys-modal",`<div id="deploys-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 860px; max-width: 1200px;">
<h3>\u{1F69A} Deploys</h3>
<p class="modal-subtitle">
Source deploys via shipdeck: build \u2192 package \u2192 systemd release \u2192 Caddy gate \u2192 DNS \u2192 verify.
</p>
<div class="sec-tabs" style="display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);">
<button class="dep-tab active" data-tab="services">Services</button>
<button class="dep-tab" data-tab="deploy">Deploy</button>
<button class="dep-tab" data-tab="journal">Journal</button>
</div>
<!-- SERVICES TAB -->
<div class="dep-panel" data-panel="services">
<div id="dep-services" class="scroll-container" style="max-height:320px;">Loading\u2026</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;">
<input id="dep-status-name" placeholder="service name" style="width:220px;" />
<button class="btn" id="dep-status-btn">Check status</button>
</div>
</div>
<!-- DEPLOY TAB -->
<div class="dep-panel" data-panel="deploy" style="display:none;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">
<select id="dep-repo-select" style="min-width:280px;"><option>Loading\u2026</option></select>
<button class="btn btn-primary" id="dep-deploy-btn">Deploy</button>
</div>
<p class="modal-subtitle">Deploys are serialized fleet-side; a run takes ~30-60s. Output streams below when it finishes.</p>
</div>
<!-- JOURNAL TAB -->
<div class="dep-panel" data-panel="journal" style="display:none;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">
<input id="dep-journal-name" placeholder="service (empty = all)" style="width:220px;" />
<button class="btn" id="dep-journal-btn">Refresh</button>
</div>
<div id="dep-journal" class="scroll-container" style="max-height:320px;">Loading\u2026</div>
</div>
<!-- OUTPUT -->
<pre id="dep-output" class="scroll-container" style="display:none;max-height:260px;margin-top:12px;background:var(--bg-2,#111);padding:10px;border-radius:8px;white-space:pre-wrap;"></pre>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:14px;">
<button class="btn" id="dep-close">Close</button>
</div>
</div>
</div>`);const b=u=>document.getElementById(u);let C=null,j=!1;function k(u){return window.escapeHtml?window.escapeHtml(String(u)):String(u).replace(/[&<>"']/g,v=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[v])}async function z(u,v){const D=await fetch("/dashcaddy-api/api/v1/deploys"+u,Object.assign({credentials:"include"},v||{}));let I=null;try{I=await D.json()}catch{I={success:!1,error:"non-JSON response"}}return{status:D.status,body:I}}function P(u){const v=b("dep-output");v.style.display="block",v.textContent=u||"(no output)",v.scrollTop=v.scrollHeight}async function H(){const u=b("dep-services"),{body:v}=await z("/services");if(!v.success){u.innerHTML="<em>"+k(v.error||"unavailable")+"</em>";return}const D=v.services||[];if(!D.length){u.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}u.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th></tr></thead><tbody>'+D.map(I=>"<tr><td><strong>"+k(I.name)+"</strong></td><td>"+k(I.last_action)+"</td><td>"+k(I.last_time)+"</td><td>"+k(I.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+k(I.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+k(I.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function A(){const u=b("dep-repo-select"),{body:v}=await z("/repos");if(!v.success){u.innerHTML="<option>"+k(v.error||"unavailable")+"</option>";return}const D=v.repos||[];u.innerHTML=D.length?D.map(I=>'<option value="'+k(I.dir)+'">'+k(I.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function x(u){const v=b("dep-journal"),D=u?"?service="+encodeURIComponent(u):"",{body:I}=await z("/journal"+D),R=I.rows||[];if(!R.length){v.innerHTML="<em>No journal rows.</em>";return}v.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th></tr></thead><tbody>'+R.map(N=>"<tr><td>"+k(N.time)+"</td><td>"+k(N.service)+"</td><td>"+k(N.action)+"</td><td>"+k(N.epoch)+"</td><td>"+k(N.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}function B(u,v,D){v&&(v.disabled=u,D&&(v.textContent=u?"Working\u2026":D))}async function w(){const u=b("dep-deploy-btn"),v=b("dep-repo-select").value;if(v){B(!0,u,"Deploy"),P("Deploying "+v+`
This can take ~30-60s\u2026`);try{const{body:D}=await z("/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir:v})});P((D.output||D.error||"")+(D.success?`
\u2705 SUCCESS`:`
\u274C FAILED`)),H()}catch(D){P("deploy error: "+D.message)}B(!1,u,"Deploy")}}async function M(u,v){if(confirm("Roll back "+u+" to the previous release?")){B(!0,v,"Rollback"),P("Rolling back "+u+"\u2026");try{const{body:D}=await z("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:u})});P((D.output||D.error||"")+(D.success?`
\u2705 ROLLED BACK`:`
\u274C FAILED`)),H()}catch(D){P("rollback error: "+D.message)}B(!1,v,"Rollback")}}async function L(u,v){B(!0,v,"Status");try{const{body:D}=await z("/status?service="+encodeURIComponent(u));P(D.output||D.error||"(no output)")}catch(D){P("status error: "+D.message)}B(!1,v,"Status")}function $(){C=document.getElementById("deploys-btn"),C&&(C.addEventListener("click",async()=>{b("deploys-modal").classList.add("open"),j||(j=!0,H(),A(),x(""))}),b("dep-close").addEventListener("click",()=>{b("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(u=>{u.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(v=>v.classList.remove("active")),u.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(v=>{v.style.display=v.dataset.panel===u.dataset.tab?"block":"none"})})}),b("dep-deploy-btn").addEventListener("click",w),b("dep-journal-btn").addEventListener("click",()=>x(b("dep-journal-name").value.trim())),b("dep-status-btn").addEventListener("click",()=>{const u=b("dep-status-name").value.trim();u&&L(u)}),b("dep-services").addEventListener("click",u=>{const v=u.target.closest(".dep-rollback");if(v)return M(v.dataset.service,v);const D=u.target.closest(".dep-status");if(D)return L(D.dataset.service,D)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",$):$()})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
<label for="weather-location-input">Location:</label> <label for="weather-location-input">Location:</label>
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100"> <input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div> <div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -1933,23 +1985,23 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label> <label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label>
</div> </div>
</div> </div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const B="weather-location",A="weather-zip",I="weather-geo",z="weather-unit";!safeGet(B)&&safeGet(A)&&safeSet(B,safeGet(A));function P(){return safeGet(z)||"imperial"}function M(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},b={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function h(L){return T[Math.round(L/22.5)%16]}async function H(L){const j=safeGet(I);if(j)try{const w=JSON.parse(j);if(w.query===L)return w}catch{}const N=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(L)}&count=1&language=en&format=json`);if(!N.ok)throw new Error("Geocoding failed");const R=await N.json();if(!R.results||!R.results.length)throw new Error("Location not found");const u=R.results[0],v={query:L,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(I,JSON.stringify(v)),v}function S(L){return L.countryCode==="US"&&L.state?`${L.city}, ${L.state}`:L.country?`${L.city}, ${L.country}`:L.city}async function $(L){try{const j=await H(L),N=P(),R=N==="metric"?"celsius":"fahrenheit",u=N==="metric"?"kmh":"mph",v=`https://api.open-meteo.com/v1/forecast?latitude=${j.lat}&longitude=${j.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${R}&wind_speed_unit=${u}`,w=await fetch(v);if(!w.ok)throw new Error("Weather fetch failed");const x=(await w.json()).current,s=x.weather_code;return{temp:Math.round(x.temperature_2m),condition:D[s]||"Unknown",icon:b[s]||"\u{1F324}\uFE0F",locationStr:S(j),windSpeed:Math.round(x.wind_speed_10m),windDir:h(x.wind_direction_10m),unit:N}}catch(j){return console.warn("Weather fetch failed:",j),null}}async function f(){const L=M();if(!L.icon||!L.temp||!L.condition||!L.location||!L.wind){console.warn("Weather widget elements not found");return}const j=safeGet(B);if(!j){L.location.textContent="Set Location",L.temp.textContent="--\xB0",L.condition.textContent="Click \u2699\uFE0F to configure",L.wind.textContent="--",L.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const N=await $(j);if(N){const R=N.unit==="metric"?"\xB0C":"\xB0F",u=N.unit==="metric"?"km/h":"mph";L.location.textContent=N.locationStr,L.temp.textContent=`${N.temp}${R}`,L.condition.textContent=N.condition,L.wind.textContent=`Wind: ${N.windSpeed} ${u} ${N.windDir}`,L.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(N.icon)}</span>`}}catch(N){k.logError("[Weather] Update Error",N,{function:"updateWeather"}),L.location.textContent="Weather Error",L.temp.textContent="Error",L.condition.textContent="Failed to load",L.wind.textContent="--"}}const E=document.getElementById("weather-modal"),O=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{O.value=safeGet(B)||"";const L=P(),j=E.querySelector(`input[name="weather-unit-radio"][value="${L}"]`);j&&(j.checked=!0),E.classList.add("show"),O.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{E.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const L=O.value.trim();if(L){safeGet(B)!==L&&safeSet(I,""),safeSet(B,L);const N=E.querySelector('input[name="weather-unit-radio"]:checked'),R=N?N.value:"imperial",u=P();safeSet(z,R),u!==R&&safeSet(I,""),E.classList.remove("show"),f()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(E),document.addEventListener("keydown",L=>{L.keyLine truncated <div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const C="weather-location",j="weather-zip",k="weather-geo",z="weather-unit";!safeGet(C)&&safeGet(j)&&safeSet(C,safeGet(j));function P(){return safeGet(z)||"imperial"}function H(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const A={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},x={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},B=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(I){return B[Math.round(I/22.5)%16]}async function M(I){const R=safeGet(k);if(R)try{const S=JSON.parse(R);if(S.query===I)return S}catch{}const N=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(I)}&count=1&language=en&format=json`);if(!N.ok)throw new Error("Geocoding failed");const O=await N.json();if(!O.results||!O.results.length)throw new Error("Location not found");const m=O.results[0],y={query:I,lat:m.latitude,lon:m.longitude,city:m.name,state:m.admin1||"",country:m.country||"",countryCode:m.country_code||""};return safeSet(k,JSON.stringify(y)),y}function L(I){return I.countryCode==="US"&&I.state?`${I.city}, ${I.state}`:I.country?`${I.city}, ${I.country}`:I.city}async function $(I){try{const R=await M(I),N=P(),O=N==="metric"?"celsius":"fahrenheit",m=N==="metric"?"kmh":"mph",y=`https://api.open-meteo.com/v1/forecast?latitude=${R.lat}&longitude=${R.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${O}&wind_speed_unit=${m}`,S=await fetch(y);if(!S.ok)throw new Error("Weather fetch failed");const E=(await S.json()).current,s=E.weather_code;return{temp:Math.round(E.temperature_2m),condition:A[s]||"Unknown",icon:x[s]||"\u{1F324}\uFE0F",locationStr:L(R),windSpeed:Math.round(E.wind_speed_10m),windDir:w(E.wind_direction_10m),unit:N}}catch(R){return console.warn("Weather fetch failed:",R),null}}async function u(){const I=H();if(!I.icon||!I.temp||!I.condition||!I.location||!I.wind){console.warn("Weather widget elements not found");return}const R=safeGet(C);if(!R){I.location.textContent="Set Location",I.temp.textContent="--\xB0",I.condition.textContent="Click \u2699\uFE0F to configure",I.wind.textContent="--",I.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const N=await $(R);if(N){const O=N.unit==="metric"?"\xB0C":"\xB0F",m=N.unit==="metric"?"km/h":"mph";I.location.textContent=N.locationStr,I.temp.textContent=`${N.temp}${O}`,I.condition.textContent=N.condition,I.wind.textContent=`Wind: ${N.windSpeed} ${m} ${N.windDir}`,I.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(N.icon)}</span>`}}catch(N){b.logError("[Weather] Update Error",N,{function:"updateWeather"}),I.location.textContent="Weather Error",I.temp.textContent="Error",I.condition.textContent="Failed to load",I.wind.textContent="--"}}const v=document.getElementById("weather-modal"),D=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{D.value=safeGet(C)||"";const I=P(),R=v.querySelector(`input[name="weather-unit-radio"][value="${I}"]`);R&&(R.checked=!0),v.classList.add("show"),D.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{v.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const I=D.value.trim();if(I){safeGet(C)!==I&&safeSet(k,""),safeSet(C,I);const N=v.querySelector('input[name="weather-unit-radio"]:checked'),O=N?N.value:"imperial",m=P();safeSet(z,O),m!==O&&safeSet(k,""),v.classList.remove("show"),u()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(v),document.addEventListener("keydown",I=>{I.keyLine truncated
<circle cx="${r}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/> <circle cx="${r}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F} ${F}
${q} ${q}
<line x1="${r}" y1="${d}" x2="${r+28*Math.cos(U*Math.PI/180)}" y2="${d+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/> <line x1="${r}" y1="${d}" x2="${r+28*Math.cos(U*Math.PI/180)}" y2="${d+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${r}" y1="${d}" x2="${r+38*Math.cos(C*Math.PI/180)}" y2="${d+38*Math.sin(C*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/> <line x1="${r}" y1="${d}" x2="${r+38*Math.cos(T*Math.PI/180)}" y2="${d+38*Math.sin(T*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${r}" y1="${d}" x2="${r+42*Math.cos(y*Math.PI/180)}" y2="${d+42*Math.sin(y*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/> <line x1="${r}" y1="${d}" x2="${r+42*Math.cos(h*Math.PI/180)}" y2="${d+42*Math.sin(h*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${r}" cy="${d}" r="3" fill="var(--fg)"/> <circle cx="${r}" cy="${d}" r="3" fill="var(--fg)"/>
</svg>`,J=t.getHours()>=12?"PM":"AM";B.innerHTML=`<div class="analog-clock-wrap">${_}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(t)}</span></div></div>`,T="analog"}function v(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(k.className!==i&&(k.className=i),P){case"lcd":L(t);break;case"lcd-blue":L(t);break;case"lcd-amber":L(t);break;case"lcd-retro":L(t);break;case"lcd-taxi":L(t);break;case"flip":j(t);break;case"binary":R(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:O(t)}a===0&&o===0&&e!==M&&(M=e,S(e)),a!==0&&(M=-1)}function w(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{v(),w()},e)}document.addEventListener("visibilitychange",()=>{b="",f(),v(),w()}),v(),w();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let x='<div class="clock-style-grid">';g.forEach(t=>{x+=`<label class="clock-style-option"> </svg>`,J=t.getHours()>=12?"PM":"AM";C.innerHTML=`<div class="analog-clock-wrap">${_}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(t)}</span></div></div>`,B="analog"}function y(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==i&&(b.className=i),P){case"lcd":I(t);break;case"lcd-blue":I(t);break;case"lcd-amber":I(t);break;case"lcd-retro":I(t);break;case"lcd-taxi":I(t);break;case"flip":R(t);break;case"binary":O(t);break;case"analog":m(t,!1);break;case"roman":m(t,!0);break;default:D(t)}a===0&&o===0&&e!==H&&(H=e,L(e)),a!==0&&(H=-1)}function S(){clearTimeout(M);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;M=setTimeout(()=>{y(),S()},e)}document.addEventListener("visibilitychange",()=>{x="",u(),y(),S()}),y(),S();const f=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let E='<div class="clock-style-grid">';f.forEach(t=>{E+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${t.id}"> <input type="radio" name="clock-style-radio" value="${t.id}">
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span> <span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
</label>`}),x+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal"> </label>`}),E+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="max-width: 420px;"> <div class="weather-modal-content" style="max-width: 420px;">
<h3>Clock Settings</h3> <h3>Clock Settings</h3>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label> <label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${x} ${E}
</div> </div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);"> <div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);"> <label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -1973,7 +2025,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<button id="clock-settings-save">Save</button> <button id="clock-settings-save">Save</button>
</div> </div>
</div> </div>
</div>`);const s=document.getElementById("clock-settings-modal"),p=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),m=document.getElementById("clock-volume-section");function c(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),p.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",m.style.opacity=p.checked?"1":"0.4"}p?.addEventListener("change",()=>{m.style.opacity=p.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{c(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(p.checked)),safeSet("clock-chime-volume",l.value),P=e,b="",f(),v(),w(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,b="",f(),v()})})})(),(function(){async function k(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[b,T]of Object.entries(D.status)){const h=document.getElementById("uptime-"+b),H=document.getElementById("uptime-bar-"+b);if(!h)continue;const S=T.uptime?.["24h"];if(S!=null){const $=S.toFixed(1);h.textContent=`${$}% uptime`,h.className="uptime-chip",S>=99.9?h.classList.add("excellent"):S>=99?h.classList.add("good"):S>=95?h.classList.add("degraded"):h.classList.add("poor"),H&&(H.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let B;try{B=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{B=new Set}let A=[];async function I(M){try{const b=await(await fetch("/api/v1/updates/available")).json();if(!b.success)return;document.querySelectorAll(".update-available-badge").forEach(h=>h.classList.remove("visible"));const T=b.updates||[];if(A=T,M&&T.length>0){const h=window._lastKnownUpdateCount||0;h>0&&T.length>h&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const h of T){const H=window.APPS||[];for(const S of H)if(S.containerId===h.containerId||S.id===h.containerName||S.name===h.containerName){if(B.has(S.id))break;const $=document.getElementById("update-badge-"+S.id),f=document.getElementById("update-btn-"+S.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=E=>{E.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(S.id)}),f&&(f.style.background="#f97316",f.style.borderColor="#f97316",f.style.boxShadow="0 0 6px #f9731688",f.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function z(){setTimeout(()=>{k(),I()},5e3),setInterval(()=>{k(),I(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(k,1e3)}catch(M){console.warn("[Card Badges] Error in refreshAll hook:",M.message)}}),z()})(),(function(){var k=null,B=null,A={},I={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},z=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var M=document.getElementById("theme-label");function D(o){if(I[o])return I[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function b(){M&&(M.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),r=o[(n+1)%o.length];window.applyTheme(r),b()}),b();function T(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated </div>`);const s=document.getElementById("clock-settings-modal"),p=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),g=document.getElementById("clock-volume-section");function c(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),p.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",g.style.opacity=p.checked?"1":"0.4"}p?.addEventListener("change",()=>{g.style.opacity=p.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{c(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(p.checked)),safeSet("clock-chime-volume",l.value),P=e,x="",u(),y(),S(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,x="",u(),y()})})})(),(function(){async function b(){try{const A=await(await fetch("/api/v1/health-checks/status")).json();if(!A.success||!A.status)return;for(const[x,B]of Object.entries(A.status)){const w=document.getElementById("uptime-"+x),M=document.getElementById("uptime-bar-"+x);if(!w)continue;const L=B.uptime?.["24h"];if(L!=null){const $=L.toFixed(1);w.textContent=`${$}% uptime`,w.className="uptime-chip",L>=99.9?w.classList.add("excellent"):L>=99?w.classList.add("good"):L>=95?w.classList.add("degraded"):w.classList.add("poor"),M&&(M.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let C;try{C=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{C=new Set}let j=[];async function k(H){try{const x=await(await fetch("/api/v1/updates/available")).json();if(!x.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const B=x.updates||[];if(j=B,H&&B.length>0){const w=window._lastKnownUpdateCount||0;w>0&&B.length>w&&showNotification(`${B.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=B.length}if(!B.length)return;for(const w of B){const M=window.APPS||[];for(const L of M)if(L.containerId===w.containerId||L.id===w.containerName||L.name===w.containerName){if(C.has(L.id))break;const $=document.getElementById("update-badge-"+L.id),u=document.getElementById("update-btn-"+L.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=v=>{v.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(L.id)}),u&&(u.style.background="#f97316",u.style.borderColor="#f97316",u.style.boxShadow="0 0 6px #f9731688",u.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function z(){setTimeout(()=>{b(),k()},5e3),setInterval(()=>{b(),k(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(b,1e3)}catch(H){console.warn("[Card Badges] Error in refreshAll hook:",H.message)}}),z()})(),(function(){var b=null,C=null,j={},k={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},z=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var H=document.getElementById("theme-label");function A(o){if(k[o])return k[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function x(){H&&(H.textContent=A(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),r=o[(n+1)%o.length];window.applyTheme(r),x()}),x();function B(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3> <h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;"> <p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -2010,16 +2062,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<button id="license-activate" class="btn-accent">Activate</button> <button id="license-activate" class="btn-accent">Activate</button>
</div> </div>
</div> </div>
</div>`);const k=document.getElementById("license-modal"),B=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),I=document.getElementById("license-deactivate"),z=document.getElementById("license-error"),P=document.getElementById("license-success"),M=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),b=document.getElementById("license-badge"),T=document.getElementById("license-details"),h=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let S=null;function $(){z.style.display="none",P.style.display="none"}function f(w){$(),z.textContent=w,z.style.display="block"}function E(w){$(),P.textContent=w,P.style.display="block"}function O(w){if(S=w,w.active){b.style.background="rgba(46,204,113,0.15)",b.style.color="var(--ok-fg)",M.textContent="\u2605",D.textContent="Premium Active";const s=w.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(w.expiresAt).toLocaleDateString()}</strong> (${w.daysRemaining} days remaining)</div>`;T.innerHTML=` </div>`);const b=document.getElementById("license-modal"),C=document.getElementById("license-code-input"),j=document.getElementById("license-activate"),k=document.getElementById("license-deactivate"),z=document.getElementById("license-error"),P=document.getElementById("license-success"),H=document.getElementById("license-badge-icon"),A=document.getElementById("license-badge-text"),x=document.getElementById("license-badge"),B=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),M=document.getElementById("license-activate-section");let L=null;function $(){z.style.display="none",P.style.display="none"}function u(S){$(),z.textContent=S,z.style.display="block"}function v(S){$(),P.textContent=S,P.style.display="block"}function D(S){if(L=S,S.active){x.style.background="rgba(46,204,113,0.15)",x.style.color="var(--ok-fg)",H.textContent="\u2605",A.textContent="Premium Active";const s=S.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(S.expiresAt).toLocaleDateString()}</strong> (${S.daysRemaining} days remaining)</div>`;B.innerHTML=`
<div>Code: <code style="font-family: monospace;">${w.code||"***"}</code></div> <div>Code: <code style="font-family: monospace;">${S.code||"***"}</code></div>
${s} ${s}
`,H.style.display="none",A.style.display="none",I.style.display=""}else b.style.background="rgba(149,165,166,0.15)",b.style.color="var(--muted)",M.textContent="\u2606",D.textContent=w.expired?"License Expired":"Free Tier",T.innerHTML=w.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",A.style.display="",I.style.display="none";const g=w.premiumFeatures||{},x=new Set(w.features||[]);h.innerHTML=Object.entries(g).map(([s,p])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);"> `,M.style.display="none",j.style.display="none",k.style.display=""}else x.style.background="rgba(149,165,166,0.15)",x.style.color="var(--muted)",H.textContent="\u2606",A.textContent=S.expired?"License Expired":"Free Tier",B.innerHTML=S.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",M.style.display="",j.style.display="",k.style.display="none";const f=S.premiumFeatures||{},E=new Set(S.features||[]);w.innerHTML=Object.entries(f).map(([s,p])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${x.has(s)?"\u2705":"\u{1F512}"}</span> <span style="font-size: 1.1rem;">${E.has(s)?"\u2705":"\u{1F512}"}</span>
<div> <div>
<div style="font-weight: 600; font-size: 0.9rem;">${p.name}</div> <div style="font-weight: 600; font-size: 0.9rem;">${p.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${p.description}</div> <div style="font-size: 0.78rem; color: var(--muted);">${p.description}</div>
</div> </div>
</div>`).join("")}async function L(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(O(g.license),R(g.license))}catch(w){console.warn("Failed to load license status:",w.message)}}async function j(){const w=B.value.trim();if(!w){f("Please enter a license code.");return}$(),A.disabled=!0,A.textContent="Activating...";try{const x=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:w})})).json();x.success?(E(x.message),B.value="",O(x.license),showNotification("License activated! Premium features unlocked.","success",5e3),R(x.license)):f(x.error||"Activation failed")}catch(g){f("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function N(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(E(g.message),await L(),showNotification("License deactivated.","info",3e3),R({active:!1})):f(g.error||"Deactivation failed")}catch(w){f("Network error: "+w.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function R(w){const g=document.getElementById("license-status-topbar"),x=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),p=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(w.active?"premium":"free"),w.active)if(x.textContent="\u2605",s.textContent="PREMIUM",w.lifetime)p.textContent="\xB7 LIFETIME";else{const l=w.daysRemaining;p.textContent=l!=null?"\xB7 "+l+"d remaining":""}else x.textContent="\u2606",s.textContent=w.expired?"EXPIRED":"FREE TIER",p.textContent=""}function u(){$(),L(),k.classList.add("show")}B.addEventListener("input",function(){let w=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(w.length>this._prevLength&&(w=w.replace(/-/g,""),w.length>2&&!w.startsWith("DC")&&(w="DC"+w),w.startsWith("DC")&&w.length>2)){const g=["DC"],x=w.substring(2);for(let s=0;s<x.length;s+=5)g.push(x.substring(s,s+5));w=g.join("-")}this._prevLength=w.length,this.value=w}),A.addEventListener("click",j),I.addEventListener("click",N),B.addEventListener("keydown",w=>{w.key==="Enter"&&j()}),wireModal(k,document.getElementById("license-cancel"));const v=document.getElementById("license-status-topbar");v&&v.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(w){try{return(await(await fetch(`/api/v1/license/feature/${w}`)).json()).available}catch{return!1}},L().then(w=>{S&&R(S)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const k=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",` </div>`).join("")}async function I(){try{const f=await(await fetch("/api/v1/license/status")).json();f.success&&(D(f.license),O(f.license))}catch(S){console.warn("Failed to load license status:",S.message)}}async function R(){const S=C.value.trim();if(!S){u("Please enter a license code.");return}$(),j.disabled=!0,j.textContent="Activating...";try{const E=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:S})})).json();E.success?(v(E.message),C.value="",D(E.license),showNotification("License activated! Premium features unlocked.","success",5e3),O(E.license)):u(E.error||"Activation failed")}catch(f){u("Network error: "+f.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function N(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){k.disabled=!0,k.textContent="Deactivating...";try{const f=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();f.success?(v(f.message),await I(),showNotification("License deactivated.","info",3e3),O({active:!1})):u(f.error||"Deactivation failed")}catch(S){u("Network error: "+S.message)}finally{k.disabled=!1,k.textContent="Deactivate"}}}function O(S){const f=document.getElementById("license-status-topbar"),E=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),p=document.getElementById("license-topbar-time");if(f)if(f.className="license-status-topbar "+(S.active?"premium":"free"),S.active)if(E.textContent="\u2605",s.textContent="PREMIUM",S.lifetime)p.textContent="\xB7 LIFETIME";else{const l=S.daysRemaining;p.textContent=l!=null?"\xB7 "+l+"d remaining":""}else E.textContent="\u2606",s.textContent=S.expired?"EXPIRED":"FREE TIER",p.textContent=""}function m(){$(),I(),b.classList.add("show")}C.addEventListener("input",function(){let S=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(S.length>this._prevLength&&(S=S.replace(/-/g,""),S.length>2&&!S.startsWith("DC")&&(S="DC"+S),S.startsWith("DC")&&S.length>2)){const f=["DC"],E=S.substring(2);for(let s=0;s<E.length;s+=5)f.push(E.substring(s,s+5));S=f.join("-")}this._prevLength=S.length,this.value=S}),j.addEventListener("click",R),k.addEventListener("click",N),C.addEventListener("keydown",S=>{S.key==="Enter"&&R()}),wireModal(b,document.getElementById("license-cancel"));const y=document.getElementById("license-status-topbar");y&&y.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=m,window.checkPremiumFeature=async function(S){try{return(await(await fetch(`/api/v1/license/feature/${S}`)).json()).available}catch{return!1}},I().then(S=>{L&&O(L)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const b=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title"> <div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;"> <div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3> <h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
@@ -2041,7 +2093,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
<div class="share-tab-panel" data-panel="public"> <div class="share-tab-panel" data-panel="public">
<label class="form-label-bold" for="share-public-ttl">Link duration:</label> <label class="form-label-bold" for="share-public-ttl">Link duration:</label>
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;"> <select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
${k.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")} ${b.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
</select> </select>
<button id="share-public-create" class="btn-accent" type="button" <button id="share-public-create" class="btn-accent" type="button"
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;"> style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
@@ -2088,17 +2140,17 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
</div> </div>
</div> </div>
</div> </div>
`);const B=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),I=document.getElementById("share-issued"),z=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),M=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),b=document.getElementById("share-success"),T=document.getElementById("share-outstanding-list"),h=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),S=document.getElementById("share-ts-create"),$=document.getElementById("share-ts-email"),f=document.getElementById("share-public-ttl");let E=null,O="public";function L(){D.style.display="none",b.style.display="none",I.style.display="none"}function j(t){b.style.display="none",I.style.display="none",D.textContent=t,D.style.display="block"}function N(t){D.style.display="none",b.textContent=t,b.style.display="block"}function R(){return window.location.origin}function u(t){return t.startsWith("http")?t:R()+t}function v(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function w(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){O=t,L(),B.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),B.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function x(){T.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){T.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===E.id);if(o.length===0){T.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}T.innerHTML=o.map(i=>{const n=i.expiresAt?w(new Date(i.expiresAt).getTime()-Date.now()):"";return` `);const C=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),k=document.getElementById("share-issued"),z=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),H=document.getElementById("share-issued-meta"),A=document.getElementById("share-error"),x=document.getElementById("share-success"),B=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),M=document.getElementById("share-public-create"),L=document.getElementById("share-ts-create"),$=document.getElementById("share-ts-email"),u=document.getElementById("share-public-ttl");let v=null,D="public";function I(){A.style.display="none",x.style.display="none",k.style.display="none"}function R(t){x.style.display="none",k.style.display="none",A.textContent=t,A.style.display="block"}function N(t){A.style.display="none",x.textContent=t,x.style.display="block"}function O(){return window.location.origin}function m(t){return t.startsWith("http")?t:O()+t}function y(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function S(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function f(t){D=t,I(),C.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),C.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function E(){B.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){B.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===v.id);if(o.length===0){B.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}B.innerHTML=o.map(i=>{const n=i.expiresAt?S(new Date(i.expiresAt).getTime()-Date.now()):"";return`
<div class="share-row" data-share-id="${escapeHtml(i.id)}" <div class="share-row" data-share-id="${escapeHtml(i.id)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);"> style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;"> <span style="flex: 1;">
<strong>${i.kind==="tailscale"?"Tailscale":"Public"}</strong> <strong>${i.kind==="tailscale"?"Tailscale":"Public"}</strong>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(v(i.expiresAt))} (${n})</span> <span style="color: var(--muted);"> \xB7 expires ${escapeHtml(y(i.expiresAt))} (${n})</span>
</span> </span>
<button class="share-revoke" data-share-id="${escapeHtml(i.id)}" type="button" <button class="share-revoke" data-share-id="${escapeHtml(i.id)}" type="button"
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;"> style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
Revoke Revoke
</button> </button>
</div> </div>
`}).join(""),T.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>s(i.dataset.shareId))})}catch{T.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));j(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}N("Share revoked."),x()}catch{j("Network error while revoking.")}}async function p(){if(!E)return;L(),H.disabled=!0;const t=H.textContent;H.textContent="Creating\u2026";try{const e=parseInt(f.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:E.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data.urlPath;z.value=u(i),M.textContent=`Public link \xB7 expires ${v(o.data.expiresAt)}`,I.style.display="block",N("Share link created."),x()}catch{j("Network error while creating share.")}finally{H.disabled=!1,H.textContent=t}}async function l(){if(!E)return;const t=($.value||"").trim();if(!t||!t.includes("@")){j("A valid recipient email is required.");return}L(),S.disabled=!0;const e=S.textContent;S.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:E.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data&&o.data.urlPath;i?(z.value=u(i),M.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(z.value="",M.textContent=`Tailscale invite sent to ${t}.`),I.style.display="block",N("Tailscale invite created."),$.value="",x()}catch{j("Network error while creating Tailscale invite.")}finally{S.disabled=!1,S.textContent=e}}function m(t,e){const a=e&&e.error||"";if(t===402){j("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){j("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){j("You do not have permission to share services.");return}if(t===404){j("Service not found. It may have been deleted.");return}j(a||`Request failed (HTTP ${t}).`)}function c(t){!t||!t.id||(E={id:t.id,name:t.name||t.id},A.textContent=E.name,L(),g("public"),$.value="",f.value=String(k[1].ms),B.classList.add("show"),x())}B.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),H.addEventListener("click",p),S.addEventListener("click",l),h.addEventListener("click",()=>B.classList.remove("show")),wireModal(B,h),P.addEventListener("click",async()=>{const t=z.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(z.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{j("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=c})(); `}).join(""),B.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>s(i.dataset.shareId))})}catch{B.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));R(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}N("Share revoked."),E()}catch{R("Network error while revoking.")}}async function p(){if(!v)return;I(),M.disabled=!0;const t=M.textContent;M.textContent="Creating\u2026";try{const e=parseInt(u.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:v.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){g(a.status,o);return}const i=o.data.urlPath;z.value=m(i),H.textContent=`Public link \xB7 expires ${y(o.data.expiresAt)}`,k.style.display="block",N("Share link created."),E()}catch{R("Network error while creating share.")}finally{M.disabled=!1,M.textContent=t}}async function l(){if(!v)return;const t=($.value||"").trim();if(!t||!t.includes("@")){R("A valid recipient email is required.");return}I(),L.disabled=!0;const e=L.textContent;L.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:v.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){g(a.status,o);return}const i=o.data&&o.data.urlPath;i?(z.value=m(i),H.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(z.value="",H.textContent=`Tailscale invite sent to ${t}.`),k.style.display="block",N("Tailscale invite created."),$.value="",E()}catch{R("Network error while creating Tailscale invite.")}finally{L.disabled=!1,L.textContent=e}}function g(t,e){const a=e&&e.error||"";if(t===402){R("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){R("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){R("You do not have permission to share services.");return}if(t===404){R("Service not found. It may have been deleted.");return}R(a||`Request failed (HTTP ${t}).`)}function c(t){!t||!t.id||(v={id:t.id,name:t.name||t.id},j.textContent=v.name,I(),f("public"),$.value="",u.value=String(b[1].ms),C.classList.add("show"),E())}C.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>f(t.dataset.tab))}),M.addEventListener("click",p),L.addEventListener("click",l),w.addEventListener("click",()=>C.classList.remove("show")),wireModal(C,w),P.addEventListener("click",async()=>{const t=z.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(z.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{R("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=c})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
+1
View File
@@ -207,6 +207,7 @@
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button> <button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button> <button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button> <button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
<button id="deploys-btn" aria-label="Deploys">🚚 Deploys</button>
<button id="log-insights-btn" aria-label="Log Insights">🔍 Insights</button> <button id="log-insights-btn" aria-label="Log Insights">🔍 Insights</button>
<button onclick="openDiskSettings()" aria-label="Disk Safety">💾 Disk</button> <button onclick="openDiskSettings()" aria-label="Disk Safety">💾 Disk</button>
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button> <button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
+245
View File
@@ -0,0 +1,245 @@
// ========== SHIPDECK DEPLOYS (DC-130) ==========
// Source deploys panel: drives the shipdeck CLI via the DashCaddy
// /api/v1/deploys bridge proxy. Modal lists deployable repos + deployed
// services, shows the journal, and runs deploy/rollback with live output.
//
// Follows the security-center.js modal pattern (injectModal + button in the
// top bar) and the weather-modal visual language.
(function () {
injectModal('deploys-modal', `<div id="deploys-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 860px; max-width: 1200px;">
<h3>🚚 Deploys</h3>
<p class="modal-subtitle">
Source deploys via shipdeck: build → package → systemd release → Caddy gate → DNS → verify.
</p>
<div class="sec-tabs" style="display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);">
<button class="dep-tab active" data-tab="services">Services</button>
<button class="dep-tab" data-tab="deploy">Deploy</button>
<button class="dep-tab" data-tab="journal">Journal</button>
</div>
<!-- SERVICES TAB -->
<div class="dep-panel" data-panel="services">
<div id="dep-services" class="scroll-container" style="max-height:320px;">Loading…</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;">
<input id="dep-status-name" placeholder="service name" style="width:220px;" />
<button class="btn" id="dep-status-btn">Check status</button>
</div>
</div>
<!-- DEPLOY TAB -->
<div class="dep-panel" data-panel="deploy" style="display:none;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">
<select id="dep-repo-select" style="min-width:280px;"><option>Loading…</option></select>
<button class="btn btn-primary" id="dep-deploy-btn">Deploy</button>
</div>
<p class="modal-subtitle">Deploys are serialized fleet-side; a run takes ~30-60s. Output streams below when it finishes.</p>
</div>
<!-- JOURNAL TAB -->
<div class="dep-panel" data-panel="journal" style="display:none;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">
<input id="dep-journal-name" placeholder="service (empty = all)" style="width:220px;" />
<button class="btn" id="dep-journal-btn">Refresh</button>
</div>
<div id="dep-journal" class="scroll-container" style="max-height:320px;">Loading…</div>
</div>
<!-- OUTPUT -->
<pre id="dep-output" class="scroll-container" style="display:none;max-height:260px;margin-top:12px;background:var(--bg-2,#111);padding:10px;border-radius:8px;white-space:pre-wrap;"></pre>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:14px;">
<button class="btn" id="dep-close">Close</button>
</div>
</div>
</div>`);
const $ = (id) => document.getElementById(id);
let openBtn = null;
let loadedOnce = false;
function esc(s) {
return window.escapeHtml ? window.escapeHtml(String(s)) : String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function api(path, opts) {
const r = await fetch('/dashcaddy-api/api/v1/deploys' + path, Object.assign({ credentials: 'include' }, opts || {}));
let body = null;
try { body = await r.json(); } catch (e) { body = { success: false, error: 'non-JSON response' }; }
return { status: r.status, body };
}
function showOutput(text) {
const el = $('dep-output');
el.style.display = 'block';
el.textContent = text || '(no output)';
el.scrollTop = el.scrollHeight;
}
async function loadServices() {
const el = $('dep-services');
const { body } = await api('/services');
if (!body.success) {
el.innerHTML = '<em>' + esc(body.error || 'unavailable') + '</em>';
return;
}
const rows = body.services || [];
if (!rows.length) {
el.innerHTML = '<em>No shipdeck deployments on record yet.</em>';
return;
}
el.innerHTML = '<table class="table" style="width:100%;"><thead><tr>' +
'<th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th>' +
'</tr></thead><tbody>' +
rows.map((s) =>
'<tr>' +
'<td><strong>' + esc(s.name) + '</strong></td>' +
'<td>' + esc(s.last_action) + '</td>' +
'<td>' + esc(s.last_time) + '</td>' +
'<td>' + esc(s.last_epoch) + '</td>' +
'<td><button class="btn btn-sm dep-rollback" data-service="' + esc(s.name) + '">Rollback</button> ' +
'<button class="btn btn-sm dep-status" data-service="' + esc(s.name) + '">Status</button></td>' +
'</tr>'
).join('') + '</tbody></table>';
}
async function loadRepos() {
const sel = $('dep-repo-select');
const { body } = await api('/repos');
if (!body.success) {
sel.innerHTML = '<option>' + esc(body.error || 'unavailable') + '</option>';
return;
}
const repos = body.repos || [];
sel.innerHTML = repos.length
? repos.map((r) => '<option value="' + esc(r.dir) + '">' + esc(r.name) + '</option>').join('')
: '<option value="">(no repos with a Shipdeckfile found)</option>';
}
async function loadJournal(service) {
const el = $('dep-journal');
const qs = service ? '?service=' + encodeURIComponent(service) : '';
const { body } = await api('/journal' + qs);
const rows = (body.rows || []);
if (!rows.length) {
el.innerHTML = '<em>No journal rows.</em>';
return;
}
el.innerHTML = '<table class="table" style="width:100%;"><thead><tr>' +
'<th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th>' +
'</tr></thead><tbody>' +
rows.map((r) =>
'<tr><td>' + esc(r.time) + '</td><td>' + esc(r.service) + '</td><td>' + esc(r.action) +
'</td><td>' + esc(r.epoch) + '</td><td>' + esc(r.duration || '—') + '</td></tr>'
).join('') + '</tbody></table>';
}
function setBusy(busy, btn, label) {
if (!btn) return;
btn.disabled = busy;
if (label) btn.textContent = busy ? 'Working…' : label;
}
async function runDeploy() {
const btn = $('dep-deploy-btn');
const dir = $('dep-repo-select').value;
if (!dir) return;
setBusy(true, btn, 'Deploy');
showOutput('Deploying ' + dir + '\nThis can take ~30-60s…');
try {
const { body } = await api('/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dir }),
});
showOutput((body.output || body.error || '') + (body.success ? '\n✅ SUCCESS' : '\n❌ FAILED'));
loadServices();
} catch (e) {
showOutput('deploy error: ' + e.message);
}
setBusy(false, btn, 'Deploy');
}
async function runRollback(service, btn) {
if (!confirm('Roll back ' + service + ' to the previous release?')) return;
setBusy(true, btn, 'Rollback');
showOutput('Rolling back ' + service + '…');
try {
const { body } = await api('/rollback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
});
showOutput((body.output || body.error || '') + (body.success ? '\n✅ ROLLED BACK' : '\n❌ FAILED'));
loadServices();
} catch (e) {
showOutput('rollback error: ' + e.message);
}
setBusy(false, btn, 'Rollback');
}
async function runStatus(service, btn) {
setBusy(true, btn, 'Status');
try {
const { body } = await api('/status?service=' + encodeURIComponent(service));
showOutput(body.output || body.error || '(no output)');
} catch (e) {
showOutput('status error: ' + e.message);
}
setBusy(false, btn, 'Status');
}
function wire() {
openBtn = document.getElementById('deploys-btn');
if (!openBtn) return;
openBtn.addEventListener('click', async () => {
$('deploys-modal').classList.add('open');
if (!loadedOnce) {
loadedOnce = true;
loadServices();
loadRepos();
loadJournal('');
}
});
$('dep-close').addEventListener('click', () => {
$('deploys-modal').classList.remove('open');
});
// tab switching
document.querySelectorAll('.dep-tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.dep-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('#deploys-modal .dep-panel').forEach((p) => {
p.style.display = p.dataset.panel === tab.dataset.tab ? 'block' : 'none';
});
});
});
$('dep-deploy-btn').addEventListener('click', runDeploy);
$('dep-journal-btn').addEventListener('click', () => loadJournal($('dep-journal-name').value.trim()));
$('dep-status-btn').addEventListener('click', () => {
const name = $('dep-status-name').value.trim();
if (name) runStatus(name);
});
// dynamic buttons (service table)
$('dep-services').addEventListener('click', (e) => {
const rb = e.target.closest('.dep-rollback');
if (rb) return runRollback(rb.dataset.service, rb);
const sb = e.target.closest('.dep-status');
if (sb) return runStatus(sb.dataset.service, sb);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wire);
} else {
wire();
}
})();
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-d39ab69dd4'; const CACHE = 'dashcaddy-shell-e494299e9f';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',