Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ jobs:
tests/test_no_direct_get_store_in_routes.py \
tests/test_moat_bug_class_v3_event_shape_gate.py \
tests/test_runtime_count_copy_sync.py \
tests/test_tab_template_page_wrapper.py \
tests/test_grok_bot_runtime_wiring.py \
tests/test_lovable_runtime_wiring.py \
tests/test_delegated_usage.py \
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Unreleased

### Fixed: Guard panel drawn on top of every tab, stuck on "Loading..." (0.12.806 regression) (2026-09-03)
- **Why:** 0.12.806 shipped the Guard tab (#5470) wrapped in `<div id="guard" class="tab-content">`. The dashboard hides panels through `.page` and `#page-<tab>`, and it has no rule for `tab-content`, so the Guard cards rendered above Home, Agents, Activity, Sessions, Cost and every other tab, frozen on "Loading..." because the loader keyed on a `#page-guard` element that did not exist. Selecting any runtime also hid the Guard nav item, since `guard` was in the per-runtime tab list but in no capability map and not marked node-wide.
- **What:** (1) guard.html is a normal `.page` panel with id `page-guard`. (2) The 38 Guard stylesheet rules are scoped to `#page-guard` instead of the old id, so buttons, pills and tables style again. (3) `guard` joins `_CM_NODE_TABS`, next to Approvals and Alerts, so the nav item survives the runtime switcher; Guard ranks sessions from every runtime.
- **Verified:** new `tests/test_tab_template_page_wrapper.py` (in the CI file list) fails on the shipped template and on an orphaned CSS scope, passes on the fix; browser check on a source boot: Home and Cost render without the Guard overlay, Guard loads its three cards styled, nav item present with Claude Code selected.

### Fix: sealed uploads are gzip-compressed before encryption, cutting the per-minute snapshot about 4x (carries #5472) (2026-09-03)
- **Why:** the wire audit of 0.12.802 measured a 1.2 to 1.4 MB sealed system snapshot every minute, about 2 GB a day per node. Ciphertext does not compress, and the snapshot cannot be skipped when idle (36 of its 80 keys change every cycle), so the only place to save the bytes is before the encryption step.
- **What:** `encrypt_payload` gzips the JSON before AES-GCM, but only once a heartbeat response has advertised `caps.blob_gzip`; until then every blob is byte-for-byte what it was. The reader recognises gzip by its magic bytes, which no JSON document can start with, so `decrypt_payload` and the published browser decryptor `cm-e2e.js` read both forms. Blobs under 4 KB (session titles) are never compressed. `CLAWMETRY_BLOB_GZIP=1|0` overrides the negotiation for self-hosted servers. The cloud advertises the codec per node and withholds it from any node with a paired desk device, whose firmware cannot inflate (clawmetry-cloud #2265, after this release is pinned).
Expand Down
76 changes: 38 additions & 38 deletions clawmetry/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -3408,89 +3408,89 @@ body.has-profile-menu #logout-btn { display: none !important; }
pills that were plain text. These definitions follow the existing
.inv-table / .card idioms and the shared token set, so Guard reads as
part of the dashboard rather than a bolted-on page.
Scoped to #guard so short names cannot collide with other tabs. */
#guard .section-header { margin-bottom: 18px; }
Scoped to #page-guard so short names cannot collide with other tabs. */
#page-guard .section-header { margin-bottom: 18px; }
/* No global h2 rule exists and .section-header is used only here, so the tab
title inherited the browser default and rendered near-black on the dark
ground. Matches .inv-heading, the house style for a tab title. */
#guard .section-header h2 {
#page-guard .section-header h2 {
margin: 0; font-size: 19px; font-weight: 600; color: var(--text-primary);
letter-spacing: -0.01em;
}
#guard .section-sub { color: var(--text-muted); font-size: 13px; line-height: 1.55; margin: 6px 0 0; max-width: 78ch; }
#guard .card { margin-bottom: 16px; }
#guard .card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
#guard .card-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text-primary); }
#guard .card-head .btn { margin-left: auto; }
#guard .card-head .btn ~ .btn { margin-left: 0; }
#page-guard .section-sub { color: var(--text-muted); font-size: 13px; line-height: 1.55; margin: 6px 0 0; max-width: 78ch; }
#page-guard .card { margin-bottom: 16px; }
#page-guard .card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
#page-guard .card-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text-primary); }
#page-guard .card-head .btn { margin-left: auto; }
#page-guard .card-head .btn ~ .btn { margin-left: 0; }

#guard .data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
#guard .data-table thead th {
#page-guard .data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
#page-guard .data-table thead th {
text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-muted); font-weight: 600; padding: 8px 12px; white-space: nowrap;
border-bottom: 1px solid var(--border-primary);
}
#guard .data-table tbody td {
#page-guard .data-table tbody td {
padding: 10px 12px; border-bottom: 1px solid var(--border-secondary);
color: var(--text-secondary); vertical-align: middle;
}
#guard .data-table tbody tr:last-child td { border-bottom: none; }
#guard .data-table tbody tr:hover { background: var(--bg-hover); }
#page-guard .data-table tbody tr:last-child td { border-bottom: none; }
#page-guard .data-table tbody tr:hover { background: var(--bg-hover); }
/* Money and counts line up column-wise. */
#guard .data-table td:nth-child(n+4) { font-variant-numeric: tabular-nums; }
#page-guard .data-table td:nth-child(n+4) { font-variant-numeric: tabular-nums; }

