/* Shared UI: VS Code transport or a standalone review fixture. No log HTML is executed. */ (() => { const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : { postMessage() {}, getState() {}, setState() {} }; const saved = vscode.getState() || {}; let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected; const expanded = new Set(saved.expanded || []); const visibleEventGroups = new Map(); const logScrollPositions = new Map(); const recentEvents = new Set(); const recentApps = new Set(); const appFlashTokens = new Map(); let receivedInitialData = false; let pointerSelecting = false; let errorView; 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], sort: $('sort').value }); } 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, '').replace(/ \[group=(?:initialization|smartcondition|value_selector):[a-f0-9]{12}\]/gi, ''); 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; } }; 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 shortTimeSince(time) { const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000)); if (seconds < 60) return `${seconds} sec ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes} min ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} hr ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days} d ago`; const months = Math.floor(days / 30); if (days < 365) return `${months} mo ago`; return `${Math.floor(days / 365)} yr ago`; } function logMessages(items, cls) { const container = node('div', undefined, cls); items.forEach((entry, index) => { if (index) container.append(document.createTextNode('\n')); container.append(node('span', cleanMessage(entry), `log-message level-${normalLevel(entry)}`)); }); return container; } function blockTitle(items, groupKey) { const first = items[0]; const kind = groupKey?.startsWith('initialization:') || first.initialization_id ? 'initialization' : items.find(entry => entry.group_kind)?.group_kind; const names = { initialization: 'Initialization', smartcondition: 'Smart condition', value_selector: 'Value selector' }; const name = names[kind] || (items.length > 1 || first.trace_id ? 'Event' : 'Log'); const label = kind === 'initialization' ? first.app : items.map(entry => entry.group_label || cleanMessage(entry).match(/^\[([^\]]+)\]/)?.[1]).find(Boolean) || first.entity || first.app; return node('div', `${name} · ${label}`, 'block-title'); } 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(blockTitle([e]), meta, logMessages([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, groupKey) { const groups = []; items.forEach(e => { const key = groupKey || (e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined); const previous = groups[groups.length - 1]; if (key && previous?.key === key) previous.entries.push(e); else groups.push({ key, entries: [e] }); }); return groups; } function appendEventRows(parent, items, groupKey) { groupedByEvent(items, groupKey).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(blockTitle(unit.entries, groupKey), meta, logMessages(unit.entries, '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() { const appList = $('apps').parentElement; const appListTop = appList.scrollTop; document.querySelectorAll('#apps details[data-app]').forEach(detail => { const rows = detail.querySelector('.rows'); if (!rows) return; logScrollPositions.set(detail.dataset.app, { top: rows.scrollTop, follow: rows.scrollHeight - rows.clientHeight - rows.scrollTop <= 24 }); }); 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; $('error-count').setAttribute('aria-pressed', String($('level').value === 'ERROR')); [...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); let preview; label.append(node('span', app, 'name')); if (last) { const line = node('span', undefined, `last-log log-message level-${normalLevel(last)}`); const age = node('span', shortTimeSince(last.time), 'last-age'); age.dataset.time = String(last.time); age.title = `${formatTime(last, true)} · Exact: ${last.timestamp}`; const message = cleanMessage(last); line.title = message; line.append(age, document.createTextNode(' : '), node('span', message.split(/\r?\n/, 1)[0].trim() || '—', 'last-message')); preview = line; } else preview = 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}`))); summary.append(preview); 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 (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 key = e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined; const previous = units[units.length - 1]; if (key && previous && !previous.id && previous.key === key) previous.entries.push(e); else units.push({ key, 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)); if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more')); units.slice(-150).forEach(unit => { appendEventRows(rows, unit.entries, unit.id ? `initialization:${unit.id}` : undefined); }); detail.append(rows); const position = logScrollPositions.get(app); rows.scrollTop = !position || position.follow ? rows.scrollHeight : position.top; } detail.addEventListener('toggle', event => { if (event.target !== detail) return; if (detail.open) expanded.add(app); else { expanded.delete(app); logScrollPositions.delete(app); } save(); fill(); }); container.append(detail); fill(); }); appList.scrollTop = appListTop; } 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(logMessages(selectedGroup, '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); const sourceEntries = selectedGroup.filter(entry => entry.source); const sourceLocations = new Set(); sourceEntries.forEach(entry => { const location = `${entry.source.path}:${entry.source.line}`; if (sourceLocations.has(location)) return; sourceLocations.add(location); const source = node('button', 'Open source'); source.title = location; source.onclick = () => vscode.postMessage({ type: 'source', id: entry.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 log files.' : 'Reading the configured AppDaemon text logs. Causal links require additional metadata that the base log format does not contain.'; const errorCount = entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && normalLevel(e) === 'ERROR').length; $('error-count').textContent = `${errorCount} error${errorCount === 1 ? '' : 's'}`; $('error-count').classList.toggle('has-errors', errorCount > 0); $('error-count').title = 'Errors and critical logs across all apps in loaded history. Click to show them.'; $('warnings').textContent = (message.warnings || []).join(' · '); renderApps(); renderDetail(); } function refreshLastLogAges() { document.querySelectorAll('.last-age[data-time]').forEach(label => { label.textContent = shortTimeSince(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 === 'clear-result') { $('clear').disabled = false; $('clear').textContent = 'Clear logs'; if (event.data.failures?.length) $('warnings').textContent = event.data.failures.join(' · '); return; } if (event.data?.type !== 'entries') return; latest = event.data; if (!selectionActive()) apply(latest); else $('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 (!selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!selectionActive() && latest !== appliedMessage) apply(latest); }, 0); }); ['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => { errorView = undefined; selected = undefined; origin = undefined; save(); renderApps(); renderDetail(); })); $('error-count').onclick = () => { const leaving = $('level').value === 'ERROR'; if (leaving) { $('search').value = errorView?.search || ''; $('period').value = errorView?.period || '0'; $('level').value = errorView?.level || 'all'; if (errorView) { expanded.clear(); errorView.expanded.forEach(app => expanded.add(app)); } errorView = undefined; } else { errorView = { search: $('search').value, period: $('period').value, level: $('level').value, expanded: [...expanded] }; $('search').value = ''; $('period').value = '0'; $('level').value = 'ERROR'; entries.filter(e => normalLevel(e) === 'ERROR').forEach(e => expanded.add(e.app)); } selected = undefined; origin = undefined; save(); renderApps(); renderDetail(); if (!leaving) { const first = document.querySelector('#apps .row'); if (first) { first.click(); first.closest('.rows').scrollTop = 0; first.focus({ preventScroll: true }); } } }; $('clear').onclick = () => { selected = undefined; origin = undefined; save(); $('clear').disabled = true; $('clear').textContent = 'Clearing…'; vscode.postMessage({ type: 'clear' }); }; $('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); }; $('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); }; $('sort').value = saved.sort || 'latest'; 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(); } })();