<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ribir Debug Server</title>
<style>
:root {
color-scheme: light dark;
--bg: #f6f7fb;
--bg-accent: #e8f0ff;
--panel: #ffffff;
--panel-soft: rgba(17, 24, 39, 0.04);
--border: rgba(15, 23, 42, 0.12);
--text: #0f172a;
--muted: rgba(30, 41, 59, 0.72);
--accent: #2563eb;
--accent-weak: rgba(37, 99, 235, 0.16);
--danger: #dc2626;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.06);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b0f16;
--bg-accent: #0c1b34;
--panel: #0f172a;
--panel-soft: rgba(148, 163, 184, 0.08);
--border: rgba(148, 163, 184, 0.2);
--text: #e2e8f0;
--muted: rgba(226, 232, 240, 0.7);
--accent: #60a5fa;
--accent-weak: rgba(96, 165, 250, 0.18);
--danger: #f87171;
--shadow: 0 10px 28px rgba(2, 6, 23, 0.55), 0 2px 8px rgba(2, 6, 23, 0.4);
}
}
* {
box-sizing: border-box;
}
body {
font-family: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
margin: 0;
color: var(--text);
background:
radial-gradient(1200px 500px at 0% -10%, var(--bg-accent), transparent 60%),
radial-gradient(1000px 400px at 100% -20%, rgba(37, 99, 235, 0.12), transparent 60%),
var(--bg);
}
header {
padding: 10px 16px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 30;
background: color-mix(in srgb, var(--bg) 78%, transparent);
backdrop-filter: blur(12px);
}
header h1 {
font-size: 16px;
margin: 0;
font-weight: 600;
}
.pill {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 12px;
background: var(--panel-soft);
}
main {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 14px;
padding: 12px;
max-width: 100%;
margin: 0;
align-items: start;
}
.pair {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
gap: 14px;
}
.span-all {
grid-column: 1 / -1;
}
@media (max-width: 980px) {
main {
grid-template-columns: 1fr;
}
.pair,
.inner-row {
grid-template-columns: 1fr !important;
}
}
.inner-row {
display: grid;
grid-template-columns: 420px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
section {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--panel);
box-shadow: var(--shadow);
}
section>.hd {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
section>.bd {
padding: 12px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.grow {
flex: 1;
min-width: 200px;
}
input[type=text],
input[type=number] {
padding: 6px 8px;
border-radius: 10px;
border: 1px solid var(--border);
min-width: 120px;
max-width: 100%;
background: var(--panel);
color: var(--text);
}
button {
padding: 7px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel-soft);
cursor: pointer;
color: var(--text);
}
button:hover {
background: color-mix(in srgb, var(--panel-soft) 70%, var(--accent-weak));
}
button.primary {
background: var(--accent-weak);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
button.danger {
background: color-mix(in srgb, var(--danger) 18%, transparent);
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
}
button.active {
background: rgba(34, 197, 94, .15);
border-color: rgba(34, 197, 94, .35);
}
small.muted {
color: var(--muted);
}
pre {
margin: 0;
padding: 10px;
background: var(--panel-soft);
border-radius: 12px;
overflow: auto;
font-size: 12px;
line-height: 1.35;
border: 1px solid var(--border);
}
#logs {
height: clamp(220px, 40vh, 520px);
}
.log-controls {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 8px;
}
@media (max-width: 980px) {
.log-controls {
grid-template-columns: 1fr 1fr;
}
}
.tiny {
font-size: 12px;
color: rgba(127, 127, 127, .9);
}
.inline {
display: inline-flex;
gap: 6px;
align-items: center;
white-space: nowrap;
}
select {
padding: 6px 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
}
.kv {
display: grid;
grid-template-columns: 160px 1fr;
gap: 6px 10px;
align-items: baseline;
}
.kv div.k {
color: rgba(127, 127, 127, .95);
}
.warn {
color: #b45309;
}
.layout-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
margin-bottom: 10px;
min-width: 0;
}
.layout-controls>label {
white-space: nowrap;
}
.layout-controls>#layoutId {
flex: 1;
min-width: 180px;
}
.layout-show {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
min-width: 0;
margin-left: auto;
}
.layout-actions {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
@media (max-width: 980px) {
.layout-controls {
flex-wrap: wrap;
}
.layout-show {
width: 100%;
justify-content: space-between;
margin-left: 0;
}
}
.layout-split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
@media (max-width: 980px) {
.layout-split {
grid-template-columns: 1fr;
}
}
.pane-hd {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin: 0 0 6px 0;
}
.pane-left,
.pane-right {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
#layoutTreeOut,
#layoutInfoOut {
height: clamp(160px, 26vh, 340px);
}
.tree-tools {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 6px;
}
.tree-tools .grow {
min-width: 180px;
}
.tree {
border: 1px solid var(--border);
border-radius: 10px;
padding: 6px 4px;
max-height: clamp(180px, 42vh, 520px);
min-height: 220px;
overflow: auto;
background: var(--panel-soft);
}
.tree-side {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
.tree-selected {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-soft);
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.tree-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
min-width: 0;
}
.tree-main.show-raw {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.tree-main>pre {
height: 100%;
}
.tree-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 6px;
cursor: default;
user-select: none;
white-space: nowrap;
}
.tree-row:hover {
background: var(--panel-soft);
}
.tree-row.selected {
background: var(--accent-weak);
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
}
.tree-toggle {
width: 14px;
text-align: center;
color: rgba(127, 127, 127, .95);
flex: 0 0 14px;
}
.tree-id {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
padding: 1px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--panel-soft);
}
.tree-props {
font-size: 11px;
padding: 1px 4px;
border-radius: 999px;
border: 1px solid rgba(127, 127, 127, .3);
color: rgba(127, 127, 127, .95);
}
.field-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
align-items: end;
}
.field {
display: grid;
gap: 4px;
min-width: 0;
}
.field label {
font-size: 12px;
color: var(--muted);
}
@media (max-width: 980px) {
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.check {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.check label {
display: flex;
gap: 6px;
align-items: center;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-soft);
}
details.inline {
position: relative;
display: inline-block;
}
details.inline>summary {
cursor: pointer;
user-select: none;
list-style: none;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
color: var(--muted);
background: var(--panel-soft);
}
details.inline>summary::-webkit-details-marker {
display: none;
}
details.inline .popover {
display: none;
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
min-width: min(560px, 90vw);
padding: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(10px);
box-shadow: var(--shadow);
}
details.inline[open] .popover {
display: block;
}
.show-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 980px) {
.show-grid {
grid-template-columns: 1fr;
}
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #999;
display: inline-block;
margin-right: 6px;
}
.status-dot.active {
background-color: #22c55e;
box-shadow: 0 0 4px #22c55e;
}
.status-dot.inactive {
background-color: #ef4444;
}
.sidebar,
.content {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>
</head>
<body>
<header>
<h1>Ribir Debug Server</h1>
<select id="windowSelector" style="max-width: 200px; padding: 2px 8px; font-size: 14px;">
<option value="">(Default Window)</option>
</select>
<span class="pill" id="pillSse">SSE: Disconnected</span>
<span class="pill" id="pillRecording">Recording: ?</span>
<span class="pill" id="pillRing">Ring: ?</span>
<span class="pill" id="pillDropped">Dropped: ?</span>
</header>
<main>
<div class="sidebar">
<section>
<div class="hd">
<span>Status</span>
<div class="row">
<button id="btnRefreshStatus">Refresh</button>
</div>
</div>
<div class="bd">
<div class="kv" id="statusKv"></div>
</div>
</section>
<section>
<div class="hd">
<span>Overlay</span>
<div class="row">
<button id="btnOverlayRefresh">Refresh</button>
<button class="danger" id="btnOverlayClear">Clear</button>
</div>
</div>
<div class="bd">
<div class="row" style="align-items: end;">
<div class="field" style="flex: 2; min-width: 220px;">
<label for="overlayId" class="inline">WidgetId</label>
<input id="overlayId" class="grow" type="text" placeholder="e.g.: 42" />
</div>
<div class="field" style="flex: 1; min-width: 180px;">
<label for="overlayColor">Color (#RRGGBB or #RRGGBBAA)</label>
<input id="overlayColor" type="text" value="#FF000080" />
</div>
<div class="row" style="align-items: end;">
<button class="primary" id="btnOverlaySet">Set / Update</button>
</div>
</div>
<div style="height:6px"></div>
<div id="overlayList"
style="display: flex; flex-direction: column; gap: 4px; max-height: 150px; min-height: 40px; overflow-y: auto; margin-bottom: 6px; border: 1px solid rgba(127, 127, 127, .25); border-radius: 6px; padding: 4px; background: rgba(0, 0, 0, 0.1);">
<small class="muted tiny" style="padding: 4px;">(Loading...)</small>
</div>
<label class="inline tiny"><input id="overlayAutoShot" type="checkbox" checked /> Auto-refresh
screenshot after setting</label>
<div style="height:6px"></div>
<small class="muted">Call <code>POST /overlay</code> to write global
overlay (drawn into frames during rendering), <code>DELETE /overlay/{id}</code>
to remove, <code>DELETE /overlays</code> to clear all.</small>
</div>
</section>
<section>
<div class="hd">
<span>Capture</span>
<div class="row">
<button class="primary" id="btnCaptureStart">Start</button>
<button class="danger" id="btnCaptureStop">Stop</button>
<button id="btnCaptureOneShot">One-Shot</button>
</div>
</div>
<div class="bd">
<div class="check">
<label><input id="capLogs" type="checkbox" checked /> Logs</label>
<label><input id="capImages" type="checkbox" checked /> Images</label>
</div>
<div style="height:8px"></div>
<div class="field-grid" style="grid-template-columns: repeat(2, minmax(0, 1fr));">
<div class="field">
<label for="capPre">Pre-process MS (pre_ms)</label>
<input id="capPre" type="number" value="2000" min="0" />
</div>
<div class="field">
<label for="capPost">Post-process MS (post_ms)</label>
<input id="capPost" type="number" value="1000" min="0" />
</div>
<div class="field">
<label for="capSettle">Settle MS (settle_ms)</label>
<input id="capSettle" type="number" value="150" min="0" />
</div>
<div class="field">
<label for="capOut">Output Dir</label>
<input id="capOut" type="text" placeholder="Default: captures/" />
</div>
</div>
<div style="height:8px"></div>
<pre id="captureOut" style="max-height: 18vh"></pre>
<small class="muted">Use
<code>/capture/start</code>, <code>/capture/stop</code>, <code>/capture/one_shot</code></small>
</div>
</section>
</div>
<div class="content">
<section>
<div class="hd">
<span>Layout</span>
<small class="muted" id="treeSelected">Selected: (none)</small>
</div>
<div class="bd">
<div class="layout-controls">
<label>WidgetId</label>
<input id="layoutId" class="grow" type="text" placeholder="e.g.: 42" />
<div class="layout-actions">
<button id="btnLayoutInfo">Query Details</button>
<button id="btnLayoutOpenInfo" title="Open /inspect/{id} in new tab">Open Details</button>
</div>
<div class="layout-show" title="Options for tree and detail queries">
<details class="inline" id="detailOptions">
<summary>detail options</summary>
<div class="popover">
<div class="muted tiny" style="margin-bottom: 6px;">Details (/inspect/{id})</div>
<div class="check">
<label><input id="infoShowId" type="checkbox" checked /> id</label>
<label><input id="infoShowLayout" type="checkbox" checked /> layout</label>
<label><input id="infoShowGlobalPos" type="checkbox" checked />
global_pos</label>
<label><input id="infoShowClamp" type="checkbox" checked /> clamp</label>
<label><input id="infoShowProps" type="checkbox" checked /> props</label>
</div>
</div>
</details>
</div>
</div>
<div class="layout-split">
<div>
<div class="pane-hd">
<div class="pane-left">
<button id="btnLayoutTree" class="primary">Get Tree</button>
</div>
<div class="pane-right">
<label class="inline tiny"><input id="treeRawToggle" type="checkbox" /> Raw</label>
<details class="inline" id="treeOptions">
<summary>tree options</summary>
<div class="popover">
<div class="muted tiny" style="margin-bottom: 6px;">Tree (/inspect/tree)</div>
<div class="check">
<label><input id="treeShowId" type="checkbox" checked /> id</label>
<label><input id="treeShowLayout" type="checkbox" /> layout</label>
<label><input id="treeShowGlobalPos" type="checkbox" /> global_pos</label>
<label><input id="treeShowClamp" type="checkbox" /> clamp</label>
<label><input id="treeShowProps" type="checkbox" /> props</label>
</div>
</div>
</details>
<button id="btnTreeClearOverlay">Clear Selected Overlay</button>
<button id="btnLayoutOpenTree" title="Open /inspect/tree in new tab">Open Tree</button>
</div>
</div>
<div class="tree-main" id="treeMain">
<div id="layoutTreeView" class="tree"></div>
<div id="treeRawWrap" style="display:none">
<pre id="layoutTreeOut"></pre>
</div>
</div>
</div>
<div>
<div class="pane-hd">
<small class="muted">Details (<code>/inspect/{id}</code>)</small>
</div>
<pre id="layoutInfoOut"></pre>
</div>
</div>
<small class="muted">WidgetId is usually a number; you can also paste values that can be parsed by
JSON (the server uses JSON to parse this path).</small>
</div>
</section>
<div class="pair">
<section>
<div class="hd">
<span><span id="logStatusDot" class="status-dot"></span>Logs (SSE)</span>
<div class="row">
<button id="btnConnection">Connect</button>
<button id="btnPause">Pause</button>
<button id="btnClear">Clear</button>
</div>
</div>
<div class="bd">
<div class="log-controls">
<span class="inline" id="serverFilterGroup">
<span class="tiny">Server Filter</span>
<span id="simpleFilterCtrl" class="inline">
<select id="filtModule" style="max-width: 110px;">
<option value="">(All)</option>
<option value="ribir_core">ribir_core</option>
<option value="ribir_widgets">ribir_widgets</option>
<option value="ribir_painter">ribir_painter</option>
<option value="ribir_algo">ribir_algo</option>
<option value="winit">winit</option>
<option value="wgpu">wgpu</option>
</select>
<select id="filtLevel" style="min-width: 70px;">
<option value="off">Off</option>
<option value="error">Error</option>
<option value="warn">Warn</option>
<option value="info" selected>Info</option>
<option value="debug">Debug</option>
<option value="trace">Trace</option>
</select>
</span>
<input id="filterInput" style="width: 180px; display: none;"
placeholder="info,ribir_core=debug" />
<button id="btnToggleFiltMode" class="tiny"
style="border:none; background:none; padding: 2px; text-decoration: underline; cursor: pointer;">Advanced</button>
<button id="btnSetFilter" title="Apply Config">Set</button>
</span>
<div style="width: 1px; height: 16px; background: rgba(127,127,127,0.3);"></div>
<input id="logSearch" class="grow" type="text"
placeholder="Search (keywords; regex supported)" />
<span class="inline">
<span class="tiny">Field</span>
<select id="logSearchField">
<option value="raw" selected>Full Line</option>
<option value="message">message</option>
<option value="target">target</option>
<option value="level">level</option>
</select>
</span>
<label class="inline tiny"><input id="logSearchRegex" type="checkbox" /> Regex</label>
<label class="inline tiny"><input id="logSearchCase" type="checkbox" />
Case-sensitive</label>
<button id="btnSearchClear">Clear Search</button>
</div>
<div class="row" style="justify-content: space-between; margin-bottom: 8px;">
<small class="muted" id="logStats">Showing: 0 lines (Cache: 0)</small>
<small class="muted" id="logSearchError"></small>
</div>
<pre id="logs"></pre>
<small class="muted">Events from <code>/logs/stream</code>; each <code>log</code> is a JSON
line.</small>
</div>
</section>
<section>
<div class="hd">
<span>Screenshot</span>
<div class="row">
<button class="primary" id="btnShot">Refresh</button>
</div>
</div>
<div class="bd">
<div class="row" style="align-items:flex-start">
<img id="shotImg" alt="screenshot"
style="max-width:100%; border-radius:10px; border:1px solid rgba(127,127,127,.25)" />
</div>
<small class="muted">Load <code>/screenshot</code> (cache-busted).</small>
</div>
</section>
</div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const logsEl = $('logs');
const capOutEl = $('captureOut');
const pillSse = $('pillSse');
const pillRecording = $('pillRecording');
const pillRing = $('pillRing');
const pillDropped = $('pillDropped');
let es = null;
let paused = false;
const MAX_LOG_BUFFER = 20_000;
const MAX_LOG_VIEW = 4_000;
const logBuf = [];
let viewLineCount = 0;
let searchTimer = null;
let treeData = null;
let treeExpanded = new Set();
let treeSelectedKey = null;
let treeSelectedId = null;
let treeSelectedPath = null;
let autoOverlayId = null;
let treeClickTimer = null;
let treeVisible = [];
function setLogStats() {
const stats = $('logStats');
stats.textContent = `Showing: ${viewLineCount} lines (Cache: ${logBuf.length})`;
}
function setSearchError(msg) {
const el = $('logSearchError');
el.textContent = msg || '';
}
function getSearchConfig() {
return {
q: ($('logSearch')?.value || '').trim(),
field: $('logSearchField')?.value || 'raw',
regex: !!$('logSearchRegex')?.checked,
caseSensitive: !!$('logSearchCase')?.checked,
};
}
function extractField(line, field) {
if (field === 'raw') return line;
try {
const obj = JSON.parse(line);
if (field === 'level') return String(obj.level ?? '');
if (field === 'target') return String(obj.target ?? obj?.fields?.['log.target'] ?? '');
if (field === 'message') {
return String(
obj?.fields?.message ??
obj?.fields?.['log.message'] ??
obj?.message ??
''
);
}
return line;
} catch (_) {
return '';
}
}
function makeMatcher() {
const cfg = getSearchConfig();
if (!cfg.q) return { ok: true, fn: () => true };
if (cfg.regex) {
try {
const flags = cfg.caseSensitive ? '' : 'i';
const re = new RegExp(cfg.q, flags);
return {
ok: true,
fn: (line) => re.test(extractField(line, cfg.field))
};
} catch (e) {
return { ok: false, err: `Regex Error: ${e.message || String(e)}` };
}
}
const needle = cfg.caseSensitive ? cfg.q : cfg.q.toLowerCase();
return {
ok: true,
fn: (line) => {
const hay = extractField(line, cfg.field);
if (!hay) return false;
const s = cfg.caseSensitive ? hay : hay.toLowerCase();
return s.includes(needle);
}
};
}
function rebuildLogsView() {
const m = makeMatcher();
if (!m.ok) {
setSearchError(m.err);
return;
}
setSearchError('');
const cfg = getSearchConfig();
const lines = [];
for (let i = logBuf.length - 1; i >= 0 && lines.length < MAX_LOG_VIEW; i--) {
const line = logBuf[i];
if (!cfg.q || m.fn(line)) lines.push(line);
}
lines.reverse();
logsEl.textContent = lines.join('\n') + (lines.length ? '\n' : '');
viewLineCount = lines.length;
setLogStats();
logsEl.scrollTop = logsEl.scrollHeight;
}
function appendLog(line) {
logBuf.push(line);
while (logBuf.length > MAX_LOG_BUFFER) {
logBuf.shift();
}
if (paused) return;
const cfg = getSearchConfig();
const m = makeMatcher();
if (!m.ok) {
setSearchError(m.err);
return;
}
setSearchError('');
if (cfg.q && !m.fn(line)) {
return;
}
logsEl.textContent += line + "\n";
viewLineCount++;
if (viewLineCount > MAX_LOG_VIEW) {
rebuildLogsView();
return;
}
setLogStats();
logsEl.scrollTop = logsEl.scrollHeight;
}
function sortKeys(val) {
if (Array.isArray(val)) {
return val.map(sortKeys);
} else if (val !== null && typeof val === 'object') {
const priority = { name: 1, id: 2, properties: 3, layout: 4, children: 5 };
const keys = Object.keys(val).sort((a, b) => {
const pA = priority[a] || 99;
const pB = priority[b] || 99;
if (pA !== pB) return pA - pB;
return a.localeCompare(b);
});
const res = {};
for (const k of keys) {
res[k] = sortKeys(val[k]);
}
return res;
}
return val;
}
function prettyTextMaybeJson(txt) {
try {
const obj = JSON.parse(txt);
return JSON.stringify(sortKeys(obj), null, 2);
} catch (_) {
return txt;
}
}
function prettyJson(obj) {
try {
return JSON.stringify(sortKeys(obj), null, 2);
} catch (_) {
return String(obj);
}
}
function formatWidgetIdValue(idVal) {
if (idVal === null || typeof idVal === 'undefined') return { display: null, raw: null };
if (typeof idVal === 'object') {
const raw = JSON.stringify(idVal);
const idx = idVal.index1 ?? idVal.index ?? idVal.id;
const display = typeof idx !== 'undefined' ? `#${idx}` : raw;
return { display, raw };
}
const raw = String(idVal);
return { display: `#${raw}`, raw };
}
function nodeKeyFor(node, pathIndexes) {
const idInfo = formatWidgetIdValue(node?.id);
if (idInfo.raw) return `id:${idInfo.raw}`;
return `path:${pathIndexes.join('/')}`;
}
function nodeLabelFor(node, idInfo) {
const name = node?.name || 'Unknown';
if (idInfo?.display) return `${idInfo.display} ${name}`;
return name;
}
function collectTreeKeys(node, pathIndexes, set) {
const key = nodeKeyFor(node, pathIndexes);
set.add(key);
const children = Array.isArray(node?.children) ? node.children : [];
for (let i = 0; i < children.length; i++) {
collectTreeKeys(children[i], pathIndexes.concat(i), set);
}
}
function updateTreeSelectedLabel() {
const el = $('treeSelected');
if (!el) return;
if (!treeSelectedPath || !treeSelectedPath.length) {
el.textContent = 'Selected: (none)';
return;
}
el.textContent = 'Selected: ' + treeSelectedPath.join(' / ');
}
function setTreeSelection(node, key, labels) {
const idInfo = formatWidgetIdValue(node?.id);
treeSelectedKey = key;
treeSelectedId = idInfo.raw;
treeSelectedPath = labels;
updateTreeSelectedLabel();
renderTreeView();
}
async function setOverlayForSelected() {
if (!treeSelectedId) {
alert('This tree was fetched without id; enable "id" in options and refresh.');
return;
}
if (autoOverlayId && autoOverlayId !== treeSelectedId) {
await overlayRemove(autoOverlayId);
}
autoOverlayId = treeSelectedId;
$('overlayId').value = treeSelectedId;
$('layoutId').value = treeSelectedId;
const color = ($('overlayColor').value || '').trim();
if (!color) {
alert('Please enter overlay color');
return;
}
const res = await fetch('/overlay', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: treeSelectedId, color, window_id: getSelectedWindowId() }),
});
if (!res.ok) {
const msg = await res.text();
alert('Set overlay failed: ' + msg);
return;
}
await refreshOverlays();
if ($('overlayAutoShot')?.checked) refreshScreenshot();
}
function toggleTreeNode(key) {
if (treeExpanded.has(key)) treeExpanded.delete(key);
else treeExpanded.add(key);
renderTreeView();
}
function renderTreeView() {
const container = $('layoutTreeView');
if (!container) return;
container.innerHTML = '';
container.tabIndex = 0;
if (!treeData) {
container.innerHTML = '<small class="muted tiny" style="padding:4px">(No tree yet)</small>';
treeVisible = [];
return;
}
treeVisible = [];
function buildNode(node, pathIndexes, pathLabels, depth, parentKey) {
const idInfo = formatWidgetIdValue(node?.id);
const currentLabel = nodeLabelFor(node, idInfo);
const key = nodeKeyFor(node, pathIndexes);
const children = Array.isArray(node?.children) ? node.children : [];
const hasChildren = children.length > 0;
const expanded = hasChildren && treeExpanded.has(key);
const labelPath = pathLabels.concat([currentLabel]);
treeVisible.push({
key,
node,
labels: labelPath,
hasChildren,
expanded,
parentKey,
});
const row = document.createElement('div');
row.className = 'tree-row' + (treeSelectedKey === key ? ' selected' : '');
row.style.paddingLeft = `${depth * 14 + 6}px`;
row.dataset.key = key;
const toggle = document.createElement('span');
toggle.className = 'tree-toggle';
toggle.textContent = hasChildren ? (expanded ? 'â–¾' : 'â–¸') : '';
row.appendChild(toggle);
if (idInfo?.display) {
const idSpan = document.createElement('span');
idSpan.className = 'tree-id';
idSpan.textContent = idInfo.display;
row.appendChild(idSpan);
}
const nameSpan = document.createElement('span');
nameSpan.textContent = node?.name || 'Unknown';
row.appendChild(nameSpan);
if (node?.properties && Object.keys(node.properties || {}).length) {
const propSpan = document.createElement('span');
propSpan.className = 'tree-props';
propSpan.textContent = 'props';
row.appendChild(propSpan);
}
row.addEventListener('click', () => {
if (treeClickTimer) clearTimeout(treeClickTimer);
treeClickTimer = setTimeout(() => {
treeClickTimer = null;
if (hasChildren) toggleTreeNode(key);
}, 220);
});
row.addEventListener('dblclick', async () => {
if (treeClickTimer) {
clearTimeout(treeClickTimer);
treeClickTimer = null;
}
setTreeSelection(node, key, labelPath);
await setOverlayForSelected();
});
container.appendChild(row);
if (expanded) {
for (let i = 0; i < children.length; i++) {
const child = children[i];
buildNode(child, pathIndexes.concat(i), labelPath, depth + 1, key);
}
}
}
buildNode(treeData, [0], [], 0, null);
}
function findVisibleIndexByKey(key) {
if (!key) return -1;
for (let i = 0; i < treeVisible.length; i++) {
if (treeVisible[i].key === key) return i;
}
return -1;
}
function scrollTreeRowIntoView(key) {
const container = $('layoutTreeView');
if (!container) return;
const safeKey = (window.CSS && CSS.escape) ? CSS.escape(String(key)) : String(key).replace(/"/g, '\\"');
const row = container.querySelector(`[data-key="${safeKey}"]`);
if (row) row.scrollIntoView({ block: 'nearest' });
}
function selectVisibleByIndex(idx) {
if (idx < 0 || idx >= treeVisible.length) return;
const item = treeVisible[idx];
setTreeSelection(item.node, item.key, item.labels);
scrollTreeRowIntoView(item.key);
}
async function selectVisibleWithOverlay(idx) {
if (idx < 0 || idx >= treeVisible.length) return;
const item = treeVisible[idx];
setTreeSelection(item.node, item.key, item.labels);
scrollTreeRowIntoView(item.key);
await setOverlayForSelected();
}
function expandNode(key) {
treeExpanded.add(key);
renderTreeView();
const idx = findVisibleIndexByKey(key);
if (idx !== -1) selectVisibleByIndex(idx);
}
function collapseNode(key) {
treeExpanded.delete(key);
renderTreeView();
const idx = findVisibleIndexByKey(key);
if (idx !== -1) selectVisibleByIndex(idx);
}
function handleTreeKeydown(e) {
if (!treeVisible.length) return;
const idx = findVisibleIndexByKey(treeSelectedKey);
const hasSelection = idx !== -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = hasSelection ? Math.min(idx + 1, treeVisible.length - 1) : 0;
selectVisibleByIndex(next);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = hasSelection ? Math.max(idx - 1, 0) : treeVisible.length - 1;
selectVisibleByIndex(prev);
return;
}
if (e.key === 'ArrowRight') {
if (!hasSelection) return;
e.preventDefault();
const item = treeVisible[idx];
if (item.hasChildren && !item.expanded) {
expandNode(item.key);
return;
}
if (item.hasChildren) {
const next = idx + 1;
if (treeVisible[next] && treeVisible[next].parentKey === item.key) {
selectVisibleByIndex(next);
}
}
return;
}
if (e.key === 'ArrowLeft') {
if (!hasSelection) return;
e.preventDefault();
const item = treeVisible[idx];
if (item.hasChildren && item.expanded) {
collapseNode(item.key);
return;
}
if (item.parentKey) {
const pIdx = findVisibleIndexByKey(item.parentKey);
if (pIdx !== -1) selectVisibleByIndex(pIdx);
}
return;
}
if (e.key === ' ') {
if (!hasSelection) return;
e.preventDefault();
selectVisibleWithOverlay(idx);
}
}
function renderStatusKV(obj) {
const kv = $('statusKv');
kv.innerHTML = '';
const entries = [
['recording', obj.recording],
['log_sink_connected', obj.log_sink_connected],
['filter_reload_installed', obj.filter_reload_installed],
['filter', obj.filter],
['dropped_total', obj.dropped_total],
['ring_len', obj.ring_len],
['capture_root', obj.capture_root],
['active_capture', obj.active_capture ? (obj.active_capture.capture_id + ' @ ' + obj.active_capture.capture_dir) : null],
];
for (const [k, v] of entries) {
const dk = document.createElement('div'); dk.className = 'k'; dk.textContent = k;
const dv = document.createElement('div'); dv.textContent = (v === null || v === undefined) ? '-' : String(v);
kv.appendChild(dk); kv.appendChild(dv);
}
}
async function refreshStatus() {
const res = await fetch('/status');
const obj = await res.json();
renderStatusKV(obj);
pillRecording.textContent = 'Recording: ' + obj.recording;
pillRing.textContent = 'Ring: ' + obj.ring_len;
pillDropped.textContent = 'Dropped: ' + obj.dropped_total;
if (obj.filter) $('filterInput').value = obj.filter;
}
async function setFilter() {
const filter = $('filterInput').value;
const res = await fetch('/logs/filter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ filter }),
});
if (!res.ok) {
const msg = await res.text();
alert('set filter failed: ' + msg);
}
await refreshStatus();
}
function toggleConnection() {
if (es) disconnectSse();
else connectSse();
}
function updateConnectionUI(connected) {
const btn = $('btnConnection');
const dot = $('logStatusDot');
if (connected) {
btn.textContent = 'Disconnect';
btn.classList.add('active');
dot.className = 'status-dot active';
pillSse.textContent = 'SSE: Connected';
} else {
btn.textContent = 'Connect';
btn.classList.remove('active');
dot.className = 'status-dot inactive';
}
}
function connectSse() {
if (es) return;
$('btnConnection').textContent = 'Connecting...';
pillSse.textContent = 'SSE: Connecting...';
es = new EventSource('/logs/stream');
es.onopen = () => {
updateConnectionUI(true);
};
es.onerror = () => {
disconnectSse();
pillSse.textContent = 'SSE: Error';
};
es.addEventListener('log', (ev) => appendLog(ev.data));
es.addEventListener('lagged', (ev) => appendLog('[lagged] ' + ev.data));
es.addEventListener('stats', (ev) => {
try {
const s = JSON.parse(ev.data);
if (typeof s.ring_len !== 'undefined') pillRing.textContent = 'Ring: ' + s.ring_len;
if (typeof s.dropped_total !== 'undefined') pillDropped.textContent = 'Dropped: ' + s.dropped_total;
if (typeof s.recording !== 'undefined') pillRecording.textContent = 'Recording: ' + s.recording;
if (typeof s.filter !== 'undefined' && s.filter) $('filterInput').value = s.filter;
} catch (_) { }
});
}
function disconnectSse() {
if (!es) return;
es.close();
es = null;
updateConnectionUI(false);
pillSse.textContent = 'SSE: Disconnected';
}
function refreshScreenshot() {
const img = $('shotImg');
img.onerror = () => {
img.onerror = null;
setTimeout(() => {
const wid = getSelectedWindowId();
const qs = wid ? `&window_id=${wid}` : '';
img.src = '/screenshot?cb=' + Date.now() + qs;
}, 1000);
};
const wid = getSelectedWindowId();
const qs = wid ? `&window_id=${wid}` : '';
img.src = '/screenshot?cb=' + Date.now() + qs;
}
function parseWidgetIdInput(raw) {
const s = (raw || '').trim();
return s || null;
}
async function refreshOverlays() {
try {
const wid = getSelectedWindowId();
const url = '/overlays' + (wid ? `?window_id=${wid}` : '');
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
const list = await res.json();
renderOverlayList(list);
} catch (e) {
const el = $('overlayList');
el.innerHTML = '';
const small = document.createElement('small');
small.className = 'warn tiny';
small.textContent = '(Fetch failed: ' + e.message + ')';
el.appendChild(small);
}
}
function renderOverlayList(list) {
console.log('renderOverlayList', list);
const el = $('overlayList');
try {
el.innerHTML = '';
if (!list || !list.length) {
const small = document.createElement('small');
small.className = 'muted tiny';
small.textContent = '(No active overlays)';
small.style.padding = '4px';
el.appendChild(small);
return;
}
for (const [id, color] of list) {
const row = document.createElement('div');
row.className = 'row tiny inline';
row.style.justifyContent = 'space-between';
row.style.background = 'rgba(127,127,127,0.1)';
row.style.padding = '2px 6px';
row.style.borderRadius = '4px';
const left = document.createElement('span');
left.className = 'inline';
const dot = document.createElement('span');
dot.style.display = 'inline-block';
dot.style.width = '10px';
dot.style.height = '10px';
dot.style.backgroundColor = color;
dot.style.border = '1px solid #777';
dot.style.borderRadius = '2px';
let displayId = id;
let rawId = id; if (typeof id === 'object' && id !== null) {
if (typeof id.index1 !== 'undefined') displayId = '#' + id.index1;
else if (typeof id.index !== 'undefined') displayId = '#' + id.index;
else displayId = JSON.stringify(id);
rawId = JSON.stringify(id);
} else {
displayId = '#' + id;
rawId = String(id);
}
left.appendChild(dot);
left.appendChild(document.createTextNode(displayId + ' (' + color + ')'));
const btnDel = document.createElement('span');
btnDel.textContent = '✕';
btnDel.style.cursor = 'pointer';
btnDel.style.color = '#ef4444';
btnDel.onclick = () => overlayRemove(rawId);
row.appendChild(left);
row.appendChild(btnDel);
el.appendChild(row);
}
} catch (err) {
console.error('Render overlay list error:', err);
const el = $('overlayList');
el.innerHTML = '<small class="warn tiny">(Render Error)</small>';
}
}
async function overlayRemove(id) {
let url = '/overlay/' + encodeURIComponent(id);
const wid = getSelectedWindowId();
if (wid) url += `?window_id=${wid}`;
const res = await fetch(url, { method: 'DELETE' });
if (res.ok) {
await refreshOverlays();
if ($('overlayAutoShot')?.checked) refreshScreenshot();
} else {
alert('Delete failed');
}
}
async function overlaySet() {
const idRaw = ($('overlayId').value || '').trim();
const id = parseWidgetIdInput(idRaw);
const color = ($('overlayColor').value || '').trim();
if (id === null) { alert('Invalid WidgetId'); return; }
if (!color) { alert('Please enter color'); return; }
const res = await fetch('/overlay', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, color, window_id: getSelectedWindowId() }),
});
if (!res.ok) {
const msg = await res.text();
alert('Set overlay failed: ' + msg);
return;
}
await refreshOverlays();
if ($('overlayAutoShot')?.checked) refreshScreenshot();
}
async function overlayClear() {
let url = '/overlays';
const wid = getSelectedWindowId();
if (wid) url += `?window_id=${wid}`;
const res = await fetch(url, { method: 'DELETE' }); if (!res.ok) {
const msg = await res.text();
alert('Clear overlay failed: ' + msg);
return;
}
await refreshOverlays();
if ($('overlayAutoShot')?.checked) refreshScreenshot();
}
async function clearSelectedOverlay() {
if (!autoOverlayId) return;
await overlayRemove(autoOverlayId);
autoOverlayId = null;
}
function showTokens(prefix) {
const wantId = !!$(prefix + 'ShowId')?.checked;
const wantLayout = !!$(prefix + 'ShowLayout')?.checked;
const wantProps = !!$(prefix + 'ShowProps')?.checked;
const wantGlobalPos = !!$(prefix + 'ShowGlobalPos')?.checked;
const wantClamp = !!$(prefix + 'ShowClamp')?.checked;
const show = [];
if (wantId) show.push('id');
if (wantProps) show.push('props');
if (wantLayout || wantGlobalPos || wantClamp) {
show.push('layout');
if (wantLayout) {
if (!wantGlobalPos) show.push('no_global_pos');
if (!wantClamp) show.push('no_clamp');
} else {
if (wantGlobalPos) show.push('global_pos');
if (wantClamp) show.push('clamp');
}
}
return show;
}
function showParam(prefix) {
const show = showTokens(prefix);
const params = new URLSearchParams();
if (show.length) params.set('options', show.join(','));
const wid = getSelectedWindowId();
if (wid) params.set('window_id', wid);
const qs = params.toString();
return qs ? ('?' + qs) : '';
}
async function fetchLayoutTree() {
const out = $('layoutTreeOut');
const url = '/inspect/tree' + showParam('tree');
out.textContent = 'Loading...';
try {
const res = await fetch(url);
const txt = await res.text();
out.textContent = prettyTextMaybeJson(txt);
try {
treeData = JSON.parse(txt);
} catch (e) {
treeData = null;
const treeEl = $('layoutTreeView');
if (treeEl) {
treeEl.innerHTML = '<small class="warn tiny" style="padding:4px">(Tree JSON parse failed)</small>';
}
return;
}
const allKeys = new Set();
collectTreeKeys(treeData, [0], allKeys);
treeExpanded = new Set([...treeExpanded].filter((k) => allKeys.has(k)));
const rootKey = nodeKeyFor(treeData, [0]);
if (!treeExpanded.has(rootKey)) treeExpanded.add(rootKey);
if (treeSelectedKey && !allKeys.has(treeSelectedKey)) {
treeSelectedKey = null;
treeSelectedId = null;
treeSelectedPath = null;
autoOverlayId = null;
updateTreeSelectedLabel();
}
renderTreeView();
} catch (e) {
out.textContent = `GET ${url}\n\nRequest failed: ` + (e?.message || String(e));
}
}
async function fetchLayoutInfo() {
const out = $('layoutInfoOut');
const raw = ($('layoutId').value || '').trim();
if (!raw) {
out.textContent = 'Please enter WidgetId (e.g.: 42)';
return;
}
const url = '/inspect/' + encodeURIComponent(raw) + showParam('info');
out.textContent = `GET ${url}\n\nLoading...`;
try {
const res = await fetch(url);
const txt = await res.text();
out.textContent = `GET ${url}\n\n` + prettyTextMaybeJson(txt);
} catch (e) {
out.textContent = `GET ${url}\n\nRequest failed: ` + (e?.message || String(e));
}
}
function capturePayload() {
const include = [];
if ($('capLogs').checked) include.push('logs');
if ($('capImages').checked) include.push('images');
const pre_ms = Number($('capPre').value || 0);
const post_ms = Number($('capPost').value || 0);
const settle_ms = Number($('capSettle').value || 0);
const output_dir = $('capOut').value.trim();
const payload = { include, pre_ms, post_ms, settle_ms };
if (output_dir) payload.output_dir = output_dir;
return payload;
}
async function capStart() {
const p = capturePayload();
const { settle_ms, ...body } = p;
const res = await fetch('/capture/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const txt = await res.text();
capOutEl.textContent = prettyTextMaybeJson(txt);
await refreshStatus();
}
async function capStop() {
const res = await fetch('/capture/stop', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
});
const txt = await res.text();
capOutEl.textContent = prettyTextMaybeJson(txt);
await refreshStatus();
}
async function capOneShot() {
const body = capturePayload();
const res = await fetch('/capture/one_shot', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const txt = await res.text();
capOutEl.textContent = prettyTextMaybeJson(txt);
await refreshStatus();
}
async function fetchWindows() {
const sel = $('windowSelector');
try {
const res = await fetch('/windows');
const list = await res.json();
const current = sel.value;
sel.innerHTML = '<option value="">(Default Window)</option>';
for (const win of list) {
const opt = document.createElement('option');
opt.value = win.id;
opt.textContent = `[${win.id}] ${win.title} (${win.width}x${win.height})`;
sel.appendChild(opt);
}
if (current) sel.value = current;
} catch (e) {
console.error('Fetch windows failed', e);
}
}
function getSelectedWindowId() {
const val = $('windowSelector').value;
return val ? Number(val) : null;
}
$('windowSelector').onchange = () => {
refreshScreenshot();
refreshOverlays();
};
$('btnRefreshStatus').onclick = refreshStatus;
$('btnSetFilter').onclick = setFilter;
$('btnConnection').onclick = toggleConnection;
$('btnPause').onclick = () => {
paused = !paused;
const b = $('btnPause');
b.textContent = paused ? 'Resume' : 'Pause';
b.classList.toggle('active', paused);
if (!paused) rebuildLogsView();
};
$('btnClear').onclick = () => {
logsEl.textContent = '';
logBuf.length = 0;
viewLineCount = 0;
setSearchError('');
setLogStats();
};
$('btnSearchClear').onclick = () => {
$('logSearch').value = '';
$('logSearchError').textContent = '';
rebuildLogsView();
};
function scheduleSearch() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
rebuildLogsView();
}, 150);
}
$('logSearch').addEventListener('input', scheduleSearch);
$('logSearchField').addEventListener('change', scheduleSearch);
$('logSearchRegex').addEventListener('change', scheduleSearch);
$('logSearchCase').addEventListener('change', scheduleSearch);
$('btnShot').onclick = refreshScreenshot;
$('btnLayoutTree').onclick = fetchLayoutTree;
$('btnLayoutInfo').onclick = fetchLayoutInfo;
$('btnLayoutOpenTree').onclick = () => window.open('/inspect/tree' + showParam('tree'), '_blank');
$('btnLayoutOpenInfo').onclick = () => {
const raw = ($('layoutId').value || '').trim();
if (!raw) {
$('layoutInfoOut').textContent = 'Please enter WidgetId (e.g.: 42)';
return;
}
window.open('/inspect/' + encodeURIComponent(raw) + showParam('info'), '_blank');
};
$('btnOverlaySet').onclick = overlaySet;
$('btnOverlayRefresh').onclick = refreshOverlays;
$('btnOverlayClear').onclick = overlayClear;
$('btnTreeClearOverlay').onclick = clearSelectedOverlay;
$('layoutTreeView')?.addEventListener('keydown', handleTreeKeydown);
$('treeRawToggle').onchange = () => {
const wrap = $('treeRawWrap');
wrap.style.display = $('treeRawToggle').checked ? 'block' : 'none';
const main = $('treeMain');
if (main) main.classList.toggle('show-raw', $('treeRawToggle').checked);
};
$('btnCaptureStart').onclick = capStart;
$('btnCaptureStop').onclick = capStop;
$('btnCaptureOneShot').onclick = capOneShot;
setLogStats();
renderTreeView();
refreshStatus();
connectSse();
fetchWindows().then(() => {
refreshScreenshot();
refreshOverlays();
});
const filtModule = $('filtModule');
const filtLevel = $('filtLevel');
const filterInput = $('filterInput');
const btnToggleFiltMode = $('btnToggleFiltMode');
const simpleFilterCtrl = $('simpleFilterCtrl');
let isReflecting = false;
function syncSimpleToRaw() {
if (isReflecting) return;
const mod = filtModule.value;
const lvl = filtLevel.value;
if (!mod) {
filterInput.value = lvl; } else {
filterInput.value = `off,${mod}=${lvl}`; }
}
function toggleFilterMode() {
const isSimple = simpleFilterCtrl.style.display !== 'none';
if (isSimple) {
simpleFilterCtrl.style.display = 'none';
filterInput.style.display = 'inline-block';
btnToggleFiltMode.textContent = 'Simple';
} else {
filterInput.style.display = 'none';
simpleFilterCtrl.style.display = 'inline-flex';
btnToggleFiltMode.textContent = 'Advanced';
}
}
filtModule.addEventListener('change', syncSimpleToRaw);
filtLevel.addEventListener('change', syncSimpleToRaw);
btnToggleFiltMode.addEventListener('click', toggleFilterMode);
syncSimpleToRaw();
</script>
</body>
</html>