#guard .empty-state {
#page-guard .empty-state {
padding: 22px 12px; text-align: center; color: var(--text-muted);
font-size: 13px; background: var(--bg-primary);
border: 1px dashed var(--border-primary); border-radius: 8px;
}

/* Status pills. pill-ok / pill-warn carry SEMANTIC state (running, flagged),
which is deliberately separate from the accent colour. */
#guard .pill {
#page-guard .pill {
display: inline-block; font-size: 11px; font-weight: 600; line-height: 1.6;
padding: 1px 9px; border-radius: 999px; white-space: nowrap;
background: var(--bg-secondary); color: var(--text-muted);
border: 1px solid var(--border-secondary);
}
#guard .pill-ok { background: rgba(34,197,94,0.13); color: var(--text-success); border-color: transparent; }
#guard .pill-warn { background: rgba(245,158,11,0.15); color: var(--text-warning); border-color: transparent; }
#guard .pill-crit { background: rgba(239,68,68,0.15); color: var(--text-error); border-color: transparent; }
#page-guard .pill-ok { background: rgba(34,197,94,0.13); color: var(--text-success); border-color: transparent; }
#page-guard .pill-warn { background: rgba(245,158,11,0.15); color: var(--text-warning); border-color: transparent; }
#page-guard .pill-crit { background: rgba(239,68,68,0.15); color: var(--text-error); border-color: transparent; }

#guard .btn {
#page-guard .btn {
font: inherit; font-size: 12px; font-weight: 500; cursor: pointer;
padding: 5px 11px; border-radius: 7px;
background: var(--button-bg); color: var(--text-secondary);
border: 1px solid var(--border-primary); transition: background 0.12s, color 0.12s;
}
#guard .btn:hover { background: var(--button-hover); color: var(--text-primary); }
#guard .btn:focus-visible { outline: 2px solid var(--text-accent); outline-offset: 2px; }
#guard .btn-sm { font-size: 12px; padding: 4px 10px; }
#guard .btn-xs { font-size: 11px; padding: 3px 8px; border-radius: 6px; }
#guard .btn-primary { background: var(--bg-accent); border-color: var(--bg-accent); color: #fff; }
#guard .btn-primary:hover { filter: brightness(1.08); color: #fff; }
#page-guard .btn:hover { background: var(--button-hover); color: var(--text-primary); }
#page-guard .btn:focus-visible { outline: 2px solid var(--text-accent); outline-offset: 2px; }
#page-guard .btn-sm { font-size: 12px; padding: 4px 10px; }
#page-guard .btn-xs { font-size: 11px; padding: 3px 8px; border-radius: 6px; }
#page-guard .btn-primary { background: var(--bg-accent); border-color: var(--bg-accent); color: #fff; }
#page-guard .btn-primary:hover { filter: brightness(1.08); color: #fff; }
/* Kill is irreversible; it reads as destructive before it is pressed. */
#guard .btn-danger { color: var(--text-error); border-color: rgba(239,68,68,0.35); background: transparent; }
#guard .btn-danger:hover { background: rgba(239,68,68,0.12); color: var(--text-error); }
#guard td .btn + .btn, #guard td .btn + .muted { margin-left: 5px; }
#page-guard .btn-danger { color: var(--text-error); border-color: rgba(239,68,68,0.35); background: transparent; }
#page-guard .btn-danger:hover { background: rgba(239,68,68,0.12); color: var(--text-error); }
#page-guard td .btn + .btn, #page-guard td .btn + .muted { margin-left: 5px; }

