Improved error feedbacks

This commit is contained in:
Pierre committed 2026-10-04 12:39:38 +02:00
1 parent ece851f5a0
commit cfb5334666
12 files changed
+253 -13

No files matched your search

+2 -1
View File
@@ -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));
+76 -9
View File
@@ -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(); }
}
+25
View File
@@ -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 };
}
+7
View File
@@ -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();