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
74 changes: 60 additions & 14 deletions agent_usage_manager/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@
td.cmd { white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 380px; color: #8b949e; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
/* The all-nowrap table measures ~1.5kpx wide, so between the phone stack
(≤700px) and a full-width window .wrap scrolls horizontally and the last
column — the only kill verb — sits off-screen. Pin the actions column to
the right edge so it's reachable at every width. The nowrap launchctl hint
inflates the column's width, so right-align too: the verbs hug the pinned
edge instead of the cell's left. Phone rows are stacked blocks with no
horizontal overflow, so this is desk/mid-width only. */
@media (min-width: 701px) {
.c-act { position: sticky; right: 0; background: #0d1117;
border-left: 1px solid #21262d; text-align: right; }
tr.child td.c-act { background: #0b0f14; }
}
.label { background: #1f6feb33; color: #79c0ff; padding: 1px 7px;
border-radius: 10px; font-size: 12px; }
.svc { background: #bb800933; color: #d29922; padding: 1px 7px; margin-left: 5px;
Expand All @@ -44,6 +56,9 @@
.kids { color: #79c0ff; font-size: 11px; margin-left: 5px; cursor: pointer;
border-bottom: 1px dotted #30363d; }
.kids:hover { color: #a5d6ff; }
/* Units are hidden on desk — the column headers already say "cpu %"/"mem MB".
The phone stack drops the headers, so there the numbers carry their own. */
.unit { display: none; }
.spark { display: block; margin-top: 3px; }
.spark polyline { fill: none; stroke: #d29922; stroke-width: 1.2; }
.spark line { stroke: #21262d; stroke-width: 1; }
Expand Down Expand Up @@ -72,11 +87,19 @@
@media (max-width: 700px) {
th, td { padding: 6px 8px; }
.c-status, .c-up, .c-cmd, .c-pid { display: none; } /* keep agent/cpu/mem/actions */
/* …but a child row's cmdline IS its identity (on desk it's what tells the
zsh wrapper from the helper from the MCP server) — keep it, wrapped. */
tr.child td.c-cmd { display: block; max-width: none; white-space: normal;
overflow-wrap: anywhere; }
.unit { display: inline; } /* headers are gone below — numbers self-label */
header { padding: 10px 12px; gap: 10px; }
#stale { padding: 6px 12px; }
/* visually truncate the launchctl hint; click-to-copy still copies the full text */
code.hint { display: inline-block; max-width: 110px; overflow: hidden;
text-overflow: ellipsis; vertical-align: middle; }
/* Truncate the launchctl hint from the LEFT: every supervised row starts
with the identical 'launchctl bootout gui/501/', so clipping the right
end renders every row's chip the same and ellipses the job label — the
one part that differs. Click-to-copy still copies the full text. */
code.hint { display: inline-block; max-width: 240px; overflow: hidden;
text-overflow: ellipsis; vertical-align: middle; direction: rtl; }
/* The flat table measures ~554px, so at 375px the kill verb sits off-screen.
Stack each row instead — agent+badges / cpu·mem / actions — with column
headers gone (they no longer map to anything) and the sparkline hidden
Expand Down Expand Up @@ -109,7 +132,7 @@ <h1>agent usage manager</h1>
<th>agent</th><th class="c-pid">pid</th><th class="c-status">status</th>
<th class="num">cpu %</th><th class="num">mem MB</th>
<th class="num gpu">gpu MB</th><th class="num c-up">uptime</th>
<th class="c-cmd">command</th><th></th>
<th class="c-cmd">command</th><th class="c-act"></th>
</tr>
</thead>
</table>
Expand Down Expand Up @@ -234,21 +257,44 @@ <h1>agent usage manager</h1>
}

function copyHint(el) {
navigator.clipboard?.writeText(el.textContent).then(() => {
// A denied clipboard write (permission prompt dismissed, unfocused document,
// no clipboard API) must not fail silently — the operator would walk away
// believing they hold a bootout command they don't have. Say so, then select
// the chip text so a manual ⌘C still works (an active selection also pauses
// auto-refresh — see refresh() — so the re-render can't yank it mid-copy).
const fail = () => {
const prev = el.textContent;
el.textContent = "copied ✓";
setTimeout(() => { el.textContent = prev; }, 1000);
});
el.textContent = "copy failed — press ⌘C";
setTimeout(() => {
// a refresh() re-render may have replaced the row mid-flash
if (!el.isConnected) return;
el.textContent = prev;
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}, 1500);
};
try {
const w = navigator.clipboard && navigator.clipboard.writeText(el.textContent);
if (!w) { fail(); return; }
w.then(() => {
const prev = el.textContent;
el.textContent = "copied ✓";
setTimeout(() => { el.textContent = prev; }, 1000);
}).catch(fail);
} catch (e) { fail(); }
}

function childRows(tree) {
return tree.filter(c => c.depth > 0).map(c => `<tr class="child">
<td style="padding-left:${12 + c.depth * 14}px">↳ ${esc(c.name)}</td>
<td class="c-pid">${c.pid}</td><td class="c-status"></td>
<td class="num">${c.cpu_percent.toFixed(1)}</td>
<td class="num">${c.mem_mb.toFixed(0)}</td>
<td class="num">${c.cpu_percent.toFixed(1)}<span class="unit">%</span></td>
<td class="num">${c.mem_mb.toFixed(0)}<span class="unit"> MB</span></td>
<td class="num gpu"></td><td class="num c-up"></td>
<td class="cmd c-cmd" title="${esc(c.cmdline)}">${esc(c.cmdline)}</td><td></td>
<td class="cmd c-cmd" title="${esc(c.cmdline)}">${esc(c.cmdline)}</td><td class="c-act"></td>
</tr>`).join("");
}

Expand Down Expand Up @@ -312,12 +358,12 @@ <h1>agent usage manager</h1>
<td><span class="label">${esc(x.label)}</span>${svc}${flag}${kids}</td>
<td class="c-pid">${x.pid}</td>
<td class="c-status"><span class="dot ${x.alive?'live':'dead'}"></span>${esc(x.status)}</td>
<td class="num" title="${x.cpu_percent.toFixed(0)}% of one core · ${(x.cpu_percent/cpus).toFixed(0)}% of all ${cpus} cores">${x.cpu_percent.toFixed(1)}${spark(x.trend)}</td>
<td class="num">${x.mem_mb.toFixed(0)}</td>
<td class="num" title="${x.cpu_percent.toFixed(0)}% of one core · ${(x.cpu_percent/cpus).toFixed(0)}% of all ${cpus} cores">${x.cpu_percent.toFixed(1)}<span class="unit">%</span>${spark(x.trend)}</td>
<td class="num">${x.mem_mb.toFixed(0)}<span class="unit"> MB</span></td>
<td class="num gpu">${x.gpu_mem_mb==null?'—':x.gpu_mem_mb.toFixed(0)}</td>
<td class="num c-up">${dur(x.uptime_s)}</td>
<td class="cmd c-cmd" title="${esc(x.cmdline)}">${esc(x.cmdline)}</td>
<td>${actions}</td>
<td class="c-act">${actions}</td>
</tr>`;
}

Expand Down
37 changes: 24 additions & 13 deletions docs/design/LLD.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# agent-usage-manager — Low-Level Design

**Refreshed:** 2026-08-19 (0.2.5 — `launchd_labels:`, `test-alert`, missing-config
**Refreshed:** 2026-08-25 (frontend dogfood fixes — clipboard-denial path,
sticky actions column, phone units/child-cmdline/left-truncated hint);
previously 2026-08-19 (0.2.5 — `launchd_labels:`, `test-alert`, missing-config
surfacing, kill-confirm/token-prompt wording, `list` flags caveat).

Code layout: one FastAPI module (`agent_usage_manager/app.py`), one CLI module
Expand Down Expand Up @@ -314,18 +316,18 @@ Applies to every request:

## 6. Frontend (static/index.html)

Single inline `<script>` (index.html:119-418), no framework.
Single inline `<script>` (index.html:140-460), no framework.

- **Poll loop:** `refresh()` every 3s (index.html:417-418). On fetch failure
- **Poll loop:** `refresh()` every 3s (index.html:459-460). On fetch failure
the table is kept but dimmed with a stale banner (`body.stale`,
index.html:343-351) — never blank data someone might kill from. Skips
index.html:378-387) — never blank data someone might kill from. Skips
re-render while text is selected (copying a launchctl hint).
- **Keyed rendering:** one `<tbody>` per agent, keyed by `pid:create_time`
(`rowKey`; the `bodies`/`expanded` maps) so a recycled pid can't inherit
another agent's row; rows are rewritten in place, tbodys sorted
flagged-first (hot/churn/leak/idle, then the server's CPU-desc order within
a group) but **only reordered when the pointer is off the table**
(`overTable`, index.html:137-140, 406-410) so the kill button can't shift
(`overTable`, index.html:159-161, 450-454) so the kill button can't shift
under the cursor. The header carries the flag counts (`1 hot · 4 idle`).
- **Kill flow:** `kill(rowKey, force)` — the row's payload is looked up in
`lastRows` (keyed by `pid:create_time`, rebuilt each refresh) so the
Expand All @@ -342,19 +344,28 @@ Single inline `<script>` (index.html:119-418), no framework.
✓ and ✗ result lines auto-clear on a `setTimeout` (longer for the ✗
partial-failure, which needs reading time) so no outcome sticks forever.
- **Tree expansion:** `toggleTree`/`loadTree`/`renderTree`
(index.html:255-276) fetch `/api/tree/{pid}` and insert indented child rows;
expanded subtrees are re-fetched on every refresh (index.html:414).
(index.html:297-318) fetch `/api/tree/{pid}` and insert indented child rows;
expanded subtrees are re-fetched on every refresh (index.html:456).
- **Rendering details:** `esc()` HTML-escapes all host data (injection into a
page with a kill endpoint is a real risk, index.html:143-148); `spark()`
page with a kill endpoint is a real risk, index.html:165-170); `spark()`
draws the SVG sparkline; badges hot/idle/churn/leak with explanatory
tooltips (index.html:291-299; the launchd badge's tooltip follows the
tooltips (index.html:333-341; the launchd badge's tooltip follows the
payload's `keepalive` — "won't stick" only for KeepAlive jobs); supervised
rows swap kill buttons for a
click-to-copy `launchctl bootout` hint; GPU column hidden when nothing
reports GPU (`body.hide-gpu`); protected rows get disabled buttons (the
server refuses regardless). At ≤700px rows stack (agent+badges / cpu·mem /
click-to-copy `launchctl bootout` hint whose denial path is loud — a
rejected clipboard write flashes "copy failed" on the chip and selects the
command for a manual ⌘C, never a silent no-op; GPU column hidden when
nothing reports GPU (`body.hide-gpu`); protected rows get disabled buttons
(the server refuses regardless). The actions column is `position: sticky`
on the right above the phone breakpoint (`.c-act`, index.html:35-39), so
the kill verb stays on-screen while the ~1.5kpx-wide nowrap table scrolls
horizontally at mid-width. At ≤700px rows stack (agent+badges / cpu·mem /
actions), the sparkline hides, and the column headers drop, so the
kill verb is on-screen at phone width.
kill verb is on-screen at phone width; with the headers gone the cpu/mem
numbers carry their own units (`.unit`), child rows keep their cmdline
(their only identity, `tr.child td.c-cmd`), and the launchd hint truncates
from the left (`direction: rtl`) so the job label — not the identical
`launchctl bootout gui/501/` prefix — stays visible.

## 7. Error handling conventions

Expand Down
24 changes: 24 additions & 0 deletions tests/test_smoke.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re

import pytest
from fastapi.testclient import TestClient
Expand Down Expand Up @@ -124,6 +125,29 @@ def test_index_serves(client):
assert client.get("/").status_code == 200


def test_index_frontend_regressions(client):
# Static contracts behind the 2026-08-24 dogfood frontend fixes (there is
# no JS test runner here; these pin the served page's behavior-bearing
# markup so the fixed paths can't silently regress).
html = client.get("/").text
# A denied clipboard write must say so, not fail silently (bug #1).
copy_fn = re.search(r"function copyHint\(.*?\n\}", html, re.S)
assert copy_fn, "copyHint not found in served page"
assert ".catch(" in copy_fn.group(0)
assert "copy failed" in copy_fn.group(0)
# Mid-width dead zone (bug #2): the actions column is sticky-pinned to the
# right edge so the kill verb is reachable while .wrap scrolls.
assert "position: sticky" in html
assert 'class="c-act"' in html
# Phone stack (F1/F2/F3): numbers carry their units, child rows keep the
# cmdline that is their only identity, and the launchd hint clips its
# identical 'launchctl …' prefix (rtl) instead of the job label.
assert '<span class="unit">%</span>' in html
assert '<span class="unit"> MB</span>' in html
assert "tr.child td.c-cmd" in html
assert "direction: rtl" in html


def test_kill_pid1_refused(client):
# PID 1 is always protected (token supplied, so it's the target check firing)
assert client.post("/api/kill/1", headers=TOKEN).status_code == 403
Expand Down
Loading