This commit is contained in:
3
.gitea/workflows/build.yml
Normal file → Executable file
3
.gitea/workflows/build.yml
Normal file → Executable file
@@ -26,6 +26,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Validate manifest v2
|
||||||
|
run: node ./scripts/validate-manifest.mjs
|
||||||
|
|
||||||
- name: Run gated analysis
|
- name: Run gated analysis
|
||||||
if: always()
|
if: always()
|
||||||
env:
|
env:
|
||||||
|
|||||||
29
README.md
Executable file
29
README.md
Executable file
@@ -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).
|
||||||
725
css/dashboard.css
Executable file
725
css/dashboard.css
Executable file
@@ -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;
|
||||||
|
}
|
||||||
115
css/shared.css
Executable file
115
css/shared.css
Executable file
@@ -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;
|
||||||
|
}
|
||||||
112
data/manifest.json
Executable file
112
data/manifest.json
Executable file
@@ -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",
|
||||||
|
|
||||||
1102
index.html
Normal file → Executable file
1102
index.html
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
589
js/dashboard.js
Executable file
589
js/dashboard.js
Executable file
@@ -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 = `
|
||||||
|
<div class="widget-head">
|
||||||
|
<h3 class="widget-title">${widget.title}</h3>
|
||||||
|
<span class="badge neutral widget-badge" id="widget-badge-${widget.id}">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="widget-body" id="widget-body-${widget.id}">Loading…</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="panel-title">${build.label}</h2>
|
||||||
|
<span class="badge neutral">${build.badge}</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" id="${btnId}">${build.runLabel}</button>
|
||||||
|
<button class="btn btn-danger kill-btn" id="${killId}">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="run-summary">
|
||||||
|
<p id="${resultId}" class="status-text">Waiting for action</p>
|
||||||
|
<div class="last-run-meta" id="${lastRunId}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="log-tools">
|
||||||
|
<span class="log-label">${build.label} log</span>
|
||||||
|
<button class="btn btn-ghost toggleLogs" data-target="${logId}">Show logs</button>
|
||||||
|
</div>
|
||||||
|
<div id="${logId}" class="log-box hidden" aria-live="polite"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<span style="color:${code === 0 ? 'var(--green)' : 'var(--red)'}">exit ${code}</span>`
|
||||||
|
: '';
|
||||||
|
el.innerHTML = `<span>Last run: ${t}</span>${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 = '<div class="status-pulse"></div><span>All systems running normally</span>';
|
||||||
|
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 = '<div class="status-pulse"></div><span>Attention required</span>';
|
||||||
|
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 = '<p style="color:var(--green);font-weight:700">Eid is now</p>';
|
||||||
|
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 = '<div class="status-pulse"></div><span>Failed to load dashboard manifest</span>';
|
||||||
|
});
|
||||||
0
package.json
Normal file → Executable file
0
package.json
Normal file → Executable file
83
scripts/gated-analysis.ps1
Normal file → Executable file
83
scripts/gated-analysis.ps1
Normal file → Executable file
@@ -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 {
|
function Test-CodeSmells {
|
||||||
Write-Host 'Running gated smell checks...'
|
Write-Host 'Running gated smell checks...'
|
||||||
|
|
||||||
@@ -120,7 +178,7 @@ function Send-DiscordReport {
|
|||||||
return
|
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 }
|
$color = if ($status -eq 'passed') { 5763719 } else { 15548997 }
|
||||||
$shaShort = if ($Sha -and $Sha.Length -ge 7) { $Sha.Substring(0, 7) } else { $Sha }
|
$shaShort = if ($Sha -and $Sha.Length -ge 7) { $Sha.Substring(0, 7) } else { $Sha }
|
||||||
$repoText = if ($Repository) { $Repository } else { 'zone' }
|
$repoText = if ($Repository) { $Repository } else { 'zone' }
|
||||||
@@ -178,6 +236,27 @@ Tests: $testStatus
|
|||||||
Write-Host 'Discord report published.'
|
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
|
$smellsPassed = Test-CodeSmells
|
||||||
if (-not $smellsPassed) {
|
if (-not $smellsPassed) {
|
||||||
$exitCode = 1
|
$exitCode = 1
|
||||||
@@ -188,5 +267,5 @@ if (-not $tests.Passed) {
|
|||||||
$exitCode = $tests.ExitCode
|
$exitCode = $tests.ExitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
Send-DiscordReport -SmellsPassed $smellsPassed -Tests $tests
|
Send-DiscordReport -SmellsPassed ($smellsPassed -and $manifestPassed) -Tests $tests
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|||||||
92
scripts/validate-manifest.mjs
Executable file
92
scripts/validate-manifest.mjs
Executable file
@@ -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)`);
|
||||||
146
test/dashboard.test.mjs
Normal file → Executable file
146
test/dashboard.test.mjs
Normal file → Executable file
@@ -3,17 +3,16 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
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() {
|
function idsInMarkup() {
|
||||||
return [...html.matchAll(/\sid=["']([^"']+)["']/g)].map(match => match[1]);
|
return [...html.matchAll(/\sid=["']([^"']+)["']/g)].map(match => match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function elementsWithClass(className) {
|
test('dashboard keeps the required shell element ids unique', () => {
|
||||||
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', () => {
|
|
||||||
const ids = idsInMarkup();
|
const ids = idsInMarkup();
|
||||||
const requiredIds = [
|
const requiredIds = [
|
||||||
'clockDisplay',
|
'clockDisplay',
|
||||||
@@ -25,8 +24,8 @@ test('dashboard keeps the required interactive element ids unique', () => {
|
|||||||
'serverDot',
|
'serverDot',
|
||||||
'webDot',
|
'webDot',
|
||||||
'webStatus',
|
'webStatus',
|
||||||
'roamDot',
|
'emacsDot',
|
||||||
'roamStatus',
|
'emacsStatus',
|
||||||
'uptimeStat',
|
'uptimeStat',
|
||||||
'uptimeDays',
|
'uptimeDays',
|
||||||
'uptimeHours',
|
'uptimeHours',
|
||||||
@@ -35,6 +34,9 @@ test('dashboard keeps the required interactive element ids unique', () => {
|
|||||||
'uptimePct',
|
'uptimePct',
|
||||||
'uptimeFill',
|
'uptimeFill',
|
||||||
'pingHistory',
|
'pingHistory',
|
||||||
|
'quickLinks',
|
||||||
|
'widgetGrid',
|
||||||
|
'buildGrid',
|
||||||
'dailyMessage',
|
'dailyMessage',
|
||||||
'countdownLabel',
|
'countdownLabel',
|
||||||
'countdownDisplay',
|
'countdownDisplay',
|
||||||
@@ -42,21 +44,6 @@ test('dashboard keeps the required interactive element ids unique', () => {
|
|||||||
'cd-hours',
|
'cd-hours',
|
||||||
'cd-mins',
|
'cd-mins',
|
||||||
'cd-secs',
|
'cd-secs',
|
||||||
'buildBtn',
|
|
||||||
'killWebBtn',
|
|
||||||
'buildResult',
|
|
||||||
'webLastRunMeta',
|
|
||||||
'logOutput',
|
|
||||||
'orgRoamBtn',
|
|
||||||
'orgRoamBtn2',
|
|
||||||
'combinedBtn',
|
|
||||||
'killRoamBtn',
|
|
||||||
'pipelineSteps',
|
|
||||||
'pipeStep1',
|
|
||||||
'pipeStep2',
|
|
||||||
'orgRoamResult',
|
|
||||||
'roamLastRunMeta',
|
|
||||||
'orgRoamLogs',
|
|
||||||
'modalOverlay',
|
'modalOverlay',
|
||||||
'modalTitle',
|
'modalTitle',
|
||||||
'modalBody',
|
'modalBody',
|
||||||
@@ -72,24 +59,39 @@ test('dashboard keeps the required interactive element ids unique', () => {
|
|||||||
assert.deepEqual(duplicates, []);
|
assert.deepEqual(duplicates, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('website and roam logs remain directly accessible from toggle buttons', () => {
|
test('manifest v2 defines integrations and widgets', () => {
|
||||||
const ids = new Set(idsInMarkup());
|
assert.equal(manifest.version, 2);
|
||||||
const toggleButtons = elementsWithClass('toggleLogs');
|
assert.ok(manifest.integrations?.authoring);
|
||||||
assert.equal(toggleButtons.length, 2);
|
assert.ok(Array.isArray(manifest.widgets) && manifest.widgets.length >= 3);
|
||||||
|
assert.ok(manifest.widgets.some(w => w.id === 'scripts-health'));
|
||||||
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('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 = [
|
const expectedEndpoints = [
|
||||||
'/api/health',
|
'/api/health',
|
||||||
'/api/uptime',
|
'/api/uptime',
|
||||||
@@ -97,48 +99,56 @@ test('build controls use the expected API endpoints and streams', () => {
|
|||||||
'/api/build-web',
|
'/api/build-web',
|
||||||
'/api/build-web/logs',
|
'/api/build-web/logs',
|
||||||
'/api/build-web/status',
|
'/api/build-web/status',
|
||||||
'/api/build-roam',
|
|
||||||
'/api/build-roam/logs',
|
|
||||||
'/api/build-roam/status',
|
|
||||||
'/api/rerun-emacs',
|
'/api/rerun-emacs',
|
||||||
'/api/rerun-emacs/logs',
|
'/api/rerun-emacs/logs',
|
||||||
'/api/rerun-emacs/status',
|
'/api/rerun-emacs/status'
|
||||||
'/api/run-combined',
|
|
||||||
'/api/run-combined/logs',
|
|
||||||
'/api/run-combined/status'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const endpoint of expectedEndpoints) {
|
for (const endpoint of expectedEndpoints) {
|
||||||
assert.ok(html.includes(endpoint), `missing endpoint ${endpoint}`);
|
assert.ok(
|
||||||
}
|
manifest.builds.some(b =>
|
||||||
|
b.start?.path === endpoint ||
|
||||||
assert.match(html, /new EventSource\(url\)/);
|
b.status?.path === endpoint ||
|
||||||
assert.match(html, /addEventListener\(['"]log['"]/);
|
b.logs?.path === endpoint
|
||||||
assert.match(html, /addEventListener\(['"]done['"]/);
|
) ||
|
||||||
});
|
Object.values(manifest.status).includes(endpoint) ||
|
||||||
|
dashboardJs.includes(endpoint),
|
||||||
test('primary buttons are wired to their handlers', () => {
|
`missing endpoint ${endpoint}`
|
||||||
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.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', () => {
|
test('layout classes and accessible structure are present', () => {
|
||||||
for (const className of ['topbar', 'dashboard-grid', 'rail', 'panel', 'run-grid', 'log-tools']) {
|
for (const className of ['topbar', 'dashboard-grid', 'rail', 'panel', 'run-grid']) {
|
||||||
assert.match(html, new RegExp(`class=["'][^"']*\\b${className}\\b`), `missing .${className}`);
|
assert.match(html, new RegExp(`class=["'][^"']*\\b${className}\\b`), `missing .${className}`);
|
||||||
}
|
}
|
||||||
|
assert.match(dashboardJs, /log-tools/);
|
||||||
|
|
||||||
assert.match(html, /<main class=["']page["']/);
|
assert.match(html, /<main class=["']page["']/);
|
||||||
assert.match(html, /aria-label=["']Build runs["']/);
|
assert.match(html, /aria-label=["']Build runs["']/);
|
||||||
assert.match(html, /role=["']dialog["']/);
|
assert.match(html, /role=["']dialog["']/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dashboard loads external stylesheets and manifest module', () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user