first draft of log explorer
This commit is contained in:
1 parent
191e69eb1d
commit
400837b41a
16 files changed
+1495
-79
No files matched your search
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 19 11 5l6 14M7.2 13.8h7.6"/>
|
||||
<circle cx="18.5" cy="7" r="2"/>
|
||||
<path d="M15.8 8.7 14 10M4 19h15"/>
|
||||
<circle cx="4" cy="19" r="1.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1,4 @@
|
||||
:root{color-scheme:dark light;--bg:var(--vscode-editor-background,#111820);--fg:var(--vscode-editor-foreground,#dae3ed);--panel:var(--vscode-sideBar-background,#18212c);--border:var(--vscode-panel-border,#334252);--muted:var(--vscode-descriptionForeground,#a0afbf);--accent:var(--vscode-focusBorder,#6caef0)}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:13px var(--vscode-font-family,system-ui,sans-serif)}header{padding:22px 24px 16px;display:flex;justify-content:space-between;gap:20px;align-items:center}h1{font-size:21px;margin:0}h1 span{font-weight:400;color:var(--muted);font-size:16px;margin-left:10px}p{color:var(--muted);line-height:1.6}header p{margin:7px 0 0}.actions{display:flex;gap:8px}button,input,select{font:inherit;color:var(--fg);background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px 10px}button{cursor:pointer}button:hover{border-color:var(--accent)}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}nav{display:flex;align-items:center;gap:10px;padding:0 24px 18px;flex-wrap:wrap}nav input[type=search]{flex:1;min-width:220px}nav label{display:flex;align-items:center;gap:6px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(350px,1fr) minmax(380px,1fr);border-top:1px solid var(--border);height:calc(100vh - 150px)}section{overflow:auto;padding:18px 20px}.apps{border-right:1px solid var(--border)}.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;gap:10px}h2{font-size:14px;margin:0}.section-title>span{color:var(--muted)}details{border:1px solid var(--border);border-radius:8px;margin-bottom:10px;background:var(--panel)}summary{cursor:pointer;padding:12px;display:flex;align-items:center;gap:10px}summary:before{content:'▸';color:var(--muted)}details[open]>summary:before{content:'▾'}summary .name{flex:1;overflow-wrap:anywhere}.badge{border-radius:5px;padding:3px 6px;font-size:11px;font-weight:600;white-space:nowrap}.INFO,.DEBUG{color:#91c5fa;background:#284561}.WARNING{color:#f3cd80;background:#594925}.ERROR,.CRITICAL{color:#ffacac;background:#5b3039}.rows{border-top:1px solid var(--border)}.row{display:block;width:100%;text-align:left;border:0;border-radius:0;background:transparent;padding:11px 13px;border-bottom:1px solid var(--border)}.row:last-child{border-bottom:0}.row.selected{background:var(--vscode-list-activeSelectionBackground,#263f57);box-shadow:inset 3px 0 var(--accent)}.row-meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px;margin-bottom:5px}.row-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.5}.link-hint{margin-left:auto;color:var(--accent)}.empty{padding:40px 15px;color:var(--muted);line-height:1.6;text-align:center}.selected-message{font-size:15px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}.metadata{color:var(--muted);font-size:12px;margin:8px 0 15px}.legend{color:var(--muted);font-size:12px;margin:15px 0}.graph{overflow:auto;position:relative;margin:20px 0;min-height:120px}.graph svg{position:absolute;left:0;top:0;pointer-events:none}.graph-stage{position:relative}.node{position:absolute;width:320px;height:115px;overflow:auto;padding:12px;text-align:left;background:var(--panel);font-size:12px;line-height:1.5}.node strong{display:block;color:var(--accent);margin-bottom:5px}.node .text{white-space:pre-wrap;overflow-wrap:anywhere}.node.target{border-color:var(--accent)}.node.missing{border-style:dashed;color:var(--muted)}.tree ul{padding-left:23px;list-style:none;border-left:1px solid var(--border)}.tree li{margin:12px 0}.tree button{text-align:left;line-height:1.5}.tree .probable{color:#f3cd80;font-size:11px}.values{width:100%;border-collapse:collapse;font-size:12px}.values td{padding:8px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}.values td:first-child{color:var(--muted)}#warnings{color:#f3cd80;padding:0 24px}#warnings:empty{display:none}.more{padding:12px;color:var(--muted)}@media(max-width:850px){main{grid-template-columns:1fr;height:auto}.apps{border-right:0;border-bottom:1px solid var(--border)}section{max-height:65vh}header{align-items:flex-start;padding:18px}nav{padding:0 18px 15px}summary{gap:6px}.badge{font-size:10px}}
|
||||
.app-label{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}.last-log{color:var(--muted);font-size:11px;font-weight:400;overflow-wrap:anywhere}.row{cursor:pointer;user-select:text;-webkit-user-select:text}.row:hover{background:var(--vscode-list-hoverBackground,#203040)}.selected-message,.row-message,.node,.tree,.values{user-select:text;-webkit-user-select:text}.init-group{margin:10px;background:var(--bg)}.init-summary{font-size:12px;color:var(--muted);flex-wrap:wrap}.init-rows>button{margin:8px 12px}.section-title{flex-wrap:wrap}.section-title select{font-size:12px}.actions{flex-wrap:wrap}
|
||||
@keyframes new-log-pulse{0%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}25%{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--accent) 85%,transparent);background-color:color-mix(in srgb,var(--accent) 12%,var(--panel))}100%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}}.new-log-app>summary{animation:new-log-pulse 1.8s ease-out}.recent-log{animation:new-log-pulse 1.8s ease-out}@media(prefers-reduced-motion:reduce){.new-log-app>summary,.recent-log{animation:none;outline:2px solid var(--accent);outline-offset:-2px}}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
|
||||
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
|
||||
<body>
|
||||
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="import">Open history</button><button id="copy">Copy selected log</button><button id="pause">Pause</button></div></header>
|
||||
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select><label><input id="group-init" type="checkbox" checked> Group initialization</label></nav>
|
||||
<div id="warnings" role="status"></div>
|
||||
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
|
||||
<script nonce="{{NONCE}}" src="{{JS}}"></script></body></html>
|
||||
@@ -0,0 +1,356 @@
|
||||
/* Shared UI: VS Code transport or a standalone review fixture. No log HTML is executed. */
|
||||
(() => {
|
||||
const inVSCode = typeof acquireVsCodeApi === 'function';
|
||||
const vscode = inVSCode ? acquireVsCodeApi() : {
|
||||
async postMessage(message) {
|
||||
if (message.type !== 'copy') return;
|
||||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(message.text);
|
||||
else {
|
||||
const area = document.createElement('textarea'); area.value = message.text;
|
||||
document.body.append(area); area.select(); document.execCommand?.('copy'); area.remove();
|
||||
}
|
||||
window.dispatchEvent(new MessageEvent('message', { data: { type: 'copied' } }));
|
||||
}, getState() {}, setState() {}
|
||||
};
|
||||
const saved = vscode.getState() || {};
|
||||
let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', paused = false, selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected;
|
||||
const expanded = new Set(saved.expanded || []);
|
||||
const expandedInit = new Set(saved.expandedInit || []);
|
||||
const visibleEventGroups = new Map();
|
||||
const recentEvents = new Set();
|
||||
const recentApps = new Set();
|
||||
const appFlashTokens = new Map();
|
||||
let receivedInitialData = false;
|
||||
let pointerSelecting = false;
|
||||
const $ = id => document.getElementById(id);
|
||||
const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; };
|
||||
const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level;
|
||||
function save() { vscode.setState({ selected, mode, origin, expanded: [...expanded], expandedInit: [...expandedInit], sort: $('sort').value, groupInit: $('group-init').checked }); }
|
||||
function choose(id, fromList = true) { if (fromList) origin = id; selected = id; save(); renderApps(); renderDetail(); }
|
||||
function filtered() {
|
||||
const q = $('search').value.toLowerCase(), level = $('level').value, minutes = Number($('period').value);
|
||||
const after = minutes ? Date.now() - minutes * 60000 : -Infinity;
|
||||
return entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && (!minutes || e.time >= after)
|
||||
&& (level === 'all' || normalLevel(e) === level)
|
||||
&& `${e.app} ${e.entity || ''} ${e.message}`.toLowerCase().includes(q));
|
||||
}
|
||||
const cleanMessage = e => e.message.replace(/ \[event=[\w-]+ trace=[\w-]+\]/g, '').replace(/ \[init=[\w-]+\]/g, '');
|
||||
const formatTime = (entry, includeYear = false) => {
|
||||
if (!Number.isFinite(entry.time)) return entry.timestamp;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, { timeZone: displayZone, day: '2-digit', month: 'short', ...(includeYear ? { year: 'numeric' } : {}), hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'shortOffset' }).format(new Date(entry.time));
|
||||
} catch { return entry.timestamp; }
|
||||
};
|
||||
const copyText = e => `${formatTime(e, true)} ${e.level} ${e.app}: ${cleanMessage(e)}`;
|
||||
function timeSince(time) {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000));
|
||||
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'} ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`;
|
||||
const years = Math.floor(days / 365);
|
||||
return `${years} year${years === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
function copy(text) { if (text) vscode.postMessage({ type: 'copy', text }); }
|
||||
function row(e) {
|
||||
const item = node('div', undefined, `row${selected === e.event_id ? ' selected' : ''}${recentEvents.has(e.event_id) ? ' recent-log' : ''}`);
|
||||
item.dataset.eventId = e.event_id; item.dataset.eventIds = e.event_id;
|
||||
item.tabIndex = 0; item.setAttribute('role', 'button');
|
||||
const meta = node('div', undefined, 'row-meta');
|
||||
const relative = node('span', timeSince(e.time), 'relative-time');
|
||||
relative.dataset.time = String(e.time);
|
||||
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
|
||||
meta.append(relative, node('span', e.level, `badge ${normalLevel(e)}`));
|
||||
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
||||
item.append(meta, node('div', cleanMessage(e), 'row-message'));
|
||||
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
|
||||
item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(e.event_id); } };
|
||||
return item;
|
||||
}
|
||||
function groupedByEvent(items) {
|
||||
const groups = [];
|
||||
items.forEach(e => {
|
||||
const trace = e.kind === 'log' ? e.trace_id : undefined;
|
||||
const previous = groups[groups.length - 1];
|
||||
if (trace && previous?.trace === trace) previous.entries.push(e);
|
||||
else groups.push({ trace, entries: [e] });
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
function appendEventRows(parent, items) {
|
||||
groupedByEvent(items).forEach(unit => {
|
||||
if (unit.entries.length === 1) { parent.append(row(unit.entries[0])); return; }
|
||||
const representative = [...unit.entries].reverse().find(e => e.cause_ids?.length) || unit.entries[unit.entries.length - 1];
|
||||
const ids = unit.entries.map(e => e.event_id);
|
||||
visibleEventGroups.set(representative.event_id, unit.entries);
|
||||
const recent = ids.some(id => recentEvents.has(id));
|
||||
const item = node('div', undefined, `row event-log-group${ids.includes(selected) ? ' selected' : ''}${recent ? ' recent-log' : ''}`);
|
||||
item.dataset.eventId = representative.event_id; item.dataset.eventIds = ids.join(',');
|
||||
item.tabIndex = 0; item.setAttribute('role', 'button');
|
||||
const meta = node('div', undefined, 'row-meta');
|
||||
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
|
||||
relative.dataset.time = String(unit.entries[0].time);
|
||||
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
|
||||
meta.append(relative);
|
||||
for (const level of ['INFO', 'WARNING', 'ERROR']) {
|
||||
const count = unit.entries.filter(e => normalLevel(e) === level).length;
|
||||
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
|
||||
}
|
||||
if (unit.entries.some(e => e.cause_ids.length)) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
||||
item.append(meta, node('div', unit.entries.map(cleanMessage).join('\n'), 'row-message'));
|
||||
item.onclick = () => { if (!window.getSelection()?.toString()) choose(representative.event_id); };
|
||||
item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(representative.event_id); } };
|
||||
parent.append(item);
|
||||
});
|
||||
}
|
||||
function renderApps() {
|
||||
visibleEventGroups.clear();
|
||||
const container = $('apps'); container.replaceChildren();
|
||||
const groups = new Map();
|
||||
filtered().forEach(e => { if (!groups.has(e.app)) groups.set(e.app, []); groups.get(e.app).push(e); });
|
||||
$('count').textContent = `${groups.size} apps`;
|
||||
if (!groups.size) container.append(node('div', 'No logs match these filters. Try a wider time range or clear the search.', 'empty'));
|
||||
const lastLogs = new Map();
|
||||
entries.filter(e => e.kind === 'log' || e.kind.startsWith('initialization_')).forEach(e => {
|
||||
if (!lastLogs.has(e.app) || e.time > lastLogs.get(e.app).time) lastLogs.set(e.app, e);
|
||||
});
|
||||
const timeOf = (app, logs) => (lastLogs.get(app) || logs[logs.length - 1]).time;
|
||||
[...groups].sort(([a, al], [b, bl]) => {
|
||||
const order = $('sort').value;
|
||||
if (order === 'name') return a.localeCompare(b);
|
||||
return (order === 'oldest' ? 1 : -1) * (timeOf(a, al) - timeOf(b, bl)) || a.localeCompare(b);
|
||||
}).forEach(([app, logs]) => {
|
||||
const detail = node('details'); detail.open = expanded.has(app); detail.dataset.app = app;
|
||||
if (recentApps.has(app)) detail.classList.add('new-log-app');
|
||||
const summary = node('summary'), label = node('div', undefined, 'app-label');
|
||||
const last = lastLogs.get(app);
|
||||
label.append(node('span', app, 'name'));
|
||||
if (last) {
|
||||
const age = node('span', `Last log: ${timeSince(last.time)}`, 'last-log last-age');
|
||||
age.dataset.time = String(last.time);
|
||||
age.title = `${formatTime(last, true)} · Exact: ${last.timestamp}`;
|
||||
label.append(age);
|
||||
} else label.append(node('span', 'No log in loaded history', 'last-log'));
|
||||
summary.append(label);
|
||||
['INFO', 'WARNING', 'ERROR'].forEach(level => summary.append(node('span', `${level} ${logs.filter(e => normalLevel(e) === level).length}`, `badge ${level}`)));
|
||||
detail.append(summary);
|
||||
function fill() {
|
||||
detail.querySelector('.rows')?.remove();
|
||||
if (!detail.open) return;
|
||||
const rows = node('div', undefined, 'rows');
|
||||
const units = [], initGroups = new Map();
|
||||
logs.forEach(e => {
|
||||
if ($('group-init').checked && e.initialization_id) {
|
||||
if (!initGroups.has(e.initialization_id)) { const unit = { id: e.initialization_id, entries: [] }; initGroups.set(e.initialization_id, unit); units.push(unit); }
|
||||
initGroups.get(e.initialization_id).entries.push(e);
|
||||
} else {
|
||||
const trace = e.kind === 'log' ? e.trace_id : undefined;
|
||||
const previous = units[units.length - 1];
|
||||
if (trace && previous && !previous.id && previous.trace === trace) previous.entries.push(e);
|
||||
else units.push({ trace, entries: [e] });
|
||||
}
|
||||
});
|
||||
const startTime = unit => unit.id
|
||||
? (entries.find(e => e.app === app && e.initialization_id === unit.id && e.kind === 'initialization_started') || unit.entries[0]).time
|
||||
: unit.entries[0].time;
|
||||
units.sort((a, b) => startTime(a) - startTime(b));
|
||||
units.slice(-150).forEach(unit => {
|
||||
if (!unit.id) { appendEventRows(rows, unit.entries); return; }
|
||||
const group = node('details', undefined, 'init-group'), key = app + ':' + unit.id;
|
||||
group.open = expandedInit.has(key);
|
||||
const first = entries.find(e => e.app === app && e.initialization_id === unit.id && e.kind === 'initialization_started') || unit.entries[0], last = unit.entries[unit.entries.length - 1];
|
||||
const header = node('summary', undefined, 'init-summary');
|
||||
const failed = unit.entries.some(e => normalLevel(e) === 'ERROR' || e.status === 'failed');
|
||||
header.append(node('span', `Initialization · ${formatTime(first, true)}`, 'name'), node('span', `${unit.entries.length} entries`));
|
||||
if (failed) header.append(node('span', 'ERROR', 'badge ERROR'));
|
||||
header.title = `Initialization ${unit.id} · Last entry: ${formatTime(last, true)} · Exact: ${last.timestamp}`;
|
||||
group.append(header);
|
||||
const body = node('div', undefined, 'init-rows');
|
||||
const copyGroup = node('button', 'Copy initialization');
|
||||
copyGroup.onclick = () => copy(unit.entries.map(copyText).join('\n'));
|
||||
body.append(copyGroup);
|
||||
appendEventRows(body, unit.entries.slice(-150));
|
||||
if (unit.entries.length > 150) body.append(node('div', 'Showing the last 150 entries. Copy initialization includes all loaded matches.', 'more'));
|
||||
group.append(body);
|
||||
group.addEventListener('toggle', event => { event.stopPropagation(); group.open ? expandedInit.add(key) : expandedInit.delete(key); save(); });
|
||||
rows.append(group);
|
||||
});
|
||||
if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more'));
|
||||
detail.append(rows);
|
||||
}
|
||||
detail.addEventListener('toggle', event => { if (event.target !== detail) return; detail.open ? expanded.add(app) : expanded.delete(app); save(); fill(); });
|
||||
fill(); container.append(detail);
|
||||
});
|
||||
}
|
||||
function renderDetail() {
|
||||
$('graph-mode').setAttribute('aria-pressed', String(mode === 'graph'));
|
||||
$('tree-mode').setAttribute('aria-pressed', String(mode === 'tree'));
|
||||
const e = entries.find(e => e.event_id === selected), container = $('detail');
|
||||
$('graph-mode').parentElement.hidden = !e || !e.cause_ids.length;
|
||||
if (!e) { container.replaceChildren(node('div', selected ? 'This entry is no longer in the loaded history.' : 'Select a log to view its details.', 'empty')); return; }
|
||||
const selectedGroup = visibleEventGroups.get(e.event_id) || [e];
|
||||
container.replaceChildren(node('div', selectedGroup.map(cleanMessage).join('\n'), 'selected-message'), node('div', `${e.app} · ${e.timestamp} · ${selectedGroup.length} log lines`, 'metadata'));
|
||||
const raw = node('button', 'Open raw log'); raw.onclick = () => vscode.postMessage({ type: 'open', id: e.event_id }); container.append(raw);
|
||||
if (e.source) { const source = node('button', 'Open source'); source.onclick = () => vscode.postMessage({ type: 'source', id: e.event_id }); container.append(source); }
|
||||
if (origin && origin !== selected) { const back = node('button', 'Back to selected log'); back.onclick = () => choose(origin); container.append(back); }
|
||||
const byId = new Map(entries.map(e => [e.event_id, e]));
|
||||
const mirroredEvaluations = new Map();
|
||||
entries.filter(candidate => candidate.kind === 'log').forEach(log => {
|
||||
log.cause_ids.forEach(id => {
|
||||
const candidate = byId.get(id);
|
||||
if (candidate?.kind === 'evaluation' && candidate.app === log.app && candidate.trace_id && candidate.trace_id === log.trace_id && cleanMessage(candidate) === cleanMessage(log)) mirroredEvaluations.set(id, log);
|
||||
});
|
||||
});
|
||||
const causesFor = entry => entry.cause_ids.flatMap(id => {
|
||||
const mirror = mirroredEvaluations.get(id);
|
||||
return mirror?.event_id === entry.event_id ? (byId.get(id)?.cause_ids || []) : [id];
|
||||
});
|
||||
const probableCausesFor = entry => {
|
||||
const causes = new Set(entry.probable_cause_ids || []);
|
||||
entry.cause_ids.forEach(id => {
|
||||
if (mirroredEvaluations.get(id)?.event_id === entry.event_id) (byId.get(id)?.probable_cause_ids || []).forEach(parent => causes.add(parent));
|
||||
});
|
||||
return causes;
|
||||
};
|
||||
const mirroredEvaluation = [...mirroredEvaluations].find(([, log]) => log.event_id === e.event_id)?.[0];
|
||||
if (!causesFor(e).length) container.append(node('p', e.kind === 'log' ? 'The base AppDaemon log format does not record what caused this entry.' : 'Observed origin: no upstream cause was recorded.'));
|
||||
const nodes = new Map(), edges = [];
|
||||
function visit(id, depth, path) {
|
||||
if (depth > 16 || nodes.size >= 80 || path.has(id)) return;
|
||||
const entry = byId.get(id);
|
||||
const existing = nodes.get(id);
|
||||
if (existing && existing.depth >= depth) return;
|
||||
nodes.set(id, { e: entry, depth, id });
|
||||
if (!entry) return;
|
||||
const next = new Set(path); next.add(id);
|
||||
const probable = probableCausesFor(entry);
|
||||
causesFor(entry).forEach(parent => {
|
||||
if (!edges.some(edge => edge.from === parent && edge.to === id)) edges.push({ from: parent, to: id, probable: probable.has(parent) });
|
||||
visit(parent, depth + 1, next);
|
||||
});
|
||||
}
|
||||
visit(e.event_id, 0, new Set());
|
||||
if (e.cause_ids.length) {
|
||||
container.append(node('p', 'Solid: explicit link · Dashed: probable link · Missing causes may be outside the loaded history. Limited to 80 events and 16 levels.', 'legend'));
|
||||
if (mode === 'graph') {
|
||||
const graph = node('div', undefined, 'graph'), stage = node('div', undefined, 'graph-stage');
|
||||
const nodeWidth = 320, nodeHeight = 115, laneGap = 350, levelGap = 155;
|
||||
const maxDepth = Math.max(...[...nodes.values()].map(n => n.depth)), positions = new Map(), lanes = new Map();
|
||||
nodes.forEach(n => { const level = maxDepth - n.depth; const lane = lanes.get(level) || 0; lanes.set(level, lane + 1); positions.set(n.id, { x: lane * laneGap + 10, y: level * levelGap + 10 }); });
|
||||
const width = Math.max(nodeWidth + 20, ...[...positions.values()].map(p => p.x + nodeWidth + 10)), height = maxDepth * levelGap + nodeHeight + 20;
|
||||
stage.style.width = `${width}px`; stage.style.height = `${height}px`;
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', width); svg.setAttribute('height', height);
|
||||
edges.forEach(edge => {
|
||||
const a = positions.get(edge.from), b = positions.get(edge.to); if (!a || !b) return;
|
||||
const path = document.createElementNS(svg.namespaceURI, 'path');
|
||||
path.setAttribute('d', `M ${a.x + nodeWidth / 2} ${a.y + nodeHeight} C ${a.x + nodeWidth / 2} ${a.y + nodeHeight + 20},${b.x + nodeWidth / 2} ${b.y - 20},${b.x + nodeWidth / 2} ${b.y}`);
|
||||
path.setAttribute('fill', 'none'); path.setAttribute('stroke', edge.probable ? '#d5ad68' : '#7199bc'); path.setAttribute('stroke-width', '2');
|
||||
if (edge.probable) path.setAttribute('stroke-dasharray', '5 5'); svg.append(path);
|
||||
const arrow = document.createElementNS(svg.namespaceURI, 'path'); arrow.setAttribute('d', `M ${b.x + nodeWidth / 2 - 5} ${b.y - 6} L ${b.x + nodeWidth / 2} ${b.y} L ${b.x + nodeWidth / 2 + 5} ${b.y - 6}`); arrow.setAttribute('fill', 'none'); arrow.setAttribute('stroke', '#7199bc'); svg.append(arrow);
|
||||
});
|
||||
stage.append(svg);
|
||||
nodes.forEach(n => { const p = positions.get(n.id), b = node('button', undefined, `node${!n.e ? ' missing' : ''}${n.id === selected ? ' target' : ''}`); const message = n.e ? (visibleEventGroups.get(n.e.event_id) || [n.e]).map(cleanMessage).join('\n') : n.id; b.style.left = `${p.x}px`; b.style.top = `${p.y}px`; b.append(node('strong', n.e?.app || 'Missing cause'), node('div', message, 'text')); b.title = message; b.disabled = !n.e; b.onclick = () => choose(n.id, false); stage.append(b); });
|
||||
graph.append(stage); container.append(graph);
|
||||
} else {
|
||||
const tree = node('div', undefined, 'tree');
|
||||
let count = 0;
|
||||
function branch(id, depth, path) {
|
||||
const list = node('ul');
|
||||
if (depth > 16 || count++ > 80 || path.has(id)) { list.append(node('li', 'Depth limit or cycle.')); return list; }
|
||||
const n = byId.get(id), item = node('li'), button = node('button', n ? `${n.app} · ${(visibleEventGroups.get(n.event_id) || [n]).map(cleanMessage).join('\n')}` : 'Cause missing from loaded history'); button.disabled = !n; button.onclick = () => choose(id, false); item.append(button);
|
||||
const next = new Set(path); next.add(id);
|
||||
if (n) { const probable = probableCausesFor(n); causesFor(n).forEach(parent => { if (probable.has(parent)) item.append(node('div', 'Probable link ↓', 'probable')); item.append(branch(parent, depth + 1, next)); }); } list.append(item); return list;
|
||||
}
|
||||
tree.append(branch(e.event_id, 0, new Set())); container.append(tree);
|
||||
}
|
||||
}
|
||||
const detailValues = e.values || (mirroredEvaluation ? byId.get(mirroredEvaluation)?.values : undefined);
|
||||
if (detailValues && Object.keys(detailValues).length) {
|
||||
container.append(node('h2', 'Values used during evaluation'));
|
||||
const table = node('table', undefined, 'values'); Object.entries(detailValues).forEach(([key, value]) => { const row = node('tr'); row.append(node('td', key), node('td', JSON.stringify(value))); table.append(row); }); container.append(table);
|
||||
}
|
||||
}
|
||||
function highlightNewLogs(incoming) {
|
||||
const byApp = new Map();
|
||||
incoming.forEach(e => {
|
||||
recentEvents.add(e.event_id);
|
||||
if (!byApp.has(e.app)) byApp.set(e.app, []);
|
||||
byApp.get(e.app).push(e.event_id);
|
||||
});
|
||||
byApp.forEach((ids, app) => {
|
||||
recentApps.add(app);
|
||||
const token = {};
|
||||
appFlashTokens.set(app, token);
|
||||
setTimeout(() => {
|
||||
ids.forEach(id => {
|
||||
recentEvents.delete(id);
|
||||
document.querySelectorAll('.row.recent-log').forEach(item => { if (item.dataset.eventId === id) item.classList.remove('recent-log'); });
|
||||
});
|
||||
document.querySelectorAll('.event-log-group.recent-log').forEach(item => {
|
||||
if (!item.dataset.eventIds.split(',').some(id => recentEvents.has(id))) item.classList.remove('recent-log');
|
||||
});
|
||||
if (appFlashTokens.get(app) === token) {
|
||||
recentApps.delete(app); appFlashTokens.delete(app);
|
||||
document.querySelectorAll('.new-log-app').forEach(item => { if (item.dataset.app === app) item.classList.remove('new-log-app'); });
|
||||
}
|
||||
}, 1800);
|
||||
});
|
||||
}
|
||||
function apply(message) {
|
||||
const nextEntries = message.entries;
|
||||
if (receivedInitialData) {
|
||||
const known = new Set(entries.map(e => e.event_id));
|
||||
highlightNewLogs(nextEntries.filter(e => e.kind === 'log' && !known.has(e.event_id)));
|
||||
}
|
||||
receivedInitialData = true;
|
||||
appliedMessage = message; displayZone = message.timezone || displayZone; entries = nextEntries;
|
||||
const logCount = entries.filter(e => e.kind === 'log' || e.kind.startsWith('initialization_')).length;
|
||||
const traceCount = entries.length - logCount;
|
||||
const traceLabel = traceCount === 1 ? 'trace event' : 'trace events';
|
||||
$('status').textContent = traceCount
|
||||
? `${logCount} log lines + ${traceCount} ${traceLabel} · Live updates · Up to ${message.limit || 10000} records`
|
||||
: `${logCount} log lines · Live updates · Up to ${message.limit || 10000} records`;
|
||||
$('status').title = traceCount
|
||||
? 'Showing AppDaemon logs and additional records from configured history files.'
|
||||
: 'Reading the configured AppDaemon text logs. Causal links require additional metadata that the base log format does not contain.';
|
||||
$('warnings').textContent = (message.warnings || []).join(' · '); renderApps(); renderDetail();
|
||||
}
|
||||
function refreshLastLogAges() {
|
||||
document.querySelectorAll('.last-age[data-time]').forEach(label => { label.textContent = `Last log: ${timeSince(label.dataset.time)}`; });
|
||||
document.querySelectorAll('.relative-time[data-time]').forEach(label => { label.textContent = timeSince(label.dataset.time); });
|
||||
}
|
||||
setInterval(refreshLastLogAges, 1000);
|
||||
function selectionActive() { return pointerSelecting || Boolean(window.getSelection()?.toString()); }
|
||||
window.addEventListener('message', event => {
|
||||
if (event.data?.type === 'copied') { $('copy').textContent = 'Copied'; setTimeout(() => $('copy').textContent = 'Copy selected log', 1200); return; }
|
||||
if (event.data?.type !== 'entries') return;
|
||||
latest = event.data;
|
||||
if (!paused && !selectionActive()) apply(latest);
|
||||
else if (!paused) $('status').textContent = 'Updates buffered while text is selected';
|
||||
});
|
||||
document.addEventListener('pointerdown', event => { pointerSelecting = Boolean(event.target.closest?.('.row, .selected-message, .node, .values')); });
|
||||
document.addEventListener('pointerup', () => { pointerSelecting = false; });
|
||||
document.addEventListener('selectionchange', () => { if (!paused && !selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!paused && !selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
|
||||
document.addEventListener('keydown', event => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && !event.target.closest?.('input, textarea, [contenteditable=true]')) {
|
||||
const entry = entries.find(e => e.event_id === selected);
|
||||
const text = window.getSelection()?.toString() || (entry ? (visibleEventGroups.get(entry.event_id) || [entry]).map(copyText).join('\n') : '');
|
||||
if (text && (inVSCode || !window.getSelection()?.toString())) { event.preventDefault(); copy(text); }
|
||||
}
|
||||
});
|
||||
$('copy').onclick = () => { const e = entries.find(e => e.event_id === selected); copy(window.getSelection()?.toString() || (e ? (visibleEventGroups.get(e.event_id) || [e]).map(copyText).join('\n') : '')); };
|
||||
['search', 'period', 'level', 'group-init', 'sort'].forEach(id => $(id).addEventListener('input', () => { save(); renderApps(); }));
|
||||
$('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; if (!paused && latest) apply(latest); else if (paused) $('status').textContent = 'Display paused · New events are still being collected'; };
|
||||
$('import').onclick = () => vscode.postMessage({ type: 'import' });
|
||||
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
|
||||
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
|
||||
$('sort').value = saved.sort || 'latest';
|
||||
$('group-init').checked = saved.groupInit !== false;
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
if (window.LOG_REVIEW_DATA) { apply({ timezone: 'Europe/Berlin', entries: window.LOG_REVIEW_DATA, warnings: ['Interface preview: sample data, no AppDaemon connection.'], limit: 10000 }); expanded.add('automation'); selected = 'review-5'; renderApps(); renderDetail(); }
|
||||
})();
|
||||
Reference in new issue
Block a user