From 569c9853bfdf32b2d9db60ec78cc23a6af89d4e1 Mon Sep 17 00:00:00 2001 From: Zaine Date: Thu, 4 Jun 2026 14:18:21 +0100 Subject: [PATCH] updates --- .gitea/workflows/build.yml | 3 + README.md | 29 + css/dashboard.css | 725 +++++++++++++++++++++ css/shared.css | 115 ++++ data/manifest.json | 112 ++++ index.html | 1110 +-------------------------------- js/dashboard.js | 589 +++++++++++++++++ package.json | 0 scripts/gated-analysis.ps1 | 83 ++- scripts/validate-manifest.mjs | 92 +++ test/dashboard.test.mjs | 146 +++-- 11 files changed, 1841 insertions(+), 1163 deletions(-) mode change 100644 => 100755 .gitea/workflows/build.yml create mode 100755 README.md create mode 100755 css/dashboard.css create mode 100755 css/shared.css create mode 100755 data/manifest.json mode change 100644 => 100755 index.html create mode 100755 js/dashboard.js mode change 100644 => 100755 package.json mode change 100644 => 100755 scripts/gated-analysis.ps1 create mode 100755 scripts/validate-manifest.mjs mode change 100644 => 100755 test/dashboard.test.mjs diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml old mode 100644 new mode 100755 index 15cc453..53a475b --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -26,6 +26,9 @@ jobs: - name: Install dependencies run: npm install + - name: Validate manifest v2 + run: node ./scripts/validate-manifest.mjs + - name: Run gated analysis if: always() env: diff --git a/README.md b/README.md new file mode 100755 index 0000000..5ce8dda --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Zone dashboard + +Home-server control UI served at https://zone.zainezq.com. Configuration is data-driven via [`data/manifest.json`](data/manifest.json). + +## Local development + +```bash +cd /home/zaine/master-folder/org-platform/zone +python3 -m http.server 8080 +# open http://127.0.0.1:8080 — API calls need org_backend on :9010 or will show offline +``` + +## Tests and CI + +```bash +npm test +``` + +Gitea Actions runs `scripts/gated-analysis.ps1` (manifest schema, smell checks, tests). + +## Manifest + +- **links** — quick-nav cards (`platform` then `infra` groups) +- **builds** — run/cancel/status/logs bindings to `org_backend` BuildController +- **status** — health, uptime, last-runs endpoints + +Production loads enriched manifest from `GET /api/zone/manifest` (injections: `integrations`, `widgets`). Live widgets poll `GET /api/zone/status` (authoring queue, watcher, timesheet week summary). + +Timesheet SPA is served from [`../time-tracking/`](../time-tracking/) at `/time-tracking/` (nginx alias). diff --git a/css/dashboard.css b/css/dashboard.css new file mode 100755 index 0000000..fcc73f3 --- /dev/null +++ b/css/dashboard.css @@ -0,0 +1,725 @@ +.page { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 1.25rem 0 2rem; +} + +.topbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: end; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + box-shadow: var(--shadow); +} + +.topbar h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; +} + +.header-meta { + min-width: 200px; + text-align: right; + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.6; +} + +.clock-display { + color: var(--text); + font-size: 1.5rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +#globalStatus { + margin: 1rem 0; + display: flex; + align-items: center; + gap: 0.625rem; + border: 1px solid; + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 600; +} + +.global-ok { + color: var(--positive); + background: var(--positive-bg); + border-color: #86efac; +} + +.global-error { + color: var(--negative); + background: var(--negative-bg); + border-color: #fca5a5; +} + +.status-pulse { + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.dashboard-grid { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.rail { + position: sticky; + top: 1rem; + display: grid; + gap: 1rem; +} + +.main-stack { + display: grid; + gap: 1rem; +} + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: #f8fafc; + border-bottom: 1px solid var(--border); +} + +.panel-title { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--text); +} + +.panel-body { + padding: 1rem; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; +} + +.status-grid .stat-card { + min-height: 72px; + display: grid; + align-content: space-between; + gap: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + background: var(--bg); +} + +.stat-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.status-grid .stat-value { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; +} + +.dot.green { + background: var(--positive); +} + +.dot.red { + background: var(--negative); +} + +.dot.yellow { + background: var(--amber); + animation: blink 0.8s step-end infinite; +} + +@keyframes blink { + 50% { + opacity: 0.4; + } +} + +.badge { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.2rem 0.5rem; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.badge.ok { + color: var(--positive); + border-color: #86efac; + background: var(--positive-bg); +} + +.badge.error { + color: var(--negative); + border-color: #fca5a5; + background: var(--negative-bg); +} + +.badge.neutral { + color: var(--text-muted); +} + +.badge.warn { + color: var(--amber); + border-color: #fcd34d; + background: #fef3c7; +} + +.quick-links { + display: grid; + gap: 0.5rem; +} + +.link-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.625rem; + min-height: 40px; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; +} + +.link-card:hover, +.link-card:focus-visible { + outline: none; + border-color: var(--accent); + color: var(--accent); + background: #eff6ff; +} + +.utility-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(240px, 0.8fr); + gap: 1rem; +} + +.daily-message { + color: var(--text); + font-size: 1rem; + line-height: 1.6; + border-left: 3px solid var(--accent); + padding-left: 0.75rem; +} + +.countdown-display { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.5rem; +} + +.time-unit { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.625rem; + background: var(--bg); + text-align: center; +} + +.time-number { + display: block; + color: var(--accent); + font-size: clamp(1.25rem, 3vw, 1.75rem); + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.time-label { + margin-top: 0.25rem; + color: var(--text-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.uptime-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.625rem; +} + +.uptime-item { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.625rem; + background: var(--bg); +} + +.uptime-item-label { + color: var(--text-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.uptime-item-value { + margin-top: 0.35rem; + color: var(--text); + font-size: 0.95rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.uptime-bar-wrap { + margin-top: 1rem; +} + +.uptime-label { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-size: 0.75rem; +} + +.uptime-bar { + height: 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + overflow: hidden; +} + +.uptime-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--positive), var(--accent)); + transition: width 1s ease; +} + +.ping-history { + display: flex; + align-items: end; + gap: 4px; + min-height: 36px; + margin-top: 0.625rem; + overflow: hidden; +} + +.ping-bar { + width: 8px; + flex: 0 0 8px; + border-radius: 2px 2px 0 0; + opacity: 0.85; +} + +.run-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.run-card .panel-body { + display: grid; + gap: 0.75rem; +} + +.run-summary { + display: grid; + gap: 0.5rem; + padding: 0.75rem; + border: 1px dashed var(--border); + border-radius: var(--radius); + background: var(--bg); +} + +.status-text { + min-height: 1.3em; + color: var(--text-muted); + font-size: 0.875rem; + line-height: 1.5; +} + +.status-text.running { + color: var(--amber); +} + +.status-text.success { + color: var(--positive); +} + +.status-text.failed { + color: var(--negative); +} + +.last-run-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.875rem; + color: var(--text-muted); + font-size: 0.75rem; +} + +.last-run-meta span { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.btn-danger { + color: var(--negative); + border-color: #fca5a5; + background: var(--surface); +} + +.btn-danger:hover:not(:disabled) { + background: var(--negative-bg); +} + +.btn-combined { + color: var(--amber); + border-color: #fcd34d; + background: #fffbeb; +} + +.btn-combined:hover:not(:disabled) { + background: #fef3c7; +} + +.kill-btn { + display: none; +} + +.kill-btn.visible { + display: inline-flex; +} + +.log-tools { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.625rem; +} + +.log-label { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 500; +} + +.log-box { + height: 260px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + background: #f8fafc; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.log-line { + display: block; +} + +.ansi-bold { + font-weight: 700; +} + +.ansi-dim { + opacity: 0.6; +} + +.ansi-red { + color: #dc2626; +} + +.ansi-green { + color: var(--positive); +} + +.ansi-yellow { + color: var(--amber); +} + +.ansi-blue { + color: var(--accent); +} + +.ansi-magenta { + color: #9333ea; +} + +.ansi-cyan { + color: #0891b2; +} + +.ansi-white { + color: var(--text); +} + +.ansi-reset { + color: inherit; + font-weight: inherit; + opacity: inherit; +} + +.pipeline-steps { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.375rem; +} + +.pipeline-step { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.25rem 0.625rem; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.pipeline-step.active { + color: var(--amber); + border-color: #fcd34d; + background: #fef3c7; +} + +.pipeline-step.done { + color: var(--positive); + border-color: #86efac; + background: var(--positive-bg); +} + +.pipeline-step.failed { + color: var(--negative); + border-color: #fca5a5; + background: var(--negative-bg); +} + +.pipeline-arrow { + color: var(--text-muted); + font-size: 0.75rem; +} + +#toast-container { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 20; + display: grid; + gap: 0.5rem; +} + +.toast { + max-width: 320px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.625rem 0.75rem; + background: var(--surface); + color: var(--text); + font-size: 0.875rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.toast.success { + border-color: #86efac; + color: var(--positive); + background: var(--positive-bg); +} + +.toast.error { + border-color: #fca5a5; + color: var(--negative); + background: var(--negative-bg); +} + +.toast.info { + border-color: #93c5fd; + color: var(--accent); + background: #eff6ff; +} + +.toast.warn { + border-color: #fcd34d; + color: var(--amber); + background: #fef3c7; +} + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 15; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.4); +} + +.modal-overlay .modal { + width: min(380px, 100%); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} + +.modal-overlay .modal h3 { + margin: 0; + padding: 1rem; + border-bottom: 1px solid var(--border); + font-size: 1rem; + font-weight: 600; +} + +.modal-overlay .modal p { + padding: 1rem; + color: var(--text-muted); + font-size: 0.875rem; + line-height: 1.6; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0 1rem 1rem; +} + +@media (max-width: 940px) { + .dashboard-grid, + .utility-grid, + .run-grid { + grid-template-columns: 1fr; + } + + .rail { + position: static; + } + + .quick-links { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .page { + width: min(100% - 20px, 1180px); + padding-top: 0.625rem; + } + + .topbar { + grid-template-columns: 1fr; + align-items: start; + } + + .header-meta { + min-width: 0; + text-align: left; + } + + .status-grid, + .quick-links, + .uptime-grid, + .countdown-display { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .btn-row .btn { + width: 100%; + } + + .log-tools { + align-items: stretch; + flex-direction: column; + } +} + +.widget-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.widget-card { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + background: var(--surface-2); +} + +.widget-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.widget-title { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} + +.widget-body { + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.45; +} diff --git a/css/shared.css b/css/shared.css new file mode 100755 index 0000000..99c8b3f --- /dev/null +++ b/css/shared.css @@ -0,0 +1,115 @@ +:root { + --bg: #f4f5f7; + --surface: #ffffff; + --border: #d8dce3; + --text: #1a1d23; + --text-muted: #5c6573; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --positive: #15803d; + --positive-bg: #dcfce7; + --negative: #b91c1c; + --negative-bg: #fee2e2; + --zero: #6b7280; + --weekend-bg: #f8fafc; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --radius: 8px; + --radius-sm: 6px; + --surface-2: #f8fafc; + --font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + + /* Aliases for dashboard JS inline styles */ + --green: var(--positive); + --red: var(--negative); + --amber: #d97706; + --blue: var(--accent); +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: var(--font); + font-size: 15px; + line-height: 1.5; + color: var(--text); + background: var(--bg); + min-height: 100vh; +} + +a { + color: inherit; +} + +button { + font: inherit; +} + +.tagline { + margin: 0.25rem 0 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font: inherit; + font-size: 0.875rem; + padding: 0.45rem 0.9rem; + border-radius: var(--radius); + border: 1px solid var(--border); + cursor: pointer; + background: var(--surface); + color: var(--text); + text-decoration: none; +} + +.btn:hover:not(:disabled) { + background: var(--bg); +} + +.btn:focus-visible:not(:disabled) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--surface); +} + +.btn-ghost { + border-color: transparent; + color: var(--text-muted); +} + +.hidden { + display: none !important; +} diff --git a/data/manifest.json b/data/manifest.json new file mode 100755 index 0000000..faa9b0b --- /dev/null +++ b/data/manifest.json @@ -0,0 +1,112 @@ +{ + "version": 2, + "dashboard": { + "title": "Home Server Control", + "tagline": "Local console and build deck" + }, + "integrations": { + "authoring": { "pollMs": 5000, "path": "/api/zone/status" }, + "watcher": { "pollMs": 10000, "path": "/api/zone/status" }, + "timesheet": { "pollMs": 60000, "path": "/api/zone/status" }, + "scripts": { "pollMs": 120000, "path": "/api/zone/status" }, + "builds": { "pollMs": 15000, "path": "/api/zone/status" } + }, + "widgets": [ + { + "id": "authoring-queue", + "type": "queue", + "title": "Authoring builds", + "integration": "authoring" + }, + { + "id": "authoring-detail", + "type": "authoringDetail", + "title": "Authoring detail", + "integration": "authoring" + }, + { + "id": "watcher", + "type": "badge", + "title": "Lima watcher", + "integration": "watcher" + }, + { + "id": "timesheet-summary", + "type": "timesheet", + "title": "Hours this week", + "integration": "timesheet" + }, + { + "id": "scripts-health", + "type": "log", + "title": "Script logs", + "integration": "scripts", + "logKey": "orgWebBuild" + }, + { + "id": "calibre-sync", + "type": "log", + "title": "Calibre export", + "integration": "scripts", + "logKey": "calibreSync" + }, + { + "id": "org-web-build", + "type": "buildStatus", + "title": "Website build", + "integration": "builds", + "lastRunKey": "org_web" + } + ], + "links": [ + { "id": "timesheet", "label": "Timesheet", "href": "/time-tracking/", "group": "platform" }, + { "id": "website", "label": "Website", "href": "https://zainezq.com", "group": "platform", "external": true }, + { "id": "author", "label": "Author", "href": "https://author.zainezq.com", "group": "platform", "external": true }, + { "id": "files", "label": "Files", "href": "https://filebrowser.zainezq.com", "group": "infra", "external": true }, + { "id": "library", "label": "Library", "href": "https://calibre.zainezq.com", "group": "infra", "external": true }, + { "id": "news", "label": "News", "href": "https://miniflux.zainezq.com", "group": "infra", "external": true }, + { "id": "rdp", "label": "RDP", "href": "https://guac.zainezq.com", "group": "infra", "external": true }, + { "id": "portainer", "label": "Portainer", "href": "https://portainer.zainezq.com", "group": "infra", "external": true }, + { "id": "nextcloud", "label": "Nextcloud", "href": "https://nextcloud.zainezq.com", "group": "infra", "external": true }, + { "id": "pgadmin", "label": "Databases", "href": "https://pgadmin.zainezq.com", "group": "infra", "external": true }, + { "id": "gitea", "label": "Gitea", "href": "https://gitea.zainezq.com", "group": "infra", "external": true } + ], + "builds": [ + { + "id": "org_web", + "label": "Website Build", + "badge": "Web", + "confirmTitle": "Update Website", + "confirmBody": "This will rebuild and deploy the website. Continue?", + "runLabel": "Run website build", + "start": { "method": "POST", "path": "/api/build-web" }, + "cancel": { "method": "DELETE", "path": "/api/build-web" }, + "status": { "path": "/api/build-web/status" }, + "logs": { "path": "/api/build-web/logs" }, + "statusDotId": "webDot", + "statusTextId": "webStatus", + "lastRunKey": "org_web" + }, + { + "id": "emacs", + "label": "Emacs", + "badge": "Emacs", + "confirmTitle": "Rerun Emacs", + "confirmBody": "This will rerun Emacs. Continue?", + "runLabel": "Rerun Emacs", + "start": { "method": "POST", "path": "/api/rerun-emacs" }, + "cancel": { "method": "DELETE", "path": "/api/rerun-emacs" }, + "status": { "path": "/api/rerun-emacs/status" }, + "logs": { "path": "/api/rerun-emacs/logs" }, + "statusDotId": "emacsDot", + "statusTextId": "emacsStatus", + "lastRunKey": "emacs", + "successExitCodes": [0, 124] + } + ], + "status": { + "health": "/api/health", + "uptime": "/api/uptime", + "lastRuns": "/api/last-runs" + } +} diff --git a/index.html b/index.html old mode 100644 new mode 100755 index 6c90ffd..b464d1d --- a/index.html +++ b/index.html @@ -4,680 +4,8 @@ Home Server Control - - - + +
@@ -685,8 +13,8 @@
-
Local console / build deck
-

