Improved error feedbacks
This commit is contained in:
1 parent
ece851f5a0
commit
cfb5334666
12 files changed
+253
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
@@ -3,7 +3,7 @@
|
||||
<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>
|
||||
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" title="Show errors across all apps in loaded history">0 errors</button><button id="import">Open history</button><button id="clear" title="Empty configured live log files; imported history files are kept">Clear logs</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></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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string>();
|
||||
private warnings = new Set<string>();
|
||||
private busy = false;
|
||||
constructor(private context: vscode.ExtensionContext) {}
|
||||
private sourceDocuments?: Promise<SourceDocument[]>;
|
||||
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<string[]>('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<string>(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<string>('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<string, string>();
|
||||
@@ -205,6 +271,7 @@ export class LogExplorer implements vscode.Disposable {
|
||||
}
|
||||
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('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(); }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Binary file not shown.
Reference in new issue
Block a user