Improved error feedbacks
This commit is contained in:
1 parent
ece851f5a0
commit
cfb5334666
12 files changed
+253
-13
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user