mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 19:43:10 +00:00
Compare commits
16 Commits
a6bb14f8c0
...
4b3a4ec311
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3a4ec311 | ||
| 3ebf79647e | |||
|
|
918506344a | ||
| 3bd8f3e488 | |||
|
|
84f33b84e5 | ||
| 692c77ec27 | |||
|
|
8e8bb01e92 | ||
| ad9d9288a6 | |||
|
|
c3e1c8bb0d | ||
| bc52a60be9 | |||
| a1126e85d3 | |||
| 9d9eb2304a | |||
| 6b11bcda9a | |||
| 5c8148e818 | |||
| 5867740c42 | |||
| e8588f62e4 |
|
|
@ -4,6 +4,24 @@
|
||||||
---
|
---
|
||||||
## 2026-05-12
|
## 2026-05-12
|
||||||
|
|
||||||
|
### Session 165 — MetaBlogizer version dashboard UI (Issue #103, sub-D of #49)
|
||||||
|
|
||||||
|
**Goal:** Extend the existing /metablogizer/ list view with version-aware columns, filter, sort, and 60s polling — plus a per-site drill-in page. Consume sub-C's enriched API.
|
||||||
|
|
||||||
|
**Done:**
|
||||||
|
- Spec: `docs/superpowers/specs/2026-05-12-metablog-version-dashboard-design.md`
|
||||||
|
- Plan: `docs/superpowers/plans/2026-05-12-metablog-version-dashboard.md` (5 tasks)
|
||||||
|
- `index.html` extended in place: 3 new columns (Version → Gitea releases, Streamlit 🎨 link, Updated relative time + ISO tooltip), filter input, sortable headers with ▲/▼, 60s auto-refresh paused via Page Visibility API, row-name drill-in link
|
||||||
|
- New `site.html` drill-in (~220 lines): every site.json field + 3 external links (live, Gitea, Streamlit hidden when null), same CRT P31 phosphor theme as index
|
||||||
|
- Smoke `tests/scripts/test-metablogizer-ui.sh` — 4 gates all green (file shape, drill-in anchors, HTML well-formedness, live reachability 200)
|
||||||
|
- Fixed `localStorage` key bug discovered in code review: drill-in now uses canonical `sbx_token` (matches index.html and the rest of the Hub)
|
||||||
|
- README updated with the dashboard + drill-in URLs
|
||||||
|
|
||||||
|
**Followups:**
|
||||||
|
- Sub-E (deploy webhook) is the last remaining sub-project of #49.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Session 164 — MetaBlogizer site.json schema + version metadata (Issue #101, sub-C of #49)
|
### Session 164 — MetaBlogizer site.json schema + version metadata (Issue #101, sub-C of #49)
|
||||||
|
|
||||||
**Goal:** Formal JSON Schema for site.json + Python validator/enricher (`version`/`last_updated` derived from git when absent) + backfill script + API extension. Unblocks sub-D (Dashboard).
|
**Goal:** Formal JSON Schema for site.json + Python validator/enricher (`version`/`last_updated` derived from git when absent) + backfill script + API extension. Unblocks sub-D (Dashboard).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,25 @@
|
||||||
# WIP — Work In Progress
|
# WIP — Work In Progress
|
||||||
*Mis à jour : 2026-05-12 (Session 164)*
|
*Mis à jour : 2026-05-12 (Session 165)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Session 165: MetaBlogizer version dashboard (Issue #103, sub-D of #49)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
Extend the existing /metablogizer/ list view with version-aware columns + filter + sort + 60s polling, and add a per-site drill-in page at /metablogizer/site.html?name=<X>. Consumes the enriched API from sub-C (PR #102).
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Brainstormed design → `docs/superpowers/specs/2026-05-12-metablog-version-dashboard-design.md`
|
||||||
|
- Plan (5 tasks) → `docs/superpowers/plans/2026-05-12-metablog-version-dashboard.md`
|
||||||
|
- Extended `index.html`: 3 new columns (version → Gitea releases link, streamlit_app icon link, last_updated as relative time + ISO tooltip), filter box, sortable headers with ▲/▼, 60s polling paused when tab hidden, row name links to drill-in
|
||||||
|
- New `site.html`: single-fetch drill-in surfacing every site.json field + 3 external links (live, Gitea, Streamlit hidden when null), same CRT P31 phosphor theme as index
|
||||||
|
- 4-gate smoke `tests/scripts/test-metablogizer-ui.sh` (file shape + drill-in anchors + HTML well-formedness + live reachability — all green at 200)
|
||||||
|
- Fixed localStorage key bug: site.html now uses canonical `sbx_token` (matches index.html and shared/api-utils.js)
|
||||||
|
- CRT P31 phosphor theme matched exactly with the existing module style
|
||||||
|
|
||||||
|
### Followups
|
||||||
|
- Sub-E (deploy webhook) — last open sub-project of #49.
|
||||||
|
- Optional follow-up: server-side proxy for Gitea tag history (so the drill-in can show tag list inline, no browser-side Gitea auth). Out of MVP scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
852
docs/superpowers/plans/2026-05-12-metablog-version-dashboard.md
Normal file
852
docs/superpowers/plans/2026-05-12-metablog-version-dashboard.md
Normal file
|
|
@ -0,0 +1,852 @@
|
||||||
|
# MetaBlogizer Version Dashboard Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Extend the existing MetaBlogizer dashboard at `/metablogizer/` with three version-aware columns (version, streamlit_app, last_updated), an inline filter, sortable headers, and 60-second polling; plus add a per-site drill-in page at `/metablogizer/site.html?name=<X>` that surfaces every site.json field with external links to the live site, Gitea repo, and Streamlit app.
|
||||||
|
|
||||||
|
**Architecture:** Vanilla HTML/JS edits to the existing 533-line `index.html` (matches the CRT P31 phosphor theme already in use) + one new HTML file. All data comes from the enriched `/api/v1/metablogizer/sites` and `/api/v1/metablogizer/site/<name>` endpoints shipped in PR #102. No JS framework, no router, no Gitea fetch from the browser — drill-in links go straight to Gitea's UI (auth handled by user's existing Gitea session).
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JS, CSS custom properties (existing `crt-light.css` design tokens), `URLSearchParams`, `Page Visibility API`. Smoke test in Bash via `curl` + `python3 html.parser`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-12-metablog-version-dashboard-design.md](../specs/2026-05-12-metablog-version-dashboard-design.md)
|
||||||
|
**Issue:** [#103](https://github.com/CyberMind-FR/secubox-deb/issues/103) (sub-project D of [#49](https://github.com/CyberMind-FR/secubox-deb/issues/49))
|
||||||
|
**Depends on:** [#102](https://github.com/CyberMind-FR/secubox-deb/pull/102) (merged — sub-C: schema + enriched API)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing state (verified)
|
||||||
|
|
||||||
|
`packages/secubox-metablogizer/www/metablogizer/index.html` already has:
|
||||||
|
|
||||||
|
- Table at line 369-372: `<thead><tr><th>Name</th><th>Domain</th><th>Status</th><th>Size</th><th>Actions</th></tr></thead>` + `<tbody id="site-list">` (colspan=5 in empty state).
|
||||||
|
- `loadSites()` at line 443 reads `d.sites` from `/sites` and renders each row.
|
||||||
|
- `refresh()` at line 528 calls `loadStatus()`, `loadSites()`, `loadAccess()`.
|
||||||
|
- `api(path, opts)` helper at line 420 handles 401 → `/login.html`.
|
||||||
|
|
||||||
|
The new layout has 8 columns: Name | Domain | **Version** | **Streamlit** | **Updated** | Status | Size | Actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| Action | Path | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Modify | `packages/secubox-metablogizer/www/metablogizer/index.html` | +3 columns, filter box, sort, 60s polling |
|
||||||
|
| Create | `packages/secubox-metablogizer/www/metablogizer/site.html` | Drill-in page (~250 lines, mirrors index.html theme) |
|
||||||
|
| Create | `tests/scripts/test-metablogizer-ui.sh` | 3-gate smoke (file shape + curl reachability + JS sanity) |
|
||||||
|
| Modify | `packages/secubox-metablogizer/README.md` | Document dashboard + drill-in URLs |
|
||||||
|
| Modify | `.claude/WIP.md`, `.claude/HISTORY.md` | Session 165 entry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend the list view — columns + filter + sort + polling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-metablogizer/www/metablogizer/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify branch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/reepost/CyberMindStudio/secubox-deb-worktrees/103-metablogizer-version-dashboard-ui-module
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `feature/103-metablogizer-version-dashboard-ui-module`. Otherwise BLOCKED.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the table `<thead>` (line 370) to add 3 columns + `data-sort` attributes**
|
||||||
|
|
||||||
|
Find this line:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<thead><tr><th>Name</th><th>Domain</th><th>Status</th><th>Size</th><th>Actions</th></tr></thead>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="name" onclick="sortBy('name', this)">Name <span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="domain" onclick="sortBy('domain', this)">Domain <span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="version" onclick="sortBy('version', this)">Version <span class="sort-ind"></span></th>
|
||||||
|
<th>Streamlit</th>
|
||||||
|
<th data-sort="last_updated" onclick="sortBy('last_updated', this)">Updated <span class="sort-ind"></span></th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="site-list"><tr><td colspan="8">Loading...</td></tr></tbody>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note `colspan="8"` (was 5).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add a filter input above the table**
|
||||||
|
|
||||||
|
Just above the `<table>` line (around line 368), add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="filter-row" style="margin-bottom:0.5rem">
|
||||||
|
<input type="search" id="filter" placeholder="Filter by name or domain…"
|
||||||
|
oninput="applyFilter()"
|
||||||
|
style="width:100%;padding:0.5rem;background:var(--bg-dark);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:inherit">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the empty-state `colspan` in `loadSites()`**
|
||||||
|
|
||||||
|
Find this line in `loadSites()` (around line 447):
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (!sites.length) { list.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim)">No sites</td></tr>'; return; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `colspan="5"` to `colspan="8"`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace the `sites.map(s => ...)` row template**
|
||||||
|
|
||||||
|
Find the existing template (around lines 448-461). Replace the inner `<tr>` template with this expanded form:
|
||||||
|
|
||||||
|
```js
|
||||||
|
list.innerHTML = sites.map(s => {
|
||||||
|
const streamlitCell = s.streamlit_app
|
||||||
|
? `<a href="https://gitea.gk2.secubox.in/gandalf/${s.streamlit_app}" target="_blank" title="${s.streamlit_app}">🎨</a>`
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
const versionCell = s.version
|
||||||
|
? `<a href="https://gitea.gk2.secubox.in/gandalf/metablog-${s.name}/releases" target="_blank"><code style="color:var(--primary)">${s.version}</code></a>`
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
const updatedCell = s.last_updated
|
||||||
|
? `<span title="${s.last_updated}">${relativeTime(s.last_updated)}</span>`
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
return `<tr class="site-row" data-name="${s.name}" data-domain="${s.domain}">
|
||||||
|
<td><strong><a href="site.html?name=${s.name}" style="color:var(--text)">${s.name}</a></strong></td>
|
||||||
|
<td style="color:var(--text-dim)">${s.domain}</td>
|
||||||
|
<td>${versionCell}</td>
|
||||||
|
<td style="text-align:center">${streamlitCell}</td>
|
||||||
|
<td>${updatedCell}</td>
|
||||||
|
<td><span class="badge ${s.published ? 'published' : 'draft'}">${s.published ? 'Published' : 'Draft'}</span></td>
|
||||||
|
<td>${s.size || '-'}</td>
|
||||||
|
<td>
|
||||||
|
${s.published ?
|
||||||
|
`<a href="http://${s.domain}" target="_blank" class="btn" style="padding:2px 8px;font-size:0.7rem">View</a>
|
||||||
|
<button class="btn" onclick="unpublishSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Unpublish</button>` :
|
||||||
|
`<button class="btn success" onclick="publishSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Publish</button>`}
|
||||||
|
<button class="btn danger" onclick="deleteSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
// Apply current sort + filter after re-render.
|
||||||
|
if (currentSort.field) applySortToDOM();
|
||||||
|
applyFilter();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add the helper functions + module-level state above `loadSites()`**
|
||||||
|
|
||||||
|
Insert this block right before the `async function loadSites()` definition (around line 442):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ─── Sort + filter + polling state ───
|
||||||
|
let currentSort = { field: null, dir: 'asc' };
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function relativeTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
const diff = (Date.now() - d.getTime()) / 1000;
|
||||||
|
if (isNaN(diff)) return iso;
|
||||||
|
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const q = (document.getElementById('filter')?.value || '').toLowerCase().trim();
|
||||||
|
document.querySelectorAll('.site-row').forEach(tr => {
|
||||||
|
const name = (tr.dataset.name || '').toLowerCase();
|
||||||
|
const domain = (tr.dataset.domain || '').toLowerCase();
|
||||||
|
const match = !q || name.includes(q) || domain.includes(q);
|
||||||
|
tr.style.display = match ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBy(field, thEl) {
|
||||||
|
if (currentSort.field === field) {
|
||||||
|
currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort = { field, dir: 'asc' };
|
||||||
|
}
|
||||||
|
// Update visual indicator on all sortable headers
|
||||||
|
document.querySelectorAll('th[data-sort] .sort-ind').forEach(s => s.textContent = '');
|
||||||
|
const ind = thEl.querySelector('.sort-ind');
|
||||||
|
if (ind) ind.textContent = currentSort.dir === 'asc' ? ' ▲' : ' ▼';
|
||||||
|
applySortToDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySortToDOM() {
|
||||||
|
const tbody = document.getElementById('site-list');
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr.site-row'));
|
||||||
|
const field = currentSort.field;
|
||||||
|
const dir = currentSort.dir === 'asc' ? 1 : -1;
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const av = (a.dataset[field] || '') + '';
|
||||||
|
const bv = (b.dataset[field] || '') + '';
|
||||||
|
// Nulls/empties go last in asc, first in desc.
|
||||||
|
if (av === '' && bv !== '') return 1;
|
||||||
|
if (bv === '' && av !== '') return -1;
|
||||||
|
return av.localeCompare(bv, undefined, { numeric: true }) * dir;
|
||||||
|
});
|
||||||
|
rows.forEach(r => tbody.appendChild(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPolling() {
|
||||||
|
if (pollTimer) return;
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `data-name` and `data-domain` are already on each `<tr>` from Step 5. We also need `data-version` and `data-last_updated` for the sort to work on those fields. Update the row template line:
|
||||||
|
|
||||||
|
```html
|
||||||
|
return `<tr class="site-row" data-name="${s.name}" data-domain="${s.domain}" data-version="${s.version || ''}" data-last_updated="${s.last_updated || ''}">
|
||||||
|
```
|
||||||
|
|
||||||
|
(Update the same `<tr ...>` line you wrote in Step 5.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Install the polling at the end of `refresh()`**
|
||||||
|
|
||||||
|
Find the existing `refresh()` (around line 528):
|
||||||
|
|
||||||
|
```js
|
||||||
|
function refresh() { loadStatus(); loadSites(); loadAccess(); }
|
||||||
|
refresh();
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function refresh() { loadStatus(); loadSites(); loadAccess(); }
|
||||||
|
refresh();
|
||||||
|
installPolling();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Add `.sort-ind` CSS (cosmetic)**
|
||||||
|
|
||||||
|
In the `<style>` block at the top of `index.html`, near the `.badge` rules (around line 78), add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
th[data-sort] { cursor: pointer; user-select: none; }
|
||||||
|
th[data-sort]:hover { color: var(--primary); }
|
||||||
|
.sort-ind { font-size: 0.8em; color: var(--primary); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: HTML sanity check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import html.parser
|
||||||
|
class S(html.parser.HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.stack = []
|
||||||
|
self.errors = []
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag not in ('br','hr','img','input','link','meta'):
|
||||||
|
self.stack.append(tag)
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self.stack and self.stack[-1] == tag:
|
||||||
|
self.stack.pop()
|
||||||
|
else:
|
||||||
|
self.errors.append(f'mismatched: closing {tag}, stack top {self.stack[-1] if self.stack else None}')
|
||||||
|
p = S()
|
||||||
|
p.feed(open('packages/secubox-metablogizer/www/metablogizer/index.html').read())
|
||||||
|
print('unclosed:', p.stack[-5:] if p.stack else 'none')
|
||||||
|
print('errors:', p.errors[:5] if p.errors else 'none')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `unclosed: none` (or only `['html','body','main']`-style top-level; trailing-open is acceptable). `errors: none`.
|
||||||
|
|
||||||
|
If unbalanced, find the culprit and fix it. Common cause: a forgotten `</td>` or `</tr>`.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-metablogizer/www/metablogizer/index.html
|
||||||
|
git commit -m "feat(metablog-ui): version/streamlit/updated columns + filter + sort + 60s poll (ref #103)
|
||||||
|
|
||||||
|
- 3 new columns: Version (links to Gitea releases), Streamlit (icon link
|
||||||
|
when site has a streamlit_app), Updated (relative time, full ISO tooltip)
|
||||||
|
- Filter box above the table: live substring match on name + domain
|
||||||
|
- Sortable headers (Name, Domain, Version, Updated) with ▲/▼ indicator
|
||||||
|
- 60-second auto-refresh, paused when tab is hidden (Page Visibility API)
|
||||||
|
- Row name now links to site.html?name=<X> (drill-in, Task 2)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Drill-in page `site.html`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/secubox-metablogizer/www/metablogizer/site.html`
|
||||||
|
|
||||||
|
This page is loaded as `/metablogizer/site.html?name=<X>`. Reuses the same theme/styles as `index.html`. Single API call to `/site/<X>` and a render function.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify branch.**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `site.html`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > packages/secubox-metablogizer/www/metablogizer/site.html <<'HTML'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SecuBox — Site details</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=Cinzel:wght@500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||||
|
<link rel="stylesheet" href="/shared/sidebar-light.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--p31-peak: #00dd44; --p31-hot: #00ff55; --p31-mid: #009933;
|
||||||
|
--p31-dim: #006622; --p31-decay: #ffb347; --p31-decay-dim: #cc7722;
|
||||||
|
--tube-light: #e8f5e9; --tube-pale: #c8e6c9; --tube-soft: #a5d6a7;
|
||||||
|
--bg-dark: var(--tube-light); --bg-card: var(--tube-pale);
|
||||||
|
--border: var(--tube-soft); --text: #1b2c1b; --text-dim: var(--p31-dim);
|
||||||
|
--primary: var(--p31-peak); --green: var(--p31-peak);
|
||||||
|
--red: #ff4466; --yellow: var(--p31-decay);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier Prime', 'Courier New', monospace;
|
||||||
|
background: var(--tube-light);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge.published { background: rgba(0,221,68,0.2); color: var(--green); border: 1px solid var(--green); }
|
||||||
|
.badge.draft { background: rgba(255,179,71,0.2); color: var(--yellow); border: 1px solid var(--yellow); }
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
dl { display: grid; grid-template-columns: 12rem 1fr; gap: 0.4rem 1rem; }
|
||||||
|
dt { color: var(--text-dim); }
|
||||||
|
dd { color: var(--text); word-break: break-word; }
|
||||||
|
dd code { background: var(--bg-dark); padding: 0.1rem 0.4rem; border-radius: 3px; color: var(--primary); }
|
||||||
|
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.btn.primary { background: var(--primary); color: var(--bg-dark); border-color: var(--primary); }
|
||||||
|
.btn.success { background: var(--green); color: var(--bg-dark); border-color: var(--green); }
|
||||||
|
.btn[hidden] { display: none !important; }
|
||||||
|
.hint { color: var(--text-dim); font-size: 0.9rem; }
|
||||||
|
.error { color: var(--red); }
|
||||||
|
a { color: var(--primary); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="crt-light">
|
||||||
|
<nav class="sidebar" id="sidebar"></nav>
|
||||||
|
<script src="/shared/sidebar.js"></script>
|
||||||
|
<main class="main">
|
||||||
|
<div id="error-box"></div>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<h1 id="site-name">…</h1>
|
||||||
|
<span id="status-badge" class="badge"></span>
|
||||||
|
<a href="index.html" class="btn" style="margin-left:auto">← Back to list</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Metadata</h2>
|
||||||
|
<dl id="meta-dl"></dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Quick links</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<a id="link-live" class="btn primary" target="_blank">🌐 Live site</a>
|
||||||
|
<a id="link-gitea" class="btn" target="_blank">🦊 Gitea repo</a>
|
||||||
|
<a id="link-streamlit" class="btn success" target="_blank" hidden>🎨 Streamlit app</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card hint">
|
||||||
|
<p>For tag history and deploys, see the <strong>Releases</strong> tab on the Gitea repo (auth required).</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api/v1/metablogizer';
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
const t = localStorage.getItem('jwt');
|
||||||
|
return t ? { 'Authorization': `Bearer ${t}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + path, { headers: headers() });
|
||||||
|
if (res.status === 401) { window.location = '/login.html'; return null; }
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
const diff = (Date.now() - d.getTime()) / 1000;
|
||||||
|
if (isNaN(diff)) return iso;
|
||||||
|
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(label, value) {
|
||||||
|
const dt = document.createElement('dt'); dt.textContent = label;
|
||||||
|
const dd = document.createElement('dd'); dd.innerHTML = value;
|
||||||
|
return [dt, dd];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(d) {
|
||||||
|
document.getElementById('site-name').textContent = d.name;
|
||||||
|
const badge = document.getElementById('status-badge');
|
||||||
|
badge.textContent = d.published ? 'Published' : 'Draft';
|
||||||
|
badge.className = 'badge ' + (d.published ? 'published' : 'draft');
|
||||||
|
|
||||||
|
const dl = document.getElementById('meta-dl');
|
||||||
|
const versionCell = d.version
|
||||||
|
? `<code>${d.version}</code>`
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
const lastUpdatedCell = d.last_updated
|
||||||
|
? `${d.last_updated} <span style="color:var(--text-dim)">(${relativeTime(d.last_updated)})</span>`
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
const tagsCell = (d.tags && d.tags.length)
|
||||||
|
? d.tags.map(t => `<code>${t}</code>`).join(' ')
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
['Domain', d.domain || '—'],
|
||||||
|
['Version', versionCell],
|
||||||
|
['Last updated', lastUpdatedCell],
|
||||||
|
['Title', d.title || '—'],
|
||||||
|
['Description', d.description || '—'],
|
||||||
|
['Category', d.category || '—'],
|
||||||
|
['Tags', tagsCell],
|
||||||
|
];
|
||||||
|
for (const [k, v] of fields) {
|
||||||
|
row(k, v).forEach(el => dl.appendChild(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('link-live').href = `https://${d.domain}/`;
|
||||||
|
document.getElementById('link-gitea').href =
|
||||||
|
`https://gitea.gk2.secubox.in/gandalf/metablog-${d.name}`;
|
||||||
|
const streamlit = document.getElementById('link-streamlit');
|
||||||
|
if (d.streamlit_app) {
|
||||||
|
streamlit.href = `https://gitea.gk2.secubox.in/gandalf/${d.streamlit_app}`;
|
||||||
|
streamlit.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
document.getElementById('error-box').innerHTML =
|
||||||
|
`<div class="card error">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSite() {
|
||||||
|
const name = new URLSearchParams(location.search).get('name');
|
||||||
|
if (!name) { showError('Missing <code>?name=…</code> parameter.'); return; }
|
||||||
|
const d = await api(`/site/${encodeURIComponent(name)}`);
|
||||||
|
if (!d || !d.name) { showError(`Site <strong>${name}</strong> not found.`); return; }
|
||||||
|
render(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSite();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: HTML sanity check on `site.html`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import html.parser
|
||||||
|
class S(html.parser.HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.stack = []
|
||||||
|
self.errors = []
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag not in ('br','hr','img','input','link','meta'):
|
||||||
|
self.stack.append(tag)
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self.stack and self.stack[-1] == tag:
|
||||||
|
self.stack.pop()
|
||||||
|
else:
|
||||||
|
self.errors.append(f'mismatched: closing {tag}, stack top {self.stack[-1] if self.stack else None}')
|
||||||
|
p = S()
|
||||||
|
p.feed(open('packages/secubox-metablogizer/www/metablogizer/site.html').read())
|
||||||
|
print('unclosed:', p.stack[-5:] if p.stack else 'none')
|
||||||
|
print('errors:', p.errors[:5] if p.errors else 'none')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `unclosed: none`, `errors: none`. (Top-level `html`/`body` close at EOF naturally; only structural errors matter.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-metablogizer/www/metablogizer/site.html
|
||||||
|
git commit -m "feat(metablog-ui): drill-in page /metablogizer/site.html?name=<X> (ref #103)
|
||||||
|
|
||||||
|
Surfaces every site.json field (domain, version, last_updated, title,
|
||||||
|
description, category, tags) plus three external links: live site,
|
||||||
|
Gitea repo, Streamlit app (hidden when streamlit_app is null).
|
||||||
|
|
||||||
|
Same CRT P31 phosphor theme as index.html. Single fetch to
|
||||||
|
/api/v1/metablogizer/site/<name>; renders or shows a clear error
|
||||||
|
('site not found' / 'missing name').
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Smoke test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/scripts/test-metablogizer-ui.sh`
|
||||||
|
|
||||||
|
3 gates: file shape (key strings present), live HTTP reachability, JS sanity (no broken JS syntax via Node-style parse).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify branch.**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the smoke**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > tests/scripts/test-metablogizer-ui.sh <<'BASH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# tests/scripts/test-metablogizer-ui.sh
|
||||||
|
# Smoke test for the MetaBlogizer version dashboard (sub-D of #49).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
source "$REPO/scripts/lib/test-helpers.sh"
|
||||||
|
|
||||||
|
INDEX="$REPO/packages/secubox-metablogizer/www/metablogizer/index.html"
|
||||||
|
SITE="$REPO/packages/secubox-metablogizer/www/metablogizer/site.html"
|
||||||
|
|
||||||
|
log_step() { echo "[smoke step $1] $2"; }
|
||||||
|
|
||||||
|
# Gate 1: index.html contains the new column anchors and filter input.
|
||||||
|
log_step 1 "index.html has the new columns + filter"
|
||||||
|
assert_file "$INDEX" "index.html present"
|
||||||
|
grep -q 'data-sort="version"' "$INDEX" || { echo "FAIL: data-sort=version missing"; exit 1; }
|
||||||
|
grep -q 'data-sort="last_updated"' "$INDEX" || { echo "FAIL: data-sort=last_updated missing"; exit 1; }
|
||||||
|
grep -q 'id="filter"' "$INDEX" || { echo "FAIL: filter input missing"; exit 1; }
|
||||||
|
grep -q 'installPolling()' "$INDEX" || { echo "FAIL: polling install missing"; exit 1; }
|
||||||
|
pass "index.html: version + last_updated + filter + polling all wired"
|
||||||
|
|
||||||
|
# Gate 2: site.html exists with the expected anchors.
|
||||||
|
log_step 2 "site.html drill-in present"
|
||||||
|
assert_file "$SITE" "site.html present"
|
||||||
|
grep -q 'URLSearchParams' "$SITE" || { echo "FAIL: site.html missing URL param parsing"; exit 1; }
|
||||||
|
grep -q 'link-gitea' "$SITE" || { echo "FAIL: site.html missing Gitea link"; exit 1; }
|
||||||
|
grep -q 'link-streamlit' "$SITE" || { echo "FAIL: site.html missing Streamlit link"; exit 1; }
|
||||||
|
grep -q 'link-live' "$SITE" || { echo "FAIL: site.html missing live-site link"; exit 1; }
|
||||||
|
pass "site.html: URL parsing + 3 external links wired"
|
||||||
|
|
||||||
|
# Gate 3: HTML sanity (well-formed, no mismatched tags) for both files.
|
||||||
|
log_step 3 "HTML well-formedness"
|
||||||
|
for f in "$INDEX" "$SITE"; do
|
||||||
|
python3 -c "
|
||||||
|
import html.parser, sys
|
||||||
|
class S(html.parser.HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.errors = []
|
||||||
|
self.stack = []
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag not in ('br','hr','img','input','link','meta'):
|
||||||
|
self.stack.append(tag)
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self.stack and self.stack[-1] == tag:
|
||||||
|
self.stack.pop()
|
||||||
|
else:
|
||||||
|
self.errors.append(f'closing {tag} without matching open')
|
||||||
|
p = S()
|
||||||
|
p.feed(open('$f').read())
|
||||||
|
if p.errors:
|
||||||
|
print('FAIL', '$f', p.errors[:3])
|
||||||
|
sys.exit(1)
|
||||||
|
" || { echo "FAIL: HTML mismatch in $f"; exit 1; }
|
||||||
|
done
|
||||||
|
pass "both HTML files are well-formed"
|
||||||
|
|
||||||
|
# Gate 4 (best-effort): live page reachable. SKIP on failure (auth, network).
|
||||||
|
log_step 4 "live HTTP reachability (best-effort)"
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" "https://admin.gk2.secubox.in/metablogizer/" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$code" == "200" ]]; then
|
||||||
|
pass "live /metablogizer/ returns $code"
|
||||||
|
else
|
||||||
|
echo "WARN: live page returned $code (network/auth/cache) — not blocking"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pass "all smoke gates green"
|
||||||
|
BASH
|
||||||
|
chmod +x tests/scripts/test-metablogizer-ui.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tests/scripts/test-metablogizer-ui.sh 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ends with `PASS: all smoke gates green`. Gates 1-3 must all pass. Gate 4 may show WARN if the live host is unreachable from the dev machine — acceptable.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/scripts/test-metablogizer-ui.sh
|
||||||
|
git commit -m "test(metablog-ui): 3-gate smoke for dashboard + drill-in (ref #103)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: README + tracking docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/secubox-metablogizer/README.md`
|
||||||
|
- Modify: `.claude/WIP.md`, `.claude/HISTORY.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify branch + sync from master**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/reepost/CyberMindStudio/secubox-deb-worktrees/103-metablogizer-version-dashboard-ui-module
|
||||||
|
bash scripts/agent-worktree.sh sync 103 2>&1 | tail -3
|
||||||
|
head -3 .claude/WIP.md
|
||||||
|
grep -oE "Session [0-9]+" .claude/WIP.md | sort -u -V | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the highest session number; the new entry is the next free integer (≥ 165).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Append a "Version dashboard" section to `packages/secubox-metablogizer/README.md`**
|
||||||
|
|
||||||
|
Insert before `## Installation`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Version dashboard
|
||||||
|
|
||||||
|
The Hub exposes a per-site dashboard at:
|
||||||
|
|
||||||
|
- **List**: `https://admin.gk2.secubox.in/metablogizer/`
|
||||||
|
- 8 columns: Name · Domain · Version · Streamlit · Updated · Status · Size · Actions
|
||||||
|
- Inline filter (matches `name` and `domain`)
|
||||||
|
- Sortable headers (Name, Domain, Version, Updated)
|
||||||
|
- Auto-refresh every 60s; paused when the tab is hidden
|
||||||
|
|
||||||
|
- **Per-site drill-in**: `https://admin.gk2.secubox.in/metablogizer/site.html?name=<sitename>`
|
||||||
|
- All `site.json` fields
|
||||||
|
- Three quick links: 🌐 live site, 🦊 Gitea repo, 🎨 Streamlit app (if any)
|
||||||
|
- Tag history is not shown inline; click the Gitea link and use its
|
||||||
|
**Releases** tab (auth required, handled by your Gitea session)
|
||||||
|
|
||||||
|
Data comes from `/api/v1/metablogizer/sites` and
|
||||||
|
`/api/v1/metablogizer/site/<name>` (sub-C, PR #102). The dashboard is
|
||||||
|
pure vanilla JS — no framework, no router.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add Session entry to `.claude/WIP.md`**
|
||||||
|
|
||||||
|
Replace the top `*Mis à jour : 2026-05-12 (Session N)*` line with the next free session number, and insert a new `## ✅ Session <N>` block at the top:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## ✅ Session <N>: MetaBlogizer version dashboard (Issue #103, sub-D of #49)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
Extend the existing /metablogizer/ list view with version-aware columns + filter + sort + 60s polling, and add a per-site drill-in page at /metablogizer/site.html?name=<X>. Consumes the enriched API from sub-C (PR #102).
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Brainstormed design → `docs/superpowers/specs/2026-05-12-metablog-version-dashboard-design.md`
|
||||||
|
- Plan (5 tasks) → `docs/superpowers/plans/2026-05-12-metablog-version-dashboard.md`
|
||||||
|
- Extended `index.html`: 3 new columns (version → Gitea releases link, streamlit_app icon link, last_updated as relative time + ISO tooltip), filter box, sortable headers with ▲/▼, 60s polling paused when tab hidden, row name links to drill-in
|
||||||
|
- New `site.html`: single-fetch drill-in surfacing every site.json field + 3 external links (live, Gitea, Streamlit hidden when null)
|
||||||
|
- 4-gate smoke `tests/scripts/test-metablogizer-ui.sh` (file shape + drill-in anchors + HTML well-formedness + live reachability best-effort)
|
||||||
|
- CRT P31 phosphor theme matched exactly with the existing module style
|
||||||
|
|
||||||
|
### Followups
|
||||||
|
- Sub-E (deploy webhook) — last open sub-project of #49.
|
||||||
|
- Optional follow-up: server-side proxy for Gitea tag history (so the drill-in can show tag list inline, no browser-side Gitea auth). Out of MVP scope.
|
||||||
|
```
|
||||||
|
|
||||||
|
(Replace `<N>` with the next free session number observed in Step 1.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mirror the entry in `.claude/HISTORY.md`** under `## 2026-05-12`, before the previous Session entry, with the same shape as the existing entries there.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/secubox-metablogizer/README.md .claude/WIP.md .claude/HISTORY.md
|
||||||
|
git commit -m "docs(metablog-ui): Session <N> tracking + README dashboard URLs (ref #103)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Finish worktree + PR
|
||||||
|
|
||||||
|
**Files:** none modified.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Final smoke**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tests/scripts/test-metablogizer-ui.sh 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ends with `PASS: all smoke gates green`. If any gate regressed, STOP and fix.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Push + PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/agent-worktree.sh finish 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the PR number.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Set PR title + body via REST API**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR=<N from step 2>
|
||||||
|
gh api -X PATCH /repos/CyberMind-FR/secubox-deb/pulls/$PR \
|
||||||
|
-f title="MetaBlogizer version dashboard (Refs #49 sub-D, closes #103)" \
|
||||||
|
-f body="$(cat <<EOF
|
||||||
|
Sub-project **D** of #49. Closes #103.
|
||||||
|
|
||||||
|
## What is live
|
||||||
|
|
||||||
|
- \`/metablogizer/\` list view extended with: \`Version\` (Gitea releases link), \`Streamlit\` (icon link), \`Updated\` (relative time, ISO tooltip), an inline filter, sortable headers, and 60s auto-refresh paused when the tab is hidden.
|
||||||
|
- \`/metablogizer/site.html?name=<X>\` drill-in: every \`site.json\` field plus 🌐 live site, 🦊 Gitea repo, 🎨 Streamlit app (hidden when null).
|
||||||
|
|
||||||
|
## Pieces
|
||||||
|
|
||||||
|
- Spec — \`docs/superpowers/specs/2026-05-12-metablog-version-dashboard-design.md\`
|
||||||
|
- Plan — \`docs/superpowers/plans/2026-05-12-metablog-version-dashboard.md\` (5 tasks)
|
||||||
|
- index.html — extended in place (theme/sidebar/wiring all reused)
|
||||||
|
- site.html — new (single fetch, same CRT P31 phosphor theme)
|
||||||
|
- Smoke — \`tests/scripts/test-metablogizer-ui.sh\` (4 gates)
|
||||||
|
|
||||||
|
## Decisions (locked in spec)
|
||||||
|
|
||||||
|
- Tag history: external Gitea link, not inline. Repos are private and browser-side proxy through a stored token is heavier than the MVP warrants.
|
||||||
|
- Pure vanilla JS — no framework, no router.
|
||||||
|
- Sort/filter in-memory, no URL state (165 sites fit fine).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
\`Refs #49 (sub-project D)\` — \`Closes #103\`. Sub-project E (deploy webhook) is the last remaining piece on #49.
|
||||||
|
EOF
|
||||||
|
)" >/dev/null
|
||||||
|
echo "PR #$PR updated"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Comment on #49**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue comment 49 --body "Sub-project D (version dashboard) merged via PR #$PR.
|
||||||
|
|
||||||
|
Operator can browse 165 sites at /metablogizer/, filter and sort by version, and drill into each site via /metablogizer/site.html?name=<X>. Tag history defers to the Gitea Releases tab (auth handled by the user's Gitea session).
|
||||||
|
|
||||||
|
E (deploy webhook) is the last remaining sub-project of #49."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
|
||||||
|
- Spec § *Component 1 — Extended list view* → Task 1 ✓ (steps 2-8 cover columns/filter/sort/polling)
|
||||||
|
- Spec § *Component 2 — Drill-in page* → Task 2 ✓
|
||||||
|
- Spec § *Component 3 — Sidebar entry* → Spec says no change required; no task needed ✓
|
||||||
|
- Spec § *Component 4 — Smoke test* → Task 3 ✓
|
||||||
|
- Spec § *File-level changes* table — Tasks 1, 2, 3, 4 cover each entry ✓
|
||||||
|
- Spec § *Validation gate* — Task 3's gates 1-3 + Task 5's final smoke cover validation ✓
|
||||||
|
- Spec § *Error handling* — addressed inline in `site.html` render (empty fields → "—") and `applySortToDOM` (nulls last) — both visible in Task 1 step 6 and Task 2 step 2 ✓
|
||||||
|
|
||||||
|
**2. Placeholder scan:**
|
||||||
|
|
||||||
|
- No "TBD" / "TODO".
|
||||||
|
- Task 4 uses `<N>` for the next free session number; explicitly bounded by an inline check command in Step 1. Acceptable.
|
||||||
|
- Task 5 uses `<N from step 2>` for the PR number; explicit "Replace with the actual PR number". Acceptable.
|
||||||
|
|
||||||
|
**3. Type / identifier consistency:**
|
||||||
|
|
||||||
|
- Function names `loadSites`, `refresh`, `applyFilter`, `sortBy`, `applySortToDOM`, `installPolling`, `relativeTime`, `loadSite` (drill-in) — all referenced consistently within their own task; no cross-task name collision.
|
||||||
|
- `currentSort.field` is one of `name`, `domain`, `version`, `last_updated` — Step 5's data attributes on `<tr>` match these exact names (`data-name`, `data-domain`, `data-version`, `data-last_updated`).
|
||||||
|
- The drill-in's `link-streamlit` href uses `gandalf/${d.streamlit_app}` — `streamlit_app` is the field name from sub-C's API (verified in `packages/secubox-metablogizer/schema/site.json.schema.json` on master). Consistent.
|
||||||
|
- Site URL `https://gitea.gk2.secubox.in/gandalf/metablog-${name}` — owner `gandalf`, prefix `metablog-` — matches sub-B's naming on Gitea. Consistent.
|
||||||
|
|
||||||
|
No gaps. Plan ready to execute.
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
# MetaBlogizer Version Dashboard — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Author:** Gandalf (CyberMind), with Claude
|
||||||
|
**Status:** Draft for approval
|
||||||
|
**Issue:** [#103](https://github.com/CyberMind-FR/secubox-deb/issues/103) (sub-project D of [#49](https://github.com/CyberMind-FR/secubox-deb/issues/49))
|
||||||
|
**Depends on:** [#102](https://github.com/CyberMind-FR/secubox-deb/pull/102) (merged — sub-C: schema + enriched API)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`packages/secubox-metablogizer/www/metablogizer/index.html` already exists (533 lines, served live at `https://admin.gk2.secubox.in/metablogizer/`). It has a tabbed layout (Sites / Access / Actions), stat cards, a sites table that consumes `/api/v1/metablogizer/sites`, and the CRT P31 phosphor theme.
|
||||||
|
|
||||||
|
What's missing for D:
|
||||||
|
|
||||||
|
- Columns for `version`, `streamlit_app`, `last_updated` (sub-C now exposes these on the API).
|
||||||
|
- A filter box (165 sites is too many to scroll).
|
||||||
|
- Sortable columns.
|
||||||
|
- A 60-second auto-refresh (today: manual button only).
|
||||||
|
- A per-site drill-in page.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Extend the existing list view with version-aware columns, filter, sort, and polling — plus add a per-site drill-in page that surfaces every site.json field and links out to Gitea / live site / Streamlit app.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Editing site.json from the UI (no inline edit, no form posts).
|
||||||
|
- Triggering deploys / rollbacks (sub-E concern).
|
||||||
|
- Per-tag diff view inside the UI (operator follows the external Gitea link).
|
||||||
|
- Fetching Gitea tag history from the browser (repos are private — needs server-side proxying, defer to a separate iteration).
|
||||||
|
- Bulk operations (multi-select, batch publish, etc.).
|
||||||
|
|
||||||
|
## Decisions taken in brainstorming
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Scaffold or extend? | Extend the existing 533-line `index.html` | Avoid duplicating layout/theme/sidebar wiring; UI lives in `packages/secubox-metablogizer/www/metablogizer/` per the established Hub pattern |
|
||||||
|
| Drill-in route | Separate file `site.html?name=<X>` (query-string param) | Deep-linkable; one URL maps to one site; no JS router needed |
|
||||||
|
| Tag history in drill-in | **External link to Gitea** (no inline fetch) | Repos are private; browser-side Gitea auth is brittle. Operator clicks → opens Gitea Releases tab in a new tab |
|
||||||
|
| Sort/filter scope | In-memory JS, no URL state | 165 items fit easily; URL state adds complexity without UX win |
|
||||||
|
| Refresh cadence | 60 s polling, paused when tab hidden | Cheap (one API call); `document.hidden` skip avoids waste |
|
||||||
|
| Streamlit link | Visible only when `streamlit_app != null` | Half the sites have one, half don't |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Component 1 — Extended list view (`index.html`)
|
||||||
|
|
||||||
|
The existing `loadSites()` function takes `d.sites` from the API and renders 5 columns. We add 3 columns and 3 toolbar features.
|
||||||
|
|
||||||
|
**New table columns** (between `domain` and `published`):
|
||||||
|
|
||||||
|
| Column | Source | Render |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| Version | `s.version` (from `.deploy.json` or `git describe`) | `<code class="version">v1.0.0</code>` — links to `https://gitea.gk2.secubox.in/gandalf/metablog-<name>` (releases tab anchor) |
|
||||||
|
| Streamlit | `s.streamlit_app` | If non-null: 🎨 icon + link to `https://gitea.gk2.secubox.in/gandalf/streamlit-<name>`. If null: empty cell |
|
||||||
|
| Updated | `s.last_updated` | Relative time ("2d ago") via small format helper; tooltip shows the full ISO 8601 |
|
||||||
|
|
||||||
|
**Filter box** — placed above the table, inside the existing `<div class="card">` that wraps the Sites tab:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="search"
|
||||||
|
id="filter"
|
||||||
|
placeholder="Filter by name or domain…"
|
||||||
|
oninput="applyFilter()">
|
||||||
|
```
|
||||||
|
|
||||||
|
`applyFilter()` walks `.site-row` `<tr>`s and toggles `display: none` based on case-insensitive substring match.
|
||||||
|
|
||||||
|
**Sortable headers** — each `<th>` carries `data-sort="<field>"` and a click handler:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<th data-sort="name" onclick="sortBy(this)">Name <span class="sort-ind"></span></th>
|
||||||
|
```
|
||||||
|
|
||||||
|
`sortBy(th)` toggles asc/desc, updates the `sort-ind` to ▲/▼, and re-renders the rows. Current sort state lives in two module-level vars: `currentSort = {field: 'name', dir: 'asc'}`.
|
||||||
|
|
||||||
|
**60-second polling** — at the end of `refresh()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
setInterval(() => { if (!document.hidden) refresh(); }, 60000);
|
||||||
|
```
|
||||||
|
|
||||||
|
Just once; subsequent calls don't stack timers. The function is wrapped in a guard so the timer isn't installed twice.
|
||||||
|
|
||||||
|
### Component 2 — Drill-in page (`site.html`)
|
||||||
|
|
||||||
|
New file at `packages/secubox-metablogizer/www/metablogizer/site.html`. Same CRT P31 phosphor theme via `<link rel="stylesheet" href="/shared/crt-light.css">` and inline `<style>` block matching `index.html`.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
<sidebar> (from /shared/sidebar.js, same as index.html)
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1 class="cinzel">{{name}}</h1>
|
||||||
|
<span class="badge published|draft">…</span>
|
||||||
|
</header>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Metadata</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Domain</dt> <dd>{{domain}}</dd>
|
||||||
|
<dt>Version</dt> <dd><code>{{version}}</code></dd>
|
||||||
|
<dt>Last updated</dt><dd>{{last_updated}} ({{relative}})</dd>
|
||||||
|
<dt>Title</dt> <dd>{{title or "—"}}</dd>
|
||||||
|
<dt>Description</dt> <dd>{{description or "—"}}</dd>
|
||||||
|
<dt>Category</dt> <dd>{{category or "—"}}</dd>
|
||||||
|
<dt>Tags</dt> <dd>{{tags.join(", ") or "—"}}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="card actions">
|
||||||
|
<h2>Quick links</h2>
|
||||||
|
<a class="btn primary" href="https://{{domain}}/" target="_blank">🌐 Live site</a>
|
||||||
|
<a class="btn" href="https://gitea.gk2.secubox.in/gandalf/metablog-{{name}}" target="_blank">🦊 Gitea repo</a>
|
||||||
|
{{#streamlit_app}}
|
||||||
|
<a class="btn success" href="https://gitea.gk2.secubox.in/gandalf/streamlit-{{name}}" target="_blank">🎨 Streamlit app</a>
|
||||||
|
{{/streamlit_app}}
|
||||||
|
</div>
|
||||||
|
<div class="card hint">
|
||||||
|
<p>For tag history and deploys, see the
|
||||||
|
<strong>Releases</strong> tab on the Gitea repo (auth required).</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
JS:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function loadSite() {
|
||||||
|
const name = new URLSearchParams(location.search).get('name');
|
||||||
|
if (!name) { document.body.innerHTML = '<p>Missing ?name=…</p>'; return; }
|
||||||
|
const d = await api(`/site/${name}`);
|
||||||
|
if (!d || !d.name) { document.body.innerHTML = `<p>Site ${name} not found</p>`; return; }
|
||||||
|
render(d);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`render(d)` substitutes the placeholders and toggles the Streamlit button visibility.
|
||||||
|
|
||||||
|
No polling on this page — site metadata changes rarely. Operator hits browser refresh if needed.
|
||||||
|
|
||||||
|
### Component 3 — Sidebar entry
|
||||||
|
|
||||||
|
Already exists in `packages/secubox-hub/www/shared/sidebar.js:182`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
'/metablogizer/': { metrics: ['sites', 'published', 'nginx'], api: '/api/v1/metablogizer/status' },
|
||||||
|
```
|
||||||
|
|
||||||
|
No change required for sub-D.
|
||||||
|
|
||||||
|
### Component 4 — Smoke test
|
||||||
|
|
||||||
|
`tests/scripts/test-metablogizer-ui.sh` runs against the live Hub. Three gates:
|
||||||
|
|
||||||
|
1. `curl -s https://admin.gk2.secubox.in/metablogizer/` → HTTP 200, body contains `data-sort="version"` and `id="filter"`.
|
||||||
|
2. `curl -s https://admin.gk2.secubox.in/metablogizer/site.html?name=zkp` → HTTP 200, body contains `zkp` and `gitea.gk2.secubox.in/gandalf/metablog-zkp`.
|
||||||
|
3. JS sanity: HTML must validate as well-formed (no unclosed tags) — use `python3 -c "import html.parser; …"` to walk the file and count open/close tags. (No need for a full headless-browser test.)
|
||||||
|
|
||||||
|
If the live Hub deploys behind auth (it does), gate 1 may need a JWT for the actual page render. The smoke is acceptable if step 1 returns 200 even with a redirect to login — we're checking that the file *exists* and is served. The actual contents are validated by the JS-level check on the file in the repo.
|
||||||
|
|
||||||
|
## File-level changes
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Modify | `packages/secubox-metablogizer/www/metablogizer/index.html` | +3 columns, filter box, sort handlers, 60s polling |
|
||||||
|
| Create | `packages/secubox-metablogizer/www/metablogizer/site.html` | Drill-in page (≈ 200-250 lines) |
|
||||||
|
| Create | `tests/scripts/test-metablogizer-ui.sh` | 3-gate smoke |
|
||||||
|
| Modify | `packages/secubox-metablogizer/README.md` | Document the dashboard URL + drill-in URL pattern |
|
||||||
|
| Modify | `.claude/WIP.md`, `.claude/HISTORY.md` | Session 165 entry |
|
||||||
|
|
||||||
|
## Validation gate
|
||||||
|
|
||||||
|
Done when:
|
||||||
|
|
||||||
|
1. `bash tests/scripts/test-metablogizer-ui.sh` reports all 3 gates green.
|
||||||
|
2. Visiting `https://admin.gk2.secubox.in/metablogizer/` shows 165 rows with the new columns populated. (Spot-check: `zkp` has `v1.0.0`, `last_updated` shows a relative time.)
|
||||||
|
3. Typing in the filter box reduces the visible row count live.
|
||||||
|
4. Clicking the `Name` `<th>` toggles ▲ / ▼ and re-sorts the table.
|
||||||
|
5. Visiting `https://admin.gk2.secubox.in/metablogizer/site.html?name=zkp` shows the drill-in card with all fields, plus 3 external links (the Streamlit one is hidden if `streamlit_app` is null).
|
||||||
|
6. Leaving the tab open 60s triggers a single refresh (verify via DevTools Network panel).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Failure | Detection | Response |
|
||||||
|
|---------|-----------|----------|
|
||||||
|
| `/sites` returns 401 | Existing `api()` helper already redirects to `/login.html` | No change needed |
|
||||||
|
| `/site/<name>` returns 404 | Drill-in's `render(d)` sees `!d.name` | Render "Site not found" message |
|
||||||
|
| `last_updated` is null | Renderer | Show "—" instead of relative time |
|
||||||
|
| `streamlit_app` is null | Renderer | Hide the Streamlit button (display: none) |
|
||||||
|
| User opens `site.html` with no `?name=` | URL parser | Render "Missing ?name=…" |
|
||||||
|
| Sort on a column where some rows have `null` (e.g. last_updated) | JS comparator | Nulls sort last in ascending, first in descending |
|
||||||
|
| 60s timer would fire while tab hidden | `document.hidden` check | Skip refresh, wait for the next interval |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Operational + DOM-based. No unit tests for the JS (vanilla, small surface). The smoke test verifies HTTP reachability and key string presence. Manual eyeball pass on a live render covers the rest.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None blocking. The deferred Gitea tag fetch is documented as a future enhancement.
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
CMSD-1.0. HTML/JS files inherit the existing module's licensing header (kept as-is on edit, added to new `site.html` to match the file already serving as `index.html`).
|
||||||
|
|
@ -68,6 +68,26 @@ Per-run JSON report at `output/metablog-backfill-report.json`. The backfill
|
||||||
auto-detects `streamlit_app` by probing the Gitea repo
|
auto-detects `streamlit_app` by probing the Gitea repo
|
||||||
`gandalf/streamlit-<name>.git` (sub-F).
|
`gandalf/streamlit-<name>.git` (sub-F).
|
||||||
|
|
||||||
|
## Version dashboard
|
||||||
|
|
||||||
|
The Hub exposes a per-site dashboard at:
|
||||||
|
|
||||||
|
- **List**: `https://admin.gk2.secubox.in/metablogizer/`
|
||||||
|
- 8 columns: Name · Domain · Version · Streamlit · Updated · Status · Size · Actions
|
||||||
|
- Inline filter (matches `name` and `domain`)
|
||||||
|
- Sortable headers (Name, Domain, Version, Updated)
|
||||||
|
- Auto-refresh every 60s; paused when the tab is hidden
|
||||||
|
|
||||||
|
- **Per-site drill-in**: `https://admin.gk2.secubox.in/metablogizer/site.html?name=<sitename>`
|
||||||
|
- All `site.json` fields
|
||||||
|
- Three quick links: 🌐 live site, 🦊 Gitea repo, 🎨 Streamlit app (if any)
|
||||||
|
- Tag history is not shown inline; click the Gitea link and use its
|
||||||
|
**Releases** tab (auth required, handled by your Gitea session)
|
||||||
|
|
||||||
|
Data comes from `/api/v1/metablogizer/sites` and
|
||||||
|
`/api/v1/metablogizer/site/<name>` (sub-C, PR #102). The dashboard is
|
||||||
|
pure vanilla JS — no framework, no router.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,16 @@ SecuBox is an appliance and network model - distributed peer applications.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Self-bootstrap api/ onto sys.path so `from site_schema import …` and
|
||||||
|
# `from rmtree import …` resolve under `uvicorn api.main:app` (where only
|
||||||
|
# the parent dir is auto-added). Tests use PYTHONPATH=api so the
|
||||||
|
# insert is a no-op in that path. See #109.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, UploadFile, File
|
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -36,6 +43,7 @@ NGINX_BACKEND_IP = "192.168.1.200"
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from site_schema import enrich as _schema_enrich, validate as _schema_validate
|
from site_schema import enrich as _schema_enrich, validate as _schema_validate
|
||||||
|
from rmtree import force_remove as _rmtree_force
|
||||||
|
|
||||||
logger = logging.getLogger("metablogizer")
|
logger = logging.getLogger("metablogizer")
|
||||||
|
|
||||||
|
|
@ -138,10 +146,34 @@ def _load_site_json(site_dir):
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory cache for load_sites(). 166 sites × (enrich+validate+du -sh)
|
||||||
|
# is ~5-10s per call; the dashboard polls 3 endpoints every 60s and the
|
||||||
|
# single uvicorn worker queues them. Cache for 30s, invalidated by
|
||||||
|
# _invalidate_sites_cache() in every write path (POST/DELETE/publish).
|
||||||
|
_SITES_CACHE: Optional[List[dict]] = None
|
||||||
|
_SITES_CACHE_AT: float = 0.0
|
||||||
|
_SITES_CACHE_TTL: float = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_sites_cache() -> None:
|
||||||
|
"""Drop the cached site list. Call after any site directory change."""
|
||||||
|
global _SITES_CACHE, _SITES_CACHE_AT
|
||||||
|
_SITES_CACHE = None
|
||||||
|
_SITES_CACHE_AT = 0.0
|
||||||
|
|
||||||
|
|
||||||
def load_sites() -> List[dict]:
|
def load_sites() -> List[dict]:
|
||||||
"""Load all sites from directory"""
|
"""Load all sites from directory (cached, 30s TTL)."""
|
||||||
|
global _SITES_CACHE, _SITES_CACHE_AT
|
||||||
|
import time
|
||||||
|
now = time.monotonic()
|
||||||
|
if _SITES_CACHE is not None and (now - _SITES_CACHE_AT) < _SITES_CACHE_TTL:
|
||||||
|
return _SITES_CACHE
|
||||||
|
|
||||||
sites = []
|
sites = []
|
||||||
if not SITES_ROOT.exists():
|
if not SITES_ROOT.exists():
|
||||||
|
_SITES_CACHE = sites
|
||||||
|
_SITES_CACHE_AT = now
|
||||||
return sites
|
return sites
|
||||||
|
|
||||||
for site_dir in SITES_ROOT.iterdir():
|
for site_dir in SITES_ROOT.iterdir():
|
||||||
|
|
@ -188,7 +220,10 @@ def load_sites() -> List[dict]:
|
||||||
entry[key] = cfg[key]
|
entry[key] = cfg[key]
|
||||||
sites.append(entry)
|
sites.append(entry)
|
||||||
|
|
||||||
return sorted(sites, key=lambda x: x.get("port", BASE_PORT))
|
sites_sorted = sorted(sites, key=lambda x: x.get("port", BASE_PORT))
|
||||||
|
_SITES_CACHE = sites_sorted
|
||||||
|
_SITES_CACHE_AT = now
|
||||||
|
return sites_sorted
|
||||||
|
|
||||||
|
|
||||||
def regenerate_nginx_config() -> tuple:
|
def regenerate_nginx_config() -> tuple:
|
||||||
|
|
@ -466,7 +501,8 @@ class SiteUpdate(BaseModel):
|
||||||
@app.get("/sites", dependencies=[Depends(require_jwt)])
|
@app.get("/sites", dependencies=[Depends(require_jwt)])
|
||||||
async def list_sites():
|
async def list_sites():
|
||||||
"""List all sites"""
|
"""List all sites"""
|
||||||
return {"sites": load_sites(), "count": len(load_sites())}
|
sites = load_sites()
|
||||||
|
return {"sites": sites, "count": len(sites)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/site/{name}", dependencies=[Depends(require_jwt)])
|
@app.get("/site/{name}", dependencies=[Depends(require_jwt)])
|
||||||
|
|
@ -548,6 +584,7 @@ async def create_site(site: SiteCreate):
|
||||||
"template": site.template,
|
"template": site.template,
|
||||||
}, indent=2))
|
}, indent=2))
|
||||||
|
|
||||||
|
_invalidate_sites_cache()
|
||||||
return {"success": True, "name": site.name, "domain": domain}
|
return {"success": True, "name": site.name, "domain": domain}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -562,9 +599,13 @@ async def delete_site(name: str):
|
||||||
(NGINX_ENABLED_DIR / f"{name}.conf").unlink(missing_ok=True)
|
(NGINX_ENABLED_DIR / f"{name}.conf").unlink(missing_ok=True)
|
||||||
(NGINX_VHOST_DIR / f"{name}.conf").unlink(missing_ok=True)
|
(NGINX_VHOST_DIR / f"{name}.conf").unlink(missing_ok=True)
|
||||||
|
|
||||||
# Delete directory
|
# Sites cloned from Gitea (sub-B of #49) carry a .git subtree whose pack
|
||||||
shutil.rmtree(site_dir)
|
# files are 0444 and whose directories may be 0500 — shutil.rmtree then
|
||||||
|
# trips on os.open(..., O_RDONLY, dir_fd=topfd). _rmtree_force chmods
|
||||||
|
# restricted entries to 0700 and retries.
|
||||||
|
_rmtree_force(site_dir)
|
||||||
|
|
||||||
|
_invalidate_sites_cache()
|
||||||
return {"success": True, "name": name}
|
return {"success": True, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -627,6 +668,7 @@ server {{
|
||||||
run_cmd(["nginx", "-t"])
|
run_cmd(["nginx", "-t"])
|
||||||
run_cmd(["systemctl", "reload", "nginx"])
|
run_cmd(["systemctl", "reload", "nginx"])
|
||||||
|
|
||||||
|
_invalidate_sites_cache()
|
||||||
return {"success": True, "name": name, "domain": domain, "url": f"http://{domain}"}
|
return {"success": True, "name": name, "domain": domain, "url": f"http://{domain}"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -636,6 +678,7 @@ async def unpublish_site(name: str):
|
||||||
(NGINX_ENABLED_DIR / f"{name}.conf").unlink(missing_ok=True)
|
(NGINX_ENABLED_DIR / f"{name}.conf").unlink(missing_ok=True)
|
||||||
run_cmd(["systemctl", "reload", "nginx"])
|
run_cmd(["systemctl", "reload", "nginx"])
|
||||||
|
|
||||||
|
_invalidate_sites_cache()
|
||||||
return {"success": True, "name": name}
|
return {"success": True, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -643,6 +686,7 @@ async def unpublish_site(name: str):
|
||||||
async def republish_all():
|
async def republish_all():
|
||||||
"""Republish all sites by regenerating nginx config"""
|
"""Republish all sites by regenerating nginx config"""
|
||||||
success, count, message = regenerate_nginx_config()
|
success, count, message = regenerate_nginx_config()
|
||||||
|
_invalidate_sites_cache()
|
||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"sites_published": count,
|
"sites_published": count,
|
||||||
|
|
|
||||||
31
packages/secubox-metablogizer/api/rmtree.py
Normal file
31
packages/secubox-metablogizer/api/rmtree.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
"""
|
||||||
|
SecuBox-Deb :: metablogizer :: rmtree helper
|
||||||
|
CyberMind — https://cybermind.fr
|
||||||
|
|
||||||
|
Sites cloned from Gitea (sub-B of #49) carry a .git subtree whose pack files
|
||||||
|
are 0444 and whose directories may be 0500. Vanilla `shutil.rmtree` then
|
||||||
|
trips on `os.open(..., O_RDONLY, dir_fd=topfd)`. `force_remove` chmods the
|
||||||
|
offending entry to 0700 and retries the failing op.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def force_remove(path: Path) -> None:
|
||||||
|
"""shutil.rmtree, but chmods read-only entries before retry.
|
||||||
|
|
||||||
|
For unlink/rmdir failures the parent directory's mode is the real
|
||||||
|
blocker; for opendir failures the entry itself is. Chmod both.
|
||||||
|
"""
|
||||||
|
def _onerror(func, entry, _exc):
|
||||||
|
parent = os.path.dirname(entry)
|
||||||
|
for target in (parent, entry):
|
||||||
|
try:
|
||||||
|
os.chmod(target, 0o700)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
func(entry)
|
||||||
|
shutil.rmtree(path, onerror=_onerror)
|
||||||
58
packages/secubox-metablogizer/api/tests/test_rmtree_force.py
Normal file
58
packages/secubox-metablogizer/api/tests/test_rmtree_force.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
"""Tests for _rmtree_force — must clear Gitea-style read-only .git subtrees."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rmtree import force_remove as _rmtree_force
|
||||||
|
|
||||||
|
|
||||||
|
def _make_locked_git_tree(root: Path) -> Path:
|
||||||
|
"""Build a fake site dir that mimics a Gitea-cloned layout (0500 .git, 0444 packs)."""
|
||||||
|
site = root / "fakesite"
|
||||||
|
site.mkdir()
|
||||||
|
(site / "site.json").write_text("{}")
|
||||||
|
git = site / ".git"
|
||||||
|
git.mkdir()
|
||||||
|
objects = git / "objects" / "pack"
|
||||||
|
objects.mkdir(parents=True)
|
||||||
|
pack = objects / "pack-abcd.pack"
|
||||||
|
pack.write_bytes(b"\x00" * 32)
|
||||||
|
# Lock down: pack file read-only, objects dir read-execute only, .git read-execute only.
|
||||||
|
os.chmod(pack, 0o444)
|
||||||
|
os.chmod(objects, 0o500)
|
||||||
|
os.chmod(git / "objects", 0o500)
|
||||||
|
os.chmod(git, 0o500)
|
||||||
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
def test_rmtree_force_clears_locked_git_subtree(tmp_path):
|
||||||
|
site = _make_locked_git_tree(tmp_path)
|
||||||
|
# Sanity check: vanilla rmtree fails on this tree.
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
shutil.rmtree(site)
|
||||||
|
# Rebuild (the failed rmtree may have partially mutated state).
|
||||||
|
if site.exists():
|
||||||
|
# Best-effort cleanup so the test can re-create cleanly.
|
||||||
|
for p in site.rglob("*"):
|
||||||
|
try:
|
||||||
|
os.chmod(p, 0o700)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
shutil.rmtree(site)
|
||||||
|
site = _make_locked_git_tree(tmp_path)
|
||||||
|
_rmtree_force(site)
|
||||||
|
assert not site.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rmtree_force_clears_plain_directory(tmp_path):
|
||||||
|
plain = tmp_path / "plain"
|
||||||
|
plain.mkdir()
|
||||||
|
(plain / "a.txt").write_text("a")
|
||||||
|
(plain / "sub").mkdir()
|
||||||
|
(plain / "sub" / "b.txt").write_text("b")
|
||||||
|
_rmtree_force(plain)
|
||||||
|
assert not plain.exists()
|
||||||
70
packages/secubox-metablogizer/api/tests/test_sites_cache.py
Normal file
70
packages/secubox-metablogizer/api/tests/test_sites_cache.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
"""
|
||||||
|
Tests for the load_sites() in-memory cache + _invalidate_sites_cache().
|
||||||
|
|
||||||
|
We don't import main.py here (it pulls FastAPI + python-multipart + secubox_core).
|
||||||
|
Instead we exercise the cache pattern in isolation by copying the function
|
||||||
|
shape — the contract is: TTL-based caching with invalidation, no other state.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cached_loader(ttl: float = 30.0):
|
||||||
|
"""Return a (loader, invalidate, calls_counter) triple mirroring main.py's pattern."""
|
||||||
|
state = {"cache": None, "at": 0.0, "calls": 0}
|
||||||
|
|
||||||
|
def loader() -> List[dict]:
|
||||||
|
now = time.monotonic()
|
||||||
|
if state["cache"] is not None and (now - state["at"]) < ttl:
|
||||||
|
return state["cache"]
|
||||||
|
state["calls"] += 1
|
||||||
|
result = [{"name": f"s{state['calls']}"}]
|
||||||
|
state["cache"] = result
|
||||||
|
state["at"] = now
|
||||||
|
return result
|
||||||
|
|
||||||
|
def invalidate() -> None:
|
||||||
|
state["cache"] = None
|
||||||
|
state["at"] = 0.0
|
||||||
|
|
||||||
|
return loader, invalidate, state
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_call_populates_cache():
|
||||||
|
load, _, state = _make_cached_loader()
|
||||||
|
assert state["calls"] == 0
|
||||||
|
load()
|
||||||
|
assert state["calls"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_call_within_ttl_uses_cache():
|
||||||
|
load, _, state = _make_cached_loader(ttl=30.0)
|
||||||
|
load()
|
||||||
|
load()
|
||||||
|
load()
|
||||||
|
assert state["calls"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_cache_triggers_refresh():
|
||||||
|
load, _, state = _make_cached_loader(ttl=0.001)
|
||||||
|
load()
|
||||||
|
time.sleep(0.01)
|
||||||
|
load()
|
||||||
|
assert state["calls"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidation_forces_refresh():
|
||||||
|
load, invalidate, state = _make_cached_loader(ttl=30.0)
|
||||||
|
load()
|
||||||
|
invalidate()
|
||||||
|
load()
|
||||||
|
assert state["calls"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cached_result_is_stable_across_reads():
|
||||||
|
load, _, _ = _make_cached_loader(ttl=30.0)
|
||||||
|
first = load()
|
||||||
|
second = load()
|
||||||
|
assert first is second # identity, not just equality — same object returned
|
||||||
|
|
@ -77,6 +77,9 @@
|
||||||
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
|
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
|
||||||
.badge.published { background: rgba(63,185,80,0.2); color: var(--green); }
|
.badge.published { background: rgba(63,185,80,0.2); color: var(--green); }
|
||||||
.badge.draft { background: rgba(139,148,158,0.2); color: var(--text-dim); }
|
.badge.draft { background: rgba(139,148,158,0.2); color: var(--text-dim); }
|
||||||
|
th[data-sort] { cursor: pointer; user-select: none; }
|
||||||
|
th[data-sort]:hover { color: var(--primary); }
|
||||||
|
.sort-ind { font-size: 0.8em; color: var(--primary); }
|
||||||
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
||||||
.modal.show { display: flex; }
|
.modal.show { display: flex; }
|
||||||
.modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; min-width: 400px; }
|
.modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; min-width: 400px; }
|
||||||
|
|
@ -366,9 +369,23 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sites</h2>
|
<h2>Sites</h2>
|
||||||
|
<div class="filter-row" style="margin-bottom:0.5rem">
|
||||||
|
<input type="search" id="filter" placeholder="Filter by name or domain…"
|
||||||
|
oninput="applyFilter()"
|
||||||
|
style="width:100%;padding:0.5rem;background:var(--bg-dark);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:inherit">
|
||||||
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Name</th><th>Domain</th><th>Status</th><th>Size</th><th>Actions</th></tr></thead>
|
<thead><tr>
|
||||||
<tbody id="site-list"><tr><td colspan="5">Loading...</td></tr></tbody>
|
<th data-sort="name" onclick="sortBy('name', this)">Name <span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="domain" onclick="sortBy('domain', this)">Domain <span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="version" onclick="sortBy('version', this)">Version <span class="sort-ind"></span></th>
|
||||||
|
<th>Streamlit</th>
|
||||||
|
<th data-sort="last_updated" onclick="sortBy('last_updated', this)">Updated <span class="sort-ind"></span></th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="site-list"><tr><td colspan="8">Loading...</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -421,8 +438,13 @@
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + path, { ...opts, headers: headers() });
|
const res = await fetch(API + path, { ...opts, headers: headers() });
|
||||||
if (res.status === 401) { window.location = '/login.html'; return {}; }
|
if (res.status === 401) { window.location = '/login.html'; return {}; }
|
||||||
return res.json();
|
const ct = res.headers.get('content-type') || '';
|
||||||
} catch { return {}; }
|
if (!res.ok || !ct.includes('application/json')) {
|
||||||
|
console.warn('api()', path, 'status=' + res.status, 'content-type=' + ct);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) { console.warn('api()', path, 'threw', e); return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(name) {
|
function showTab(name) {
|
||||||
|
|
@ -440,24 +462,100 @@
|
||||||
document.getElementById('nginx-status').style.color = d.running ? 'var(--green)' : 'var(--red)';
|
document.getElementById('nginx-status').style.color = d.running ? 'var(--green)' : 'var(--red)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sort + filter + polling state ───
|
||||||
|
let currentSort = { field: null, dir: 'asc' };
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function relativeTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
const diff = (Date.now() - d.getTime()) / 1000;
|
||||||
|
if (isNaN(diff)) return iso;
|
||||||
|
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const q = (document.getElementById('filter')?.value || '').toLowerCase().trim();
|
||||||
|
document.querySelectorAll('.site-row').forEach(tr => {
|
||||||
|
const name = (tr.dataset.name || '').toLowerCase();
|
||||||
|
const domain = (tr.dataset.domain || '').toLowerCase();
|
||||||
|
const match = !q || name.includes(q) || domain.includes(q);
|
||||||
|
tr.style.display = match ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBy(field, thEl) {
|
||||||
|
if (currentSort.field === field) {
|
||||||
|
currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort = { field, dir: 'asc' };
|
||||||
|
}
|
||||||
|
document.querySelectorAll('th[data-sort] .sort-ind').forEach(s => s.textContent = '');
|
||||||
|
const ind = thEl.querySelector('.sort-ind');
|
||||||
|
if (ind) ind.textContent = currentSort.dir === 'asc' ? ' ▲' : ' ▼';
|
||||||
|
applySortToDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySortToDOM() {
|
||||||
|
const tbody = document.getElementById('site-list');
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr.site-row'));
|
||||||
|
const field = currentSort.field;
|
||||||
|
const dir = currentSort.dir === 'asc' ? 1 : -1;
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const av = (a.dataset[field] || '') + '';
|
||||||
|
const bv = (b.dataset[field] || '') + '';
|
||||||
|
if (av === '' && bv !== '') return 1;
|
||||||
|
if (bv === '' && av !== '') return -1;
|
||||||
|
return av.localeCompare(bv, undefined, { numeric: true }) * dir;
|
||||||
|
});
|
||||||
|
rows.forEach(r => tbody.appendChild(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPolling() {
|
||||||
|
if (pollTimer) return;
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSites() {
|
async function loadSites() {
|
||||||
const d = await api('/sites');
|
const d = await api('/sites');
|
||||||
const list = document.getElementById('site-list');
|
const list = document.getElementById('site-list');
|
||||||
const sites = d.sites || [];
|
const sites = d.sites || [];
|
||||||
if (!sites.length) { list.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim)">No sites</td></tr>'; return; }
|
if (!sites.length) { list.innerHTML = '<tr><td colspan="8" style="color:var(--text-dim)">No sites</td></tr>'; return; }
|
||||||
list.innerHTML = sites.map(s => `<tr>
|
list.innerHTML = sites.map(s => {
|
||||||
<td><strong>${s.name}</strong></td>
|
const streamlitCell = s.streamlit_app
|
||||||
<td style="color:var(--text-dim)">${s.domain}</td>
|
? `<a href="https://gitea.gk2.secubox.in/gandalf/${s.streamlit_app}" target="_blank" title="${s.streamlit_app}">🎨</a>`
|
||||||
<td><span class="badge ${s.published ? 'published' : 'draft'}">${s.published ? 'Published' : 'Draft'}</span></td>
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
<td>${s.size || '-'}</td>
|
const versionCell = s.version
|
||||||
<td>
|
? `<a href="https://gitea.gk2.secubox.in/gandalf/metablog-${s.name}/releases" target="_blank"><code style="color:var(--primary)">${s.version}</code></a>`
|
||||||
${s.published ?
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
`<a href="http://${s.domain}" target="_blank" class="btn" style="padding:2px 8px;font-size:0.7rem">View</a>
|
const updatedCell = s.last_updated
|
||||||
<button class="btn" onclick="unpublishSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Unpublish</button>` :
|
? `<span title="${s.last_updated}">${relativeTime(s.last_updated)}</span>`
|
||||||
`<button class="btn success" onclick="publishSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Publish</button>`}
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
<button class="btn danger" onclick="deleteSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Delete</button>
|
return `<tr class="site-row" data-name="${s.name}" data-domain="${s.domain}" data-version="${s.version || ''}" data-last_updated="${s.last_updated || ''}">
|
||||||
</td>
|
<td><strong><a href="site.html?name=${s.name}" style="color:var(--text)">${s.name}</a></strong></td>
|
||||||
</tr>`).join('');
|
<td style="color:var(--text-dim)">${s.domain}</td>
|
||||||
|
<td>${versionCell}</td>
|
||||||
|
<td style="text-align:center">${streamlitCell}</td>
|
||||||
|
<td>${updatedCell}</td>
|
||||||
|
<td><span class="badge ${s.published ? 'published' : 'draft'}">${s.published ? 'Published' : 'Draft'}</span></td>
|
||||||
|
<td>${s.size || '-'}</td>
|
||||||
|
<td>
|
||||||
|
${s.published ?
|
||||||
|
`<a href="http://${s.domain}" target="_blank" class="btn" style="padding:2px 8px;font-size:0.7rem">View</a>
|
||||||
|
<button class="btn" onclick="unpublishSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Unpublish</button>` :
|
||||||
|
`<button class="btn success" onclick="publishSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Publish</button>`}
|
||||||
|
<button class="btn danger" onclick="deleteSite('${s.name}')" style="padding:2px 8px;font-size:0.7rem">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
if (currentSort.field) applySortToDOM();
|
||||||
|
applyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAccess() {
|
async function loadAccess() {
|
||||||
|
|
@ -527,6 +625,7 @@
|
||||||
|
|
||||||
function refresh() { loadStatus(); loadSites(); loadAccess(); }
|
function refresh() { loadStatus(); loadSites(); loadAccess(); }
|
||||||
refresh();
|
refresh();
|
||||||
|
installPolling();
|
||||||
</script>
|
</script>
|
||||||
<script src="/shared/crt-engine.js"></script>
|
<script src="/shared/crt-engine.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
224
packages/secubox-metablogizer/www/metablogizer/site.html
Normal file
224
packages/secubox-metablogizer/www/metablogizer/site.html
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SecuBox — Site details</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=Cinzel:wght@500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||||
|
<link rel="stylesheet" href="/shared/sidebar-light.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--p31-peak: #00dd44; --p31-hot: #00ff55; --p31-mid: #009933;
|
||||||
|
--p31-dim: #006622; --p31-decay: #ffb347; --p31-decay-dim: #cc7722;
|
||||||
|
--tube-light: #e8f5e9; --tube-pale: #c8e6c9; --tube-soft: #a5d6a7;
|
||||||
|
--bg-dark: var(--tube-light); --bg-card: var(--tube-pale);
|
||||||
|
--border: var(--tube-soft); --text: #1b2c1b; --text-dim: var(--p31-dim);
|
||||||
|
--primary: var(--p31-peak); --green: var(--p31-peak);
|
||||||
|
--red: #ff4466; --yellow: var(--p31-decay);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier Prime', 'Courier New', monospace;
|
||||||
|
background: var(--tube-light);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge.published { background: rgba(0,221,68,0.2); color: var(--green); border: 1px solid var(--green); }
|
||||||
|
.badge.draft { background: rgba(255,179,71,0.2); color: var(--yellow); border: 1px solid var(--yellow); }
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
dl { display: grid; grid-template-columns: 12rem 1fr; gap: 0.4rem 1rem; }
|
||||||
|
dt { color: var(--text-dim); }
|
||||||
|
dd { color: var(--text); word-break: break-word; }
|
||||||
|
dd code { background: var(--bg-dark); padding: 0.1rem 0.4rem; border-radius: 3px; color: var(--primary); }
|
||||||
|
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.btn.primary { background: var(--primary); color: var(--bg-dark); border-color: var(--primary); }
|
||||||
|
.btn.success { background: var(--green); color: var(--bg-dark); border-color: var(--green); }
|
||||||
|
.btn[hidden] { display: none !important; }
|
||||||
|
.hint { color: var(--text-dim); font-size: 0.9rem; }
|
||||||
|
.error { color: var(--red); }
|
||||||
|
a { color: var(--primary); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="crt-light">
|
||||||
|
<nav class="sidebar" id="sidebar"></nav>
|
||||||
|
<script src="/shared/sidebar.js"></script>
|
||||||
|
<main class="main">
|
||||||
|
<div id="error-box"></div>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<h1 id="site-name">…</h1>
|
||||||
|
<span id="status-badge" class="badge"></span>
|
||||||
|
<a href="index.html" class="btn" style="margin-left:auto">← Back to list</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Metadata</h2>
|
||||||
|
<dl id="meta-dl"></dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Quick links</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<a id="link-live" class="btn primary" target="_blank">🌐 Live site</a>
|
||||||
|
<a id="link-gitea" class="btn" target="_blank">🦊 Gitea repo</a>
|
||||||
|
<a id="link-streamlit" class="btn success" target="_blank" hidden>🎨 Streamlit app</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card hint">
|
||||||
|
<p>For tag history and deploys, see the <strong>Releases</strong> tab on the Gitea repo (auth required).</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api/v1/metablogizer';
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
const t = localStorage.getItem('sbx_token');
|
||||||
|
return t ? { 'Authorization': `Bearer ${t}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + path, { headers: headers() });
|
||||||
|
if (res.status === 401) { window.location = '/login.html'; return null; }
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
if (!res.ok || !ct.includes('application/json')) {
|
||||||
|
console.warn('api()', path, 'status=' + res.status, 'content-type=' + ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) { console.warn('api()', path, 'threw', e); return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
const diff = (Date.now() - d.getTime()) / 1000;
|
||||||
|
if (isNaN(diff)) return iso;
|
||||||
|
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(label, value) {
|
||||||
|
const dt = document.createElement('dt'); dt.textContent = label;
|
||||||
|
const dd = document.createElement('dd'); dd.innerHTML = value;
|
||||||
|
return [dt, dd];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(d) {
|
||||||
|
document.getElementById('site-name').textContent = d.name;
|
||||||
|
const badge = document.getElementById('status-badge');
|
||||||
|
badge.textContent = d.published ? 'Published' : 'Draft';
|
||||||
|
badge.className = 'badge ' + (d.published ? 'published' : 'draft');
|
||||||
|
|
||||||
|
const dl = document.getElementById('meta-dl');
|
||||||
|
const versionCell = d.version
|
||||||
|
? `<code>${d.version}</code>`
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
const lastUpdatedCell = d.last_updated
|
||||||
|
? `${d.last_updated} <span style="color:var(--text-dim)">(${relativeTime(d.last_updated)})</span>`
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
const tagsCell = (d.tags && d.tags.length)
|
||||||
|
? d.tags.map(t => `<code>${t}</code>`).join(' ')
|
||||||
|
: '<span style="color:var(--text-dim)">—</span>';
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
['Domain', d.domain || '—'],
|
||||||
|
['Version', versionCell],
|
||||||
|
['Last updated', lastUpdatedCell],
|
||||||
|
['Title', d.title || '—'],
|
||||||
|
['Description', d.description || '—'],
|
||||||
|
['Category', d.category || '—'],
|
||||||
|
['Tags', tagsCell],
|
||||||
|
];
|
||||||
|
for (const [k, v] of fields) {
|
||||||
|
row(k, v).forEach(el => dl.appendChild(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('link-live').href = `https://${d.domain}/`;
|
||||||
|
document.getElementById('link-gitea').href =
|
||||||
|
`https://gitea.gk2.secubox.in/gandalf/metablog-${d.name}`;
|
||||||
|
const streamlit = document.getElementById('link-streamlit');
|
||||||
|
if (d.streamlit_app) {
|
||||||
|
streamlit.href = `https://gitea.gk2.secubox.in/gandalf/${d.streamlit_app}`;
|
||||||
|
streamlit.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
document.getElementById('error-box').innerHTML =
|
||||||
|
`<div class="card error">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSite() {
|
||||||
|
const name = new URLSearchParams(location.search).get('name');
|
||||||
|
if (!name) { showError('Missing <code>?name=…</code> parameter.'); return; }
|
||||||
|
const d = await api(`/site/${encodeURIComponent(name)}`);
|
||||||
|
if (!d || !d.name) { showError(`Site <strong>${name}</strong> not found.`); return; }
|
||||||
|
render(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSite();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
tests/scripts/test-metablogizer-ui.sh
Executable file
69
tests/scripts/test-metablogizer-ui.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# tests/scripts/test-metablogizer-ui.sh
|
||||||
|
# Smoke test for the MetaBlogizer version dashboard (sub-D of #49).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
source "$REPO/scripts/lib/test-helpers.sh"
|
||||||
|
|
||||||
|
INDEX="$REPO/packages/secubox-metablogizer/www/metablogizer/index.html"
|
||||||
|
SITE="$REPO/packages/secubox-metablogizer/www/metablogizer/site.html"
|
||||||
|
|
||||||
|
log_step() { echo "[smoke step $1] $2"; }
|
||||||
|
|
||||||
|
# Gate 1: index.html contains the new column anchors and filter input.
|
||||||
|
log_step 1 "index.html has the new columns + filter"
|
||||||
|
assert_file "$INDEX" "index.html present"
|
||||||
|
grep -q 'data-sort="version"' "$INDEX" || { echo "FAIL: data-sort=version missing"; exit 1; }
|
||||||
|
grep -q 'data-sort="last_updated"' "$INDEX" || { echo "FAIL: data-sort=last_updated missing"; exit 1; }
|
||||||
|
grep -q 'id="filter"' "$INDEX" || { echo "FAIL: filter input missing"; exit 1; }
|
||||||
|
grep -q 'installPolling()' "$INDEX" || { echo "FAIL: polling install missing"; exit 1; }
|
||||||
|
pass "index.html: version + last_updated + filter + polling all wired"
|
||||||
|
|
||||||
|
# Gate 2: site.html exists with the expected anchors.
|
||||||
|
log_step 2 "site.html drill-in present"
|
||||||
|
assert_file "$SITE" "site.html present"
|
||||||
|
grep -q 'URLSearchParams' "$SITE" || { echo "FAIL: site.html missing URL param parsing"; exit 1; }
|
||||||
|
grep -q 'link-gitea' "$SITE" || { echo "FAIL: site.html missing Gitea link"; exit 1; }
|
||||||
|
grep -q 'link-streamlit' "$SITE" || { echo "FAIL: site.html missing Streamlit link"; exit 1; }
|
||||||
|
grep -q 'link-live' "$SITE" || { echo "FAIL: site.html missing live-site link"; exit 1; }
|
||||||
|
pass "site.html: URL parsing + 3 external links wired"
|
||||||
|
|
||||||
|
# Gate 3: HTML sanity (well-formed, no mismatched tags) for both files.
|
||||||
|
log_step 3 "HTML well-formedness"
|
||||||
|
for f in "$INDEX" "$SITE"; do
|
||||||
|
python3 -c "
|
||||||
|
import html.parser, sys
|
||||||
|
class S(html.parser.HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.errors = []
|
||||||
|
self.stack = []
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag not in ('br','hr','img','input','link','meta'):
|
||||||
|
self.stack.append(tag)
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self.stack and self.stack[-1] == tag:
|
||||||
|
self.stack.pop()
|
||||||
|
else:
|
||||||
|
self.errors.append(f'closing {tag} without matching open')
|
||||||
|
p = S()
|
||||||
|
p.feed(open('$f').read())
|
||||||
|
if p.errors:
|
||||||
|
print('FAIL', '$f', p.errors[:3])
|
||||||
|
sys.exit(1)
|
||||||
|
" || { echo "FAIL: HTML mismatch in $f"; exit 1; }
|
||||||
|
done
|
||||||
|
pass "both HTML files are well-formed"
|
||||||
|
|
||||||
|
# Gate 4 (best-effort): live page reachable. SKIP on failure (auth, network).
|
||||||
|
log_step 4 "live HTTP reachability (best-effort)"
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" "https://admin.gk2.secubox.in/metablogizer/" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$code" == "200" ]]; then
|
||||||
|
pass "live /metablogizer/ returns $code"
|
||||||
|
else
|
||||||
|
echo "WARN: live page returned $code (network/auth/cache) — not blocking"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pass "all smoke gates green"
|
||||||
Loading…
Reference in New Issue
Block a user