diff --git a/vscode-appdaemon/README.md b/vscode-appdaemon/README.md index 9bca721..d24fa76 100644 --- a/vscode-appdaemon/README.md +++ b/vscode-appdaemon/README.md @@ -19,6 +19,8 @@ By default, it reads the existing AppDaemon text logs from the workspace: The explorer groups lines by the app name in the standard AppDaemon log format. It provides INFO/WARNING/ERROR counts, expandable app histories, relative timestamps, sorting by latest activity or app name, text/time/level filters, live updates, copy/paste, raw-log navigation, and archived-log import. Duplicate error lines found in multiple files are merged. The log reader handles file rotation, truncation, multiline exceptions, UTF-8, and legacy Windows-1252 bytes. +**AD Logs** turns red when the loaded logs contain an ERROR or CRITICAL entry. **Clear logs** empties the configured live log files and clears the current explorer history; imported archive files are left on disk. + The standard AppDaemon text logs do not contain causal links or reliable initialization boundaries. The details panel will say so for ordinary entries. Existing messages that already contain trace or initialization metadata can still be grouped or linked; the explorer does not create that metadata. **Open history** can also load archived files. No AppDaemon Python changes are needed for this raw-log view. ## Settings diff --git a/vscode-appdaemon/media/logExplorer.css b/vscode-appdaemon/media/logExplorer.css index a991b56..fc97ca9 100644 --- a/vscode-appdaemon/media/logExplorer.css +++ b/vscode-appdaemon/media/logExplorer.css @@ -6,3 +6,5 @@ .log-message.level-WARNING{color:var(--vscode-editorWarning-foreground,#f3cd80)}.log-message.level-ERROR{color:var(--vscode-editorError-foreground,#ffacac)} .block-title{font-weight:600;margin-bottom:6px;color:var(--fg);overflow-wrap:anywhere} + +#error-count{font-weight:700;font-size:14px}#error-count.has-errors{color:var(--vscode-editorError-foreground,#ffacac);border-color:currentColor;background:var(--vscode-inputValidation-errorBackground,#5b3039)} diff --git a/vscode-appdaemon/media/logExplorer.html b/vscode-appdaemon/media/logExplorer.html index c1c01da..f2a6816 100644 --- a/vscode-appdaemon/media/logExplorer.html +++ b/vscode-appdaemon/media/logExplorer.html @@ -3,7 +3,7 @@ AppDaemon · Logs -

AppDaemon Logs

Reading logs…

+

AppDaemon Logs

Reading logs…

Applications

Log details

Select a log to view its details.

The base AppDaemon log format does not include causal links.

