<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>adbrent</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2313202B'/%3E%3Crect x='10' y='5' width='12' height='22' rx='3' fill='none' stroke='%23F4C534' stroke-width='2.5'/%3E%3C/svg%3E">
<style>
:root {
--bench: #E8EDEF; --surface: #F8FAFB; --ink: #13202B; --muted: #4E5E6B; --line: #C3CED6;
--tape: #F4C534; --tape-ink: #13202B; --free: #177A50; --stop: #B5371F; --warn: #8A5A00; --note: #1F5F99;
--focus: #13202B;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bench: #0E161C; --surface: #162029; --ink: #E7EEF2; --muted: #96A6B1; --line: #2A3843;
--free: #4CC38A; --stop: #FF8266; --warn: #E3B341; --note: #8FC7F2; --focus: #F4C534;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bench); color: var(--ink); font: 400 16px/1.5 var(--sans); padding: 26px 20px 56px; -webkit-font-smoothing: antialiased; }
main { max-width: 1040px; margin: 0 auto; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
header { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.brand { font: 800 20px/1 var(--sans); letter-spacing: -0.01em; }
.version { font: 400 13px var(--mono); color: var(--muted); }
.live { margin-left: auto; font-size: 14px; color: var(--muted); }
.live::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--free); margin-right: 8px; }
.live.down { color: var(--stop); font-weight: 600; } .live.down::before { background: var(--stop); }
h1 { font: 800 clamp(1.7rem, 4vw, 2.5rem)/1.05 var(--sans); letter-spacing: -0.025em; margin: 30px 0 6px; text-wrap: balance; }
.sub { color: var(--muted); min-height: 1.5em; }
h2 { font: 700 1.1rem/1.2 var(--sans); }
.head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 38px 0 12px; }
.head .pick { margin-left: auto; display: flex; gap: 6px; }
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.empty { padding: 22px 20px; color: var(--muted); }
.empty code { font-family: var(--mono); font-size: 14px; color: var(--ink); }
.dev { display: grid; grid-template-columns: minmax(150px, 1.1fr) 170px minmax(0, 2fr) auto; gap: 16px; align-items: center; padding: 16px 20px; border-top: 1px solid var(--line); }
.dev:first-child { border-top: 0; }
.model { font-weight: 650; overflow-wrap: anywhere; }
.serial { font: 400 12px/1.4 var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.tag { display: inline-block; font: 700 12px/1 var(--mono); padding: 6px 8px; border-radius: 3px; border: 1.5px dashed var(--muted); color: var(--muted); }
.tag.free { border-color: var(--free); color: var(--free); }
.tag.outside { border-color: var(--warn); color: var(--warn); }
.tag.off { border-style: solid; }
.tag.held { background: var(--tape); color: var(--tape-ink); border: 1.5px solid var(--tape-ink); transform: rotate(-1.5deg); box-shadow: 1.5px 2px 0 var(--ink); }
.what { min-width: 0; overflow-wrap: anywhere; }
.what .more { color: var(--muted); font-size: 14px; }
.what .more .time { font-family: var(--mono); font-size: 13px; }
.act { display: flex; gap: 6px; justify-content: flex-end; min-width: 108px; }
button { font: 600 13px var(--sans); border-radius: 7px; min-height: 34px; padding: 6px 12px; cursor: pointer; color: var(--ink); background: transparent; border: 1px solid var(--line); }
button:hover { border-color: var(--ink); }
button.danger { color: var(--stop); } button.danger:hover { border-color: var(--stop); }
button.sure { color: #fff; background: #B5371F; border-color: #B5371F; }
button[aria-pressed="true"] { color: var(--surface); background: var(--ink); border-color: var(--ink); }
.ev { display: grid; grid-template-columns: 78px 150px 190px minmax(0, 1fr); gap: 14px; align-items: baseline; padding: 11px 20px; border-top: 1px solid var(--line); font-size: 14px; }
.ev:first-child { border-top: 0; }
.ev time, .ev .who { font: 400 12.5px/1.5 var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.ev .kind { font-weight: 650; }
.ev .kind.no { color: var(--stop); } .ev .kind.warn { color: var(--warn); } .ev .kind.note { color: var(--note); }
.ev .text { overflow-wrap: anywhere; }
.ev .text .on { font-family: var(--mono); font-size: 12.5px; color: var(--muted); }
.ev.feedback .text { font-style: italic; }
.badge { font: 700 12px/1 var(--mono); padding: 5px 8px; border-radius: 3px; background: var(--tape); color: var(--tape-ink); border: 1.5px solid var(--tape-ink); }
.badge[hidden] { display: none; }
.debug { padding: 18px 20px; display: grid; gap: 8px; }
.debug .state { font-weight: 650; }
.debug .more { color: var(--muted); font-size: 14.5px; }
.debug h3 { font: 700 15px/1.3 var(--sans); margin-top: 8px; }
.debug ul { padding-left: 20px; display: grid; gap: 6px; font-size: 15px; }
.debug li::marker { color: var(--warn); }
footer { margin-top: 28px; color: var(--muted); font-size: 14px; }
footer code { font-family: var(--mono); font-size: 13px; color: var(--ink); }
@media (max-width: 760px) {
.dev { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
.dev .what { grid-column: 1 / -1; } .act { grid-column: 1 / -1; justify-content: flex-start; min-width: 0; }
.ev { grid-template-columns: 1fr; gap: 2px; }
}
</style>
</head>
<body>
<main>
<header>
<span class="brand">adbrent</span><span class="version" id="version"></span>
<span class="badge" id="badge" hidden>debug on</span>
<span class="live" id="live" role="status">connecting</span>
</header>
<h1 id="summary">Looking for devices</h1>
<p class="sub" id="sub"></p>
<div class="head"><h2 id="devices-title">Devices</h2></div>
<div class="panel" id="devices" role="list" aria-labelledby="devices-title"><div class="empty">Loading</div></div>
<div class="head">
<h2 id="debug-title">Debug mode</h2>
<div class="pick"><button type="button" id="debug-toggle">Turn debug on</button></div>
</div>
<div class="panel debug" id="debug" aria-labelledby="debug-title"><div class="more">Loading</div></div>
<div class="head">
<h2 id="log-title">What agents ran into</h2>
<div class="pick" role="group" aria-label="Show">
<button type="button" id="problems" aria-pressed="true">Problems</button>
<button type="button" id="notes" aria-pressed="false">Agent feedback</button>
<button type="button" id="commands" aria-pressed="false" hidden>Commands</button>
<button type="button" id="all" aria-pressed="false">Everything</button>
</div>
</div>
<div class="panel" id="log" role="list" aria-labelledby="log-title"><div class="empty">Loading</div></div>
<footer>This page is served to this machine only, and stops when you stop <code>adbrent serve</code>. Keep a device away from agents with <code>adbrent exclude <serial></code>.</footer>
<p class="sr" id="say" aria-live="polite"></p>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
const span = s => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : s < 86400 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}m` : `${Math.floor(s / 86400)}d ${Math.floor(s % 86400 / 3600)}h`;
let state = null; let skew = 0; let drawn = ""; let only = "problems"; const problem = e => e.kind.startsWith("refused") || ["forced", "error", "feedback", "taken back", "wait gave up"].includes(e.kind);
const command = e => ["adb", "adbrent", "tool call"].includes(e.kind);
const wanted = e => only === "all" || (only === "feedback" ? e.kind === "feedback" : only === "commands" ? command(e) : problem(e));
let armed = null; let disarm = 0;
const now = () => Math.floor(Date.now() / 1000) + skew;
function describe(d) {
if (d.rented_by) return ["held", "rented"];
if (d.state !== "device") return ["", d.state];
if (d.excluded) return ["off", "off limits"];
if (d.in_use_outside_adbrent.length) return ["outside", "in use outside"];
return ["free", "free"];
}
function device(d) {
const [cls, label] = describe(d);
const row = el("div", "dev"); row.setAttribute("role", "listitem");
const name = el("div");
name.append(el("div", "model", d.model || d.serial), el("div", "serial", d.serial));
const state = el("div"); state.append(el("span", `tag ${cls}`, label));
const what = el("div", "what");
if (d.rented_by) {
what.append(el("div", "", d.rented_by));
const more = el("div", "more");
const time = el("span", "time"); time.dataset.since = d.since;
more.append("for ", time, d.shared_with ? `, shared with ${plural(d.shared_with, "other session")}` : "");
what.append(more);
} else if (d.state !== "device") {
what.append(el("div", "more", d.state === "absent" ? "Rented, but not connected right now." : "Connected, but not ready for adb."));
} else if (d.excluded) {
what.append(el("div", "more", "You excluded it. Agents cannot see or use it."));
} else if (d.in_use_outside_adbrent.length) {
what.append(el("div", "", "Something outside adbrent is running on it"), el("div", "more", d.in_use_outside_adbrent.join("; ")));
} else {
what.append(el("div", "more", "The next agent that runs adb can have it."));
}
const act = el("div", "act");
if (d.rented_by) {
const button = (text, cls, key, go) => { const b = el("button", cls, text); b.type = "button"; b.dataset.key = `${key}:${d.serial}`; b.onclick = go; act.append(b); };
if (armed === d.serial) {
button("Yes, take it back", "sure", "sure", () => takeBack(d));
button("Keep", "", "keep", () => { armed = null; render(); });
} else {
button("Take back", "danger", "take", () => { armed = d.serial; clearTimeout(disarm); disarm = setTimeout(() => { armed = null; render(); }, 8000); render(`sure:${d.serial}`); });
}
}
row.append(name, state, what, act);
return row;
}
function entry(e) {
const kind = e.kind.startsWith("refused") || e.kind === "error" || (e.kind === "adb" && (e.detail || "").includes("[stopped")) ? "no" : e.kind === "forced" || e.kind === "wait gave up" ? "warn" : e.kind === "feedback" ? "note" : "";
const row = el("div", "ev" + (e.kind === "feedback" ? " feedback" : "")); row.setAttribute("role", "listitem");
const time = el("time"); time.dataset.t = e.t;
const text = el("div", "text");
if (e.serial) text.append(el("span", "on", e.serial + (e.detail ? " " : "")));
text.append(e.detail || "");
row.append(time, el("div", `kind ${kind}`, e.kind), el("div", "who", e.who), text);
return row;
}
function render(focusKey) {
if (!state) return;
const log = state.log.filter(wanted).slice(0, 80);
const next = JSON.stringify([state.devices, log, only, armed, state.debug, state.report]);
if (next === drawn && !focusKey) return tick();
drawn = next;
const focused = focusKey || (document.activeElement && document.activeElement.dataset ? document.activeElement.dataset.key : null);
$("version").textContent = `v${state.version}`;
const here = state.devices.filter(d => d.state !== "absent");
const rented = state.devices.filter(d => d.rented_by).length;
const outside = here.filter(d => !d.rented_by && d.in_use_outside_adbrent.length).length;
const off = here.filter(d => !d.rented_by && d.excluded).length;
const free = here.filter(d => !d.rented_by && !d.excluded && d.state === "device" && !d.in_use_outside_adbrent.length).length;
$("summary").textContent = here.length ? `${rented} of ${plural(here.length, "device")} rented` : "No devices connected";
$("sub").textContent = here.length
? [`${free} free`, outside ? `${outside} in use outside adbrent` : "", off ? `${off} off limits` : ""].filter(Boolean).join(", ") + "."
: "Plug in a device or start an emulator. It appears here as soon as adb sees it.";
$("devices").replaceChildren(...(state.devices.length ? state.devices.map(device) : [el("div", "empty", "Nothing to show yet.")]));
debugPanel();
const none = only === "commands" ? "No commands recorded yet. They appear as agents run adb."
: only === "feedback" ? "No feedback yet. Agents leave it with `adbrent feedback`."
: only === "all" ? "Nothing has happened yet."
: "No problems. Refusals, forced rentals, errors and agent feedback appear here.";
$("log").replaceChildren(...(log.length ? log.map(entry) : [el("div", "empty", none)]));
tick();
if (focused) {
const again = document.querySelector(`[data-key="${focused}"]`) || document.querySelector(`[data-key="take:${focused.split(":")[1]}"]`);
if (again) again.focus();
}
}
const picks = { problems: "problems", notes: "feedback", commands: "commands", all: "all" };
const press = () => Object.entries(picks).forEach(([id, name]) => $(id).setAttribute("aria-pressed", name === only));
function debugPanel() {
const report = state.report || { summary: [], look: [], notes: [], trace: [] };
const parts = [el("div", "state", state.debug ? "On: everything agents do at the desk is being recorded." : "Off: only problems are recorded.")];
parts.push(el("div", "more", state.debug
? "Rentals, releases, waits and every adb and adbrent command are logged, with who ran them. Command lines can include what agents type into apps, so turn it off when you are done."
: "Refusals, forced rentals, errors and agent feedback are always kept. Turn debug on to also record rentals, hold times, waits and every command agents run."));
report.summary.forEach(line => parts.push(el("div", "", line)));
if (report.look.length) {
const list = el("ul"); report.look.forEach(line => list.append(el("li", "", line)));
parts.push(el("h3", "", "Worth a look"), list);
}
if (report.trace.length) parts.push(el("div", "more", "From the trace: " + report.trace.join(" ")));
$("debug").replaceChildren(...parts);
$("debug-toggle").textContent = state.debug ? "Turn debug off" : "Turn debug on";
$("badge").hidden = !state.debug;
$("commands").hidden = !state.debug;
if (!state.debug && only === "commands") { only = "problems"; press(); }
}
async function toggleDebug() {
const to = state && state.debug ? "off" : "on";
await fetch(`/api/debug/${to}`, { method: "POST", headers: { "X-Adbrent": "1" } }).catch(() => null);
$("say").textContent = `Debug mode is ${to}.`;
await refresh();
}
function tick() {
document.querySelectorAll("[data-since]").forEach(e => { e.textContent = span(Math.max(0, now() - e.dataset.since)); });
document.querySelectorAll("[data-t]").forEach(e => { e.textContent = `${span(Math.max(0, now() - e.dataset.t))} ago`; });
}
async function takeBack(d) {
armed = null; clearTimeout(disarm);
const reply = await fetch(`/api/release/${encodeURIComponent(d.serial)}`, { method: "POST", headers: { "X-Adbrent": "1" } }).then(r => r.json()).catch(() => null);
$("say").textContent = reply && reply.released ? `${d.model || d.serial} was taken back and is free.` : `${d.model || d.serial} could not be taken back.`;
await refresh();
}
async function refresh() {
try {
const fresh = await (await fetch("/api/state")).json();
skew = fresh.now - Math.floor(Date.now() / 1000);
state = fresh;
$("live").className = "live"; $("live").textContent = "live";
render();
} catch (error) {
$("live").className = "live down"; $("live").textContent = "adbrent serve is not running";
}
}
const pick = which => { only = which; press(); render(); };
Object.entries(picks).forEach(([id, name]) => { $(id).dataset.key = `pick:${name}`; $(id).onclick = () => pick(name); });
$("debug-toggle").dataset.key = "debug:toggle"; $("debug-toggle").onclick = toggleDebug;
refresh();
setInterval(refresh, 3000);
setInterval(tick, 1000);
})();
</script>
</body>
</html>