mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
Merge feat/sessions-companion-emoji: session auditing + Companion auth/system/metrics + billets emoji hashtags (#868)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
commit
28c4fbde84
|
|
@ -3,6 +3,22 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-07-17 — Session auditing + Companion auth/system/metrics + billets emoji hashtags (branch feat/sessions-companion-emoji)
|
||||
|
||||
Sessions became auditable, the two stub Companion modules became real, and billets gained emoji-hashtag quick views. All live-verified on gk2.
|
||||
|
||||
- **Session logging (root-caused)** 🔑 — `/login/mfa` and `/totp/confirm` hardcoded `"ip": ""` and dropped `user_agent`; only the password path captured them. **admin is forced-TOTP, so every real admin login took the MFA path** → every session row was unauditable and the users webui sessions tab (which already existed, fully built) rendered blanks. Fix: `_client_meta(request)` (X-Forwarded-For first — nginx/HAProxy front every login, so `request.client.host` is only ever the proxy) on all three paths. Proven by driving the real `/login/mfa` handler in-process with `SECUBOX_AUTH_SESSIONS` on a temp file: records `ip=192.168.1.77` (first XFF hop) + full UA.
|
||||
- **Companion auth + system** ⚙️ — both were 34-line stubs guessing routes (`/api/v1/auth/users` → **404**; identities are under `/api/v1/users`). Rebuilt against routes read from handler source. auth shows identities + **sessions (who/IP/device/age/current)**; system shows status/metrics/resources/health_score/network/security, each section degrading independently.
|
||||
- **Emoji metrics** 📊 — `core/metrics.js`: per-kind severity thresholds (disk strictest, cpu tolerant) so gk2's disk 94.5% reads 🔴 while cpu 10% reads 🟢; plain language ("Memory 75% — 5.5 GB of 7.7 GB used") + byte/uptime formatters.
|
||||
- **Billets emoji hashtags** 🏷️ — migration 0004 (`tag` + `billet_tag`, indexed); tags **extracted from the body** (`#secu`) so authoring costs nothing and applies retroactively — the backfill tagged **37 of 46** existing billets. Accent-folded slugs (#Réseau==#reseau); emoji stored (no silent restyling of published billets); URL anchors don't become tags; unknown tags get a neutral badge. Quick views via `?tag=` on `/` + `/feed.json` (EXISTS not JOIN, so keyset paging can't duplicate rows; pager carries the tag). feed.json gains standard `summary`+`tags` with emoji in the `_secubox` extension.
|
||||
- **Feed resumes** 📄 — long billets show a short excerpt + "Lire la suite"; short ones render whole (`is_long` measured on markdown-collapsed text). 16/20 resumed on the live feed.
|
||||
- **`GET /admin/api/billets`** — the authoring list. The public feed can't serve it: its item `id` is a permalink URL (**why Edit/Delete 404'd**) and it hides drafts.
|
||||
- **Companion 401 re-login** 🔓 — box tokens live 24h but the Companion seals one at pairing and reuses it forever → a day later every authed call 401'd with no escape but unpairing ("Failed: unauthorized" in billets + podcaster). `api.js` now triggers re-auth and **replays the request once**, single-flight (one prompt, not one per request), so an in-flight write survives. SW v10.
|
||||
|
||||
Follow-ups: rebuild the billets `.deb` (migration 0004 + tags.py); consider a token refresh so re-login isn't needed every 24h.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-17 — Billets JWT admin surface + Companion authoring & image upload (branch fix/companion-billets-feed)
|
||||
|
||||
Made the SecuBox Companion a working authoring client for the billets micro-blog — the last gap ("New billet missing upload", comment moderation 404).
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@
|
|||
|
||||
---
|
||||
|
||||
## ✅ 2026-07-17 : Sessions auditables + Companion auth/system/metrics + billets hashtags emoji (branche feat/sessions-companion-emoji)
|
||||
|
||||
Sessions traçables, les 2 modules Companion stubs deviennent réels, billets gagne les vues rapides par hashtag emoji. Live-vérifié gk2. Détail dans HISTORY.md.
|
||||
|
||||
- **Sessions (root-cause)** 🔑 — `/login/mfa` + `/totp/confirm` codaient en dur `"ip": ""` (et pas d'UA) ; seul le chemin mot-de-passe les captait. **admin est forcé TOTP ⇒ tout login admin réel passait par MFA** → lignes de session non auditables, onglet Sessions de la webui users (déjà complet) affichait du vide. Fix `_client_meta(request)` (X-Forwarded-For d'abord : nginx/HAProxy sont devant, `request.client.host` = le proxy). Prouvé en pilotant le vrai handler `/login/mfa` en-process (fichier sessions temporaire) : `ip=192.168.1.77` + UA complet.
|
||||
- **Companion auth + system** ⚙️ — 2 stubs de 34 lignes qui devinaient les routes (`/api/v1/auth/users` → **404** ; les identités sont sous `/api/v1/users`). Reconstruits sur les routes lues dans le source. auth = identités + **sessions (qui/IP/device/âge/courante)** ; system = status/metrics/resources/health_score/network/security, chaque section dégradant indépendamment.
|
||||
- **Metrics emoji** 📊 — `core/metrics.js` : seuils **par type** (disk le plus strict, cpu tolérant) ⇒ disk 94.5% = 🔴 / cpu 10% = 🟢 ; langage simple ("Memory 75% — 5.5 GB of 7.7 GB used").
|
||||
- **Billets hashtags emoji** 🏷️ — migration 0004 (`tag`+`billet_tag` indexés) ; tags **extraits du corps** (`#secu`) ⇒ zéro effort auteur + rétroactif : le backfill a taggé **37/46** billets. Slugs sans accents (#Réseau==#reseau), emoji stocké (pas de restyle silencieux), ancres d'URL non taggées, tag inconnu = badge neutre. Vues rapides `?tag=` sur `/` + `/feed.json` (EXISTS, pas JOIN → pagination keyset non dupliquée ; le pager garde le tag).
|
||||
- **Résumés du feed** 📄 — billets longs = extrait court + "Lire la suite" ; courts = entiers (16/20 résumés en live).
|
||||
- **`GET /admin/api/billets`** — liste d'authoring : le feed public ne peut pas la servir (son `id` est une URL permalien → **cause des 404 Edit/Delete**, et il masque les drafts).
|
||||
- **Companion re-login 401** 🔓 — les tokens box vivent 24h mais le Companion scelle le sien au pairing et le réutilise → le lendemain tout 401 sans issue ("Failed: unauthorized" billets + podcaster). `api.js` déclenche la ré-auth et **rejoue la requête une fois**, single-flight (1 prompt), donc un write en cours (un billet tapé) n'est pas perdu. SW v10.
|
||||
|
||||
### ⬜ Next / follow-ups
|
||||
- **Rebuild `.deb` billets** (migration 0004 + `services/tags.py` + postinst .pth).
|
||||
- **Refresh token** : éviter la re-login toutes les 24h sur le Companion.
|
||||
- **APK périmé** : l'APK publié (16/07) embarque le vieux code (SW v5, `/feed`) — rebuild nécessaire.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 2026-07-17 : Billets — API admin JWT + authoring/upload Companion (branche fix/companion-billets-feed)
|
||||
|
||||
Companion devient un vrai client d'écriture pour le micro-blog billets. Live-vérifié gk2. Détail dans HISTORY.md.
|
||||
|
|
|
|||
|
|
@ -260,13 +260,21 @@ def _check_scope(authorization: Optional[str], expected_scope: str) -> dict:
|
|||
return payload
|
||||
|
||||
|
||||
@_login_router.post("/login")
|
||||
def _login_v2(req: _LoginIn, request: _Request, response: _Response):
|
||||
"""Branching login: setup_token / mfa_token / enrollment_token / access_token."""
|
||||
def _client_meta(request: _Request) -> tuple:
|
||||
"""(ip, user_agent) of the caller. nginx/HAProxy front every login, so the
|
||||
real client is in X-Forwarded-For; request.client.host would just be the
|
||||
proxy. Every login path MUST record these — a session row without them is
|
||||
unauditable (who logged in from where)."""
|
||||
ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||
or request.headers.get("X-Real-IP", "")
|
||||
or (request.client.host if request.client else ""))
|
||||
ua = request.headers.get("User-Agent", "")[:100]
|
||||
return ip, request.headers.get("User-Agent", "")[:100]
|
||||
|
||||
|
||||
@_login_router.post("/login")
|
||||
def _login_v2(req: _LoginIn, request: _Request, response: _Response):
|
||||
"""Branching login: setup_token / mfa_token / enrollment_token / access_token."""
|
||||
ip, ua = _client_meta(request)
|
||||
user = user_store.get_user(req.username)
|
||||
|
||||
if not user or not user.get("enabled"):
|
||||
|
|
@ -329,7 +337,10 @@ async def _login_mfa(req: _MfaIn, request: _Request, response: _Response):
|
|||
jti = secrets.token_hex(8)
|
||||
tok = create_token(username, jti=jti)
|
||||
_set_session_cookie(response, tok) # SSO-lite (#400)
|
||||
_on_session_event("login_success", username, {"jti": jti, "expires_in": 86400, "ip": ""})
|
||||
ip, ua = _client_meta(request)
|
||||
_on_session_event("login_success", username, {
|
||||
"jti": jti, "expires_in": 86400, "ip": ip, "user_agent": ua,
|
||||
})
|
||||
_users_engine.touch_last_login(username)
|
||||
return {"access_token": tok, "token_type": "bearer", "expires_in": 86400}
|
||||
|
||||
|
|
@ -365,7 +376,10 @@ def _totp_confirm(req: _MfaIn, request: _Request, response: _Response):
|
|||
jti = secrets.token_hex(8)
|
||||
tok = create_token(username, jti=jti)
|
||||
_set_session_cookie(response, tok) # SSO-lite (#400)
|
||||
_on_session_event("login_success", username, {"jti": jti, "expires_in": 86400, "ip": ""})
|
||||
ip, ua = _client_meta(request)
|
||||
_on_session_event("login_success", username, {
|
||||
"jti": jti, "expires_in": 86400, "ip": ip, "user_agent": ua,
|
||||
})
|
||||
return {
|
||||
"access_token": tok, "token_type": "bearer", "expires_in": 86400,
|
||||
"backup_codes": backup_plain,
|
||||
|
|
|
|||
|
|
@ -114,10 +114,17 @@ _SECURITY_HEADERS = {
|
|||
}
|
||||
|
||||
|
||||
def _billet_view(row: aiosqlite.Row, base: str = "", media_rows=None) -> dict:
|
||||
def _billet_view(row: aiosqlite.Row, base: str = "", media_rows=None, tags=None) -> dict:
|
||||
from urllib.parse import urlparse
|
||||
d = dict(row)
|
||||
d["body_html"] = render_markdown(d["body"])
|
||||
d["tags"] = tags or []
|
||||
# A few words for list/preview contexts; body_html keeps the full billet.
|
||||
d["summary"] = feeds.excerpt(d["body"], max_len=140)
|
||||
# Only long billets get resumed in the feed — `excerpt` collapses markdown, so
|
||||
# compare against IT, not len(body): a short billet padded with link syntax
|
||||
# would otherwise be "resumed" to a copy of itself followed by "Lire la suite".
|
||||
d["is_long"] = len(feeds.excerpt(d["body"], max_len=10_000)) > 140
|
||||
d["ref_host"] = (urlparse(d["ref_url"]).hostname if d.get("ref_url") else None)
|
||||
title = feeds.billet_title(d["body"])
|
||||
d["title"] = title
|
||||
|
|
@ -173,14 +180,20 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
|
|||
return {"status": "ok", "module": "billets"}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def feed(request: Request, cursor: str | None = None):
|
||||
rows, next_cursor = await repo.list_published(app.state.conn, limit=PAGE_SIZE, cursor=cursor)
|
||||
async def feed(request: Request, cursor: str | None = None, tag: str | None = None):
|
||||
rows, next_cursor = await repo.list_published(app.state.conn, limit=PAGE_SIZE,
|
||||
cursor=cursor, tag=tag)
|
||||
base = _base(request)
|
||||
media_map = await repo.list_media_for(app.state.conn, [r["id"] for r in rows])
|
||||
# One batched lookup for the page's chips + the whole-site chip bar.
|
||||
tag_map = await repo.tags_for_many(app.state.conn, [r["id"] for r in rows])
|
||||
resp = templates.TemplateResponse(request, "feed.html", {
|
||||
"site_title": SITE_TITLE, "tagline": SITE_TAGLINE,
|
||||
"billets": [_billet_view(r, base, media_map.get(r["id"])) for r in rows],
|
||||
"billets": [_billet_view(r, base, media_map.get(r["id"]), tag_map.get(r["id"]))
|
||||
for r in rows],
|
||||
"next_cursor": next_cursor,
|
||||
"all_tags": await repo.list_tags(app.state.conn),
|
||||
"active_tag": tag,
|
||||
})
|
||||
# Embeds render inline in the feed too; allow any self-hosted embed hosts
|
||||
# of the shown billets in frame-src (static providers already covered).
|
||||
|
|
@ -256,18 +269,33 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
|
|||
self_url=f"{base}/feed.xml", entries=entries, updated=updated)
|
||||
return Response(xml, media_type="application/atom+xml")
|
||||
|
||||
@app.get("/tags.json")
|
||||
async def tags_json():
|
||||
"""The quick-view chip bar: every emoji hashtag in use, most-used first."""
|
||||
return {"tags": await repo.list_tags(app.state.conn)}
|
||||
|
||||
@app.get("/feed.json")
|
||||
async def feed_json(request: Request):
|
||||
async def feed_json(request: Request, tag: str | None = None):
|
||||
base = _base(request)
|
||||
rows = await _feed_rows()
|
||||
rows, _ = await repo.list_published(app.state.conn, limit=30, tag=tag)
|
||||
# One batched lookup for the whole page — never per-item (N+1).
|
||||
tag_map = await repo.tags_for_many(app.state.conn, [r["id"] for r in rows])
|
||||
items = [{
|
||||
"id": f"{base}/b/{r['slug']}", "url": f"{base}/b/{r['slug']}",
|
||||
"title": feeds.billet_title(r["body"]),
|
||||
# Short excerpt so list views can show a few words instead of a wall
|
||||
# of text; content_html still carries the full billet.
|
||||
"summary": feeds.excerpt(r["body"], max_len=140),
|
||||
"content_html": render_markdown(r["body"]),
|
||||
"date_published": r["published_at"] or r["updated_at"],
|
||||
"tags": tag_map.get(r["id"], []),
|
||||
} for r in rows]
|
||||
return feeds.build_jsonfeed(site_title=SITE_TITLE, base_url=base,
|
||||
feed = feeds.build_jsonfeed(site_title=SITE_TITLE, base_url=base,
|
||||
feed_url=f"{base}/feed.json", items=items)
|
||||
if tag:
|
||||
feed["feed_url"] = f"{base}/feed.json?tag={tag}"
|
||||
feed["title"] = f"{SITE_TITLE} · #{tag}"
|
||||
return feed
|
||||
|
||||
@app.get("/stats.json")
|
||||
async def stats_json(request: Request):
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from datetime import datetime, timezone
|
|||
from . import db, repo
|
||||
from .seed import seed_billets
|
||||
from .services import security as sec
|
||||
from .services.tags import emoji_for as tags_emoji_for
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
|
|
@ -62,6 +63,41 @@ async def _seed() -> int:
|
|||
await conn.close()
|
||||
|
||||
|
||||
async def _backfill_tags() -> int:
|
||||
"""Extract #hashtags from billets written before tags existed.
|
||||
|
||||
Idempotent: sync_tags replaces a billet's tag rows from its body every time,
|
||||
so re-running only ever converges. Covers drafts too — publishing one later
|
||||
must not need a second backfill.
|
||||
"""
|
||||
conn = await db.connect(now=_now())
|
||||
try:
|
||||
async with conn.execute("SELECT id, body FROM billet") as cur:
|
||||
rows = await cur.fetchall()
|
||||
tagged = 0
|
||||
for r in rows:
|
||||
if await repo.sync_tags(conn, r["id"], r["body"]):
|
||||
tagged += 1
|
||||
# Re-apply the curated map to tags that already exist. sync_tags never
|
||||
# restyles a stored tag (a published billet must not silently change
|
||||
# look); doing it here keeps that an explicit, operator-invoked step —
|
||||
# run this after editing TAG_EMOJI.
|
||||
restyled = 0
|
||||
async with conn.execute("SELECT slug, emoji FROM tag") as cur:
|
||||
existing = await cur.fetchall()
|
||||
for t in existing:
|
||||
want = tags_emoji_for(t["slug"])
|
||||
if want != t["emoji"]:
|
||||
await conn.execute("UPDATE tag SET emoji = ? WHERE slug = ?", (want, t["slug"]))
|
||||
restyled += 1
|
||||
await conn.commit()
|
||||
print(f"backfilled {len(rows)} billets — {tagged} carry at least one tag; "
|
||||
f"{restyled} tag(s) restyled from the curated map")
|
||||
return 0
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="billets-manage")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
|
@ -69,8 +105,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||
sp = sub.add_parser(name)
|
||||
sp.add_argument("username")
|
||||
sub.add_parser("seed")
|
||||
sub.add_parser("backfill-tags")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.cmd == "backfill-tags":
|
||||
return asyncio.run(_backfill_tags())
|
||||
|
||||
if args.cmd in ("create-author", "set-password"):
|
||||
pw = getpass.getpass("Password: ")
|
||||
if len(pw) < 10:
|
||||
|
|
|
|||
32
packages/secubox-billets/api/migrations/0004_tags.sql
Normal file
32
packages/secubox-billets/api/migrations/0004_tags.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
-- Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
-- billets :: emoji hashtags for quick-view categorisation.
|
||||
-- Authors just type `#secu #waf` in the body — no extra editor step, and it
|
||||
-- works retroactively on billets written before this migration (backfilled by
|
||||
-- `python -m api.manage backfill-tags`). Tags are EXTRACTED into real rows
|
||||
-- rather than re-parsed per request so the feed can filter on an index
|
||||
-- instead of scanning every body with LIKE.
|
||||
--
|
||||
-- tag.slug : normalised (lowercase, accents folded) — #Réseau and #reseau are
|
||||
-- the same tag. This is the identity, so it is the PK.
|
||||
-- tag.emoji : curated in services/tags.py; unknown tags fall back to a default
|
||||
-- badge emoji. Stored (not derived at read time) so a later map
|
||||
-- change never silently rewrites what a published billet displayed.
|
||||
--
|
||||
-- billet_tag rows die with their billet (ON DELETE CASCADE); orphan tag rows are
|
||||
-- harmless (they simply stop appearing in counts) and get reused on the next use.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag (
|
||||
slug TEXT PRIMARY KEY,
|
||||
emoji TEXT NOT NULL,
|
||||
label TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billet_tag (
|
||||
billet_id TEXT NOT NULL REFERENCES billet(id) ON DELETE CASCADE,
|
||||
tag_slug TEXT NOT NULL REFERENCES tag(slug) ON DELETE CASCADE,
|
||||
PRIMARY KEY (billet_id, tag_slug)
|
||||
);
|
||||
|
||||
-- The feed's filter direction is tag -> billets, so index that side.
|
||||
CREATE INDEX IF NOT EXISTS idx_billet_tag_slug ON billet_tag(tag_slug);
|
||||
|
|
@ -14,6 +14,7 @@ import aiosqlite
|
|||
|
||||
from .ids import new_ulid
|
||||
from .models import BilletIn, slugify
|
||||
from .services.tags import extract as tags_extract
|
||||
|
||||
_FEED_COLUMNS = ("id,created_at,updated_at,published_at,body,ref_url,embed_url,"
|
||||
"embed_html,embed_provider,embed_fetched_at,slug,status,"
|
||||
|
|
@ -49,6 +50,7 @@ async def create_billet(conn: aiosqlite.Connection, data: BilletIn, *, now: str,
|
|||
data.embed_url, slug, status, data.style),
|
||||
)
|
||||
await conn.commit()
|
||||
await sync_tags(conn, billet_id, data.body)
|
||||
return billet_id
|
||||
|
||||
|
||||
|
|
@ -67,11 +69,21 @@ async def get_by_id(conn: aiosqlite.Connection, billet_id: str) -> Optional[aios
|
|||
|
||||
|
||||
async def list_published(conn: aiosqlite.Connection, *, limit: int = 20,
|
||||
cursor: Optional[str] = None) -> tuple[list[aiosqlite.Row], Optional[str]]:
|
||||
"""Return (rows, next_cursor). `next_cursor` is None on the last page."""
|
||||
cursor: Optional[str] = None,
|
||||
tag: Optional[str] = None) -> tuple[list[aiosqlite.Row], Optional[str]]:
|
||||
"""Return (rows, next_cursor). `next_cursor` is None on the last page.
|
||||
|
||||
`tag` restricts the feed to one emoji-hashtag (the quick view). It uses an
|
||||
EXISTS against the indexed billet_tag rather than a JOIN, so keyset paging
|
||||
stays correct — a JOIN could duplicate a row per matching tag.
|
||||
"""
|
||||
limit = max(1, min(limit, 100))
|
||||
params: list[Any] = []
|
||||
where = "status = 'published'"
|
||||
if tag:
|
||||
where += (" AND EXISTS (SELECT 1 FROM billet_tag bt WHERE bt.billet_id = billet.id "
|
||||
"AND bt.tag_slug = ?)")
|
||||
params.append(tag)
|
||||
if cursor:
|
||||
decoded = decode_cursor(cursor)
|
||||
if decoded:
|
||||
|
|
@ -161,6 +173,7 @@ async def update_billet(conn: aiosqlite.Connection, billet_id: str, *, body: str
|
|||
(body, ref_url, embed_url, style, now, billet_id),
|
||||
)
|
||||
await conn.commit()
|
||||
await sync_tags(conn, billet_id, body) # a #tag removed from the body loses its chip
|
||||
|
||||
|
||||
async def set_embed_snapshot(conn: aiosqlite.Connection, billet_id: str,
|
||||
|
|
@ -363,3 +376,60 @@ async def stats(conn: aiosqlite.Connection) -> dict:
|
|||
"reactions_total": await _one("SELECT COUNT(*) FROM reaction"),
|
||||
"reactions_by": reactions_by,
|
||||
}
|
||||
|
||||
|
||||
# Tags (emoji hashtags) ──────────────────────────────────────────────────
|
||||
async def sync_tags(conn: aiosqlite.Connection, billet_id: str, body: str) -> list[tuple[str, str]]:
|
||||
"""Re-extract the body's #hashtags and make billet_tag match it exactly.
|
||||
|
||||
Called on every create/update, so removing a #tag from the body removes the
|
||||
chip. The tag row itself is upserted (never deleted here) — other billets may
|
||||
still point at it, and an orphan tag is harmless.
|
||||
"""
|
||||
pairs = tags_extract(body)
|
||||
for slug, emoji in pairs:
|
||||
# Keep the stored emoji stable once a tag exists: a later curation change
|
||||
# must not silently restyle billets that already published with it.
|
||||
await conn.execute(
|
||||
"INSERT INTO tag(slug,emoji,label) VALUES (?,?,?) ON CONFLICT(slug) DO NOTHING",
|
||||
(slug, emoji, slug),
|
||||
)
|
||||
await conn.execute("DELETE FROM billet_tag WHERE billet_id = ?", (billet_id,))
|
||||
if pairs:
|
||||
await conn.executemany(
|
||||
"INSERT OR IGNORE INTO billet_tag(billet_id,tag_slug) VALUES (?,?)",
|
||||
[(billet_id, slug) for slug, _ in pairs],
|
||||
)
|
||||
await conn.commit()
|
||||
return pairs
|
||||
|
||||
|
||||
async def tags_for_many(conn: aiosqlite.Connection,
|
||||
billet_ids: list[str]) -> dict[str, list[dict]]:
|
||||
"""{billet_id: [{slug, emoji}]} for a whole page in ONE query — the feed
|
||||
renders chips for every row, so a per-row lookup would be an N+1."""
|
||||
if not billet_ids:
|
||||
return {}
|
||||
marks = ",".join("?" * len(billet_ids))
|
||||
q = (f"SELECT bt.billet_id, t.slug, t.emoji FROM billet_tag bt "
|
||||
f"JOIN tag t ON t.slug = bt.tag_slug "
|
||||
f"WHERE bt.billet_id IN ({marks}) ORDER BY t.slug")
|
||||
out: dict[str, list[dict]] = {}
|
||||
async with conn.execute(q, billet_ids) as cur:
|
||||
for row in await cur.fetchall():
|
||||
out.setdefault(row["billet_id"], []).append(
|
||||
{"slug": row["slug"], "emoji": row["emoji"]})
|
||||
return out
|
||||
|
||||
|
||||
async def list_tags(conn: aiosqlite.Connection) -> list[dict]:
|
||||
"""Every tag that has at least one PUBLISHED billet, with its count —
|
||||
this is the quick-view chip bar, so drafts must not leak into it."""
|
||||
q = ("SELECT t.slug, t.emoji, COUNT(*) AS n FROM billet_tag bt "
|
||||
"JOIN tag t ON t.slug = bt.tag_slug "
|
||||
"JOIN billet b ON b.id = bt.billet_id "
|
||||
"WHERE b.status = 'published' "
|
||||
"GROUP BY t.slug, t.emoji ORDER BY n DESC, t.slug")
|
||||
async with conn.execute(q) as cur:
|
||||
return [{"slug": r["slug"], "emoji": r["emoji"], "count": r["n"]}
|
||||
for r in await cur.fetchall()]
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from pydantic import BaseModel, ValidationError
|
|||
from .. import repo
|
||||
from ..ids import new_ulid
|
||||
from ..models import BilletIn
|
||||
from ..services import media
|
||||
from ..services import feeds, media
|
||||
|
||||
try:
|
||||
from secubox_core.auth import require_jwt
|
||||
|
|
@ -43,18 +43,22 @@ class BilletPayload(BaseModel):
|
|||
status: str = "published" # "published" | "draft"
|
||||
|
||||
|
||||
def _view(row) -> dict:
|
||||
def _view(row, tags: Optional[list] = None) -> dict:
|
||||
d = dict(row)
|
||||
return {
|
||||
"id": d.get("id"),
|
||||
"slug": d.get("slug"),
|
||||
"body": d.get("body"),
|
||||
# A short excerpt so list views can show a few words instead of the whole
|
||||
# billet; `body` stays available for the editor.
|
||||
"summary": feeds.excerpt(d.get("body") or "", max_len=140),
|
||||
"status": d.get("status"),
|
||||
"style": d.get("style"),
|
||||
"ref_url": d.get("ref_url"),
|
||||
"embed_url": d.get("embed_url"),
|
||||
"published_at": d.get("published_at"),
|
||||
"created_at": d.get("created_at"),
|
||||
"tags": tags or [],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -70,6 +74,29 @@ def register_jwt_admin(app: FastAPI) -> None:
|
|||
except ValidationError as exc:
|
||||
raise HTTPException(422, f"invalid billet: {exc.errors()}")
|
||||
|
||||
@app.get("/admin/api/billets")
|
||||
async def api_list(request: Request, status: Optional[str] = None,
|
||||
tag: Optional[str] = None, limit: int = 100,
|
||||
user=Depends(require_jwt)):
|
||||
"""Authoring list — REAL billet ids, and drafts included.
|
||||
|
||||
The public JSON Feed cannot serve this: its item `id` is a permalink URL
|
||||
(not the billet id, so edit/delete could not address a row) and it omits
|
||||
drafts entirely, which an authoring client must see.
|
||||
"""
|
||||
conn = request.app.state.conn
|
||||
rows = await repo.list_all(conn, status=status, limit=max(1, min(limit, 200)))
|
||||
tag_map = await repo.tags_for_many(conn, [r["id"] for r in rows]) # batched, no N+1
|
||||
out = [_view(r, tag_map.get(r["id"], [])) for r in rows]
|
||||
if tag:
|
||||
out = [b for b in out if any(t["slug"] == tag for t in b["tags"])]
|
||||
return {"billets": out, "count": len(out)}
|
||||
|
||||
@app.get("/admin/api/tags")
|
||||
async def api_tags(request: Request, user=Depends(require_jwt)):
|
||||
"""Emoji hashtags in use (published), most-used first — the chip bar."""
|
||||
return {"tags": await repo.list_tags(request.app.state.conn)}
|
||||
|
||||
@app.post("/admin/api/billets")
|
||||
async def api_create(request: Request, payload: BilletPayload, user=Depends(require_jwt)):
|
||||
data = _billet_in(payload)
|
||||
|
|
|
|||
|
|
@ -54,15 +54,32 @@ def build_atom(*, site_title: str, base_url: str, self_url: str,
|
|||
|
||||
def build_jsonfeed(*, site_title: str, base_url: str, feed_url: str,
|
||||
items: list[dict]) -> dict:
|
||||
"""items: {id, url, title, content_html, date_published}."""
|
||||
"""items: {id, url, title, content_html, date_published}, plus optional
|
||||
`summary` (short excerpt) and `tags` [{slug, emoji}].
|
||||
|
||||
`summary` and `tags` are JSON Feed 1.1 standard fields, so generic readers
|
||||
understand them; the emoji has no standard home, so it rides in the
|
||||
`_secubox` extension (the spec reserves `_`-prefixed keys for exactly this).
|
||||
Optional keys are OMITTED rather than emitted as null — the spec asks for
|
||||
absence, and a null `summary` would render as the string "null" in naive
|
||||
readers.
|
||||
"""
|
||||
out_items = []
|
||||
for it in items:
|
||||
entry = {"id": it["id"], "url": it["url"], "title": it["title"],
|
||||
"content_html": it["content_html"],
|
||||
"date_published": it["date_published"]}
|
||||
if it.get("summary"):
|
||||
entry["summary"] = it["summary"]
|
||||
tags = it.get("tags") or []
|
||||
if tags:
|
||||
entry["tags"] = [t["slug"] for t in tags] # spec: array of strings
|
||||
entry["_secubox"] = {"tags": tags} # spec-legal extension: slug + emoji
|
||||
out_items.append(entry)
|
||||
return {
|
||||
"version": "https://jsonfeed.org/version/1.1",
|
||||
"title": site_title,
|
||||
"home_page_url": f"{base_url}/",
|
||||
"feed_url": feed_url,
|
||||
"items": [
|
||||
{"id": it["id"], "url": it["url"], "title": it["title"],
|
||||
"content_html": it["content_html"], "date_published": it["date_published"]}
|
||||
for it in items
|
||||
],
|
||||
"items": out_items,
|
||||
}
|
||||
|
|
|
|||
93
packages/secubox-billets/api/services/tags.py
Normal file
93
packages/secubox-billets/api/services/tags.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: billets — emoji hashtag extraction
|
||||
|
||||
Authors write `#secu #waf` inline in the billet body; we lift those into real tag
|
||||
rows and render them as emoji chips for quick-view filtering. Parsing the body
|
||||
(rather than a separate tag picker) means tagging costs the author nothing and
|
||||
applies retroactively to billets written before tags existed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# Curated tag -> emoji. Keys are already-normalised slugs. Unknown tags are NOT
|
||||
# rejected — they get DEFAULT_EMOJI — so authors are never blocked by the map;
|
||||
# entries here just make the common categories legible at a glance.
|
||||
TAG_EMOJI: dict[str, str] = {
|
||||
# security
|
||||
"secu": "🛡️", "security": "🛡️", "waf": "🧱", "firewall": "🧱",
|
||||
"alerte": "🚨", "alert": "🚨", "incident": "🚨", "cve": "☢️",
|
||||
"ids": "👁️", "crowdsec": "🦅", "ban": "⛔", "attaque": "⚔️",
|
||||
# network
|
||||
"reseau": "🌐", "network": "🌐", "dns": "📡", "vpn": "🔐",
|
||||
"wireguard": "🔐", "tor": "🧅", "mesh": "🕸️", "p2p": "🕸️",
|
||||
"dpi": "🔍", "proxy": "🔀",
|
||||
# box / ops
|
||||
"box": "📦", "secubox": "📦", "debian": "🐧", "kernel": "🐧",
|
||||
"hardware": "🔌", "led": "💡", "boot": "🥾", "backup": "💾",
|
||||
"update": "⬆️", "deploy": "🚀", "build": "🏗️",
|
||||
# dev
|
||||
"bug": "🐛", "fix": "🔧", "feature": "✨", "refactor": "♻️",
|
||||
"test": "🧪", "doc": "📚", "docs": "📚", "api": "🔌",
|
||||
# editorial
|
||||
"news": "📰", "note": "📝", "billet": "📝", "communique": "📢",
|
||||
"annonce": "📢", "idee": "💡", "question": "❓", "wip": "🚧",
|
||||
# misc
|
||||
"perf": "⚡", "crypto": "🔑", "zkp": "🧮", "ia": "🤖", "ai": "🤖",
|
||||
"mail": "📧", "media": "🎬", "photo": "📸", "audio": "🎧",
|
||||
# media / broadcast — the tags this box's billets actually carry
|
||||
"podcast": "🎙️", "rtbf": "📻", "lapremierertbf": "📻", "radio": "📻",
|
||||
"tv": "📺", "video": "📹", "interview": "🎤", "musique": "🎵",
|
||||
# editorial topics seen in the archive. Descriptive of the SUBJECT only —
|
||||
# a tag emoji labels what a billet is about, it does not endorse it.
|
||||
"complot": "🕵️", "ovnis": "🛸", "chemtrails": "✈️", "matrix": "🕶️",
|
||||
"simulation": "🎮", "atlantis": "🌊", "bermuda": "🌀", "titanic": "🚢",
|
||||
"climate": "🌡️", "vaccine": "💉", "aids": "🧬", "sida": "🧬",
|
||||
"windsor": "👑", "marilynmonroe": "💋", "jfk": "🎯", "attentat": "💥",
|
||||
"worldtradecenter": "🏙️", "spirituality": "✨", "histoire": "📜",
|
||||
"science": "🔬", "sante": "🏥", "politique": "🏛️",
|
||||
}
|
||||
|
||||
DEFAULT_EMOJI = "🏷️"
|
||||
|
||||
# `\w` is unicode-aware in Python 3, so #réseau matches; we fold the accents when
|
||||
# normalising so #réseau and #reseau collapse to one tag. Bounded length keeps a
|
||||
# wall of text from becoming a wall of chips.
|
||||
_HASHTAG_RE = re.compile(r"(?<!\w)#(\w{2,32})\b", re.UNICODE)
|
||||
MAX_TAGS = 8
|
||||
|
||||
|
||||
def normalise(raw: str) -> str:
|
||||
"""`#Réseau` -> `reseau`. Accent-folded + lowercased so visually-equal tags
|
||||
are actually equal. Returns '' if nothing usable survives."""
|
||||
s = unicodedata.normalize("NFKD", raw)
|
||||
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||
s = s.lower().strip("_-")
|
||||
return s if re.fullmatch(r"[0-9a-z_-]{2,32}", s) else ""
|
||||
|
||||
|
||||
def emoji_for(slug: str) -> str:
|
||||
return TAG_EMOJI.get(slug, DEFAULT_EMOJI)
|
||||
|
||||
|
||||
def extract(body: str) -> list[tuple[str, str]]:
|
||||
"""Body -> ordered, de-duplicated [(slug, emoji)], capped at MAX_TAGS.
|
||||
|
||||
Order follows first appearance so the chip row reads like the author wrote it.
|
||||
A tag that normalises to nothing (e.g. `#__`) is dropped rather than stored.
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
for m in _HASHTAG_RE.finditer(body or ""):
|
||||
slug = normalise(m.group(1))
|
||||
if not slug or slug in seen:
|
||||
continue
|
||||
seen[slug] = emoji_for(slug)
|
||||
if len(seen) >= MAX_TAGS:
|
||||
break
|
||||
return list(seen.items())
|
||||
|
|
@ -166,3 +166,28 @@ body.lb-lock{overflow:hidden}
|
|||
top:-8px;left:14px;background:#0d1117;padding:0 6px;font-size:9px;
|
||||
letter-spacing:.2em;color:#00d4ff;font-weight:700}
|
||||
.embed-vignette img{width:100%;height:auto;display:block;border-radius:4px}
|
||||
|
||||
/* ── emoji hashtags ─────────────────────────────────────────────────────
|
||||
Tags come from the #hashtags authors type in the body. The bar is the
|
||||
quick view: each chip is a real link (/?tag=slug), so a filtered view is
|
||||
shareable and works with JS off. */
|
||||
.tagbar{display:flex;flex-wrap:wrap;gap:.4rem;margin:0 0 1.2rem}
|
||||
.tag-chip{display:inline-flex;align-items:center;gap:.3rem;padding:.3rem .7rem;
|
||||
border-radius:999px;font-size:.82rem;font-weight:600;text-decoration:none;
|
||||
color:var(--fg);background:var(--card);border:1px solid var(--border);
|
||||
transition:transform .12s ease,border-color .12s ease}
|
||||
.tag-chip:hover{transform:translateY(-1px);border-color:var(--link)}
|
||||
/* The active chip is the one state that must survive a squint — give it the
|
||||
site's gradient rather than another subtle border tint. */
|
||||
.tag-chip.on{background:var(--grad);color:#fff;border-color:transparent}
|
||||
.tag-chip.sm{font-size:.75rem;padding:.2rem .55rem}
|
||||
.tag-n{opacity:.6;font-variant-numeric:tabular-nums}
|
||||
.tag-chip.on .tag-n{opacity:.85}
|
||||
.tag-row{display:flex;flex-wrap:wrap;gap:.35rem;margin:.6rem 0 .2rem}
|
||||
.tag-active{font-size:.9rem;color:var(--muted);margin:0 0 1rem}
|
||||
@media (prefers-reduced-motion:reduce){.tag-chip{transition:none}.tag-chip:hover{transform:none}}
|
||||
/* Feed resumes: long billets show a few words + a link to the full permalink. */
|
||||
.resume{font-size:1rem;line-height:1.55;color:var(--fg);margin:.2rem 0 .5rem}
|
||||
.read-more{display:inline-block;font-size:.85rem;font-weight:600;text-decoration:none;
|
||||
color:var(--link)}
|
||||
.read-more:hover{text-decoration:underline}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,37 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
{# Quick-view chip bar: the emoji hashtags authors typed in their billets.
|
||||
Plain links (not JS) so it works without scripts and each view is a real,
|
||||
shareable URL. #}
|
||||
{% if all_tags %}
|
||||
<nav class="tagbar" aria-label="Filtrer par tag">
|
||||
<a class="tag-chip{% if not active_tag %} on{% endif %}" href="/">🌀 tous</a>
|
||||
{% for t in all_tags %}
|
||||
<a class="tag-chip{% if active_tag == t.slug %} on{% endif %}" href="/?tag={{ t.slug }}"
|
||||
rel="{% if active_tag == t.slug %}nofollow{% endif %}">{{ t.emoji }} #{{ t.slug }} <span class="tag-n">{{ t.count }}</span></a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if active_tag %}
|
||||
<p class="tag-active">Vue rapide : <b>#{{ active_tag }}</b> — <a href="/">tout afficher</a></p>
|
||||
{% endif %}
|
||||
<div class="h-feed">
|
||||
{% for b in billets %}
|
||||
<article class="h-entry billet">
|
||||
{# The feed is a scan surface: show a short resume for long billets and send
|
||||
the reader to the permalink for the rest. Short billets render whole —
|
||||
clipping three words off a one-liner would be noise, not brevity. #}
|
||||
{% if b.is_long %}
|
||||
<p class="p-summary resume">{{ b.summary }}</p>
|
||||
<a class="read-more" href="/b/{{ b.slug }}">Lire la suite →</a>
|
||||
{% else %}
|
||||
<div class="e-content">{{ b.body_html|safe }}</div>
|
||||
{% endif %}
|
||||
{% if b.tags %}
|
||||
<div class="tag-row">
|
||||
{% for t in b.tags %}<a class="tag-chip sm p-category" href="/?tag={{ t.slug }}">{{ t.emoji }} #{{ t.slug }}</a>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if b.media %}
|
||||
<div class="gallery" data-lightbox>
|
||||
{% for m in b.media %}
|
||||
|
|
@ -32,6 +60,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% if next_cursor %}
|
||||
<nav class="pager"><a rel="next" href="/?cursor={{ next_cursor }}">Billets plus anciens →</a></nav>
|
||||
{# Carry the active tag across pages, else page 2 silently drops the filter. #}
|
||||
<nav class="pager"><a rel="next" href="/?cursor={{ next_cursor }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Billets plus anciens →</a></nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,25 @@ let BASE = ''; // e.g. https://box.example.in
|
|||
let TOKEN = '';
|
||||
const listeners = new Set();
|
||||
|
||||
// Box tokens expire (24h). The Companion seals one at pairing and reuses it, so
|
||||
// without this the app dead-ends on "unauthorized" a day later with no way back
|
||||
// except unpairing. On a 401 we ask the app to re-authenticate, then retry the
|
||||
// request once — single-flight, so a screenful of concurrent 401s produces ONE
|
||||
// login prompt, not one per request.
|
||||
let _onUnauthorized = null;
|
||||
let _reauthInFlight = null;
|
||||
|
||||
async function reauthenticate() {
|
||||
if (!_onUnauthorized) return false;
|
||||
if (!_reauthInFlight) {
|
||||
_reauthInFlight = Promise.resolve()
|
||||
.then(() => _onUnauthorized())
|
||||
.catch(() => false)
|
||||
.finally(() => { _reauthInFlight = null; });
|
||||
}
|
||||
return await _reauthInFlight;
|
||||
}
|
||||
|
||||
function emit() { for (const fn of listeners) try { fn(state()); } catch {} }
|
||||
function state() { return { online: navigator.onLine, base: BASE }; }
|
||||
|
||||
|
|
@ -24,6 +43,10 @@ export const api = {
|
|||
isReady() { return !!BASE && !!TOKEN; },
|
||||
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
||||
|
||||
/** Register the app's re-login flow. It must return truthy once fresh
|
||||
* credentials have been passed to api.init(), falsy if the user cancelled. */
|
||||
onUnauthorized(fn) { _onUnauthorized = fn; },
|
||||
|
||||
headers(extra = {}) {
|
||||
const h = { 'Accept': 'application/json', ...extra };
|
||||
if (TOKEN) h['Authorization'] = 'Bearer ' + TOKEN;
|
||||
|
|
@ -31,11 +54,15 @@ export const api = {
|
|||
},
|
||||
|
||||
/** GET with cache fallback. `path` is absolute on the box (e.g. /api/v1/billets/feed). */
|
||||
async get(path, { cache = true } = {}) {
|
||||
async get(path, { cache = true, _retried = false } = {}) {
|
||||
const url = BASE + path;
|
||||
try {
|
||||
const r = await fetch(url, { headers: this.headers(), credentials: 'include' });
|
||||
if (r.status === 401) throw new ApiError('unauthorized', 401);
|
||||
if (r.status === 401) {
|
||||
// Expired/rotated token → re-login once, then replay this read.
|
||||
if (!_retried && await reauthenticate()) return this.get(path, { cache, _retried: true });
|
||||
throw new ApiError('unauthorized', 401);
|
||||
}
|
||||
if (!r.ok) throw new ApiError(await safeText(r), r.status);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (cache) store.cachePut('GET ' + path, data).catch(() => {});
|
||||
|
|
@ -56,13 +83,18 @@ export const api = {
|
|||
/** Send a write, or queue it if offline / the network drops.
|
||||
* Returns { queued:true, id } when deferred, otherwise the parsed response. */
|
||||
async _write(method, path, body, { form = false } = {}) {
|
||||
const send = async () => {
|
||||
const send = async (retried = false) => {
|
||||
const headers = form ? this.headers() : this.headers({ 'Content-Type': 'application/json' });
|
||||
const r = await fetch(BASE + path, {
|
||||
method, headers, credentials: 'include',
|
||||
body: body == null ? undefined : (form ? body : JSON.stringify(body)),
|
||||
});
|
||||
if (r.status === 401) throw new ApiError('unauthorized', 401);
|
||||
if (r.status === 401) {
|
||||
// Re-login and replay ONCE — losing a billet the author just wrote to an
|
||||
// expired token would be the worst possible outcome here.
|
||||
if (!retried && await reauthenticate()) return send(true);
|
||||
throw new ApiError('unauthorized', 401);
|
||||
}
|
||||
if (!r.ok) throw new ApiError(await safeText(r), r.status);
|
||||
return r.json().catch(() => ({}));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -121,6 +121,20 @@ function route() {
|
|||
|
||||
// ── boot ──────────────────────────────────────────────────────────
|
||||
async function boot(forceLock = false) {
|
||||
// Box tokens live 24h; the sealed one goes stale and every authed call then
|
||||
// 401s. Re-login in place (the URL is prefilled) and re-render, so an expired
|
||||
// session is a prompt rather than a dead end — api.js replays the request that
|
||||
// hit the 401, so an in-progress write is not lost.
|
||||
api.onUnauthorized(async () => {
|
||||
const creds = await pairingScreen(app);
|
||||
if (!creds || !creds.token) return false;
|
||||
api.init(creds);
|
||||
chrome();
|
||||
await registry.discover();
|
||||
route();
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!store.isPaired()) {
|
||||
const creds = await pairingScreen(app);
|
||||
api.init(creds);
|
||||
|
|
|
|||
100
secubox-companion/www/core/metrics.js
Normal file
100
secubox-companion/www/core/metrics.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// SecuBox Companion :: core/metrics — emoji gauges + human formatters.
|
||||
//
|
||||
// Companion is glanced at on a phone, often in passing — raw psutil numbers
|
||||
// ("disk_percent: 94.5") don't read as urgent fast enough. This module turns
|
||||
// any percent-ish value into a severity-coded gauge (emoji + colour + bar)
|
||||
// using PER-KIND thresholds, because a disk at 94% and a cpu at 94% do not
|
||||
// carry the same risk and must not look the same at a glance.
|
||||
|
||||
import { el } from './ui.js';
|
||||
|
||||
// [warnAt, critAt] as a 0-100 value, per metric kind. Disk fills up silently
|
||||
// and is the most dangerous to under-alarm on, so it's the strictest; cpu
|
||||
// bursts constantly and is tolerated much higher before it means anything.
|
||||
const THRESHOLDS = {
|
||||
cpu: [70, 90],
|
||||
mem: [75, 90],
|
||||
disk: [80, 92], // 94.5% on this box → correctly reads as crit
|
||||
temp: [70, 85], // °C, reuses the same [warn,crit] shape as a percent scale
|
||||
load: [70, 90], // caller normalises load average to % of core count first
|
||||
default: [70, 90],
|
||||
};
|
||||
|
||||
const LEVELS = {
|
||||
ok: { emoji: '🟢', badge: 'ok', word: 'nominal' },
|
||||
warn: { emoji: '🟡', badge: 'warn', word: 'elevated' },
|
||||
crit: { emoji: '🔴', badge: 'err', word: 'critical' },
|
||||
};
|
||||
|
||||
/** Classify a 0-100 value for `kind` into 'ok' | 'warn' | 'crit'. */
|
||||
export function severity(kind, value) {
|
||||
if (value == null || Number.isNaN(value)) return 'ok';
|
||||
const [warn, crit] = THRESHOLDS[kind] || THRESHOLDS.default;
|
||||
if (value >= crit) return 'crit';
|
||||
if (value >= warn) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function emojiFor(kind, value) { return LEVELS[severity(kind, value)].emoji; }
|
||||
export function wordFor(kind, value) { return LEVELS[severity(kind, value)].word; }
|
||||
|
||||
/** bytes → "5.5 GB" — binary units, plain language over raw byte counts. */
|
||||
export function fmtBytes(n) {
|
||||
if (n == null || Number.isNaN(n)) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let v = Number(n), i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(i === 0 || v >= 10 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** seconds → "7d 3h" — coarsest two units only; nobody needs the minutes. */
|
||||
export function fmtUptime(sec) {
|
||||
if (sec == null || Number.isNaN(sec)) return '—';
|
||||
sec = Math.floor(sec);
|
||||
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export function fmtPercent(n, digits = 0) {
|
||||
if (n == null || Number.isNaN(n)) return '—';
|
||||
return `${Number(n).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Memory 75% — 5.5 GB of 7.7 GB used" style plain-language sentence.
|
||||
* Prefer this over showing a bare field name + number.
|
||||
*/
|
||||
export function usageSentence(label, percent, used, total) {
|
||||
const pct = fmtPercent(percent);
|
||||
if (used == null || total == null) return `${label} ${pct}`;
|
||||
return `${label} ${pct} — ${fmtBytes(used)} of ${fmtBytes(total)} used`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A gauge card: emoji + plain label + big value + proportional bar.
|
||||
* `percent` (0-100) drives both the bar width and the severity colour/emoji;
|
||||
* pass `value`/`unit` when the displayed text isn't the percent itself
|
||||
* (e.g. a °C reading, still classified on the same 0-100 severity scale).
|
||||
*/
|
||||
export function gauge({ kind = 'default', label, percent, value, unit = '%', sub }) {
|
||||
const lvl = severity(kind, percent);
|
||||
const { emoji, badge } = LEVELS[lvl];
|
||||
const shown = value != null ? `${value}${unit}` : fmtPercent(percent);
|
||||
const pct = Math.max(0, Math.min(100, percent ?? 0));
|
||||
return el('div.card', { style: 'text-align:center' }, [
|
||||
el('div', { style: 'font-size:1.3rem;line-height:1', text: emoji }),
|
||||
el('div.data', { style: 'font-size:1.35rem;font-weight:700;margin:3px 0', text: shown }),
|
||||
el('div.kicker', { text: label }),
|
||||
el('div.gauge-bar', {}, [el(`div.gauge-fill.${badge}`, { style: `width:${pct}%` })]),
|
||||
sub ? el('div.muted', { style: 'font-size:.7rem;margin-top:4px', text: sub }) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Small severity badge for non-gauge contexts (list rows, headers). */
|
||||
export function severityBadge(kind, value, text) {
|
||||
const { badge, emoji } = LEVELS[severity(kind, value)];
|
||||
return el(`span.badge.${badge}`, { text: `${emoji} ${text}` });
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
"module": "AUTH",
|
||||
"icon": "🔑",
|
||||
"description": "Identities, sessions, access.",
|
||||
"api_base": "/api/v1/auth",
|
||||
"status_endpoint": "/api/v1/auth/status",
|
||||
"api_base": "/api/v1/users",
|
||||
"status_endpoint": "/api/v1/users/status",
|
||||
"capabilities": ["read"],
|
||||
"routes": [{ "path": "", "label": "Identities" }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,146 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// SecuBox Companion :: auth — identities & sessions (read).
|
||||
//
|
||||
// The stub this replaces called `${base}/users` against api_base
|
||||
// `/api/v1/auth`, which 404s — auth identity data actually lives under
|
||||
// `/api/v1/users` (see module.json, updated alongside this file). Routes
|
||||
// below are verified live against packages/secubox-users/api/main.py:
|
||||
// /users (JWT) → { users:[{username,email,role,enabled,totp}], total }
|
||||
// /sessions (JWT) → { sessions:[{id,username,email,ip,user_agent,type,
|
||||
// created_at,expires_at,last_active,current}], total }
|
||||
// /roles → { roles:[{id,name,description,permissions,builtin,color}], total }
|
||||
// /groups (JWT) → { groups:[{name,description,permissions,members,created}] }
|
||||
// Each section is fetched independently so one failing route degrades to a
|
||||
// small inline notice instead of blanking the whole module.
|
||||
import { gauge } from '../../core/metrics.js';
|
||||
|
||||
// The 'master' concept isn't a stored field — the source (api/main.py,
|
||||
// engine.py) consistently treats the single literal username "admin" as
|
||||
// the master account (e.g. the only one synced to YaCy). Mirror that here.
|
||||
const isMaster = (u) => u.username === 'admin';
|
||||
|
||||
// `created_at`/`expires_at` come from a JWT session file: sometimes a raw
|
||||
// epoch (seconds OR the rarer ms), sometimes an ISO string, sometimes ''.
|
||||
// ui.ago() assumes a bare number is already milliseconds, so seconds must
|
||||
// be upscaled first or old sessions render as "1970".
|
||||
function toMs(ts) {
|
||||
if (ts === '' || ts == null) return null;
|
||||
if (typeof ts === 'number') return ts < 1e12 ? ts * 1000 : ts;
|
||||
const p = Date.parse(ts);
|
||||
return Number.isNaN(p) ? null : p;
|
||||
}
|
||||
function until(ts) {
|
||||
const ms = toMs(ts);
|
||||
if (ms == null) return '—';
|
||||
const s = Math.floor((ms - Date.now()) / 1000);
|
||||
if (s <= 0) return 'expired';
|
||||
if (s < 3600) return `in ${Math.floor(s / 60)}m`;
|
||||
if (s < 86400) return `in ${Math.floor(s / 3600)}h`;
|
||||
return `in ${Math.floor(s / 86400)}d`;
|
||||
}
|
||||
|
||||
// IP may be '' (pre-fix rows) or the literal string "unknown" (the API's
|
||||
// own fallback when the key is absent entirely) — both read as "no data",
|
||||
// so both collapse to the same neutral placeholder rather than leaking
|
||||
// either raw form to the UI.
|
||||
const ipOr = (ip) => (ip && ip !== 'unknown') ? ip : '—';
|
||||
|
||||
export default async function mount(ctx) {
|
||||
const { root, api, base, ui } = ctx;
|
||||
const { el, esc, ago, clear } = ui;
|
||||
|
||||
const host = clear(root);
|
||||
host.append(el('p.muted', { text: 'Loading identities…' }));
|
||||
clear(host);
|
||||
|
||||
host.append(el('div.panel-head', {}, [
|
||||
el('div', { style: 'font-size:1.6rem', text: '🔑' }),
|
||||
el('h3', { style: 'margin:0', text: 'Auth' }),
|
||||
]));
|
||||
|
||||
// ── sessions (prominent — who / IP / device / age / current) ────
|
||||
try {
|
||||
const users = await api.get(`${base}/users`).catch(() => ({ users: [] })); // TODO(api): confirm users route
|
||||
const list = users.users || users.items || [];
|
||||
clear(host);
|
||||
const card = el('div.card', {}, [el('h3', { text: `Users (${list.length})` })]);
|
||||
if (!list.length) card.append(el('div.empty', { text: 'No users listed (auth users route may be admin-gated — TODO(api)).' }));
|
||||
for (const u of list) card.append(el('div.item', {}, [
|
||||
const d = await api.get(`${base}/sessions`);
|
||||
const items = d.sessions || [];
|
||||
const card = el('div.card', {}, [el('h3', { text: `🟢 Active sessions (${d.total ?? items.length})` })]);
|
||||
if (!items.length) card.append(el('div.empty', { text: 'No active sessions.' }));
|
||||
for (const s of items) {
|
||||
const device = s.user_agent || '—';
|
||||
card.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [
|
||||
el('b', {}, [esc(s.username || '?'), s.current ? el('span.badge.ok', { style: 'margin-left:6px', text: '📍 this device' }) : null]),
|
||||
el('span', { text: `${ipOr(s.ip)} · ${esc(device)} · signed in ${ago(toMs(s.created_at) ?? undefined) || '—'}` }),
|
||||
]),
|
||||
el('span.badge' + (until(s.expires_at) === 'expired' ? '.err' : ''), { text: '⏳ ' + until(s.expires_at) }),
|
||||
]));
|
||||
}
|
||||
host.append(card);
|
||||
} catch (e) {
|
||||
host.append(el('div.card', {}, [el('div.muted', { text: 'Sessions unavailable: ' + esc(e.message) })]));
|
||||
}
|
||||
|
||||
// ── identities ────────────────────────────────────────────────
|
||||
try {
|
||||
const d = await api.get(`${base}/users`);
|
||||
const list = d.users || [];
|
||||
const enabled = list.filter(u => u.enabled !== false).length;
|
||||
const disabled = list.length - enabled;
|
||||
const card = el('div.card', {}, [el('h3', { text: `👤 Identities (${d.total ?? list.length})` })]);
|
||||
if (list.length) {
|
||||
// Gauge on the *disabled* share, not enabled — severity() reads "higher
|
||||
// = worse", and a pile of locked-out accounts is the anomaly worth
|
||||
// flagging, not the (normal) state of everyone being enabled.
|
||||
card.append(gauge({ kind: 'default', label: 'Accounts disabled', percent: (disabled / list.length) * 100, value: `${enabled}/${list.length} enabled`, unit: '' }));
|
||||
} else {
|
||||
card.append(el('div.empty', { text: 'No users listed.' }));
|
||||
}
|
||||
for (const u of list) {
|
||||
const twofa = u.totp && u.totp.enabled;
|
||||
card.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [
|
||||
el('b', {}, [esc(u.username), isMaster(u) ? el('span.badge.ok', { style: 'margin-left:6px', text: '★ master' }) : null]),
|
||||
el('span', { text: [u.role || (u.roles || []).join(',') || 'viewer', esc(u.email || ''), twofa ? '🔐 2FA' : null].filter(Boolean).join(' · ') }),
|
||||
]),
|
||||
el('span.badge' + (u.enabled === false ? '.err' : '.ok'), { text: u.enabled === false ? '🔴 disabled' : '🟢 enabled' }),
|
||||
]));
|
||||
}
|
||||
host.append(card);
|
||||
} catch (e) {
|
||||
host.append(el('div.card', {}, [el('div.muted', { text: 'Identities unavailable: ' + esc(e.message) })]));
|
||||
}
|
||||
|
||||
// ── roles ─────────────────────────────────────────────────────
|
||||
try {
|
||||
const d = await api.get(`${base}/roles`);
|
||||
const roles = d.roles || [];
|
||||
const card = el('div.card', {}, [el('h3', { text: `🎭 Roles (${d.total ?? roles.length})` })]);
|
||||
if (!roles.length) card.append(el('div.empty', { text: 'No roles defined.' }));
|
||||
for (const r of roles) card.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [
|
||||
el('b', { text: esc(u.username || u.name || u.id) }),
|
||||
el('span', { text: [u.role, u.admin && 'admin', u.totp && '2FA'].filter(Boolean).join(' · ') }),
|
||||
el('b', { text: esc(r.name || r.id) }),
|
||||
el('span', { text: `${esc(r.description || '')} · ${(r.permissions || []).length} permissions` }),
|
||||
]),
|
||||
u.master ? el('span.badge.ok', { text: 'master' }) : null,
|
||||
r.builtin ? el('span.badge', { text: 'builtin' }) : null,
|
||||
]));
|
||||
host.append(card);
|
||||
} catch (e) {
|
||||
host.append(el('div.card', {}, [el('div.muted', { text: 'Roles unavailable: ' + esc(e.message) })]));
|
||||
}
|
||||
|
||||
const sess = await api.get(`${base}/sessions`).catch(() => null); // TODO(api): confirm sessions route
|
||||
if (sess) {
|
||||
const items = sess.sessions || sess.items || [];
|
||||
const sc = el('div.card', {}, [el('h3', { text: `Active sessions (${items.length})` })]);
|
||||
for (const s of items.slice(0, 30)) sc.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [el('b', { text: esc(s.user || s.username || '?') }), el('span', { text: `${esc(s.ip || '')} · ${ago(s.created_at || s.ts)}` })]),
|
||||
]));
|
||||
host.append(sc);
|
||||
}
|
||||
} catch (e) { clear(host).append(el('div.empty', { text: 'Failed: ' + esc(e.message) })); }
|
||||
// ── groups ────────────────────────────────────────────────────
|
||||
try {
|
||||
const d = await api.get(`${base}/groups`);
|
||||
const groups = d.groups || [];
|
||||
const card = el('div.card', {}, [el('h3', { text: `👥 Groups (${groups.length})` })]);
|
||||
if (!groups.length) card.append(el('div.empty', { text: 'No groups defined.' }));
|
||||
for (const g of groups) card.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [
|
||||
el('b', { text: esc(g.name) }),
|
||||
el('span', { text: `${esc(g.description || '')} · ${(g.members || []).length} members` }),
|
||||
]),
|
||||
]));
|
||||
host.append(card);
|
||||
} catch (e) {
|
||||
host.append(el('div.card', {}, [el('div.muted', { text: 'Groups unavailable: ' + esc(e.message) })]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// SecuBox Companion :: billets — write/publish billets + moderate comments.
|
||||
//
|
||||
// Endpoints below are the clean interface the Companion consumes. Where the
|
||||
// exact box route/shape is unconfirmed it is marked TODO(api); the box may need
|
||||
// a JWT-authed admin API for billets (its web admin is session-based). Reads use
|
||||
// the public feed and work today; writes go through these paths + auto-queue.
|
||||
// All routes below are served by billets' JWT/SSO admin surface (routes/jwt_admin.py),
|
||||
// which trusts the SecuBox session — reads use the public JSON Feed.
|
||||
// Every route below is served by billets' JWT/SSO admin surface
|
||||
// (routes/jwt_admin.py), which trusts the SecuBox session.
|
||||
//
|
||||
// The list deliberately uses the ADMIN route, not the public JSON Feed: the
|
||||
// feed's item `id` is a permalink URL rather than the billet id (so edit/delete
|
||||
// could not address a row) and the feed omits drafts, which an authoring client
|
||||
// must see.
|
||||
const EP = {
|
||||
feed: (b) => `${b}/feed.json`, // public JSON Feed (jsonfeed.org)
|
||||
list: (b) => `${b}/admin/api/billets`, // GET {billets:[{id,summary,status,tags[]}]}
|
||||
tags: (b) => `${b}/admin/api/tags`, // GET {tags:[{slug,emoji,count}]}
|
||||
create: (b) => `${b}/admin/api/billets`, // POST {body,ref_url,embed_url,style,status}
|
||||
update: (b, id) => `${b}/admin/api/billets/${id}`, // PUT
|
||||
remove: (b, id) => `${b}/admin/api/billets/${id}`, // DELETE
|
||||
|
|
@ -43,24 +45,59 @@ export default async function mount(ctx) {
|
|||
}
|
||||
|
||||
// ── list ────────────────────────────────────────────────────────
|
||||
let tagFilter = ''; // '' = all; otherwise a tag slug (the quick view)
|
||||
|
||||
async function list() {
|
||||
const host = clear(body);
|
||||
host.append(el('p.muted', { text: 'Loading…' }));
|
||||
try {
|
||||
const d = await api.get(EP.feed(base));
|
||||
const items = d.billets || d.items || d.feed || (Array.isArray(d) ? d : []);
|
||||
const d = await api.get(EP.list(base));
|
||||
const items = d.billets || [];
|
||||
clear(host);
|
||||
if (d.__cached) host.append(el('div.badge.warn', { text: 'cached (offline)' }));
|
||||
if (!items.length) return host.append(el('div.empty', { text: 'No billets yet.' }));
|
||||
for (const b of items) {
|
||||
const title = (b.title || b.body || '').replace(/\s+/g, ' ').slice(0, 70) || '(untitled)';
|
||||
|
||||
// Quick-view chip bar. Tags come from the #hashtags authors type in the
|
||||
// body; failing to load them must not hide the billets themselves.
|
||||
const td = await api.get(EP.tags(base)).catch(() => null);
|
||||
const tags = (td && td.tags) || [];
|
||||
if (tags.length) {
|
||||
const bar = el('div.row', { style: 'gap:6px;margin-bottom:12px;flex-wrap:wrap' });
|
||||
const chip = (label, slug) => el(`button.btn.sm${tagFilter === slug ? '.primary' : ''}`, {
|
||||
text: label,
|
||||
onclick: () => { tagFilter = tagFilter === slug ? '' : slug; list(); },
|
||||
});
|
||||
bar.append(chip('🌀 All', ''));
|
||||
for (const t of tags) bar.append(chip(`${t.emoji} #${t.slug} ${t.count}`, t.slug));
|
||||
host.append(bar);
|
||||
}
|
||||
|
||||
const shown = tagFilter
|
||||
? items.filter(b => (b.tags || []).some(t => t.slug === tagFilter))
|
||||
: items;
|
||||
if (!shown.length) {
|
||||
return host.append(el('div.empty', {
|
||||
text: tagFilter ? `No billets tagged #${tagFilter}.` : 'No billets yet.',
|
||||
}));
|
||||
}
|
||||
|
||||
for (const b of shown) {
|
||||
// Show a few words, not the whole billet — `summary` is the box-side
|
||||
// excerpt; fall back to a clipped body if it is ever absent.
|
||||
const excerpt = b.summary || (b.body || '').replace(/\s+/g, ' ').slice(0, 140) || '(empty)';
|
||||
const chips = el('span', { style: 'margin-left:6px' });
|
||||
for (const t of (b.tags || []))
|
||||
chips.append(el('span.badge', { text: `${t.emoji} ${t.slug}`, style: 'margin-right:4px' }));
|
||||
const draft = b.status === 'draft';
|
||||
host.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [
|
||||
el('b', { text: title }),
|
||||
el('span', { text: `${b.status || 'published'} · ${ago(b.date_published || b.published_at || b.created_at)} · ${b.style || 'default'}` }),
|
||||
el('b', { text: excerpt }),
|
||||
el('span', {}, [
|
||||
el('span', { text: `${draft ? '📝 draft' : '✅ published'} · ${ago(b.published_at || b.created_at)}${b.style === 'communique' ? ' · 📢 communiqué' : ''}` }),
|
||||
chips,
|
||||
]),
|
||||
]),
|
||||
el('div.actions', {}, [
|
||||
el('button.btn.sm', { text: 'Edit', onclick: () => { active = 'edit:' + (b.id || b.slug); renderTabs(); editor(b.id || b.slug, b); } }),
|
||||
el('button.btn.sm', { text: 'Edit', onclick: () => { active = 'edit:' + b.id; renderTabs(); editor(b.id, b); } }),
|
||||
el('button.btn.sm.danger', { text: 'Del', onclick: () => remove(b) }),
|
||||
]),
|
||||
]));
|
||||
|
|
@ -71,7 +108,7 @@ export default async function mount(ctx) {
|
|||
async function remove(b) {
|
||||
if (!confirmAction('Delete this billet?')) return;
|
||||
try {
|
||||
const r = await api.del(EP.remove(base, b.id || b.slug));
|
||||
const r = await api.del(EP.remove(base, b.id));
|
||||
toast(r.queued ? 'Delete queued (offline)' : 'Deleted');
|
||||
list();
|
||||
} catch (e) { toast('Delete failed: ' + e.message, 'err'); }
|
||||
|
|
|
|||
|
|
@ -1,34 +1,148 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// SecuBox Companion :: system — health, resources, services (read).
|
||||
//
|
||||
// Routes below are verified live against packages/secubox-system/api/main.py:
|
||||
// /status (JWT) → { model, board, hostname, uptime_sec, cpu_count,
|
||||
// mem_total_mb, mem_free_mb, services_sample, health, cached_at }
|
||||
// /metrics → { cpu_percent, mem_percent, disk_percent, load_avg_1,
|
||||
// cpu_temp, uptime_seconds, hostname, memory_used, memory_total }
|
||||
// /resources → { cpu_percent, memory_percent, memory_used, memory_total,
|
||||
// disk_percent, disk_used, disk_total, load_avg:[1,5,15] }
|
||||
// /health_score (JWT) → { score, max, issues:[name…], services:[{name,active,status,enabled}] }
|
||||
// /network → { interfaces:[{name,addresses,up,speed}] }
|
||||
// /security → { firewall, ssh_status, apparmor, crowdsec } (plain status words)
|
||||
// Each section is fetched independently so one failing route degrades to a
|
||||
// small inline notice instead of blanking the whole module.
|
||||
import { gauge, fmtUptime, fmtBytes } from '../../core/metrics.js';
|
||||
|
||||
export default async function mount(ctx) {
|
||||
const { root, api, base, ui } = ctx;
|
||||
const { el, esc, clear } = ui;
|
||||
|
||||
const host = clear(root);
|
||||
host.append(el('p.muted', { text: 'Loading system health…' }));
|
||||
try {
|
||||
const s = await api.get(`${base}/status`);
|
||||
clear(host);
|
||||
const g = el('div.grid');
|
||||
const gauge = (label, val, unit = '') => g.append(el('div.card', { style: 'text-align:center' }, [
|
||||
el('div.data', { style: 'font-size:1.5rem;font-weight:700', text: (val ?? '—') + unit }),
|
||||
el('div.kicker', { text: label }),
|
||||
]));
|
||||
gauge('CPU', s.cpu ?? s.cpu_percent, '%');
|
||||
gauge('Memory', s.mem ?? s.memory_percent ?? s.mem_percent, '%');
|
||||
gauge('Disk', s.disk ?? s.disk_percent, '%');
|
||||
gauge('Uptime', s.uptime_days ?? s.uptime, s.uptime_days ? 'd' : '');
|
||||
host.append(g);
|
||||
|
||||
const svcs = await api.get(`${base}/services`).catch(() => null); // TODO(api): confirm services route
|
||||
if (svcs) {
|
||||
const items = svcs.services || svcs.items || [];
|
||||
const card = el('div.card', {}, [el('h3', { text: `Services (${items.length})` })]);
|
||||
for (const sv of items.slice(0, 60)) card.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [el('b', { text: esc(sv.name || sv.unit) })]),
|
||||
el('span.badge' + (sv.active || sv.status === 'active' || sv.running ? '.ok' : '.err'), { text: sv.status || (sv.active ? 'active' : 'down') }),
|
||||
]));
|
||||
host.append(card);
|
||||
// /metrics is the one call the whole panel leans on (cpu/mem/disk/temp/
|
||||
// uptime/hostname in a single response) — if it fails there is nothing
|
||||
// meaningful to render, so that failure alone blanks the module.
|
||||
let m;
|
||||
try {
|
||||
m = await api.get(`${base}/metrics`);
|
||||
} catch (e) {
|
||||
clear(host).append(el('div.empty', { text: 'System metrics unavailable: ' + esc(e.message) }));
|
||||
return;
|
||||
}
|
||||
clear(host);
|
||||
if (m.__cached) host.append(el('div.badge.warn', { text: 'cached (offline)' }));
|
||||
|
||||
// ── header: who / how long up ──────────────────────────────────
|
||||
host.append(el('div.panel-head', {}, [
|
||||
el('div', { style: 'font-size:1.6rem', text: '🖥️' }),
|
||||
el('div', {}, [
|
||||
el('h3', { style: 'margin:0', text: esc(m.hostname || 'SecuBox') }),
|
||||
el('div.muted', { style: 'font-size:.78rem', text: `up ${fmtUptime(m.uptime_seconds)}` }),
|
||||
]),
|
||||
]));
|
||||
|
||||
// ── gauges: cpu / memory / disk / temp / load ──────────────────
|
||||
const g = el('div.grid');
|
||||
host.append(g);
|
||||
|
||||
// disk byte totals aren't in /metrics — fetch /resources for the "N GB of
|
||||
// N GB" sub-line; the disk % gauge itself still renders if this fails.
|
||||
let res = null;
|
||||
try { res = await api.get(`${base}/resources`); } catch { /* degrade: percent-only gauges */ }
|
||||
|
||||
g.append(gauge({ kind: 'cpu', label: 'CPU', percent: m.cpu_percent }));
|
||||
g.append(gauge({
|
||||
kind: 'mem', label: 'Memory', percent: m.mem_percent,
|
||||
sub: (m.memory_used != null && m.memory_total != null) ? `${fmtBytes(m.memory_used)} of ${fmtBytes(m.memory_total)} used` : undefined,
|
||||
}));
|
||||
g.append(gauge({
|
||||
kind: 'disk', label: 'Disk', percent: m.disk_percent,
|
||||
sub: (res && res.disk_used != null && res.disk_total != null) ? `${fmtBytes(res.disk_used)} of ${fmtBytes(res.disk_total)} used` : undefined,
|
||||
}));
|
||||
g.append(gauge({ kind: 'temp', label: 'Temperature', percent: m.cpu_temp, value: m.cpu_temp?.toFixed?.(1) ?? m.cpu_temp, unit: '°C' }));
|
||||
|
||||
// Load average isn't a percent on its own — normalise against core count
|
||||
// from /status (JWT) when reachable; otherwise show the raw figure without
|
||||
// a false severity read (an un-normalised "3.4" means nothing alone).
|
||||
try {
|
||||
const st = await api.get(`${base}/status`);
|
||||
const cores = st.cpu_count;
|
||||
const loadPct = cores ? Math.min(100, (m.load_avg_1 / cores) * 100) : null;
|
||||
g.append(gauge({
|
||||
kind: 'load', label: `Load (1m, ${cores || '?'} cores)`,
|
||||
percent: loadPct, value: m.load_avg_1?.toFixed?.(2) ?? m.load_avg_1, unit: '',
|
||||
}));
|
||||
} catch {
|
||||
g.append(gauge({ kind: 'load', label: 'Load (1m)', percent: null, value: m.load_avg_1?.toFixed?.(2) ?? m.load_avg_1, unit: '' }));
|
||||
}
|
||||
|
||||
// ── health score + services ─────────────────────────────────────
|
||||
try {
|
||||
const hs = await api.get(`${base}/health_score`);
|
||||
const services = hs.services || [];
|
||||
const scoreEmoji = hs.score >= 90 ? '🟢' : hs.score >= 70 ? '🟡' : '🔴';
|
||||
const card = el('div.card', {}, [
|
||||
el('h3', {}, [`${scoreEmoji} Health `, el('span.data', { text: `${hs.score ?? '—'}/${hs.max ?? 100}` })]),
|
||||
]);
|
||||
if ((hs.issues || []).length) {
|
||||
card.append(el('div.muted', { style: 'margin-bottom:8px', text: '⚠️ ' + hs.issues.join(', ') + ' down' }));
|
||||
}
|
||||
} catch (e) { clear(host).append(el('div.empty', { text: 'Failed: ' + esc(e.message) })); }
|
||||
for (const sv of services) {
|
||||
card.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [
|
||||
el('b', { text: esc(sv.name) }),
|
||||
el('span', { text: sv.enabled ? 'enabled at boot' : 'not enabled at boot' }),
|
||||
]),
|
||||
el('span.badge' + (sv.active ? '.ok' : '.err'), { text: sv.active ? '🟢 ' + (sv.status || 'active') : '🔴 ' + (sv.status || 'down') }),
|
||||
]));
|
||||
}
|
||||
host.append(card);
|
||||
} catch (e) {
|
||||
host.append(el('div.card', {}, [el('div.muted', { text: 'Services/health unavailable: ' + esc(e.message) })]));
|
||||
}
|
||||
|
||||
// ── network ──────────────────────────────────────────────────────
|
||||
try {
|
||||
const net = await api.get(`${base}/network`);
|
||||
const ifaces = net.interfaces || [];
|
||||
const card = el('div.card', {}, [el('h3', { text: `📡 Network (${ifaces.length})` })]);
|
||||
if (!ifaces.length) card.append(el('div.empty', { text: 'No interfaces reported.' }));
|
||||
for (const i of ifaces) {
|
||||
const addr = (i.addresses || []).join(', ') || 'no address';
|
||||
const speed = i.up && i.speed ? ` · ${i.speed} Mbps` : '';
|
||||
card.append(el('div.item', {}, [
|
||||
el('div.meta', {}, [
|
||||
el('b', { text: esc(i.name) }),
|
||||
el('span', { text: addr + speed }),
|
||||
]),
|
||||
el('span.badge' + (i.up ? '.ok' : ''), { text: i.up ? '🟢 up' : '⚪ down' }),
|
||||
]));
|
||||
}
|
||||
host.append(card);
|
||||
} catch (e) {
|
||||
host.append(el('div.card', {}, [el('div.muted', { text: 'Network unavailable: ' + esc(e.message) })]));
|
||||
}
|
||||
|
||||
// ── security ─────────────────────────────────────────────────────
|
||||
try {
|
||||
const sec = await api.get(`${base}/security`);
|
||||
const good = (v) => ['Active', 'Running', 'Enabled'].includes(v);
|
||||
const bad = (v) => ['Stopped', 'Disabled'].includes(v);
|
||||
const row = (label, val) => el('div.item', {}, [
|
||||
el('div.meta', {}, [el('b', { text: label })]),
|
||||
el(`span.badge${good(val) ? '.ok' : bad(val) ? '.err' : '.warn'}`, { text: `${good(val) ? '🟢' : bad(val) ? '🔴' : '🟡'} ${val ?? '—'}` }),
|
||||
]);
|
||||
host.append(el('div.card', {}, [
|
||||
el('h3', { text: '🛡️ Security' }),
|
||||
row('Firewall', sec.firewall),
|
||||
row('SSH', sec.ssh_status),
|
||||
row('AppArmor', sec.apparmor),
|
||||
row('CrowdSec', sec.crowdsec),
|
||||
]));
|
||||
} catch (e) {
|
||||
host.append(el('div.card', {}, [el('div.muted', { text: 'Security status unavailable: ' + esc(e.message) })]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,15 @@ body.offline .offline-bar { display: block; }
|
|||
.queued-bar { display: none; background: var(--mind); color: #fff; font-size: .74rem; text-align: center; padding: 4px; }
|
||||
body.has-queue .queued-bar { display: block; }
|
||||
|
||||
/* ── emoji gauges (core/metrics.js) ────────────────────────────────
|
||||
* Bar colour mirrors the .badge severity classes so a gauge and its
|
||||
* matching badge always agree at a glance. */
|
||||
.gauge-bar { height: 5px; border-radius: 4px; background: var(--surf-2); margin-top: 8px; overflow: hidden; }
|
||||
.gauge-fill { height: 100%; border-radius: 4px; background: var(--muted); transition: width .3s; }
|
||||
.gauge-fill.ok { background: var(--root); }
|
||||
.gauge-fill.warn { background: var(--wall); }
|
||||
.gauge-fill.err { background: var(--boot); }
|
||||
|
||||
.empty { color: var(--muted); text-align: center; padding: 40px 16px; font-size: .85rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.mono-sm { font-family: var(--font-mono); font-size: .72rem; }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// Same-origin app assets → stale-while-revalidate. Box API (cross-origin) →
|
||||
// passthrough (the page handles offline reads/queue).
|
||||
|
||||
const VERSION = 'sbx-companion-v8';
|
||||
const VERSION = 'sbx-companion-v10';
|
||||
const SHELL = [
|
||||
'./', './index.html', './manifest.webmanifest',
|
||||
'./styles/charte.css', './styles/fonts.css',
|
||||
|
|
@ -15,7 +15,7 @@ const SHELL = [
|
|||
'./styles/fonts/space-grotesk-700.woff2', './styles/fonts/jetbrains-mono-400.woff2',
|
||||
'./styles/fonts/jetbrains-mono-700.woff2',
|
||||
'./core/app.js', './core/api.js', './core/store.js',
|
||||
'./core/registry.js', './core/auth.js', './core/ui.js',
|
||||
'./core/registry.js', './core/auth.js', './core/ui.js', './core/metrics.js',
|
||||
'./modules/index.json',
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user