Compare commits

...

16 Commits

Author SHA1 Message Date
CyberMind
4b3a4ec311
Merge pull request #112 from CyberMind-FR/fix/111-metablogizer-api-load-sites-called-repea
Some checks are pending
License Headers / check (push) Waiting to run
fix(metablog-api): cache load_sites() + fix /sites double-call (closes #111)
2026-05-12 20:07:03 +02:00
3ebf79647e fix(metablog-api): cache load_sites() + fix /sites double-call (ref #111)
After deploying sub-C runtime (#110), /status and /sites became
extremely slow (3-30 s per request) because load_sites() walks
all 166 sites and runs enrich+validate+du -sh per entry. The new
60s polling on the dashboard amplified the load via concurrent
queued requests → browser-side NetworkError.

Two fixes here:

1. In-memory cache of load_sites() with a 30 s TTL. Hot reads
   now return in microseconds. Invalidated explicitly from every
   write path:
   - POST /site (create)
   - DELETE /site/<name>
   - POST /site/<name>/publish
   - POST /site/<name>/unpublish
   - POST /republish-all

2. /sites no longer calls load_sites() twice. Was:
       return {"sites": load_sites(), "count": len(load_sites())}
   Now:
       sites = load_sites()
       return {"sites": sites, "count": len(sites)}

Tests in api/tests/test_sites_cache.py (5 cases) mirror the
contract in isolation — TTL-based caching, explicit invalidation,
identity stable for same-tick reads. All 15 metablogizer api tests
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:06:46 +02:00
CyberMind
918506344a
Merge pull request #110 from CyberMind-FR/fix/109-metablogizer-api-sub-c-runtime-imports-f
fix(metablog-api): self-bootstrap api/ onto sys.path (closes #109)
2026-05-12 19:51:39 +02:00
3bd8f3e488 fix(metablog-api): self-bootstrap api/ onto sys.path (ref #109)
`uvicorn api.main:app` runs with WorkingDirectory=/usr/lib/secubox/metablogizer,
which puts the parent dir on sys.path but not api/ itself. Sub-C's
`from site_schema import …` (PR #102) and #106's `from rmtree import …`
both fail in production with ModuleNotFoundError, sending the service
into a restart loop and nginx into 502.

Insert `sys.path.insert(0, str(Path(__file__).resolve().parent))` at the
top of main.py so api/ is discoverable regardless of invocation style.
The pytest path (PYTHONPATH=api) is unaffected: the insert is a no-op
when api/ is already on sys.path.

All 10 metablogizer api tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:51:25 +02:00
CyberMind
84f33b84e5
Merge pull request #108 from CyberMind-FR/fix/106-metablogizer-api-delete-site-name-fails
fix(metablog-api): DELETE /site/<name> survives locked .git subtrees (closes #106)
2026-05-12 19:46:14 +02:00
692c77ec27 fix(metablog-api): DELETE /site/<name> survives locked .git subtrees (ref #106)
Sites cloned from Gitea (sub-B of #49 — 166 sites) ship a .git tree
whose pack files are 0444 and whose directories are 0500.
`shutil.rmtree` then fails on both `os.open(dir, O_RDONLY, ...)` (for
restricted directories) and `os.unlink(file)` (for files inside a
non-writable parent).

Extract _rmtree_force into api/rmtree.py with an onerror that chmods
both the parent dir AND the entry to 0700 before retrying the failing
op. The new helper handles both classes of EACCES that rmtree raises.

Tests in api/tests/test_rmtree_force.py:
  - locked Gitea-style .git subtree (0500 dirs + 0444 packs) is cleared
  - plain directory still cleared (no regression on non-locked sites)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:45:19 +02:00
CyberMind
8e8bb01e92
Merge pull request #107 from CyberMind-FR/fix/105-metablogizer-ui-api-helper-swallows-res
fix(metablog-ui): defensive api() helper (closes #105)
2026-05-12 19:40:55 +02:00
ad9d9288a6 fix(metablog-ui): defensive api() helper (ref #105)
The `return res.json()` pattern in both index.html and site.html
returned the parse promise unawaited, so a JSON.parse rejection
escaped the surrounding try/catch and surfaced as
"Uncaught (in promise) SyntaxError" in the browser console.

Changes per file (index.html and site.html):
- `return await res.json()` so rejections enter the catch block
- Check `res.ok` and the `content-type` header before parsing
- `console.warn(path, status, content-type)` on any non-OK or
  non-JSON response, so future failures are diagnosable in the
  browser console without server-side digging
- Symmetric catch: log the thrown error with path context

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:39:28 +02:00
CyberMind
c3e1c8bb0d
Merge pull request #104 from CyberMind-FR/feature/103-metablogizer-version-dashboard-ui-module
MetaBlogizer version dashboard (Refs #49 sub-D, closes #103)
2026-05-12 19:30:11 +02:00
bc52a60be9 docs(metablog-ui): Session 165 tracking + README dashboard URLs (ref #103)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:27:28 +02:00
a1126e85d3 test(metablog-ui): 3-gate smoke for dashboard + drill-in (ref #103)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:21:28 +02:00
9d9eb2304a fix(metablog-ui): site.html uses canonical sbx_token key (ref #103)
Match the rest of the SecuBox Hub codebase (index.html, login.html,
shared/api-utils.js, every other module) which reads localStorage
under 'sbx_token'. The plan's heredoc had 'jwt' which would have
caused an infinite login redirect on every drill-in page load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:44 +02:00
6b11bcda9a 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>
2026-05-12 19:15:28 +02:00
5c8148e818 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>
2026-05-12 18:51:11 +02:00
5867740c42 docs(metablog-ui): Implementation plan for version dashboard (ref #103)
5 tasks covering index.html extension (3 columns + filter + sort + 60s
polling), site.html drill-in page, smoke test, docs, and PR finish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:48:10 +02:00
e8588f62e4 docs(metablog-ui): Design spec for version dashboard (ref #103)
Sub-project D of #49. Extends existing /metablogizer/ list view with
3 columns (version, streamlit_app, last_updated) + filter box + sort
+ 60s polling. New site.html drill-in page that surfaces every
site.json field plus 3 external links (live site, Gitea repo,
Streamlit app). Tag history defers to Gitea's UI (browser auth)
because the repos are private and browser-side proxying through
a stored token is heavier than the MVP warrants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:43:34 +02:00
12 changed files with 1733 additions and 24 deletions

View File

@ -4,6 +4,24 @@
---
## 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)
**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).

View File

@ -1,5 +1,25 @@
# 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.
---

View 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.

View File

@ -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`).

View File

@ -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
`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
```bash

View File

@ -9,9 +9,16 @@ SecuBox is an appliance and network model - distributed peer applications.
"""
import subprocess
import os
import sys
import json
import shutil
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 fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, UploadFile, File
from pydantic import BaseModel
@ -36,6 +43,7 @@ NGINX_BACKEND_IP = "192.168.1.200"
import logging
from site_schema import enrich as _schema_enrich, validate as _schema_validate
from rmtree import force_remove as _rmtree_force
logger = logging.getLogger("metablogizer")
@ -138,10 +146,34 @@ def _load_site_json(site_dir):
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]:
"""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 = []
if not SITES_ROOT.exists():
_SITES_CACHE = sites
_SITES_CACHE_AT = now
return sites
for site_dir in SITES_ROOT.iterdir():
@ -188,7 +220,10 @@ def load_sites() -> List[dict]:
entry[key] = cfg[key]
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:
@ -466,7 +501,8 @@ class SiteUpdate(BaseModel):
@app.get("/sites", dependencies=[Depends(require_jwt)])
async def list_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)])
@ -548,6 +584,7 @@ async def create_site(site: SiteCreate):
"template": site.template,
}, indent=2))
_invalidate_sites_cache()
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_VHOST_DIR / f"{name}.conf").unlink(missing_ok=True)
# Delete directory
shutil.rmtree(site_dir)
# Sites cloned from Gitea (sub-B of #49) carry a .git subtree whose pack
# 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}
@ -627,6 +668,7 @@ server {{
run_cmd(["nginx", "-t"])
run_cmd(["systemctl", "reload", "nginx"])
_invalidate_sites_cache()
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)
run_cmd(["systemctl", "reload", "nginx"])
_invalidate_sites_cache()
return {"success": True, "name": name}
@ -643,6 +686,7 @@ async def unpublish_site(name: str):
async def republish_all():
"""Republish all sites by regenerating nginx config"""
success, count, message = regenerate_nginx_config()
_invalidate_sites_cache()
return {
"success": success,
"sites_published": count,

View 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)

View 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()

View 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

View File

@ -77,6 +77,9 @@
.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.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.show { display: flex; }
.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 class="card">
<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>
<thead><tr><th>Name</th><th>Domain</th><th>Status</th><th>Size</th><th>Actions</th></tr></thead>
<tbody id="site-list"><tr><td colspan="5">Loading...</td></tr></tbody>
<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>
</table>
</div>
</div>
@ -421,8 +438,13 @@
try {
const res = await fetch(API + path, { ...opts, headers: headers() });
if (res.status === 401) { window.location = '/login.html'; return {}; }
return res.json();
} catch { return {}; }
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 {};
}
return await res.json();
} catch (e) { console.warn('api()', path, 'threw', e); return {}; }
}
function showTab(name) {
@ -440,24 +462,100 @@
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() {
const d = await api('/sites');
const list = document.getElementById('site-list');
const sites = d.sites || [];
if (!sites.length) { list.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim)">No sites</td></tr>'; return; }
list.innerHTML = sites.map(s => `<tr>
<td><strong>${s.name}</strong></td>
<td style="color:var(--text-dim)">${s.domain}</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 (!sites.length) { list.innerHTML = '<tr><td colspan="8" style="color:var(--text-dim)">No sites</td></tr>'; return; }
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}" data-version="${s.version || ''}" data-last_updated="${s.last_updated || ''}">
<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('');
if (currentSort.field) applySortToDOM();
applyFilter();
}
async function loadAccess() {
@ -527,6 +625,7 @@
function refresh() { loadStatus(); loadSites(); loadAccess(); }
refresh();
installPolling();
</script>
<script src="/shared/crt-engine.js"></script>
</body>

View 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>

View 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"