#guard .muted { color: var(--text-faint); font-size: 12px; }
#page-guard .muted { color: var(--text-faint); font-size: 12px; }

#guard .form-row { display: flex; align-items: center; gap: 12px; margin-bottom: 9px; }
#guard .form-row > label { flex: 0 0 190px; font-size: 12px; color: var(--text-muted); }
#guard .form-row input, #guard .form-row select {
#page-guard .form-row { display: flex; align-items: center; gap: 12px; margin-bottom: 9px; }
#page-guard .form-row > label { flex: 0 0 190px; font-size: 12px; color: var(--text-muted); }
#page-guard .form-row input, #page-guard .form-row select {
font: inherit; font-size: 13px; padding: 5px 9px; border-radius: 7px;
background: var(--bg-primary); color: var(--text-primary);
border: 1px solid var(--border-primary);
}
#guard #guard-policy-form {
#page-guard #guard-policy-form {
background: var(--bg-primary); border: 1px solid var(--border-primary);
border-radius: 10px; padding: 16px; margin-bottom: 14px;
}
#guard .gp-step { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-secondary); }
#page-guard .gp-step { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-secondary); }

#guard .banner {
#page-guard .banner {
display: flex; gap: 10px; padding: 11px 14px; border-radius: 9px;
font-size: 13px; margin-bottom: 16px; border: 1px solid transparent;
}
#guard .banner-info { background: var(--bg-warning); color: var(--text-warning); border-color: rgba(245,158,11,0.3); }
#page-guard .banner-info { background: var(--bg-warning); color: var(--text-warning); border-color: rgba(245,158,11,0.3); }
5 changes: 4 additions & 1 deletion clawmetry/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12167,7 +12167,10 @@ var _CM_CAP_TABS = {
// inside Memory/Skills would be unreachable because the whole tab was
// hidden by _cmApplyRuntimeTabVisibility whenever a non-OpenClaw runtime
// was selected in the top-of-page runtime dropdown.
var _CM_NODE_TABS = ['alerts','notifications','security','approvals','memory','skills'];
// guard: one Guard view ranks running sessions from EVERY runtime by spend
// at risk, and policies apply node-wide. It was in _CM_RT_ALL_TABS but in
// no capability map, so selecting any runtime hid it (0.12.806 field hit).
var _CM_NODE_TABS = ['alerts','notifications','security','approvals','guard','memory','skills'];
// Every togglable sidebar tab (so switching runtimes RE-SHOWS what a prior one
// hid). overview is never togglable.
var _CM_RT_ALL_TABS = ['flow','brain','models','tracing','turn-anatomy',
Expand Down
2 changes: 1 addition & 1 deletion clawmetry/templates/tabs/guard.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div id="guard" class="tab-content">
<div class="page" id="page-guard">
<div class="section-header">
<h2 data-i18n="guard.title">Guard</h2>
<p class="section-sub" data-i18n="guard.sub">
Expand Down
79 changes: 79 additions & 0 deletions tests/test_tab_template_page_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Every tab template must be a `.page` panel the tab switcher can hide.

The dashboard shows exactly one `<div class="page" id="page-<tab>">` at a
time: `.page { display:none }` and `.page.active { display:block }`, and
`switchTab(name)` toggles `.active` on `#page-<name>`. A template wrapped
in any other class is never hidden, so it renders on TOP of every tab.

That is precisely what shipped in 0.12.806 (#5367): guard.html opened with
`<div id="guard" class="tab-content">`, a class this app has no CSS or JS
for. Every tab (Agents, Activity, Sessions, Cost, Quality, ...) showed the
Guard cards stuck on "Loading..." above its own content, and the Guard
loader never fired because `#page-guard` did not exist.

Second half of the same field hit: `guard` sat in `_CM_RT_ALL_TABS`
(togglable per runtime) but in neither `_CM_NODE_TABS` nor any capability
map, so selecting any runtime hid the Guard nav item entirely.

Acceptance:
pytest tests/test_tab_template_page_wrapper.py -q
"""
from __future__ import annotations

import pathlib
import re

ROOT = pathlib.Path(__file__).resolve().parent.parent
TABS_DIR = ROOT / "clawmetry" / "templates" / "tabs"
APP_JS = ROOT / "clawmetry" / "static" / "js" / "app.js"


def test_every_tab_template_declares_a_page_panel() -> None:
bad = []
for tpl in sorted(TABS_DIR.glob("*.html")):
text = tpl.read_text(encoding="utf-8")
if 'id="page-' not in text:
bad.append(f"{tpl.name}: no <div class=\"page\" id=\"page-...\"> wrapper")
if "tab-content" in text:
bad.append(f"{tpl.name}: uses class=\"tab-content\", which nothing hides")
assert not bad, (
"Tab templates that the switcher cannot hide (they will render on top of "
"every other tab):\n" + "\n".join(f" {b}" for b in bad)
)


def test_guard_template_is_the_guard_page() -> None:
text = (TABS_DIR / "guard.html").read_text(encoding="utf-8")
first_div = re.search(r"<div[^>]*>", text)
assert first_div is not None
tag = first_div.group(0)
assert 'class="page"' in tag and 'id="page-guard"' in tag, tag


def test_guard_nav_is_node_wide() -> None:
"""Guard ranks sessions from every runtime, so its nav item must survive
the per-runtime tab filter (`_cmApplyRuntimeTabVisibility`)."""
js = APP_JS.read_text(encoding="utf-8")
m = re.search(r"^var _CM_NODE_TABS = \[(.*?)\];", js, re.MULTILINE)
assert m, "_CM_NODE_TABS not found in app.js"
node_tabs = re.findall(r"'([^']+)'", m.group(1))
assert "guard" in node_tabs, node_tabs
m2 = re.search(r"^var _CM_RT_ALL_TABS = \[(.*?)\];", js, re.MULTILINE | re.DOTALL)
assert m2 and "'guard'" in m2.group(1), "guard must stay in _CM_RT_ALL_TABS so it re-shows on runtime switch"


def test_tab_css_scopes_target_an_id_that_exists() -> None:
"""A stylesheet block scoped to `#<stem> ...` is dead the moment the
template's wrapper is renamed to `id="page-<stem>"`. Guard's 38 rules were
scoped to `#guard` while the panel became `#page-guard`, leaving every
button and pill unstyled."""
css = (ROOT / "clawmetry" / "static" / "css" / "dashboard.css").read_text(encoding="utf-8")
orphaned = []
for tpl in sorted(TABS_DIR.glob("*.html")):
stem = tpl.stem
if not re.search(r"#" + re.escape(stem) + r"(?![-\w])", css):
continue
text = tpl.read_text(encoding="utf-8")
if f'id="{stem}"' not in text:
orphaned.append(f"{tpl.name}: dashboard.css scopes rules to #{stem} but no element has id=\"{stem}\"")
assert not orphaned, "\n".join(orphaned)
Loading