Add session error log and transient error icon

This commit is contained in:
Jordan Wages 2026-01-06 22:01:20 -06:00
commit 9269225a0c
4 changed files with 135 additions and 15 deletions

View file

@ -24,8 +24,9 @@ with JSON indicating whether the message meets a specified criterion.
- **Rule enable/disable** temporarily turn a rule off without removing it. - **Rule enable/disable** temporarily turn a rule off without removing it.
- **Account & folder filters** limit rules to specific accounts or folders. - **Account & folder filters** limit rules to specific accounts or folders.
- **Context menu** apply AI rules from the message list or the message display action button. - **Context menu** apply AI rules from the message list or the message display action button.
- **Status icons** toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red until you dismiss the notification. - **Status icons** toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red briefly before returning to normal.
- **Error notification** failed classification displays a notification with a button to clear the error and reset the icon. - **Error notification** failed classification displays a notification in Thunderbird.
- **Session error log** the Errors tab (visible only when errors occur) shows errors recorded since the last add-on start.
- **View reasoning** inspect why rules matched via the Details popup. - **View reasoning** inspect why rules matched via the Details popup.
- **Cache management** clear cached results from the context menu or options page. - **Cache management** clear cached results from the context menu or options page.
- **Queue & timing stats** monitor processing time on the Maintenance tab. - **Queue & timing stats** monitor processing time on the Maintenance tab.
@ -88,7 +89,7 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e
open a compose window using the account that received the message. open a compose window using the account that received the message.
3. Save your settings. New mail will be evaluated automatically using the 3. Save your settings. New mail will be evaluated automatically using the
configured rules. configured rules.
4. If the toolbar icon shows a red X, click the notification's **Dismiss** button to clear the error. 4. If the toolbar icon shows a red X, it will clear after a few seconds. Open the Errors tab in Options to review the latest failures.
### Example Filters ### Example Filters

View file