Home Server Control

+

Home Server Control

+

Local console and build deck

--:--:--
@@ -718,8 +46,8 @@
Idle
-
Roam
-
Idle
+
Emacs
+
Idle
Uptime
@@ -734,18 +62,7 @@

Links

@@ -797,58 +114,16 @@ -
-
-
-

Website Build

- Web -
-
-
- - -
-
-

Waiting for action

-
-
-
- Website run log - -
- -
-
- -
-
-

Roam Build

- Roam -
-
-
- - - - -
- -
-

Waiting for action

-
-
-
- Roam run log - -
- -
-
+
+
+

Integrations

+
+
+
+
+ +
@@ -864,357 +139,6 @@ - + diff --git a/js/dashboard.js b/js/dashboard.js new file mode 100755 index 0000000..8dfb934 --- /dev/null +++ b/js/dashboard.js @@ -0,0 +1,589 @@ +const PING_MAX = 30; +let pingResults = []; +let manifest = null; +const logStreams = new Map(); +let statusPollTimer = null; + +/** Fallback when manifest API fails and static file is an older v1 without widgets. */ +const DEFAULT_WIDGETS = [ + { id: 'authoring-queue', type: 'queue', title: 'Authoring builds', integration: 'authoring' }, + { id: 'authoring-detail', type: 'authoringDetail', title: 'Authoring detail', integration: 'authoring' }, + { id: 'watcher', type: 'badge', title: 'Lima watcher', integration: 'watcher' }, + { id: 'timesheet-summary', type: 'timesheet', title: 'Hours this week', integration: 'timesheet' }, + { id: 'scripts-health', type: 'log', title: 'Script logs', integration: 'scripts', logKey: 'orgWebBuild' }, + { id: 'calibre-sync', type: 'log', title: 'Calibre export', integration: 'scripts', logKey: 'calibreSync' }, + { id: 'org-web-build', type: 'buildStatus', title: 'Website build', integration: 'builds', lastRunKey: 'org_web' }, +]; + +function manifestWidgets(data) { + return Array.isArray(data?.widgets) && data.widgets.length > 0 ? data.widgets : DEFAULT_WIDGETS; +} + +function apiFetch(url, options = {}) { + return fetch(url, { cache: 'no-store', ...options }); +} + +export async function initDashboard() { + manifest = await loadManifest(); + applyDashboardMeta(manifest); + renderLinks(manifest.links); + renderWidgets(manifestWidgets(manifest)); + renderBuilds(manifest.builds); + await refreshIntegrationWidgets(); + startStatusPolling(manifest.integrations); + updateClock(); + setInterval(updateClock, 1000); + setDailyMessage(); + startCountdown(); + setInterval(fetchHealth, 10000); + fetchHealth(); +} + +async function loadManifest() { + try { + const apiRes = await apiFetch('/api/zone/manifest'); + if (apiRes.ok) { + const data = await apiRes.json(); + validateManifest(data); + return data; + } + } catch { + /* fallback to static file for local dev */ + } + const res = await apiFetch('data/manifest.json'); + if (!res.ok) throw new Error('Failed to load manifest'); + const data = await res.json(); + validateManifest(data); + return data; +} + +function validateManifest(data) { + const required = ['dashboard', 'links', 'builds', 'status']; + for (const key of required) { + if (!(key in data)) throw new Error(`manifest missing ${key}`); + } + const ids = new Set(); + for (const link of data.links) { + if (ids.has(link.id)) throw new Error(`duplicate link id ${link.id}`); + ids.add(link.id); + } + const buildIds = new Set(); + for (const build of data.builds) { + if (buildIds.has(build.id)) throw new Error(`duplicate build id ${build.id}`); + buildIds.add(build.id); + } +} + +function applyDashboardMeta({ dashboard }) { + document.title = dashboard.title; + document.querySelector('.topbar h1').textContent = dashboard.title; + document.querySelector('.tagline').textContent = dashboard.tagline; +} + +function renderLinks(links) { + const nav = document.getElementById('quickLinks'); + nav.innerHTML = ''; + const order = ['platform', 'infra']; + for (const group of order) { + for (const link of links.filter(l => l.group === group)) { + const a = document.createElement('a'); + a.href = link.href; + a.className = 'link-card'; + a.dataset.linkId = link.id; + if (link.external) { + a.target = '_blank'; + a.rel = 'noreferrer'; + } + const span = document.createElement('span'); + span.textContent = link.label; + a.appendChild(span); + nav.appendChild(a); + } + } +} + +function renderWidgets(widgets) { + const grid = document.getElementById('widgetGrid'); + if (!grid) return; + grid.innerHTML = ''; + for (const widget of widgets) { + grid.appendChild(createWidgetCard(widget)); + } +} + +function createWidgetCard(widget) { + const card = document.createElement('article'); + card.className = 'widget-card'; + card.dataset.widgetId = widget.id; + card.dataset.integration = widget.integration || ''; + card.innerHTML = ` +
+

${widget.title}

+ +
+
Loading…
`; + return card; +} + +async function refreshIntegrationWidgets() { + try { + const res = await apiFetch('/api/zone/status'); + if (!res.ok) { + setIntegrationPollMessage(`Status poll failed (${res.status})`); + return false; + } + const status = await res.json(); + updateWidgets(status); + setIntegrationPollMessage(`Integrations updated: ${new Date().toLocaleTimeString()}`); + return true; + } catch (err) { + setIntegrationPollMessage('Status poll offline'); + console.warn('zone status poll failed', err); + return false; + } +} + +function setIntegrationPollMessage(text) { + const el = document.getElementById('lastUpdated'); + if (el) el.textContent = text; +} + +function startStatusPolling(integrations) { + if (statusPollTimer) clearInterval(statusPollTimer); + const pollValues = Object.values(integrations || {}).map(i => i.pollMs || 10000); + const pollMs = pollValues.length > 0 ? Math.min(...pollValues, 10000) : 10000; + + statusPollTimer = setInterval(() => refreshIntegrationWidgets(), pollMs); +} + +function widgetElements(widgetId) { + const card = document.querySelector(`[data-widget-id="${widgetId}"]`); + return { + body: card?.querySelector('.widget-body'), + badge: card?.querySelector('.widget-badge'), + }; +} + +function updateWidgets(status) { + const widgets = manifestWidgets(manifest); + for (const widget of widgets) { + try { + updateWidgetCard(widget, status); + } catch (err) { + const { body } = widgetElements(widget.id); + if (body) body.textContent = 'Update error'; + console.warn(`widget ${widget.id} update failed`, err); + } + } +} + +function updateWidgetCard(widget, status) { + const { body, badge } = widgetElements(widget.id); + if (!body) return; + + const type = widget.type || widget.integration; + + switch (type) { + case 'queue': { + const a = status.authoring || {}; + const running = a.running ? 'Running' : 'Idle'; + setBadge(badge, running, a.running ? 'warn' : a.error ? 'error' : 'ok'); + const current = a.current?.title || a.message || 'No builds yet'; + const queued = a.queued ?? a.pendingCount ?? (a.pending?.length || 0); + body.textContent = `${current} · queued: ${queued}`; + break; + } + case 'authoringDetail': { + const a = status.authoring || {}; + const pending = a.pendingCount ?? (a.pending?.length || 0); + setBadge(badge, String(pending), pending > 0 ? 'warn' : 'ok'); + const failed = a.lastFailedTitle ? `Last failed: ${a.lastFailedTitle}` : 'No recent failures'; + body.textContent = `Pending: ${pending} · ${failed}`; + break; + } + case 'badge': { + const w = status.watcher || {}; + const active = w.ActiveState || w.active || 'unknown'; + const isActive = active === 'active'; + setBadge(badge, active, isActive ? 'ok' : 'warn'); + body.textContent = w.SubState ? `SubState: ${w.SubState}` : (w.message || 'Watcher'); + break; + } + case 'timesheet': { + const t = status.timesheet || {}; + setBadge(badge, t.available ? 'OK' : '—', t.available ? 'ok' : 'neutral'); + body.textContent = t.available + ? `Week ${t.weekStart}: ${t.hoursWorked}h worked (${t.delta >= 0 ? '+' : ''}${t.delta}h vs contracted)` + : (t.message || 'No data'); + break; + } + case 'log': { + const info = (status.scripts || {})[widget.logKey] || {}; + const label = info.label || widget.logKey; + const stale = info.stale; + setBadge(badge, stale ? 'Stale' : (info.exists === false ? 'Missing' : 'OK'), + stale || info.exists === false ? 'error' : 'ok'); + const mtime = info.mtime ? new Date(info.mtime).toLocaleString() : 'never'; + const line = typeof info.lastLine === 'string' ? info.lastLine.slice(0, 80) : ''; + body.textContent = `${label}: ${mtime}${info.staleReason ? ` (${info.staleReason})` : ''}${line ? ` · ${line}` : ''}`; + break; + } + case 'buildStatus': { + const run = (status.lastRuns || {})[widget.lastRunKey] || {}; + const exit = run.exitCode; + const ok = exit === 0 || exit === '0'; + setBadge(badge, + run.running ? 'Running' : (exit === '' || exit == null ? '—' : `exit ${exit}`), + run.running ? 'warn' : ok ? 'ok' : 'error'); + const when = run.lastRun && run.lastRun !== 'never' + ? new Date(run.lastRun).toLocaleString() + : 'never'; + body.textContent = `Last run: ${when}`; + break; + } + default: + body.textContent = `Unknown widget type: ${type}`; + setBadge(badge, '?', 'neutral'); + } +} + +function setBadge(el, text, tone) { + if (!el) return; + el.textContent = text; + el.className = `badge ${tone} widget-badge`; +} + +function renderBuilds(builds) { + const grid = document.getElementById('buildGrid'); + grid.innerHTML = ''; + for (const build of builds) { + grid.appendChild(createBuildCard(build)); + } + document.querySelectorAll('.toggleLogs').forEach(btn => { + btn.addEventListener('click', () => { + const target = document.getElementById(btn.dataset.target); + target.classList.toggle('hidden'); + btn.textContent = target.classList.contains('hidden') ? 'Show logs' : 'Hide logs'; + }); + }); +} + +function createBuildCard(build) { + const article = document.createElement('article'); + article.className = 'panel run-card'; + article.dataset.buildId = build.id; + article.setAttribute('data-build-id', build.id); + + const btnId = build.id === 'org_web' ? 'buildBtn' : 'emacsBtn'; + const killId = build.id === 'org_web' ? 'killWebBtn' : 'killEmacsBtn'; + const resultId = build.id === 'org_web' ? 'buildResult' : 'emacsResult'; + const logId = build.id === 'org_web' ? 'logOutput' : 'emacsLogs'; + const lastRunId = build.id === 'org_web' ? 'webLastRunMeta' : 'emacsLastRunMeta'; + + article.innerHTML = ` +
+

${build.label}

+ ${build.badge} +
+
+
+ + +
+
+

Waiting for action

+
+
+
+ ${build.label} log + +
+ +
`; + + article.querySelector(`#${btnId}`).addEventListener('click', () => runBuild(build, btnId, killId, resultId, logId)); + article.querySelector(`#${killId}`).addEventListener('click', async () => { + const ok = await showConfirm('Cancel Build', `Force-kill the running ${build.label.toLowerCase()}?`); + if (ok) killEndpoint(build.cancel.path, build.label); + }); + + return article; +} + +function updateClock() { + const now = new Date(); + document.getElementById('clockDisplay').textContent = now.toTimeString().slice(0, 8); + document.getElementById('dateDisplay').textContent = now.toLocaleDateString('en-GB', { + weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' + }); +} + +function showToast(msg, type = 'info', duration = 3500) { + const el = document.createElement('div'); + el.className = `toast ${type}`; + el.textContent = msg; + document.getElementById('toast-container').appendChild(el); + setTimeout(() => { + el.style.transition = 'opacity 0.3s,transform 0.3s'; + el.style.opacity = '0'; + el.style.transform = 'translateX(120%)'; + setTimeout(() => el.remove(), 300); + }, duration); +} + +function showConfirm(title, body) { + return new Promise(resolve => { + const overlay = document.getElementById('modalOverlay'); + document.getElementById('modalTitle').textContent = title; + document.getElementById('modalBody').textContent = body; + overlay.classList.remove('hidden'); + function cleanup(r) { + overlay.classList.add('hidden'); + document.getElementById('modalConfirm').removeEventListener('click', onY); + document.getElementById('modalCancel').removeEventListener('click', onN); + resolve(r); + } + const onY = () => cleanup(true); + const onN = () => cleanup(false); + document.getElementById('modalConfirm').addEventListener('click', onY); + document.getElementById('modalCancel').addEventListener('click', onN); + }); +} + +function addPingResult(ok) { + pingResults.push(ok); + if (pingResults.length > PING_MAX) pingResults.shift(); + const container = document.getElementById('pingHistory'); + container.innerHTML = ''; + pingResults.forEach(r => { + const b = document.createElement('div'); + b.className = 'ping-bar'; + b.style.height = r ? '28px' : '10px'; + b.style.background = r ? 'var(--green)' : 'var(--red)'; + container.appendChild(b); + }); + const pct = Math.round((pingResults.filter(Boolean).length / pingResults.length) * 100); + document.getElementById('uptimePct').textContent = `${pct}%`; + document.getElementById('uptimeFill').style.width = `${pct}%`; +} + +async function fetchUptime() { + try { + const res = await fetch(manifest.status.uptime); + const data = await res.json(); + const ms = data.uptimeMs; + const d = Math.floor(ms / 86400000); + const h = Math.floor((ms % 86400000) / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + document.getElementById('uptimeDays').textContent = d; + document.getElementById('uptimeHours').textContent = h; + document.getElementById('uptimeMins').textContent = m; + document.getElementById('uptimeStart').textContent = new Date(data.serverStart).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); + document.getElementById('uptimeStat').textContent = `${d}d ${h}h ${m}m`; + } catch { /* ignore */ } +} + +async function fetchLastRuns() { + try { + const res = await fetch(manifest.status.lastRuns); + const data = await res.json(); + for (const build of manifest.builds) { + const metaId = build.id === 'org_web' ? 'webLastRunMeta' : 'emacsLastRunMeta'; + renderLastRun(metaId, data[build.lastRunKey]); + updateBuildStatusFromLastRun(build, data[build.lastRunKey]); + } + } catch { /* ignore */ } +} + +function updateBuildStatusFromLastRun(build, info) { + if (!build.statusDotId || !info || info.lastRun === 'never') return; + const dot = document.getElementById(build.statusDotId); + const text = document.getElementById(build.statusTextId); + if (!dot || !text) return; + if (info.exitCode === 0 || (build.successExitCodes && build.successExitCodes.includes(info.exitCode))) { + dot.className = 'dot green'; + text.textContent = 'Idle'; + } else if (info.exitCode !== 'never' && info.exitCode !== undefined) { + dot.className = 'dot red'; + text.textContent = 'Error'; + } +} + +function renderLastRun(elId, info) { + const el = document.getElementById(elId); + if (!el || !info) return; + const t = info.lastRun && info.lastRun !== 'never' + ? new Date(info.lastRun).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + : 'Never'; + const code = info.exitCode !== undefined && info.exitCode !== 'never' ? info.exitCode : null; + const codeHtml = code !== null + ? `exit ${code}` + : ''; + el.innerHTML = `Last run: ${t}${codeHtml}`; +} + +async function fetchHealth() { + try { + await fetch(manifest.status.health); + document.getElementById('health').textContent = 'Online'; + document.getElementById('healthBadge').textContent = 'Online'; + document.getElementById('healthBadge').className = 'badge ok'; + document.getElementById('serverDot').className = 'dot green'; + document.getElementById('globalStatus').className = 'global-ok'; + document.getElementById('globalStatus').innerHTML = '
All systems running normally'; + addPingResult(true); + fetchUptime(); + fetchLastRuns(); + } catch { + document.getElementById('health').textContent = 'Offline'; + document.getElementById('healthBadge').textContent = 'Offline'; + document.getElementById('healthBadge').className = 'badge error'; + document.getElementById('serverDot').className = 'dot red'; + document.getElementById('globalStatus').className = 'global-error'; + document.getElementById('globalStatus').innerHTML = '
Attention required'; + addPingResult(false); + } + document.getElementById('lastUpdated').textContent = `Last checked: ${new Date().toLocaleTimeString()}`; +} + +function startLogStream(url, container, buildId) { + closeStream(buildId); + container.innerHTML = ''; + const es = new EventSource(url); + es.addEventListener('log', e => { + const line = document.createElement('span'); + line.className = 'log-line'; + line.innerHTML = e.data; + container.appendChild(line); + container.scrollTop = container.scrollHeight; + }); + es.addEventListener('done', () => { + es.close(); + logStreams.delete(buildId); + }); + es.onerror = () => { + es.close(); + logStreams.delete(buildId); + const line = document.createElement('span'); + line.className = 'log-line ansi-dim'; + line.textContent = '-- stream closed --'; + container.appendChild(line); + }; + logStreams.set(buildId, es); +} + +function closeStream(buildId) { + const s = logStreams.get(buildId); + if (s) s.close(); + logStreams.delete(buildId); +} + +async function killEndpoint(url, label) { + try { + const res = await fetch(url, { method: 'DELETE' }); + if (res.ok) showToast(`${label} cancelled`, 'warn'); + else showToast('Nothing to cancel', 'info'); + } catch { + showToast('Kill request failed', 'error'); + } +} + +async function runBuild(build, btnId, killId, resultId, logId) { + const ok = await showConfirm(build.confirmTitle, build.confirmBody); + if (!ok) return; + + const btn = document.getElementById(btnId); + const killBtn = document.getElementById(killId); + const res = document.getElementById(resultId); + const logEl = document.getElementById(logId); + const dot = document.getElementById(build.statusDotId); + const statusText = document.getElementById(build.statusTextId); + + btn.disabled = true; + killBtn.classList.add('visible'); + res.textContent = `Running ${build.label.toLowerCase()}...`; + res.className = 'status-text running'; + if (dot) dot.className = 'dot yellow'; + if (statusText) statusText.textContent = 'Running'; + showToast(`${build.label} started`, 'info'); + + try { + const r = await fetch(build.start.path, { method: build.start.method || 'POST' }); + if (!r.ok) throw new Error(); + closeStream(build.id); + startLogStream(build.logs.path, logEl, build.id); + pollStatus(build, btnId, killId, resultId); + } catch { + res.textContent = `Could not start ${build.label.toLowerCase()}`; + res.className = 'status-text failed'; + btn.disabled = false; + killBtn.classList.remove('visible'); + if (dot) dot.className = 'dot red'; + showToast(`Failed to start ${build.label.toLowerCase()}`, 'error'); + } +} + +function pollStatus(build, btnId, killId, resultId) { + const interval = setInterval(async () => { + const r = await fetch(build.status.path); + const s = await r.json(); + if (!s.running) { + clearInterval(interval); + document.getElementById(btnId).disabled = false; + document.getElementById(killId).classList.remove('visible'); + const res = document.getElementById(resultId); + const dot = document.getElementById(build.statusDotId); + const statusText = document.getElementById(build.statusTextId); + const ok = build.successExitCodes + ? build.successExitCodes.includes(s.lastExitCode) + : s.lastExitCode === 0; + if (ok) { + res.textContent = `${build.label} completed`; + res.className = 'status-text success'; + if (dot) dot.className = 'dot green'; + if (statusText) statusText.textContent = 'Done'; + showToast(`${build.label} complete`, 'success'); + } else { + res.textContent = `${build.label} finished with issue (code ${s.lastExitCode})`; + res.className = 'status-text failed'; + if (dot) dot.className = 'dot red'; + if (statusText) statusText.textContent = 'Error'; + showToast(`${build.label} finished with errors`, 'error'); + } + closeStream(build.id); + fetchLastRuns(); + } + }, 2000); +} + +const messages = ['You are my favourite person.']; + +function setDailyMessage() { + const today = new Date().toDateString(); + const index = today.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % messages.length; + document.getElementById('dailyMessage').textContent = messages[index]; +} + +function startCountdown() { + const target = new Date('2026-05-27T00:00:00'); + document.getElementById('countdownLabel').textContent = 'Eid'; + function update() { + const diff = target - new Date(); + if (diff <= 0) { + document.getElementById('countdownDisplay').innerHTML = '

Eid is now

'; + return; + } + document.getElementById('cd-days').textContent = String(Math.floor(diff / 86400000)).padStart(3, '0'); + document.getElementById('cd-hours').textContent = String(Math.floor((diff / 3600000) % 24)).padStart(2, '0'); + document.getElementById('cd-mins').textContent = String(Math.floor((diff / 60000) % 60)).padStart(2, '0'); + document.getElementById('cd-secs').textContent = String(Math.floor((diff / 1000) % 60)).padStart(2, '0'); + } + update(); + setInterval(update, 1000); +} + +initDashboard().catch(err => { + console.error(err); + document.getElementById('globalStatus').className = 'global-error'; + document.getElementById('globalStatus').innerHTML = '
Failed to load dashboard manifest'; +}); diff --git a/package.json b/package.json old mode 100644 new mode 100755 diff --git a/scripts/gated-analysis.ps1 b/scripts/gated-analysis.ps1 old mode 100644 new mode 100755 index 53199ef..956e2ea --- a/scripts/gated-analysis.ps1 +++ b/scripts/gated-analysis.ps1 @@ -32,6 +32,64 @@ function Get-ProjectFiles { } } +function Test-ManifestSchema { + Write-Host 'Validating data/manifest.json...' + $manifestPath = Join-Path (Get-Location) 'data/manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath)) { + Add-SmellFailure 'data/manifest.json is missing.' + return $false + } + + try { + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + } catch { + Add-SmellFailure "data/manifest.json is not valid JSON: $($_.Exception.Message)" + return $false + } + + $requiredRoots = @('dashboard', 'links', 'builds', 'status') + foreach ($key in $requiredRoots) { + if (-not ($manifest.PSObject.Properties.Name -contains $key)) { + Add-SmellFailure "manifest missing required key '$key'." + return $false + } + } + + $linkIds = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($link in @($manifest.links)) { + if ([string]::IsNullOrWhiteSpace($link.id)) { + Add-SmellFailure 'manifest link missing id.' + return $false + } + if (-not $linkIds.Add([string]$link.id)) { + Add-SmellFailure "duplicate manifest link id '$($link.id)'." + return $false + } + } + + $buildIds = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($build in @($manifest.builds)) { + if ([string]::IsNullOrWhiteSpace($build.id)) { + Add-SmellFailure 'manifest build missing id.' + return $false + } + if (-not $buildIds.Add([string]$build.id)) { + Add-SmellFailure "duplicate manifest build id '$($build.id)'." + return $false + } + foreach ($pathKey in @('start', 'status', 'logs')) { + $section = $build.$pathKey + if ($null -eq $section -or [string]::IsNullOrWhiteSpace($section.path)) { + Add-SmellFailure "manifest build '$($build.id)' missing $pathKey.path." + return $false + } + } + } + + Write-Host 'Manifest schema validation passed.' + return $true +} + function Test-CodeSmells { Write-Host 'Running gated smell checks...' @@ -120,7 +178,7 @@ function Send-DiscordReport { return } - $status = if ($SmellsPassed -and $Tests.Passed) { 'passed' } else { 'failed' } + $status = if ($SmellsPassed -and $Tests.Passed -and $manifestPassed) { 'passed' } else { 'failed' } $color = if ($status -eq 'passed') { 5763719 } else { 15548997 } $shaShort = if ($Sha -and $Sha.Length -ge 7) { $Sha.Substring(0, 7) } else { $Sha } $repoText = if ($Repository) { $Repository } else { 'zone' } @@ -178,6 +236,27 @@ Tests: $testStatus Write-Host 'Discord report published.' } +function Test-ManifestV2Validator { + Write-Host 'Running scripts/validate-manifest.mjs...' + $validator = Join-Path (Get-Location) 'scripts/validate-manifest.mjs' + if (-not (Test-Path -LiteralPath $validator)) { + Add-SmellFailure 'scripts/validate-manifest.mjs is missing.' + return $false + } + $output = & node $validator 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Add-SmellFailure "validate-manifest.mjs failed: $output" + return $false + } + Write-Host $output.Trim() + return $true +} + +$manifestPassed = (Test-ManifestSchema) -and (Test-ManifestV2Validator) +if (-not $manifestPassed) { + $exitCode = 1 +} + $smellsPassed = Test-CodeSmells if (-not $smellsPassed) { $exitCode = 1 @@ -188,5 +267,5 @@ if (-not $tests.Passed) { $exitCode = $tests.ExitCode } -Send-DiscordReport -SmellsPassed $smellsPassed -Tests $tests +Send-DiscordReport -SmellsPassed ($smellsPassed -and $manifestPassed) -Tests $tests exit $exitCode diff --git a/scripts/validate-manifest.mjs b/scripts/validate-manifest.mjs new file mode 100755 index 0000000..bfe1684 --- /dev/null +++ b/scripts/validate-manifest.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Validates zone/data/manifest.json for Phase 3 manifest v2. + * Exit 0 on success, 1 on validation errors (prints to stderr). + */ +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifestPath = resolve(root, 'data/manifest.json'); + +const errors = []; + +function fail(msg) { + errors.push(msg); +} + +function requireArray(name, value) { + if (!Array.isArray(value) || value.length === 0) { + fail(`${name} must be a non-empty array`); + return false; + } + return true; +} + +function requireObject(name, value) { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${name} must be an object`); + return false; + } + return true; +} + +let manifest; +try { + const raw = readFileSync(manifestPath, 'utf8'); + manifest = JSON.parse(raw); +} catch (e) { + console.error(`Cannot read manifest: ${manifestPath}: ${e.message}`); + process.exit(1); +} + +if (manifest.version !== 2) { + fail(`version must be 2 (got ${manifest.version})`); +} + +requireObject('dashboard', manifest.dashboard); +requireArray('links', manifest.links); +requireArray('builds', manifest.builds); +requireObject('status', manifest.status); +requireObject('integrations', manifest.integrations); +requireArray('widgets', manifest.widgets); + +const widgetTypes = new Set([ + 'queue', + 'badge', + 'timesheet', + 'log', + 'buildStatus', + 'authoringDetail', +]); +const widgetIds = new Set(); + +for (const w of manifest.widgets || []) { + if (!w.id) fail('widget missing id'); + else if (widgetIds.has(w.id)) fail(`duplicate widget id: ${w.id}`); + else widgetIds.add(w.id); + if (!w.type || !widgetTypes.has(w.type)) { + fail(`widget ${w.id || '?'} has invalid type: ${w.type}`); + } + if (!w.title) fail(`widget ${w.id} missing title`); + if (w.type === 'log' && !w.logKey) { + fail(`widget ${w.id} type log requires logKey`); + } + if (w.type === 'buildStatus' && !w.lastRunKey) { + fail(`widget ${w.id} type buildStatus requires lastRunKey`); + } +} + +for (const b of manifest.builds || []) { + if (!b.id) fail('build missing id'); + if (!b.lastRunKey) fail(`build ${b.id} missing lastRunKey`); +} + +if (errors.length) { + console.error('Manifest validation failed:'); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} + +console.log(`OK: ${manifestPath} (v2, ${manifest.widgets.length} widgets)`); diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs old mode 100644 new mode 100755 index f51e006..96b6f96 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -3,17 +3,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; const html = await readFile(new URL('../index.html', import.meta.url), 'utf8'); +const manifest = JSON.parse( + await readFile(new URL('../data/manifest.json', import.meta.url), 'utf8') +); +const dashboardJs = await readFile(new URL('../js/dashboard.js', import.meta.url), 'utf8'); function idsInMarkup() { return [...html.matchAll(/\sid=["']([^"']+)["']/g)].map(match => match[1]); } -function elementsWithClass(className) { - const pattern = new RegExp(`<[^>]*class=["'][^"']*\\b${className}\\b[^"']*["'][^>]*>`, 'g'); - return [...html.matchAll(pattern)].map(match => match[0]); -} - -test('dashboard keeps the required interactive element ids unique', () => { +test('dashboard keeps the required shell element ids unique', () => { const ids = idsInMarkup(); const requiredIds = [ 'clockDisplay', @@ -25,8 +24,8 @@ test('dashboard keeps the required interactive element ids unique', () => { 'serverDot', 'webDot', 'webStatus', - 'roamDot', - 'roamStatus', + 'emacsDot', + 'emacsStatus', 'uptimeStat', 'uptimeDays', 'uptimeHours', @@ -35,6 +34,9 @@ test('dashboard keeps the required interactive element ids unique', () => { 'uptimePct', 'uptimeFill', 'pingHistory', + 'quickLinks', + 'widgetGrid', + 'buildGrid', 'dailyMessage', 'countdownLabel', 'countdownDisplay', @@ -42,21 +44,6 @@ test('dashboard keeps the required interactive element ids unique', () => { 'cd-hours', 'cd-mins', 'cd-secs', - 'buildBtn', - 'killWebBtn', - 'buildResult', - 'webLastRunMeta', - 'logOutput', - 'orgRoamBtn', - 'orgRoamBtn2', - 'combinedBtn', - 'killRoamBtn', - 'pipelineSteps', - 'pipeStep1', - 'pipeStep2', - 'orgRoamResult', - 'roamLastRunMeta', - 'orgRoamLogs', 'modalOverlay', 'modalTitle', 'modalBody', @@ -72,24 +59,39 @@ test('dashboard keeps the required interactive element ids unique', () => { assert.deepEqual(duplicates, []); }); -test('website and roam logs remain directly accessible from toggle buttons', () => { - const ids = new Set(idsInMarkup()); - const toggleButtons = elementsWithClass('toggleLogs'); - assert.equal(toggleButtons.length, 2); - - for (const button of toggleButtons) { - const target = button.match(/data-target=["']([^"']+)["']/)?.[1]; - assert.ok(target, `toggle missing data-target: ${button}`); - assert.ok(ids.has(target), `toggle target #${target} is missing`); - } - - assert.match(html, /data-target=["']logOutput["']/); - assert.match(html, /data-target=["']orgRoamLogs["']/); - assert.match(html, /id=["']logOutput["'][^>]*class=["'][^"']*\blog-box\b[^"']*\bhidden\b/); - assert.match(html, /id=["']orgRoamLogs["'][^>]*class=["'][^"']*\blog-box\b[^"']*\bhidden\b/); +test('manifest v2 defines integrations and widgets', () => { + assert.equal(manifest.version, 2); + assert.ok(manifest.integrations?.authoring); + assert.ok(Array.isArray(manifest.widgets) && manifest.widgets.length >= 3); + assert.ok(manifest.widgets.some(w => w.id === 'scripts-health')); }); -test('build controls use the expected API endpoints and streams', () => { +test('manifest defines unique link and build ids', () => { + const linkIds = manifest.links.map(l => l.id); + const buildIds = manifest.builds.map(b => b.id); + assert.equal(new Set(linkIds).size, linkIds.length); + assert.equal(new Set(buildIds).size, buildIds.length); + assert.ok(manifest.links.some(l => l.id === 'timesheet' && l.href === '/time-tracking/')); + assert.ok(manifest.builds.some(b => b.id === 'org_web')); + assert.ok(manifest.builds.some(b => b.id === 'emacs')); +}); + +test('manifest build cards expose stable dom ids', () => { + for (const build of manifest.builds) { + assert.ok(build.id, `build needs id`); + if (build.id === 'org_web') { + assert.equal(build.start.path, '/api/build-web'); + assert.equal(build.logs.path, '/api/build-web/logs'); + assert.equal(build.lastRunKey, 'org_web'); + } + if (build.id === 'emacs') { + assert.equal(build.start.path, '/api/rerun-emacs'); + assert.equal(build.lastRunKey, 'emacs'); + } + } +}); + +test('dashboard module references manifest-driven API endpoints', () => { const expectedEndpoints = [ '/api/health', '/api/uptime', @@ -97,48 +99,56 @@ test('build controls use the expected API endpoints and streams', () => { '/api/build-web', '/api/build-web/logs', '/api/build-web/status', - '/api/build-roam', - '/api/build-roam/logs', - '/api/build-roam/status', '/api/rerun-emacs', '/api/rerun-emacs/logs', - '/api/rerun-emacs/status', - '/api/run-combined', - '/api/run-combined/logs', - '/api/run-combined/status' + '/api/rerun-emacs/status' ]; for (const endpoint of expectedEndpoints) { - assert.ok(html.includes(endpoint), `missing endpoint ${endpoint}`); - } - - assert.match(html, /new EventSource\(url\)/); - assert.match(html, /addEventListener\(['"]log['"]/); - assert.match(html, /addEventListener\(['"]done['"]/); -}); - -test('primary buttons are wired to their handlers', () => { - const expectedWiring = [ - ["buildBtn", "runWebBuild"], - ["orgRoamBtn", "runOrgRoamBuild"], - ["orgRoamBtn2", "rerunEmacs"], - ["combinedBtn", "runCombined"] - ]; - - for (const [id, handler] of expectedWiring) { - assert.match( - html, - new RegExp(`getElementById\\(['"]${id}['"]\\)\\.addEventListener\\(['"]click['"], ${handler}\\)`) + assert.ok( + manifest.builds.some(b => + b.start?.path === endpoint || + b.status?.path === endpoint || + b.logs?.path === endpoint + ) || + Object.values(manifest.status).includes(endpoint) || + dashboardJs.includes(endpoint), + `missing endpoint ${endpoint}` ); } + + assert.match(dashboardJs, /new EventSource\(url\)/); + assert.match(dashboardJs, /addEventListener\(['"]log['"]/); + assert.match(dashboardJs, /\/api\/zone\/manifest/); + assert.match(dashboardJs, /data\/manifest\.json/); + assert.match(dashboardJs, /\/api\/zone\/status/); + assert.match(dashboardJs, /renderWidgets/); + assert.doesNotMatch(dashboardJs, /build-roam/); + assert.doesNotMatch(dashboardJs, /run-combined/); }); -test('retro layout classes and accessible structure are present', () => { - for (const className of ['topbar', 'dashboard-grid', 'rail', 'panel', 'run-grid', 'log-tools']) { +test('layout classes and accessible structure are present', () => { + for (const className of ['topbar', 'dashboard-grid', 'rail', 'panel', 'run-grid']) { assert.match(html, new RegExp(`class=["'][^"']*\\b${className}\\b`), `missing .${className}`); } + assert.match(dashboardJs, /log-tools/); assert.match(html, /
{ + assert.match(html, /href=["']css\/shared\.css["']/); + assert.match(html, /href=["']css\/dashboard\.css["']/); + assert.match(html, /type=["']module["'][^>]*src=["']js\/dashboard\.js(\?[^"']*)?["']/); +}); + +test('gated manifest schema has required keys', () => { + for (const key of ['version', 'dashboard', 'links', 'builds', 'status']) { + assert.ok(key in manifest, `manifest missing ${key}`); + } + assert.equal(typeof manifest.dashboard.title, 'string'); + assert.ok(Array.isArray(manifest.links) && manifest.links.length > 0); + assert.ok(Array.isArray(manifest.builds) && manifest.builds.length > 0); +});