diff --git a/vscode-appdaemon/media/logExplorer.js b/vscode-appdaemon/media/logExplorer.js index 9d83da5..0503954 100644 --- a/vscode-appdaemon/media/logExplorer.js +++ b/vscode-appdaemon/media/logExplorer.js @@ -21,6 +21,7 @@ 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; @@ -135,6 +136,7 @@ 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); @@ -193,7 +195,15 @@ 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); - if (e.source) { const source = node('button', 'Open source'); source.onclick = () => vscode.postMessage({ type: 'source', id: e.event_id }); container.append(source); } + 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(); @@ -315,6 +325,10 @@ $('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.'; + 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() { @@ -325,6 +339,12 @@ 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 === '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 (!paused && !selectionActive()) apply(latest); @@ -341,9 +361,33 @@ } }); $('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', 'sort'].forEach(id => $(id).addEventListener('input', () => { save(); renderApps(); })); + ['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => { + errorView = undefined; selected = undefined; origin = undefined; + save(); renderApps(); renderDetail(); + })); $('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'; }; + $('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 }); } + } + }; $('import').onclick = () => vscode.postMessage({ type: 'import' }); + $('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'; diff --git a/vscode-appdaemon/src/extension.ts b/vscode-appdaemon/src/extension.ts index a2a77b9..fd932a0 100644 --- a/vscode-appdaemon/src/extension.ts +++ b/vscode-appdaemon/src/extension.ts @@ -345,9 +345,10 @@ export async function activate(context: vscode.ExtensionContext) { haClient = new HAClient(outputChannel); adClient = new ADClient(outputChannel); errorViewer = new ErrorViewer(); - const logExplorer = new LogExplorer(context); + const logExplorer = new LogExplorer(context, count => statusBar.setLogErrorCount(count)); context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show())); statusBar = new StatusBarManager(adClient); + logExplorer.startPolling(); // Wire error count → status bar badge errorViewer.onErrorCountChanged((count) => statusBar.setErrorCount(count)); diff --git a/vscode-appdaemon/src/logExplorer.ts b/vscode-appdaemon/src/logExplorer.ts index e767d8e..0b76d1b 100644 --- a/vscode-appdaemon/src/logExplorer.ts +++ b/vscode-appdaemon/src/logExplorer.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { randomBytes } from 'crypto'; import { LogEntry, parseLine, decodeLogLine } from './logModel'; +import { findLogSource, SourceDocument } from './logSource'; type Cursor = { offset: number; identity: string; pending: Buffer; last?: LogEntry; discard: boolean; generation: string }; export class LogExplorer implements vscode.Disposable { @@ -16,7 +17,10 @@ export class LogExplorer implements vscode.Disposable { private imported = new Set(); private warnings = new Set(); private busy = false; - constructor(private context: vscode.ExtensionContext) {} + private sourceDocuments?: Promise; + private sourceDocumentsAt = 0; + private publication = 0; + constructor(private context: vscode.ExtensionContext, private onErrorCountChanged?: (count: number) => void) {} show() { if (this.view) { this.view.show(); return; } if (this.panel) { this.panel.reveal(); return; } @@ -27,7 +31,7 @@ export class LogExplorer implements vscode.Disposable { this.configureWebview(this.panel.webview); this.panel.onDidDispose(() => { this.panel = undefined; - if (!this.view) { this.stopPolling(); this.activeWebview = undefined; } + if (!this.view) { this.activeWebview = undefined; } }); this.startPolling(); } @@ -38,11 +42,11 @@ export class LogExplorer implements vscode.Disposable { localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')] }; this.configureWebview(view.webview); - const updateVisibility = () => view.visible ? this.startPolling() : this.stopPolling(); + const updateVisibility = () => { if (view.visible) { this.startPolling(); } }; view.onDidChangeVisibility(updateVisibility, undefined, this.context.subscriptions); view.onDidDispose(() => { if (this.view === view) { this.view = undefined; } - if (!this.panel) { this.stopPolling(); this.activeWebview = undefined; } + if (!this.panel) { this.activeWebview = undefined; } }, undefined, this.context.subscriptions); updateVisibility(); } @@ -77,7 +81,7 @@ export class LogExplorer implements vscode.Disposable { const source = this.entries.get(message.id)?.source; const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!source || !root || typeof source.path !== 'string' || !Number.isInteger(source.line)) { return; } - const target = source.path.startsWith('/conf/') ? path.resolve(root, source.path.slice(6)) : path.resolve(source.path); + const target = source.path.startsWith('/conf/') ? path.resolve(root, source.path.slice(6)) : path.resolve(root, source.path); const relative = path.relative(root, target); if (relative.startsWith('..') || path.isAbsolute(relative)) { vscode.window.showInformationMessage('The source is outside the workspace.'); return; } try { @@ -93,10 +97,11 @@ export class LogExplorer implements vscode.Disposable { files?.forEach(uri => this.imported.add(uri.fsPath)); this.poll(); } + if (message.type === 'clear') { this.clearLogs(); } }, undefined, this.context.subscriptions); this.activeWebview = webview; } - private startPolling() { + startPolling() { if (this.timer) { return; } this.poll(); this.timer = setInterval(() => this.poll(), 1000); @@ -104,12 +109,40 @@ export class LogExplorer implements vscode.Disposable { private stopPolling() { if (this.timer) { clearInterval(this.timer); this.timer = undefined; } } - private files(): string[] { + private configuredFiles(): string[] { const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!folder) { return [...this.imported]; } + if (!folder) { return []; } const config = vscode.workspace.getConfiguration('appdaemon'); const configured = config.get('logExplorerPaths', ['logs/appdaemon.log', 'logs/error.log', 'logs/virtualsensors.log']); - return [...new Set([...configured.map(p => path.resolve(folder, p)), ...this.imported])]; + return [...new Set(configured.map(p => path.resolve(folder, p)))]; + } + private files(): string[] { + return [...new Set([...this.configuredFiles(), ...this.imported])]; + } + private clearLogs() { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const cleared = new Set(this.imported); + const failures: string[] = []; + if (folder) { + const realRoot = fs.realpathSync(folder); + for (const file of this.configuredFiles()) { + try { + const relative = path.relative(realRoot, fs.realpathSync(file)); + if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('outside the workspace'); } + if (!fs.statSync(file).isFile()) { throw new Error('not a regular file'); } + fs.truncateSync(file, 0); + this.cursors.delete(file); + cleared.add(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { failures.push(`${path.basename(file)}: ${String(error)}`); } + } + } + } + this.imported.clear(); + for (const [id, entry] of this.entries) { if (entry.file && cleared.has(entry.file)) { this.entries.delete(id); } } + failures.forEach(failure => this.warnings.add(`Unable to clear ${failure}`)); + this.publish(); + this.activeWebview?.postMessage({ type: 'clear-result', cleared: cleared.size, failures }); } private poll() { if (this.busy) { return; } @@ -168,7 +201,39 @@ export class LogExplorer implements vscode.Disposable { if (changed) { this.publish(); } } finally { this.busy = false; } } + private async resolveSources(entries: LogEntry[], publication: number) { + if (!entries.some(entry => !entry.source && entry.message.includes('Error parsing string')) || !vscode.workspace.findFiles) { return; } + if (!this.sourceDocuments || Date.now() - this.sourceDocumentsAt > 5000) { + this.sourceDocumentsAt = Date.now(); + this.sourceDocuments = (async () => { + const files = await vscode.workspace.findFiles('**/*.{yaml,yml}', '**/{node_modules,.git}/**'); + const documents: SourceDocument[] = []; + for (const uri of files) { + try { + const doc = await vscode.workspace.openTextDocument(uri); + documents.push({ path: uri.fsPath, text: doc.getText() }); + } catch { /* A configuration file may have been removed. */ } + } + return documents; + })(); + } + try { + const documents = await this.sourceDocuments; + if (publication !== this.publication) { return; } + let changed = false; + for (const entry of entries) { + if (entry.source) { continue; } + const source = findLogSource(entry, documents); + if (source) { entry.source = source; changed = true; } + } + if (changed) { + const zone = vscode.workspace.getConfiguration('appdaemon').get('logTimezone', 'Europe/Berlin'); + this.activeWebview?.postMessage({ type: 'entries', entries, warnings: [...this.warnings], limit: 10000, timezone: zone }); + } + } catch { this.sourceDocuments = undefined; } + } private publish() { + const publication = ++this.publication; const entries = [...this.entries.values()].sort((a, b) => { const time = a.time - b.time; if (time) { return time; } @@ -183,6 +248,7 @@ export class LogExplorer implements vscode.Disposable { const key = e.trace_id ? e.event_id : `${e.timestamp}|${e.app}|${e.level}|${e.message}`; if (seen.has(key)) { return false; } seen.add(key); return true; }); + this.onErrorCountChanged?.(unique.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && (e.level === 'ERROR' || e.level === 'CRITICAL')).length); // Direct app.log() calls have no LoggerInterface tag. Infer their initialization // group while an explicit per-app initialize() scope is open. const activeInitialization = new Map(); @@ -205,6 +271,7 @@ export class LogExplorer implements vscode.Disposable { } const zone = vscode.workspace.getConfiguration('appdaemon').get('logTimezone', 'Europe/Berlin'); this.activeWebview?.postMessage({ type: 'entries', entries: unique, warnings: [...this.warnings], limit: 10000, timezone: zone }); + void this.resolveSources(unique, publication); } dispose() { this.stopPolling(); this.messageListener?.dispose(); this.panel?.dispose(); } } diff --git a/vscode-appdaemon/src/logSource.ts b/vscode-appdaemon/src/logSource.ts new file mode 100644 index 0000000..42788c4 --- /dev/null +++ b/vscode-appdaemon/src/logSource.ts @@ -0,0 +1,25 @@ +import { LogEntry } from './logModel'; + +export type SourceDocument = { path: string; text: string }; + +/** Resolve parsing failures to the expression in their app's YAML configuration. */ +export function findLogSource(entry: LogEntry, documents: SourceDocument[]): LogEntry['source'] | undefined { + const expression = entry.message.match(/Error parsing string "([^\n]+)"(?: at character|:|\n|$)/)?.[1]; + if (!expression) { return undefined; } + const matches: Array<{ path: string; line: number; app: string }> = []; + for (const document of documents) { + let app = ''; + document.text.split(/\r?\n/).forEach((line, index) => { + const key = line.match(/^([^\s#][^:]*):(?:\s|$)/); + if (key) { app = key[1].replace(/^['"]|['"]$/g, ''); } + if (!line.trimStart().startsWith('#') && line.includes(expression)) { + matches.push({ path: document.path, line: index + 1, app }); + } + }); + } + const local = matches.filter(match => match.app === entry.app); + const candidates = local.length ? local : matches; + if (candidates.length !== 1) { return undefined; } + const { path, line } = candidates[0]; + return { path, line }; +} diff --git a/vscode-appdaemon/src/statusBar.ts b/vscode-appdaemon/src/statusBar.ts index fcb0df0..21a1b97 100644 --- a/vscode-appdaemon/src/statusBar.ts +++ b/vscode-appdaemon/src/statusBar.ts @@ -96,6 +96,13 @@ export class StatusBarManager { } } + setLogErrorCount(count: number) { + this.logsItem.backgroundColor = count > 0 ? new vscode.ThemeColor('statusBarItem.errorBackground') : undefined; + this.logsItem.tooltip = count > 0 + ? `${count} error${count === 1 ? '' : 's'} in loaded AppDaemon logs — open Log Explorer` + : 'Open AppDaemon Log Explorer'; + } + dispose() { this.restartADItem.dispose(); this.restartHAItem.dispose(); diff --git a/vscode-appdaemon/tests/logExplorer.test.js b/vscode-appdaemon/tests/logExplorer.test.js index 27e7fc4..e276b05 100644 --- a/vscode-appdaemon/tests/logExplorer.test.js +++ b/vscode-appdaemon/tests/logExplorer.test.js @@ -60,3 +60,24 @@ test('groups plain app.log lines between explicit initialization markers',()=>{ assert.equal(grouped[1].group_inferred,true); assert.equal(grouped[2].group_inferred,undefined); }); + +test('clears configured live files, keeps imported archives, and updates the log error count',()=>{ + const counts=[];const e=new LogExplorer({},count=>counts.push(count)); + messages=[];const webview={postMessage:m=>messages.push(m)};e.panel={webview};e.activeWebview=webview; + const errorFile=path.join(root,'logs/error.log'); + const archive=path.join(root,'logs/history.log.1'); + fs.writeFileSync(file,'27-09-26 10:12:13 ERROR app: failed\n'); + fs.writeFileSync(errorFile,'27-09-26 10:12:13 ERROR app: failed\n'); + fs.writeFileSync(archive,'27-09-26 10:12:14 INFO app: archived\n'); + e.imported.add(archive);e.poll(); + assert.equal(counts.at(-1),1); + e.clearLogs(); + assert.equal(fs.statSync(file).size,0); + assert.equal(fs.statSync(errorFile).size,0); + assert.match(fs.readFileSync(archive,'utf8'),/archived/); + assert.equal(counts.at(-1),0); + assert.deepEqual(messages.filter(message=>message.type==='entries').at(-1).entries,[]); + fs.appendFileSync(file,'27-09-26 10:12:15 CRITICAL app: new failure\n');e.poll(); + assert.equal(counts.at(-1),1); + assert.equal(latest()[0].message,'new failure'); +}); diff --git a/vscode-appdaemon/tests/logSource.test.js b/vscode-appdaemon/tests/logSource.test.js new file mode 100644 index 0000000..4a27139 --- /dev/null +++ b/vscode-appdaemon/tests/logSource.test.js @@ -0,0 +1,17 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {findLogSource}=require('../out/logSource'); +const expression='sensor.target_temperature_heatpump + 0.5'; +const entry={app:'living_room_heatpump',message:`Error parsing string "${expression}" at character 0:Unknown variable : sensor.target_temperature_heatpump`}; +test('resolves the failing expression inside the matching app rather than another app',()=>{ + const docs=[{path:'/conf/apps/heating.yaml',text:`bedroom_heatpump:\n target_temperature: ${expression}\nliving_room_heatpump:\n target_temperature: ${expression}\n`}]; + assert.deepEqual(findLogSource(entry,docs),{path:'/conf/apps/heating.yaml',line:4}); +}); +test('resolves a unique expression in a shared template and ignores comments',()=>{ + assert.deepEqual(findLogSource(entry,[{path:'/conf/apps/templates.yml',text:`# ${expression}\nlibrary:\n value: ${expression}`}]),{path:'/conf/apps/templates.yml',line:3}); +}); +test('does not guess when a source is missing or ambiguous',()=>{ + assert.equal(findLogSource(entry,[]),undefined); + assert.equal(findLogSource(entry,[{path:'a.yaml',text:`living_room_heatpump:\n a: ${expression}\n b: ${expression}`}]),undefined); + assert.equal(findLogSource({...entry,message:'Initialization failed'},[]),undefined); +}); diff --git a/vscode-appdaemon/tests/logUi.test.js b/vscode-appdaemon/tests/logUi.test.js index 27d44a1..950d902 100644 --- a/vscode-appdaemon/tests/logUi.test.js +++ b/vscode-appdaemon/tests/logUi.test.js @@ -157,3 +157,57 @@ test('shows consecutive logs from one explicit group as a single visible row',() assert.doesNotMatch(rows[0].textContent,/\[group=/); dom.window.close(); }); + +test('global error counter shows all loaded errors and opens them despite active filters',()=>{ + const {dom,d,entries,send}=setup(); + send([...entries,{...entries[3],event_id:'alpha-error',level:'ERROR',message:'Alpha failed'}, + {...entries[4],event_id:'beta-critical',level:'CRITICAL',message:'Beta failed'}, + {...entries[4],event_id:'supplement',kind:'evaluation',level:'ERROR',message:'Supplemental'}]); + const counter=d.getElementById('error-count'); + assert.equal(counter.textContent,'2 errors');assert.ok(counter.classList.contains('has-errors')); + d.getElementById('search').value='nothing';d.getElementById('period').value='15'; + counter.click(); + assert.equal(d.getElementById('search').value,'');assert.equal(d.getElementById('period').value,'0'); + assert.equal(d.getElementById('level').value,'ERROR'); + assert.equal(d.querySelectorAll('#apps .row').length,2); + assert.match(d.getElementById('detail').textContent,/failed/); + dom.window.close(); +}); + +test('exits errors with the counter or All levels and restores normal app sorting',()=>{ + const {dom,d,entries,send}=setup(); + send([...entries,{...entries[3],event_id:'old-alpha-error',time:entries[0].time,level:'ERROR',message:'Old error'}]); + const counter=d.getElementById('error-count'); + counter.click();assert.equal(counter.getAttribute('aria-pressed'),'true'); + assert.equal(d.querySelector('[data-app]').dataset.app,'alpha'); + counter.click(); + assert.equal(d.getElementById('level').value,'all'); + assert.equal(counter.getAttribute('aria-pressed'),'false'); + assert.equal(d.querySelector('[data-app]').dataset.app,'beta'); + counter.click(); + d.getElementById('level').value='all';d.getElementById('level').dispatchEvent(new dom.window.Event('input')); + assert.equal(d.querySelector('[data-app]').dataset.app,'beta'); + assert.equal(d.querySelectorAll('.row.selected').length,0); + assert.equal(counter.getAttribute('aria-pressed'),'false'); + dom.window.close(); +}); + +test('Open source in a group targets the parsing error even when the last entry has no source',()=>{ + const {dom,d,entries,send,sent}=setup(); + send([{...entries[0],event_id:'parse-error',level:'ERROR',source:{path:'/conf/apps/heating.yaml',line:160}}, + {...entries[1],event_id:'init-failed',level:'ERROR',message:'Initialization failed'}]); + const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); + app.querySelector('.row').click(); + const source=[...d.querySelectorAll('#detail button')].find(button=>button.textContent==='Open source'); + assert.ok(source);source.click();assert.equal(sent.at(-1).type,'source');assert.equal(sent.at(-1).id,'parse-error'); + dom.window.close(); +}); + +test('Clear logs sends a request and resets its button after completion',()=>{ + const {dom,d,sent}=setup(); + const button=d.getElementById('clear');button.click(); + assert.equal(sent.at(-1).type,'clear');assert.equal(button.disabled,true); + dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:{type:'clear-result',cleared:2,failures:[]}})); + assert.equal(button.disabled,false);assert.equal(button.textContent,'Clear logs'); + dom.window.close(); +}); diff --git a/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix b/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix index 9e2097a..4002bd3 100644 Binary files a/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix and b/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix differ