@ -19,6 +19,7 @@ let queue = Promise.resolve();
let queuedCount = 0; let queuedCount = 0;
let processing = false; let processing = false;
let iconTimer = null; let iconTimer = null;
let errorTimer = null;
let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 }; let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 };
let currentStart = 0; let currentStart = 0;
let logGetTiming = true; let logGetTiming = true;
@ -33,8 +34,11 @@ let userTheme = 'auto';
let currentTheme = 'light'; let currentTheme = 'light';
let detectSystemTheme; let detectSystemTheme;
let errorPending = false; let errorPending = false;
let errorLog = [];
let showDebugTab = false; let showDebugTab = false;
const ERROR_NOTIFICATION_ID = 'sortana-error'; const ERROR_NOTIFICATION_ID = 'sortana-error';
const ERROR_ICON_TIMEOUT = 4500;
const MAX_ERROR_LOG = 50;
function normalizeRules(rules) { function normalizeRules(rules) {
return Array.isArray(rules) ? rules.map(r => { return Array.isArray(rules) ? rules.map(r => {
@ -108,11 +112,33 @@ function showTransientIcon(factory, delay = 1500) {
async function clearError() { async function clearError() {
errorPending = false; errorPending = false;
await storage.local.set({ errorPending: false }); clearTimeout(errorTimer);
await browser.notifications.clear(ERROR_NOTIFICATION_ID); await browser.notifications.clear(ERROR_NOTIFICATION_ID);
updateActionIcon(); updateActionIcon();
} }
function recordError(context, err) {
const message = err instanceof Error ? err.message : String(err || 'Unknown error');
const detail = err instanceof Error ? err.stack : '';
errorLog.unshift({
time: Date.now(),
context,
message,
detail
});
if (errorLog.length > MAX_ERROR_LOG) {
errorLog.length = MAX_ERROR_LOG;
}
errorPending = true;
updateActionIcon();
clearTimeout(errorTimer);
errorTimer = setTimeout(() => {
errorPending = false;
updateActionIcon();
}, ERROR_ICON_TIMEOUT);
browser.runtime.sendMessage({ type: 'sortana:errorLogUpdated', count: errorLog.length }).catch(() => {});
}
function refreshMenuIcons() { function refreshMenuIcons() {
browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') });
browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') });
@ -382,16 +408,14 @@ async function processMessage(id) {
const elapsed = Date.now() - currentStart; const elapsed = Date.now() - currentStart;
currentStart = 0; currentStart = 0;
updateTimingStats(elapsed); updateTimingStats(elapsed);
await storage.local.set({ classifyStats: timingStats, errorPending: true }); await storage.local.set({ classifyStats: timingStats });
errorPending = true;
logger.aiLog("failed to apply AI rules", { level: 'error' }, e); logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
setIcon(ICONS.error()); recordError("Failed to apply AI rules", e);
browser.notifications.create(ERROR_NOTIFICATION_ID, { browser.notifications.create(ERROR_NOTIFICATION_ID, {
type: 'basic', type: 'basic',
iconUrl: browser.runtime.getURL('resources/img/logo.png'), iconUrl: browser.runtime.getURL('resources/img/logo.png'),
title: 'Sortana Error', title: 'Sortana Error',
message: 'Failed to apply AI rules', message: 'Failed to apply AI rules'
buttons: [{ title: 'Dismiss' }]
}); });
} }
} }
@ -451,7 +475,7 @@ async function clearCacheForMessages(idsInput) {
} }
try { try {
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "errorPending", "showDebugTab"]); const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]);
logger.setDebug(store.debugLogging); logger.setDebug(store.debugLogging);
await AiClassifier.setConfig(store); await AiClassifier.setConfig(store);
userTheme = store.theme || 'auto'; userTheme = store.theme || 'auto';
@ -465,7 +489,6 @@ async function clearCacheForMessages(idsInput) {
if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') { if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') {
maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens; maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens;
} }
errorPending = store.errorPending === true;
showDebugTab = store.showDebugTab === true; showDebugTab = store.showDebugTab === true;
const savedStats = await storage.local.get('classifyStats'); const savedStats = await storage.local.get('classifyStats');
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') { if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
@ -524,10 +547,6 @@ async function clearCacheForMessages(idsInput) {
if (changes.showDebugTab) { if (changes.showDebugTab) {
showDebugTab = changes.showDebugTab.newValue === true; showDebugTab = changes.showDebugTab.newValue === true;
} }
if (changes.errorPending) {
errorPending = changes.errorPending.newValue === true;
updateActionIcon();
}
if (changes.theme) { if (changes.theme) {
userTheme = changes.theme.newValue || 'auto'; userTheme = changes.theme.newValue || 'auto';
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
@ -720,6 +739,8 @@ async function clearCacheForMessages(idsInput) {
} }
} else if (msg?.type === "sortana:getQueueCount") { } else if (msg?.type === "sortana:getQueueCount") {
return { count: queuedCount + (processing ? 1 : 0) }; return { count: queuedCount + (processing ? 1 : 0) };
} else if (msg?.type === "sortana:getErrorLog") {
return { errors: errorLog.slice() };
} else if (msg?.type === "sortana:getTiming") { } else if (msg?.type === "sortana:getTiming") {
const t = timingStats; const t = timingStats;
const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0; const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0;
@ -751,6 +772,7 @@ async function clearCacheForMessages(idsInput) {
// Catch any unhandled rejections // Catch any unhandled rejections
window.addEventListener("unhandledrejection", ev => { window.addEventListener("unhandledrejection", ev => {
logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason); logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason);
recordError("Unhandled promise rejection", ev.reason);
}); });
browser.notifications.onClicked.addListener(id => { browser.notifications.onClicked.addListener(id => {

View file

@ -51,6 +51,7 @@
<li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li> <li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li>
<li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li> <li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li>
<li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li> <li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li>
<li id="errors-tab-button" class="is-hidden" data-tab="errors"><a><span class="icon is-small"><img data-icon="x" data-size="16" src="../resources/img/x-light-16.png" alt=""></span><span>Errors</span></a></li>
<li id="debug-tab-button" class="is-hidden" data-tab="debug"><a><span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span><span>Debug</span></a></li> <li id="debug-tab-button" class="is-hidden" data-tab="debug"><a><span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span><span>Debug</span></a></li>
</ul> </ul>
</div> </div>
@ -285,6 +286,32 @@
</div> </div>
</div> </div>
<div id="errors-tab" class="tab-content is-hidden">
<h2 class="title is-4">
<span class="icon is-small"><img data-icon="x" data-size="16" src="../resources/img/x-light-16.png" alt=""></span>
<span>Session Errors</span>
</h2>
<div id="errors-empty" class="notification is-success is-light">
No errors have been recorded since the last start.
</div>
<div id="errors-panel" class="is-hidden">
<div class="box mb-4">
<div class="level">
<div class="level-left">
<div>
<p class="title is-5 mb-1">Error Log</p>
<p class="subtitle is-6">Visible only for this session.</p>
</div>
</div>
<div class="level-right">
<span class="tag is-danger is-light" id="errors-count">0</span>
</div>
</div>
</div>
<div id="errors-list"></div>
</div>
</div>
<div id="debug-tab" class="tab-content is-hidden"> <div id="debug-tab" class="tab-content is-hidden">
<h2 class="title is-4"> <h2 class="title is-4">
<span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span> <span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span>

View file

@ -196,6 +196,11 @@ document.addEventListener('DOMContentLoaded', async () => {
const debugTabToggle = document.getElementById('show-debug-tab'); const debugTabToggle = document.getElementById('show-debug-tab');
const debugTabBtn = document.getElementById('debug-tab-button'); const debugTabBtn = document.getElementById('debug-tab-button');
const errorTabBtn = document.getElementById('errors-tab-button');
const errorsEmpty = document.getElementById('errors-empty');
const errorsPanel = document.getElementById('errors-panel');
const errorsList = document.getElementById('errors-list');
const errorsCount = document.getElementById('errors-count');
function updateDebugTab() { function updateDebugTab() {
const visible = debugTabToggle.checked; const visible = debugTabToggle.checked;
debugTabBtn.classList.toggle('is-hidden', !visible); debugTabBtn.classList.toggle('is-hidden', !visible);
@ -204,6 +209,71 @@ document.addEventListener('DOMContentLoaded', async () => {
debugTabToggle.addEventListener('change', () => { updateDebugTab(); markDirty(); }); debugTabToggle.addEventListener('change', () => { updateDebugTab(); markDirty(); });
updateDebugTab(); updateDebugTab();
function formatErrorTime(value) {
try {
return new Date(value).toLocaleString();
} catch (e) {
return '';
}
}
function renderErrors(entries = []) {
const hasErrors = entries.length > 0;
errorTabBtn.classList.toggle('is-hidden', !hasErrors);
errorsEmpty.classList.toggle('is-hidden', hasErrors);
errorsPanel.classList.toggle('is-hidden', !hasErrors);
errorsList.innerHTML = '';
errorsCount.textContent = String(entries.length);
if (!hasErrors) {
return;
}
entries.forEach(entry => {
const card = document.createElement('article');
card.className = 'message is-danger is-light mb-4';
const header = document.createElement('div');
header.className = 'message-header';
const title = document.createElement('p');
title.textContent = entry.context || 'Error';
const time = document.createElement('span');
time.className = 'is-size-7 has-text-weight-normal';
time.textContent = formatErrorTime(entry.time);
header.appendChild(title);
header.appendChild(time);
const body = document.createElement('div');
body.className = 'message-body';
const summary = document.createElement('p');
summary.className = 'mb-2';
summary.textContent = entry.message || 'Unknown error';
body.appendChild(summary);
if (entry.detail) {
const detail = document.createElement('pre');
detail.className = 'is-family-monospace is-size-7';
detail.textContent = entry.detail;
body.appendChild(detail);
}
card.appendChild(header);
card.appendChild(body);
errorsList.appendChild(card);
});
}
async function loadErrors() {
try {
const response = await browser.runtime.sendMessage({ type: 'sortana:getErrorLog' });
renderErrors(response?.errors || []);
} catch (e) {
renderErrors([]);
}
}
browser.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'sortana:errorLogUpdated') {
loadErrors();
}
});
await loadErrors();
updateDiffDisplay(); updateDiffDisplay();
[htmlToggle, stripUrlToggle, altTextToggle, collapseWhitespaceToggle, tokenReductionToggle].forEach(toggle => { [htmlToggle, stripUrlToggle, altTextToggle, collapseWhitespaceToggle, tokenReductionToggle].forEach(toggle => {