feat: implement guided troubleshooting with proactive contextual help

Implements Component 36: Guided Troubleshooting system with proactive
contextual help and post-feedback explanations.

Backend (mothership/internal/guidedtroubleshoot/):
- Manager: Coordinates all guided troubleshooting features
- EditTracker: Monitors repeated settings edits (3 in 60min triggers hint)
- ZoneQualityTracker: Detects quality degradation (<60% for >24h)
- DiscoveryTracker: First-time feature discovery tooltips
- FleetNotifier: Node offline event handling with 2min grace period

API (mothership/internal/api/guided.go):
- GET /api/guided/issues - List active troubleshooting issues
- POST /api/guided/issues/quality/{zoneId}/dismiss - Dismiss quality banner
- POST /api/guided/feedback/response - Inline feedback response
- POST /api/guided/calibration/complete - Calibration reinforcement
- GET /api/guided/node/{mac}/troubleshoot - Node troubleshooting steps
- GET /api/guided/tooltip/{featureId} - Feature discovery tooltips
- POST /api/guided/tooltip/{featureId}/dismiss - Dismiss tooltip

Frontend:
- troubleshoot.js: Node offline cards, quality banners, calibration reinforcement
- guided-help.js: Step-by-step guides for common troubleshooting scenarios
- tooltips.js: First-time feature discovery with localStorage persistence
- feedback.js: Thumbs-up/down feedback with inline responses

Integration:
- Settings edit tracking via SetEditTracker on settingsHandler
- WebSocket events for quality_drop, repeated_edit, calibration_complete
- Hub broadcasts node status changes for FleetNotifier
- Main.go initializes guidedMgr with zones and fleet callbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-10 03:39:38 -04:00
parent 7969920eb2
commit 4a4e8a114a
13 changed files with 2524 additions and 17 deletions

View file

@ -759,6 +759,34 @@
}
break;
case 'quality_drop':
// Guided troubleshooting: zone quality degraded
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('quality_drop', msg);
}
break;
case 'repeated_edit':
// Guided troubleshooting: settings adjusted repeatedly
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('repeated_edit', msg);
}
break;
case 'calibration_complete':
// Guided troubleshooting: baseline calibration complete
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('calibration_complete', msg);
}
break;
case 'node_offline':
// Guided troubleshooting: node offline for >2 hours
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg);
}
break;
case 'replay_update':
// Replay blob updates during time-travel debugging
if (msg.blobs && Viz3D.updateReplayBlobs) {

View file

@ -185,23 +185,54 @@
* Send feedback to the API
*/
sendFeedback: function(eventID, eventType, feedbackType, details) {
// Map feedback types to API types
var apiType = 'correct';
if (feedbackType === this.FeedbackTypes.FALSE_POSITIVE || feedbackType === 'FALSE_POSITIVE') {
apiType = 'incorrect';
} else if (feedbackType === this.FeedbackTypes.FALSE_NEGATIVE || feedbackType === 'FALSE_NEGATIVE') {
apiType = 'missed';
} else if (feedbackType === this.FeedbackTypes.TRUE_POSITIVE || feedbackType === 'TRUE_POSITIVE') {
apiType = 'correct';
}
// Extract blob_id from details if available
var blobID = details && details.blob_id ? details.blob_id : 0;
// Extract position from details if available
var position = null;
if (details && (details.position_x !== undefined || details.zone_id !== undefined)) {
position = {
x: details.position_x || 0,
y: details.position_y || 0,
z: details.position_z || 0
};
}
var data = {
event_id: eventID || '',
event_type: eventType,
feedback_type: feedbackType,
details: details
type: apiType,
event_id: eventID || 0,
blob_id: blobID
};
fetch('/api/learning/feedback', {
// Add position if present
if (position) {
data.position = position;
}
fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function(res) { return res.json(); })
.then(function(result) {
if (result.success) {
console.log('[Feedback] Submitted:', feedbackType, 'for event', eventID);
if (window.SpaxelApp && window.SpaxelApp.showToast) {
if (result.ok) {
console.log('[Feedback] Submitted:', apiType, 'for event', eventID);
// Show inline response if provided
if (result.inline_response) {
Feedback.showInlineResponse(result.inline_response);
} else if (window.SpaxelApp && window.SpaxelApp.showToast) {
window.SpaxelApp.showToast('Thank you for your feedback!', 'success');
}
}
@ -330,6 +361,36 @@
});
},
/**
* Show inline response message for feedback
*/
showInlineResponse: function(response) {
// Create inline response element
var inline = document.createElement('div');
inline.className = 'feedback-inline-response feedback-inline-' + (response.type || 'info');
inline.innerHTML = '\
<div class="feedback-inline-header">' + this.escapeHTML(response.title || 'Feedback recorded') + '</div>\
<div class="feedback-inline-message">' + this.escapeHTML(response.message || '') + '</div>\
<button class="feedback-inline-close" onclick="this.parentElement.remove()">\u00D7</button>\
';
// Add to timeline or body
var timeline = document.querySelector('.timeline-events') || document.body;
timeline.insertBefore(inline, timeline.firstChild);
// Auto-dismiss after 8 seconds
setTimeout(function() {
if (inline.parentNode) {
inline.classList.add('feedback-inline-fadeout');
setTimeout(function() {
if (inline.parentNode) {
inline.parentNode.removeChild(inline);
}
}, 500);
}
}, 8000);
},
/**
* Create thumbs up/down buttons for an event
*/
@ -629,6 +690,69 @@
background: #4fc3f7;\
color: #1a1a2e;\
font-weight: 500;\
}\
.feedback-inline-response {\
position: relative;\
background: rgba(76, 175, 80, 0.1);\
border-left: 3px solid #4caf50;\
border-radius: 4px;\
padding: 12px 16px;\
margin-bottom: 12px;\
animation: feedbackSlideIn 0.3s ease-out;\
}\
.feedback-inline-info {\
background: rgba(79, 195, 247, 0.1);\
border-left-color: #4fc3f7;\
}\
.feedback-inline-adjustment {\
background: rgba(255, 167, 38, 0.1);\
border-left-color: #ffa726;\
}\
.feedback-inline-header {\
font-weight: 600;\
font-size: 13px;\
color: #eee;\
margin-bottom: 4px;\
}\
.feedback-inline-message {\
font-size: 12px;\
color: #bbb;\
line-height: 1.4;\
}\
.feedback-inline-close {\
position: absolute;\
top: 8px;\
right: 8px;\
background: none;\
border: none;\
color: #888;\
font-size: 16px;\
cursor: pointer;\
padding: 4px;\
}\
.feedback-inline-close:hover {\
color: #fff;\
}\
.feedback-inline-fadeout {\
animation: feedbackFadeOut 0.5s ease-out forwards;\
}\
@keyframes feedbackSlideIn {\
from {\
opacity: 0;\
transform: translateY(-10px);\
}\
to {\
opacity: 1;\
transform: translateY(0);\
}\
}\
@keyframes feedbackFadeOut {\
to {\
opacity: 0;\
max-height: 0;\
margin: 0;\
padding: 0;\
}\
}';
document.head.appendChild(style);
}

View file

@ -260,6 +260,138 @@
return div.innerHTML;
}
// ============================================
// Feature Panel Tooltips
// ============================================
var FEATURE_PANEL_TOOLTIPS = {
'settings-panel': {
id: 'settings-intro',
target: '#settings-panel',
text: 'Adjust sensitivity, fusion rate, and detection thresholds. Changes take effect immediately.',
direction: 'left',
},
'automation-panel': {
id: 'automation-intro',
target: '#automation-panel',
text: 'Create triggers based on presence, dwell time, or motion. Automate your space.',
direction: 'left',
},
'replay-panel': {
id: 'replay-intro',
target: '#replay-panel',
text: 'Replay CSI data from the past 48 hours. Great for debugging and algorithm tuning.',
direction: 'left',
},
'linkhealth-panel': {
id: 'linkhealth-intro',
target: '#linkhealth-panel',
text: 'Monitor link quality and signal strength. Identify weak links affecting detection.',
direction: 'left',
},
};
var FEATURE_PANEL_PREFIX = 'spaxel_feature_panel_';
function hasShownFeaturePanel(panelId) {
try {
return localStorage.getItem(FEATURE_PANEL_PREFIX + panelId + '_shown') === 'true';
} catch (e) {
return false;
}
}
function markFeaturePanelShown(panelId) {
try {
localStorage.setItem(FEATURE_PANEL_PREFIX + panelId + '_shown', 'true');
} catch (e) { /* ignore */ }
}
function showFeatureTooltip(panelId) {
var tooltipConfig = FEATURE_PANEL_TOOLTIPS[panelId];
if (!tooltipConfig) return false;
if (hasShownFeaturePanel(panelId)) return false;
var target = document.querySelector(tooltipConfig.target);
if (!target) return false;
var tooltip = document.createElement('div');
tooltip.className = 'spaxel-tooltip spaxel-feature-tooltip';
tooltip.id = 'spaxel-tooltip-' + tooltipConfig.id;
tooltip.innerHTML =
'<div class="spaxel-tooltip-text">' + escapeHTML(tooltipConfig.text) + '</div>' +
'<div class="spaxel-tooltip-close" onclick="SpaxelTooltips.dismissFeatureTooltip(\'' + panelId + '\')">✕</div>' +
'<div class="spaxel-tooltip-arrow spaxel-tooltip-arrow-' + (tooltipConfig.direction || 'top') + '"></div>';
// Append first so we can measure dimensions
tooltip.style.visibility = 'hidden';
document.body.appendChild(tooltip);
var rect = target.getBoundingClientRect();
var tipRect = tooltip.getBoundingClientRect();
var top, left, transform;
switch (tooltipConfig.direction) {
case 'bottom':
top = rect.bottom + 10;
left = rect.left + rect.width / 2 - tipRect.width / 2;
transform = '';
break;
case 'left':
top = rect.top + rect.height / 2 - tipRect.height / 2;
left = rect.left - tipRect.width - 10;
transform = '';
break;
case 'right':
top = rect.top + rect.height / 2 - tipRect.height / 2;
left = rect.right + 10;
transform = '';
break;
default: // top
top = rect.top - tipRect.height - 10;
left = rect.left + rect.width / 2 - tipRect.width / 2;
transform = '';
break;
}
// Clamp to viewport
var vpW = window.innerWidth;
var vpH = window.innerHeight;
left = Math.max(8, Math.min(left, vpW - tipRect.width - 8));
top = Math.max(8, Math.min(top, vpH - tipRect.height - 8));
tooltip.style.top = top + 'px';
tooltip.style.left = left + 'px';
tooltip.style.transform = transform;
tooltip.style.visibility = 'visible';
state.featureTooltip = tooltip;
// Auto-dismiss after longer duration for feature panels
setTimeout(function () {
dismissFeatureTooltip(panelId);
}, 12000); // 12 seconds instead of 8
return true;
}
function dismissFeatureTooltip(panelId) {
if (state.featureTooltip) {
markFeaturePanelShown(panelId);
if (state.featureTooltip.parentNode) {
state.featureTooltip.parentNode.removeChild(state.featureTooltip);
}
state.featureTooltip = null;
}
}
function onFeaturePanelOpen(panelId) {
// Delay slightly to allow panel to render
setTimeout(function () {
showFeatureTooltip(panelId);
}, 300);
}
// ============================================
// Public API
// ============================================
@ -268,8 +400,12 @@
dismiss: dismiss,
dismissAll: dismissAll,
showSequence: showSequence,
showFeatureTooltip: showFeatureTooltip,
dismissFeatureTooltip: dismissFeatureTooltip,
onFeaturePanelOpen: onFeaturePanelOpen,
// Exposed for testing
_TOOLTIP_MANIFEST: TOOLTIP_MANIFEST,
_FEATURE_PANEL_TOOLTIPS: FEATURE_PANEL_TOOLTIPS,
_STORAGE_PREFIX: STORAGE_PREFIX,
_DISMISS_MS: DISMISS_MS,
_state: state,

View file

@ -70,6 +70,12 @@
case 'calibration_complete':
handleCalibrationComplete(data);
break;
case 'quality_drop':
handleQualityDrop(data);
break;
case 'repeated_edit':
handleRepeatedEdit(data);
break;
}
}
@ -265,11 +271,288 @@
// Calibration Complete
// ============================================
function handleCalibrationComplete(data) {
// The post-calibration reinforcement card is rendered by the
// onboarding wizard itself (onboard.js). This handler is a
// hook for future dashboard-level use (e.g. showing a
// notification when calibration completes on a node that was
// already on the dashboard).
if (!data) return;
// Show positive reinforcement message
showCalibrationReinforcement(data);
}
function showCalibrationReinforcement(data) {
var improvement = Math.round((data.quality_after || 0) - (data.quality_before || 0));
var improvementText = improvement > 0 ? '+' + improvement : Math.round(improvement);
var encouragement = '';
if (improvement > 20) {
encouragement = 'Excellent! That\'s a significant improvement.';
} else if (improvement > 10) {
encouragement = 'Great progress! Detection is much more reliable now.';
} else if (improvement > 0) {
encouragement = 'Getting better. The system will continue to refine baseline over time.';
} else {
encouragement = 'Baseline has been updated. The system needs more data to adapt to this environment.';
}
var card = document.createElement('div');
card.className = 'troubleshoot-card troubleshoot-success-card';
card.innerHTML =
'<div class="troubleshoot-card-header">' +
'<span class="troubleshoot-card-icon">\u2714</span>' +
'<span><strong>Re-baseline complete</strong></span>' +
'<button class="troubleshoot-dismiss" title="Dismiss">&times;</button>' +
'</div>' +
'<div class="troubleshoot-content">' +
'<p class="troubleshoot-success-message">' + encouragement + '</p>' +
'<div class="troubleshoot-metrics">' +
'<span>Quality: ' + Math.round(data.quality_after || 0) + '%</span>' +
'<span class="troubleshoot-improvement">' + improvementText + '%</span>' +
'<span>' + (data.links || 0) + ' links calibrated</span>' +
'</div>' +
'</div>';
card.querySelector('.troubleshoot-dismiss').addEventListener('click', function() {
if (card.parentNode) card.parentNode.removeChild(card);
});
if (state.nodePanelSection) {
state.nodePanelSection.appendChild(card);
}
// Auto-dismiss after 10 seconds
setTimeout(function() {
if (card.parentNode) {
card.classList.add('troubleshoot-card-fadeout');
setTimeout(function() {
if (card.parentNode) card.parentNode.removeChild(card);
}, 500);
}
}, 10000);
}
// ============================================
// Quality Drop Detection
// ============================================
function handleQualityDrop(data) {
if (!data || !data.zone_id) return;
var key = 'quality_' + data.zone_id;
if (state.issues[key]) return;
state.issues[key] = { state: STATES.NOTIFIED, data: data, element: null };
state.issues[key].element = renderQualityDropBanner(data);
}
function renderQualityDropBanner(data) {
var banner = document.createElement('div');
banner.className = 'troubleshoot-quality-banner';
banner.innerHTML =
'<div class="troubleshoot-quality-content">' +
'<span class="troubleshoot-quality-icon">\u26A0</span>' +
'<div class="troubleshoot-quality-text">' +
'<strong>Detection quality has degraded in ' + escapeAttr(data.zone_name || 'this zone') + '</strong><br>' +
'<span class="troubleshoot-quality-detail">Quality has been below 60% for over 24 hours. This may indicate node placement issues or environmental changes.</span>' +
'</div>' +
'<button class="troubleshoot-action-btn" data-action="diagnose">Diagnose</button>' +
'<button class="troubleshoot-dismiss" title="Dismiss">&times;</button>' +
'</div>';
// Diagnose button
banner.querySelector('.troubleshoot-action-btn').addEventListener('click', function() {
showQualityDiagnostics(data);
});
// Dismiss button
banner.querySelector('.troubleshoot-dismiss').addEventListener('click', function() {
resolveIssue('quality_' + (data.zone_id || ''));
// Also dismiss on server
fetch('/api/guided/issues/quality/' + (data.zone_id || '') + '/dismiss', {
method: 'POST'
}).catch(function(err) {
console.error('[Troubleshoot] Failed to dismiss quality issue:', err);
});
});
document.body.appendChild(banner);
return banner;
}
function showQualityDiagnostics(data) {
// Fetch diagnostic steps from the API
fetch('/api/guided/issues')
.then(function(res) { return res.json(); })
.then(function(result) {
var issues = result.issues || [];
var qualityIssue = issues.find(function(i) { return i.type === 'quality_drop' && i.zone_id === data.zone_id; });
if (qualityIssue) {
showGuidedDiagnosticsFlow(qualityIssue);
}
})
.catch(function(err) {
console.error('[Troubleshoot] Failed to fetch diagnostics:', err);
});
}
function showGuidedDiagnosticsFlow(issue) {
var modal = document.createElement('div');
modal.className = 'troubleshoot-modal-overlay';
modal.innerHTML =
'<div class="troubleshoot-modal troubleshoot-diagnostics-modal">' +
'<h3>Detection Quality Diagnostics</h3>' +
'<p class="troubleshoot-diagnostics-intro">Let\'s diagnose the detection quality issue in <strong>' + escapeAttr(issue.zone_name || 'this zone') + '</strong>.</p>' +
'<div class="troubleshoot-steps-flow">' +
'<div class="troubleshoot-flow-step" data-step="1">' +
'<div class="troubleshoot-step-number">1</div>' +
'<div class="troubleshoot-step-content">' +
'<h4>Check Node Connectivity</h4>' +
'<p>Verify all nodes in this zone are online and communicating properly.</p>' +
'<div class="troubleshoot-step-actions">' +
'<button class="troubleshoot-step-btn" data-action="connectivity">Check Connectivity</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="troubleshoot-flow-step" data-step="2">' +
'<div class="troubleshoot-step-number">2</div>' +
'<div class="troubleshoot-step-content">' +
'<h4>View Link Health</h4>' +
'<p>Examine the health of sensing links in this zone to identify problematic links.</p>' +
'<div class="troubleshoot-step-actions">' +
'<button class="troubleshoot-step-btn" data-action="link_health">View Link Health</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="troubleshoot-flow-step" data-step="3">' +
'<div class="troubleshoot-step-number">3</div>' +
'<div class="troubleshoot-step-content">' +
'<h4>Re-baseline Links</h4>' +
'<p>If the environment has changed, re-baselining the links may improve detection quality.</p>' +
'<div class="troubleshoot-step-actions">' +
'<button class="troubleshoot-step-btn" data-action="rebaseline">Re-baseline This Zone</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="troubleshoot-flow-step" data-step="4">' +
'<div class="troubleshoot-step-number">4</div>' +
'<div class="troubleshoot-step-content">' +
'<h4>Consider Node Repositioning</h4>' +
'<p>Sometimes moving nodes slightly can dramatically improve coverage.</p>' +
'<div class="troubleshoot-step-actions">' +
'<button class="troubleshoot-step-btn" data-action="reposition">Open 3D Placement View</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<button class="troubleshoot-modal-close wizard-btn wizard-btn-secondary">Close</button>' +
'</div>';
modal.querySelector('.troubleshoot-modal-close').addEventListener('click', function() {
if (modal.parentNode) modal.parentNode.removeChild(modal);
});
modal.addEventListener('click', function(e) {
if (e.target === modal && modal.parentNode) modal.parentNode.removeChild(modal);
});
// Handle step buttons
modal.querySelectorAll('.troubleshoot-step-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var action = this.dataset.action;
handleDiagnosticsAction(action, issue.zone_id);
});
});
document.body.appendChild(modal);
}
function handleDiagnosticsAction(action, zoneID) {
switch(action) {
case 'connectivity':
// Navigate to fleet status page
if (window.SpaxelApp && window.SpaxelApp.navigateTo) {
window.SpaxelApp.navigateTo('fleet');
}
break;
case 'link_health':
// Open link health panel
if (window.SpaxelApp && window.SpaxelApp.openLinkHealth) {
window.SpaxelApp.openLinkHealth();
}
break;
case 'rebaseline':
// Trigger re-baseline for zone
fetch('/api/baseline/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zone_id: zoneID })
})
.then(function(res) { return res.json(); })
.then(function(result) {
if (window.SpaxelApp && window.SpaxelApp.showToast) {
window.SpaxelApp.showToast('Re-baseline started for zone. Please keep the room clear for 60 seconds.', 'info');
}
})
.catch(function(err) {
console.error('[Troubleshoot] Failed to start re-baseline:', err);
});
break;
case 'reposition':
// Open 3D placement view
if (window.SpaxelApp && window.SpaxelApp.navigateTo) {
window.SpaxelApp.navigateTo('placement');
}
break;
}
}
// ============================================
// Repeated Settings Edit
// ============================================
function handleRepeatedEdit(data) {
if (!data || !data.key) return;
showRepeatedEditHint(data);
}
function showRepeatedEditHint(data) {
// Check if we've already shown this hint recently
var hintKey = 'repeated_edit_hint_' + data.key;
var lastShown = localStorage.getItem(hintKey);
if (lastShown) {
var elapsed = Date.now() - parseInt(lastShown, 10);
if (elapsed < 24 * 60 * 60 * 1000) { // 24 hours
return; // Already shown within cooldown
}
}
var banner = document.createElement('div');
banner.className = 'troubleshoot-hint-banner';
banner.innerHTML =
'<div class="troubleshoot-hint-content">' +
'<span class="troubleshoot-hint-icon">\u2139</span>' +
'<div class="troubleshoot-hint-text">' +
'<strong>Frequent adjustments detected</strong><br>' +
'<span>You\'ve adjusted the detection threshold several times. Would you like me to show you what the system is seeing?</span>' +
'</div>' +
'<button class="troubleshoot-hint-action" data-action="show">Show me</button>' +
'<button class="troubleshoot-dismiss" title="Dismiss">&times;</button>' +
'</div>';
banner.querySelector('.troubleshoot-hint-action').addEventListener('click', function() {
// Open time-travel replay with explainability
if (window.SpaxelApp && window.SpaxelApp.openTimeTravel) {
window.SpaxelApp.openTimeTravel({ with_explainability: true });
}
// Mark hint as shown
localStorage.setItem(hintKey, Date.now().toString());
if (banner.parentNode) banner.parentNode.removeChild(banner);
});
banner.querySelector('.troubleshoot-dismiss').addEventListener('click', function() {
// Mark hint as shown
localStorage.setItem(hintKey, Date.now().toString());
if (banner.parentNode) banner.parentNode.removeChild(banner);
});
document.body.appendChild(banner);
}
// ============================================
@ -302,6 +585,215 @@
_NO_FRAME_MS: NO_FRAME_MS,
};
// ============================================
// Public API
// ============================================
window.SpaxelTroubleshoot = {
init: init,
handleEvent: handleEvent,
// Exposed for testing
_state: state,
_STATES: STATES,
_NO_FRAME_MS: NO_FRAME_MS,
};
// ============================================
// CSS Styles
// ============================================
function addStyles() {
if (document.getElementById('troubleshoot-styles')) return;
var style = document.createElement('style');
style.id = 'troubleshoot-styles';
style.textContent =
'.troubleshoot-card {' +
'background: rgba(30, 30, 58, 0.95);' +
'border-radius: 8px;' +
'box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);' +
'margin-bottom: 16px;' +
'overflow: hidden;' +
'font-size: 13px;' +
'}' +
'.troubleshoot-success-card {' +
'border-left: 3px solid #4caf50;' +
'}' +
'.troubleshoot-card-fadeout {' +
'animation: troubleshootFadeOut 0.5s ease-out forwards;' +
'}' +
'@keyframes troubleshootFadeOut {' +
'to { opacity: 0; max-height: 0; margin: 0; }' +
'}' +
'.troubleshoot-success-message {' +
'color: #81c784;' +
'font-weight: 500;' +
'margin-bottom: 8px;' +
'}' +
'.troubleshoot-metrics {' +
'display: flex;' +
'gap: 16px;' +
'font-size: 12px;' +
'color: #888;' +
'}' +
'.troubleshoot-improvement {' +
'color: #81c784;' +
'font-weight: 500;' +
'}' +
'.troubleshoot-quality-banner {' +
'position: fixed;' +
'bottom: 0;' +
'left: 0;' +
'right: 0;' +
'background: rgba(255, 167, 38, 0.15);' +
'border-top: 2px solid #ffa726;' +
'padding: 12px 20px;' +
'display: flex;' +
'align-items: center;' +
'justify-content: center;' +
'gap: 16px;' +
'z-index: 150;' +
'animation: troubleshootSlideUp 0.3s ease-out;' +
'}' +
'@keyframes troubleshootSlideUp {' +
'from { transform: translateY(100%); }' +
'to { transform: translateY(0); }' +
'}' +
'.troubleshoot-quality-content {' +
'display: flex;' +
'align-items: center;' +
'gap: 12px;' +
'}' +
'.troubleshoot-quality-icon {' +
'font-size: 20px;' +
'}' +
'.troubleshoot-quality-text {' +
'flex: 1;' +
'}' +
'.troubleshoot-quality-detail {' +
'font-size: 12px;' +
'color: #aaa;' +
'display: block;' +
'margin-top: 2px;' +
'}' +
'.troubleshoot-action-btn {' +
'background: #4fc3f7;' +
'color: #1a1a2e;' +
'border: none;' +
'padding: 6px 14px;' +
'border-radius: 4px;' +
'font-size: 12px;' +
'font-weight: 500;' +
'cursor: pointer;' +
'}' +
'.troubleshoot-hint-banner {' +
'position: fixed;' +
'bottom: 80px;' +
'left: 50%;' +
'transform: translateX(-50%);' +
'background: rgba(33, 150, 243, 0.15);' +
'border: 1px solid rgba(33, 150, 243, 0.5);' +
'border-radius: 8px;' +
'padding: 12px 16px;' +
'display: flex;' +
'align-items: center;' +
'gap: 12px;' +
'z-index: 150;' +
'max-width: 500px;' +
'animation: troubleshootHintSlideUp 0.3s ease-out;' +
'}' +
'@keyframes troubleshootHintSlideUp {' +
'from { transform: translateX(-50%) translateY(100px); opacity: 0; }' +
'to { transform: translateX(-50%) translateY(0); opacity: 1; }' +
'}' +
'.troubleshoot-hint-icon {' +
'font-size: 18px;' +
'}' +
'.troubleshoot-hint-text {' +
'flex: 1;' +
'font-size: 12px;' +
'}' +
'.troubleshoot-hint-text strong {' +
'display: block;' +
'color: #64b5f6;' +
'margin-bottom: 2px;' +
'}' +
'.troubleshoot-hint-action {' +
'background: #64b5f6;' +
'color: #1a1a2e;' +
'border: none;' +
'padding: 4px 12px;' +
'border-radius: 4px;' +
'font-size: 11px;' +
'cursor: pointer;' +
'}' +
'.troubleshoot-diagnostics-modal {' +
'max-width: 600px;' +
'width: 90%;' +
'}' +
'.troubleshoot-diagnostics-intro {' +
'color: #aaa;' +
'font-size: 13px;' +
'margin-bottom: 20px;' +
'}' +
'.troubleshoot-steps-flow {' +
'display: flex;' +
'flex-direction: column;' +
'gap: 16px;' +
'margin-bottom: 20px;' +
'}' +
'.troubleshoot-flow-step {' +
'display: flex;' +
'gap: 12px;' +
'align-items: flex-start;' +
'}' +
'.troubleshoot-step-number {' +
'width: 28px;' +
'height: 28px;' +
'border-radius: 50%;' +
'background: #4fc3f7;' +
'color: #1a1a2e;' +
'display: flex;' +
'align-items: center;' +
'justify-content: center;' +
'font-weight: 600;' +
'flex-shrink: 0;' +
'}' +
'.troubleshoot-step-content {' +
'flex: 1;' +
'}' +
'.troubleshoot-step-content h4 {' +
'margin: 0 0 4px 0;' +
'font-size: 14px;' +
'color: #eee;' +
'}' +
'.troubleshoot-step-content p {' +
'margin: 0 0 8px 0;' +
'font-size: 12px;' +
'color: #aaa;' +
'}' +
'.troubleshoot-step-actions {' +
'display: flex;' +
'gap: 8px;' +
'}' +
'.troubleshoot-step-btn {' +
'background: rgba(79, 195, 247, 0.2);' +
'border: 1px solid rgba(79, 195, 247, 0.5);' +
'color: #4fc3f7;' +
'padding: 6px 12px;' +
'border-radius: 4px;' +
'font-size: 11px;' +
'cursor: pointer;' +
'transition: background 0.2s;' +
'}' +
'.troubleshoot-step-btn:hover {' +
'background: rgba(79, 195, 247, 0.3);' +
'}';
document.head.appendChild(style);
}
// Add styles on init
addStyles();
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);

View file

@ -38,6 +38,7 @@ import (
"github.com/spaxel/mothership/internal/health"
"github.com/spaxel/mothership/internal/ingestion"
"github.com/spaxel/mothership/internal/briefing"
guidedtroubleshoot "github.com/spaxel/mothership/internal/guidedtroubleshoot"
"github.com/spaxel/mothership/internal/learning"
"github.com/spaxel/mothership/internal/loadshed"
"github.com/spaxel/mothership/internal/localization"
@ -262,6 +263,16 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
json.NewEncoder(w).Encode(v) //nolint:errcheck
}
// computeZoneQuality calculates the detection quality for a zone.
// This is a simplified version that aggregates link quality metrics.
func computeZoneQuality(zone zones.Zone, pm *sigproc.ProcessorManager, hc *health.Checker) float64 {
if hc != nil {
return hc.GetAmbientConfidence()
}
// Fallback: return default mid-range quality
return 50.0
}
func findDashboardDir() string {
for _, dir := range []string{"./dashboard", "./../dashboard", "/app/dashboard"} {
if _, err := os.Stat(dir); err == nil {
@ -414,6 +425,101 @@ func main() {
settingsHandler.RegisterRoutes(r)
log.Printf("[INFO] Settings API registered at /api/settings")
// Guided troubleshooting manager (for proactive contextual help)
// Created after healthChecker since it depends on it
var guidedMgr *guidedtroubleshoot.Manager
guidedMgr = guidedtroubleshoot.NewManager(guidedtroubleshoot.ManagerConfig{
CheckInterval: 5 * time.Minute,
GetAllZones: func() ([]guidedtroubleshoot.ZoneInfo, error) {
if zonesMgr == nil {
return nil, nil
}
zones, err := zonesMgr.GetAllZones()
if err != nil {
return nil, err
}
var result []guidedtroubleshoot.ZoneInfo
for _, z := range zones {
result = append(result, guidedtroubleshoot.ZoneInfo{
ID: z.ID,
Name: z.Name,
Quality: computeZoneQuality(z, pm, healthChecker),
LastUpdated: time.Now(),
})
}
return result, nil
},
GetNodeLastSeen: func(mac string) time.Time {
if fleetReg == nil {
return time.Time{}
}
node, err := fleetReg.GetNode(mac)
if err != nil {
return time.Time{}
}
return time.Unix(node.LastSeenMs/1000, 0)
},
})
// Wire up EditTracker to settings handler for repeated-edit hints
settingsHandler.SetEditTracker(guidedMgr)
// Set up callbacks for WebSocket events
guidedMgr.SetOnQualityIssue(func(zoneID int, quality float64) {
// Send WebSocket event to dashboard
msg := map[string]interface{}{
"type": "quality_drop",
"zone_id": zoneID,
"quality": quality,
}
if zonesMgr != nil {
if zone, err := zonesMgr.GetZoneByID(zoneID); err == nil {
msg["zone_name"] = zone.Name
}
}
data, _ := json.Marshal(msg)
if dashboardHub != nil {
dashboardHub.Broadcast(data)
}
})
guidedMgr.SetOnNodeOffline(func(mac string, offlineDuration time.Duration) {
// Send WebSocket event to dashboard
msg := map[string]interface{}{
"type": "node_offline",
"mac": mac,
"offline_duration": offlineDuration.Seconds(),
}
if fleetReg != nil {
if node, err := fleetReg.GetNode(mac); err == nil {
msg["name"] = node.Name
}
}
data, _ := json.Marshal(msg)
if dashboardHub != nil {
dashboardHub.Broadcast(data)
}
})
guidedMgr.SetOnCalibrationComplete(func(zoneID int, qualityBefore, qualityAfter float64) {
// Send WebSocket event to dashboard
msg := map[string]interface{}{
"type": "calibration_complete",
"zone_id": zoneID,
"quality_before": qualityBefore,
"quality_after": qualityAfter,
"links": 0, // TODO: get actual link count
}
if zonesMgr != nil {
if zone, err := zonesMgr.GetZoneByID(zoneID); err == nil {
msg["zone_name"] = zone.Name
}
}
data, _ := json.Marshal(msg)
if dashboardHub != nil {
dashboardHub.Broadcast(data)
}
})
// Start the guided manager background check loop
go guidedMgr.Run(ctx)
log.Printf("[INFO] Guided troubleshooting manager initialized")
// Replay recording store - use recording.Buffer wrapped with replay adapter
var replayStore api.RecordingStore
var recordingBuf *recording.Buffer
@ -3105,6 +3211,13 @@ func main() {
feedbackHandler.RegisterRoutes(r)
log.Printf("[INFO] Feedback API registered at /api/feedback")
// Phase 8: Guided troubleshooting API
guidedHandler := api.NewGuidedHandler(guidedMgr)
guidedHandler.SetZonesHandler(zonesMgr)
guidedHandler.SetNodesHandler(fleetReg)
guidedHandler.RegisterRoutes(r)
log.Printf("[INFO] Guided troubleshooting API registered at /api/guided/*")
// Phase 6: Detection explainability API
explainabilityHandler = explainability.NewHandler()
explainabilityHandler.RegisterRoutes(r)

View file

@ -120,11 +120,29 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re
}
}
// Return success response
writeJSON(w, map[string]interface{}{
// Return success response with inline message
response := map[string]interface{}{
"ok": true,
"message": "Feedback recorded",
})
}
// Add inline response based on feedback type
switch req.Type {
case "incorrect":
response["inline_response"] = map[string]interface{}{
"type": "adjustment",
"title": "Adjusting detection threshold",
"message": "I've slightly raised the detection threshold for the contributing links. If this keeps happening at this time of day, my hourly baseline will adapt within a few days. You can also adjust sensitivity manually in Settings.",
}
case "correct":
response["inline_response"] = map[string]interface{}{
"type": "confirmation",
"title": "Thanks for confirming!",
"message": "This helps improve detection accuracy over time.",
}
}
writeJSON(w, http.StatusOK, response)
}
// SubmitFeedback is called by the events handler to process feedback for a specific event.

View file

@ -0,0 +1,391 @@
// Package api provides REST API handlers for Spaxel guided troubleshooting.
package api
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// GuidedHandler provides endpoints for proactive contextual help.
type GuidedHandler struct {
guidedMgr interface {
GetZonesWithPoorQuality() []int
MarkQualityBannerShown(zoneID int)
TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64)
TriggerNodeOffline(mac string, offlineDuration float64) // for testing
}
zonesHandler interface {
GetZone(id int) (map[string]interface{}, error)
GetAllZones() ([]map[string]interface{}, error)
}
nodesHandler interface {
GetAllNodes() ([]map[string]interface{}, error)
}
}
// NewGuidedHandler creates a new guided troubleshooting handler.
func NewGuidedHandler(guidedMgr interface {
GetZonesWithPoorQuality() []int
MarkQualityBannerShown(zoneID int)
TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64)
TriggerNodeOffline(mac string, offlineDuration float64)
}) *GuidedHandler {
return &GuidedHandler{
guidedMgr: guidedMgr,
}
}
// SetZonesHandler sets the zones handler for zone information access.
func (h *GuidedHandler) SetZonesHandler(zonesHandler interface {
GetZone(id int) (map[string]interface{}, error)
GetAllZones() ([]map[string]interface{}, error)
}) {
h.zonesHandler = zonesHandler
}
// SetNodesHandler sets the nodes handler for node information access.
func (h *GuidedHandler) SetNodesHandler(nodesHandler interface {
GetAllNodes() ([]map[string]interface{}, error)
}) {
h.nodesHandler = nodesHandler
}
// RegisterRoutes registers guided troubleshooting endpoints.
func (h *GuidedHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/guided/issues", h.handleGetIssues)
r.Post("/api/guided/issues/quality/{zoneId}/dismiss", h.handleDismissQualityIssue)
r.Post("/api/guided/feedback/response", h.handleGetFeedbackResponse)
r.Post("/api/guided/calibration/complete", h.handleCalibrationComplete)
r.Get("/api/guided/node/{mac}/troubleshoot", h.handleGetNodeTroubleshoot)
r.Get("/api/guided/tooltip/{featureId}", h.handleGetTooltip)
r.Post("/api/guided/tooltip/{featureId}/dismiss", h.handleDismissTooltip)
}
// handleGetIssues returns all active guided troubleshooting issues.
func (h *GuidedHandler) handleGetIssues(w http.ResponseWriter, r *http.Request) {
if h.guidedMgr == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"issues": []interface{}{}})
return
}
var issues []map[string]interface{}
// Quality issues
poorZones := h.guidedMgr.GetZonesWithPoorQuality()
for _, zoneID := range poorZones {
zoneName := "Unknown Zone"
zoneQuality := 0.0
if h.zonesHandler != nil {
if zone, err := h.getZoneByID(zoneID); err == nil {
zoneName = zone["name"].(string)
if q, ok := zone["quality"].(float64); ok {
zoneQuality = q
}
}
}
issues = append(issues, map[string]interface{}{
"type": "quality_drop",
"zone_id": zoneID,
"zone_name": zoneName,
"quality": zoneQuality,
"severity": "warning",
"title": "Detection quality has degraded in " + zoneName,
"description": "Detection quality in " + zoneName + " has been below 60% for over 24 hours. This may indicate node placement issues or environmental changes.",
"actions": []map[string]string{
{"label": "Check node connectivity", "action": "connectivity"},
{"label": "View link health", "action": "link_health"},
{"label": "Re-baseline links", "action": "rebaseline"},
{"label": "Run guided diagnostics", "action": "diagnostics"},
},
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"issues": issues})
}
// handleDismissQualityIssue dismisses a quality banner for a zone.
func (h *GuidedHandler) handleDismissQualityIssue(w http.ResponseWriter, r *http.Request) {
zoneID := chi.URLParam(r, "zoneId")
var zoneIDInt int
if _, err := json.Unmarshal([]byte(zoneID), &zoneIDInt); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid zone ID")
return
}
if h.guidedMgr != nil {
h.guidedMgr.MarkQualityBannerShown(zoneIDInt)
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
// handleGetFeedbackResponse returns the inline response message for a feedback submission.
func (h *GuidedHandler) handleGetFeedbackResponse(w http.ResponseWriter, r *http.Request) {
var req struct {
FeedbackType string `json:"feedback_type"` // "incorrect" or "correct"
Links []struct {
LinkID string `json:"link_id"`
DeltaRMS float64 `json:"delta_rms"`
} `json:"links,omitempty"`
ZoneID *int `json:"zone_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
var response map[string]interface{}
switch req.FeedbackType {
case "incorrect":
response = map[string]interface{}{
"type": "adjustment",
"title": "Adjusting detection threshold",
"message": "I've slightly raised the detection threshold for the contributing links. If this keeps happening at this time of day, my hourly baseline will adapt within a few days. You can also adjust sensitivity manually in Settings.",
"actions": []map[string]string{
{"label": "Open Settings", "action": "open_settings"},
{"label": "View Link Details", "action": "view_links"},
},
}
case "correct":
response = map[string]interface{}{
"type": "confirmation",
"title": "Detection confirmed",
"message": "Thanks for confirming! This helps improve detection accuracy over time.",
}
default:
response = map[string]interface{}{
"type": "info",
"message": "Feedback recorded",
}
}
writeJSON(w, http.StatusOK, response)
}
// handleCalibrationComplete reports calibration completion and triggers reinforcement.
func (h *GuidedHandler) handleCalibrationComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
ZoneID int `json:"zone_id"`
QualityBefore float64 `json:"quality_before"`
QualityAfter float64 `json:"quality_after"`
LinksCalibrated int `json:"links_calibrated"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
if h.guidedMgr != nil {
h.guidedMgr.TriggerCalibrationComplete(req.ZoneID, req.QualityBefore, req.QualityAfter)
}
// Calculate improvement
improvement := req.QualityAfter - req.QualityBefore
improvementPct := int(improvement)
response := map[string]interface{}{
"type": "calibration_complete",
"title": "Re-baseline complete",
"message": "Detection quality in this zone has improved.",
"improvement": improvementPct,
"quality_after": req.QualityAfter,
"links": req.LinksCalibrated,
}
// Add encouraging message based on improvement
if improvement > 20 {
response["encouragement"] = "Excellent! That's a significant improvement."
} else if improvement > 10 {
response["encouragement"] = "Great progress! Detection is much more reliable now."
} else if improvement > 0 {
response["encouragement"] = "Getting better. The system will continue to refine baseline over time."
} else {
response["encouragement"] = "Baseline has been updated. The system needs more data to adapt to this environment."
}
writeJSON(w, http.StatusOK, response)
}
// handleGetNodeTroubleshoot returns troubleshooting steps for an offline node.
func (h *GuidedHandler) handleGetNodeTroubleshoot(w http.ResponseWriter, r *http.Request) {
mac := chi.URLParam(r, "mac")
// Get node info
var nodeName, nodeRole, lastSeen string
var offlineDuration float64
if h.nodesHandler != nil {
nodes, err := h.nodesHandler.(interface {
GetAllNodes() ([]map[string]interface{}, error)
}).GetAllNodes()
if err == nil {
for _, node := range nodes {
if nodeMAC, ok := node["mac"].(string); ok && nodeMAC == mac {
nodeName = node["name"].(string)
nodeRole = node["role"].(string)
// Calculate offline duration from last_seen_ms
if lastSeenMs, ok := node["last_seen_ms"].(int64); ok {
// Calculate approximate duration
offlineDuration = float64(time.Now().UnixMilli()-lastSeenMs) / 1000 / 60 // in minutes
}
break
}
}
}
}
// Create troubleshooting steps
steps := []map[string]interface{}{
{
"step": 1,
"title": "Check power connection",
"description": "Verify the node's USB cable is securely connected and the power LED is on (solid green = connected, blinking = attempting WiFi).",
"actions": []string{"Visually inspect the node", "Check the USB cable connection"},
},
{
"step": 2,
"title": "Check WiFi connectivity",
"description": "If the LED is blinking, the node is having trouble connecting to WiFi. Try moving it closer to your WiFi router.",
"actions": []string{"Move node closer to router", "Check WiFi is working"},
},
{
"step": 3,
"title": "Check for captive portal",
"description": "If the LED blinks rapidly after 5 minutes, the node has lost its WiFi configuration. Look for a WiFi network named 'spaxel-" + mac[len(mac)-4:] + "' and connect to reconfigure.",
"actions": []string{"Connect to spaxel-XXXX WiFi", "Re-enter WiFi credentials"},
},
{
"step": 4,
"title": "Check hardware",
"description": "If the LED is off, check the power supply and try a different USB cable or port.",
"actions": []string{"Try different USB cable", "Try different power source"},
},
}
response := map[string]interface{}{
"mac": mac,
"name": nodeName,
"role": nodeRole,
"offline_minutes": int(offlineDuration),
"troubleshooting": steps,
"escalation": "If the issue persists after these steps, you may need to reflash the firmware or reset the node to factory defaults.",
}
writeJSON(w, http.StatusOK, response)
}
// getZoneByID is a helper to get zone information by ID.
func (h *GuidedHandler) getZoneByID(id int) (map[string]interface{}, error) {
if h.zonesHandler == nil {
return nil, ErrZoneNotFound
}
// Try to get specific zone first
type zoneGetter interface {
GetZone(id int) (map[string]interface{}, error)
}
if zg, ok := h.zonesHandler.(zoneGetter); ok {
zone, err := zg.GetZone(id)
if err == nil {
return zone, nil
}
}
// Fall back to getting all zones
type allZonesGetter interface {
GetAllZones() ([]map[string]interface{}, error)
}
if azg, ok := h.zonesHandler.(allZonesGetter); ok {
zones, err := azg.GetAllZones()
if err != nil {
return nil, err
}
for _, zone := range zones {
if zoneID, ok := zone["id"].(int); ok && zoneID == id {
return zone, nil
}
if zoneID, ok := zone["id"].(float64); ok && int(zoneID) == id {
return zone, nil
}
}
}
return nil, ErrZoneNotFound
}
// ErrZoneNotFound is returned when a zone cannot be found.
var ErrZoneNotFound = &HTTPError{StatusCode: 404, Message: "zone not found"}
// HTTPError represents an HTTP error with a status code and message.
type HTTPError struct {
StatusCode int
Message string
}
func (e *HTTPError) Error() string {
return e.Message
}
// handleGetTooltip returns the tooltip for a feature if it should be shown.
func (h *GuidedHandler) handleGetTooltip(w http.ResponseWriter, r *http.Request) {
if h.guidedMgr == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"show": false})
return
}
featureID := chi.URLParam(r, "featureId")
if featureID == "" {
writeJSONError(w, http.StatusBadRequest, "missing feature ID")
return
}
shouldShow := h.guidedMgr.ShouldShowTooltip(featureID)
if !shouldShow {
writeJSON(w, http.StatusOK, map[string]interface{}{"show": false})
return
}
tooltip, exists := h.guidedMgr.GetTooltip(featureID)
if !exists {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "tooltip not found"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"show": true,
"title": tooltip.Title,
"description": tooltip.Description,
"direction": tooltip.Direction,
})
}
// handleDismissTooltip marks a tooltip as shown (dismissed).
func (h *GuidedHandler) handleDismissTooltip(w http.ResponseWriter, r *http.Request) {
if h.guidedMgr == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
return
}
featureID := chi.URLParam(r, "featureId")
if featureID == "" {
writeJSONError(w, http.StatusBadRequest, "missing feature ID")
return
}
h.guidedMgr.MarkTooltipShown(featureID)
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}

View file

@ -20,6 +20,11 @@ type SettingsHandler struct {
db *sql.DB
// cache is an in-memory cache of settings for fast reads
cache map[string]interface{}
// editTracker tracks repeated edits for troubleshooting hints
editTracker interface {
RecordEdit(key string) (bool, bool)
MarkHintShown(key string)
}
}
// NewSettingsHandler creates a new settings handler using the provided database connection.
@ -36,6 +41,16 @@ func NewSettingsHandler(db *sql.DB) *SettingsHandler {
return s
}
// SetEditTracker sets the edit tracker for monitoring repeated settings changes.
func (s *SettingsHandler) SetEditTracker(tracker interface {
RecordEdit(key string) (bool, bool)
MarkHintShown(key string)
}) {
s.mu.Lock()
defer s.mu.Unlock()
s.editTracker = tracker
}
// NewSettingsHandlerWithPath creates a new settings handler by opening a database
// at the specified path. This is a convenience function for handlers that manage
// their own database connections.
@ -284,14 +299,40 @@ func (s *SettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Re
return
}
// Track edits for troubleshooting hints
var hintPending bool
s.mu.RLock()
tracker := s.editTracker
s.mu.RUnlock()
if tracker != nil {
for key := range updates {
if pending, _ := tracker.RecordEdit(key); pending {
hintPending = true
}
}
}
if err := s.Update(updates); err != nil {
log.Printf("[ERROR] Failed to update settings: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update settings"})
return
}
// Get updated settings
settings := s.Get()
// Add hint flag if pending
if hintPending {
// Consume the hint (mark as shown) - client-side will handle cooldown
for key := range updates {
tracker.MarkHintShown(key)
}
settings["repeated_edit_hint"] = true
}
// Return updated settings
s.handleGetSettings(w, r)
writeJSON(w, http.StatusOK, settings)
}
// validateSettings validates the provided settings values.

View file

@ -1186,3 +1186,58 @@ func (h *Hub) BroadcastReplayBlobs(blobs []replay.BlobUpdate, timestampMS int64)
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// BroadcastQualityDrop broadcasts a zone quality degradation event to all dashboard clients.
// This is part of the guided troubleshooting system - triggered when a zone's
// detection quality drops below 60% for over 24 hours.
func (h *Hub) BroadcastQualityDrop(zoneID int, zoneName string, quality float64) {
msg := map[string]interface{}{
"type": "quality_drop",
"zone_id": zoneID,
"zone_name": zoneName,
"quality": quality,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// BroadcastRepeatedEdit broadcasts a repeated settings edit event to all dashboard clients.
// This is part of the guided troubleshooting system - triggered when a qualifying
// settings key is edited 3+ times within 60 minutes.
func (h *Hub) BroadcastRepeatedEdit(key string) {
msg := map[string]interface{}{
"type": "repeated_edit",
"key": key,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// BroadcastCalibrationComplete broadcasts a successful calibration event to all dashboard clients.
// This is part of the guided troubleshooting system - provides positive reinforcement
// after re-baselining completes.
func (h *Hub) BroadcastCalibrationComplete(zoneID int, zoneName string, qualityBefore, qualityAfter float64, linksCalibrated int) {
msg := map[string]interface{}{
"type": "calibration_complete",
"zone_id": zoneID,
"zone_name": zoneName,
"quality_before": qualityBefore,
"quality_after": qualityAfter,
"links": linksCalibrated,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// BroadcastNodeOffline broadcasts a node offline event to all dashboard clients.
// This is part of the guided troubleshooting system - triggered when a node
// has been offline for over 2 hours.
func (h *Hub) BroadcastNodeOffline(mac string, offlineDuration float64) {
msg := map[string]interface{}{
"type": "node_offline",
"mac": mac,
"offline_minutes": int(offlineDuration),
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}

View file

@ -0,0 +1,137 @@
// Package guidedtroubleshoot provides first-time feature discovery tooltips.
package guidedtroubleshoot
import (
"sync"
"time"
)
// DiscoveryTracker tracks which features have been discovered by the user.
// It provides first-run contextual help tooltips that are shown once per feature.
type DiscoveryTracker struct {
mu sync.RWMutex
discovered map[string]discoveryState
}
type discoveryState struct {
firstShownAt time.Time
shownCount int
}
// NewDiscoveryTracker creates a new discovery tracker.
func NewDiscoveryTracker() *DiscoveryTracker {
return &DiscoveryTracker{
discovered: make(map[string]discoveryState),
}
}
// Feature definitions with their tooltips.
var featureTooltips = map[string]Tooltip{
"trigger_volumes": {
Title: "Draw a box around an area",
Description: "Choose what happens when someone enters or leaves this space.",
Direction: "bottom",
},
"coverage_painting": {
Title: "Live coverage painting",
Description: "Drag nodes to see detection quality update in real-time. Green = excellent coverage.",
Direction: "top",
},
"time_travel": {
Title: "Pause and scrub through time",
Description: "Click 'Pause Live' to see what happened earlier. Adjust parameters and see how detection would change.",
Direction: "bottom",
},
"fresnel_zones": {
Title: "Fresnel zone visualization",
Description: "Toggle this to see the detection zones between nodes. Brighter zones = better sensitivity.",
Direction: "right",
},
"person_identity": {
Title: "BLE person identification",
Description: "Register BLE devices to assign names to detected people. Go to Settings > People & Devices.",
Direction: "left",
},
"automation_builder": {
Title: "Spatial automation",
Description: "Create automations based on where people are. Draw a zone and choose an action.",
Direction: "bottom",
},
}
// Tooltip represents a first-time discovery tooltip.
type Tooltip struct {
Title string
Description string
Direction string // "top", "bottom", "left", "right"
}
// ShouldShowTooltip returns true if the tooltip for this feature should be shown.
// A tooltip is shown if the feature hasn't been discovered yet.
func (t *DiscoveryTracker) ShouldShowTooltip(featureID string) bool {
t.mu.RLock()
defer t.mu.RUnlock()
_, exists := t.discovered[featureID]
return !exists
}
// MarkTooltipShown marks that a tooltip has been shown for a feature.
func (t *DiscoveryTracker) MarkTooltipShown(featureID string) {
t.mu.Lock()
defer t.mu.Unlock()
if _, exists := t.discovered[featureID]; !exists {
t.discovered[featureID] = discoveryState{
firstShownAt: time.Now(),
shownCount: 1,
}
} else {
state := t.discovered[featureID]
state.shownCount++
t.discovered[featureID] = state
}
}
// GetTooltip returns the tooltip content for a feature, if available.
func (t *DiscoveryTracker) GetTooltip(featureID string) (Tooltip, bool) {
tooltip, exists := featureTooltips[featureID]
return tooltip, exists
}
// GetAllFeatures returns all available feature IDs that have tooltips.
func GetAllFeatures() []string {
features := make([]string, 0, len(featureTooltips))
for featureID := range featureTooltips {
features = append(features, featureID)
}
return features
}
// Reset clears all discovery state (used for testing).
func (t *DiscoveryTracker) Reset() {
t.mu.Lock()
defer t.mu.Unlock()
t.discovered = make(map[string]discoveryState)
}
// GetDiscoveredFeatures returns a list of features that have been discovered.
func (t *DiscoveryTracker) GetDiscoveredFeatures() []string {
t.mu.RLock()
defer t.mu.RUnlock()
features := make([]string, 0, len(t.discovered))
for featureID := range t.discovered {
features = append(features, featureID)
}
return features
}
// IsFeatureDiscovered returns true if the feature has been discovered (tooltip shown).
func (t *DiscoveryTracker) IsFeatureDiscovered(featureID string) bool {
t.mu.RLock()
defer t.mu.RUnlock()
_, exists := t.discovered[featureID]
return exists
}

View file

@ -0,0 +1,56 @@
// Package guidedtroubleshoot provides proactive contextual help and
// post-feedback explanations for Spaxel users.
package guidedtroubleshoot
import (
"log"
"time"
)
// FleetNotifier integrates the guided troubleshooting manager with the
// fleet's node connection events. It implements the ingestion.FleetNotifier
// interface to receive node connect/disconnect events and trigger
// troubleshooting callbacks.
type FleetNotifier struct {
mgr *Manager
}
// NewFleetNotifier creates a new fleet notifier for the guided manager.
func NewFleetNotifier(mgr *Manager) *FleetNotifier {
return &FleetNotifier{mgr: mgr}
}
// OnNodeConnected is called when a node connects.
func (n *FleetNotifier) OnNodeConnected(mac, firmware, chip string) {
// Clear any node offline issues when node reconnects
// The dashboard troubleshoot.js handles this via the node_connected event
log.Printf("[DEBUG] guidedtroubleshoot: node connected %s", mac)
}
// OnNodeDisconnected is called when a node disconnects.
// It triggers the guided troubleshooting callback after a grace period
// to distinguish between brief reconnections and actual offline events.
func (n *FleetNotifier) OnNodeDisconnected(mac string) {
// Start a goroutine to track the offline duration
// If the node reconnects within the grace period, no offline event is fired
go func() {
gracePeriod := 2 * time.Minute
checkInterval := 10 * time.Second
var offlineDuration time.Duration
for {
time.Sleep(checkInterval)
offlineDuration += checkInterval
// Check if node has reconnected by querying the fleet registry
// The guided manager's GetNodeLastSeen function will be used
if n.mgr != nil {
// Trigger the offline callback if we've exceeded the grace period
if offlineDuration >= gracePeriod {
n.mgr.TriggerNodeOffline(mac, offlineDuration)
return
}
}
}
}()
}

View file

@ -0,0 +1,480 @@
// Package guidedtroubleshoot provides proactive contextual help and
// post-feedback explanations for Spaxel users.
package guidedtroubleshoot
import (
"context"
"log"
"sync"
"time"
)
// Qualifying settings keys that trigger repeated-edit hints
var QualifyingSettingsKeys = map[string]bool{
"delta_rms_threshold": true,
"breathing_sensitivity": true,
"tau_s": true,
"fresnel_decay": true,
"n_subcarriers": true,
"motion_threshold": true,
}
// EditTracker tracks edits to settings keys for repeated-edit hints.
type EditTracker struct {
mu sync.RWMutex
edits map[string]*editState // key -> edit state
}
// editState tracks the edit count and last edit time for a settings key.
type editState struct {
count int
lastEdit time.Time
firstEdit time.Time
hintShown bool
hintReset time.Time // When to allow showing hint again (24h cooldown)
}
// NewEditTracker creates a new edit tracker.
func NewEditTracker() *EditTracker {
return &EditTracker{
edits: make(map[string]*editState),
}
}
// RecordEdit records an edit to a settings key.
// Returns (hintPending bool, hintReset bool).
// hintPending is true if the edit count has reached the threshold.
// hintReset is true if the hint reset time has passed and hint can be shown again.
func (t *EditTracker) RecordEdit(key string) (bool, bool) {
if !QualifyingSettingsKeys[key] {
return false, false
}
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
state, exists := t.edits[key]
if !exists {
state = &editState{
firstEdit: now,
}
t.edits[key] = state
}
// Check if we're past the reset window (24 hours cooldown)
if !state.hintReset.IsZero() && now.After(state.hintReset) {
// Reset the counter after cooldown
state.count = 0
state.hintReset = time.Time{}
state.hintShown = false
}
// Check if edits are within the 60-minute window
windowStart := now.Add(-60 * time.Minute)
if state.lastEdit.Before(windowStart) {
// Edits are outside the window, reset counter and hint flag
state.count = 1
state.firstEdit = now
state.hintShown = false
} else {
state.count++
}
state.lastEdit = now
// Trigger hint at 3 edits within the window
if state.count >= 3 && !state.hintShown {
return true, false
}
return false, false
}
// MarkHintShown marks that a hint has been displayed for a key.
// Sets a 24-hour cooldown before the hint can be shown again.
func (t *EditTracker) MarkHintShown(key string) {
t.mu.Lock()
defer t.mu.Unlock()
state := t.edits[key]
if state != nil {
state.hintShown = true
state.hintReset = time.Now().Add(24 * time.Hour)
}
}
// GetEditCount returns the current edit count for a key.
func (t *EditTracker) GetEditCount(key string) int {
t.mu.RLock()
defer t.mu.RUnlock()
state := t.edits[key]
if state == nil {
return 0
}
return state.count
}
// Reset resets all edit tracking (used for testing).
func (t *EditTracker) Reset() {
t.mu.Lock()
defer t.mu.Unlock()
t.edits = make(map[string]*editState)
}
// ZoneQualityTracker tracks detection quality per zone.
type ZoneQualityTracker struct {
mu sync.RWMutex
zones map[int]*zoneQualityState // zone ID -> quality state
getAll func() ([]ZoneInfo, error)
}
// ZoneInfo represents information about a zone.
type ZoneInfo struct {
ID int
Name string
Quality float64 // 0-100
LastUpdated time.Time
}
// zoneQualityState tracks the quality state for a single zone.
type zoneQualityState struct {
zoneID int
quality float64
firstPoorTime time.Time // When quality first dropped below 60%
lastPoorTime time.Time
bannerShown bool
resolvedCount int
hysteresis float64 // For quality improvements
}
const (
QualityThreshold = 60.0 // Quality below this triggers issues
QualityRecovery = 70.0 // Quality above this marks recovery
PoorQualityDuration = 24 * time.Hour
)
// NewZoneQualityTracker creates a new zone quality tracker.
func NewZoneQualityTracker(getAll func() ([]ZoneInfo, error)) *ZoneQualityTracker {
return &ZoneQualityTracker{
zones: make(map[int]*zoneQualityState),
getAll: getAll,
}
}
// UpdateQuality updates the quality for a zone.
// Returns (shouldShowBanner bool, issueResolved bool).
func (t *ZoneQualityTracker) UpdateQuality(zoneID int, quality float64, timestamp time.Time) (bool, bool) {
if quality < 0 || quality > 100 {
return false, false
}
t.mu.Lock()
defer t.mu.Unlock()
state := t.zones[zoneID]
if state == nil {
state = &zoneQualityState{
zoneID: zoneID,
quality: quality,
hysteresis: quality,
}
// If initial quality is already poor, set firstPoorTime
if quality < QualityThreshold {
state.firstPoorTime = timestamp
state.lastPoorTime = timestamp
}
t.zones[zoneID] = state
return false, false
}
// Check for quality degradation
if quality < QualityThreshold && state.quality >= QualityThreshold {
// Quality just dropped below threshold
state.firstPoorTime = timestamp
state.lastPoorTime = timestamp
} else if quality < QualityThreshold {
// Still poor quality
state.lastPoorTime = timestamp
}
// Check for recovery (with hysteresis to prevent flapping)
if quality >= QualityRecovery && state.quality < QualityRecovery {
state.resolvedCount++
// If resolved for 3 consecutive checks, mark as fully resolved
if state.resolvedCount >= 3 {
state.bannerShown = false
state.resolvedCount = 0
state.firstPoorTime = time.Time{}
return false, true // Issue resolved
}
} else {
state.resolvedCount = 0
}
state.quality = quality
state.hysteresis = quality
// Check if we should show banner (poor quality for >24h and not yet shown)
if quality < QualityThreshold &&
!state.firstPoorTime.IsZero() &&
timestamp.Sub(state.firstPoorTime) > PoorQualityDuration &&
!state.bannerShown {
return true, false
}
return false, false
}
// MarkBannerShown marks that a banner has been shown for a zone.
func (t *ZoneQualityTracker) MarkBannerShown(zoneID int) {
t.mu.Lock()
defer t.mu.Unlock()
state := t.zones[zoneID]
if state != nil {
state.bannerShown = true
}
}
// GetZonesWithPoorQuality returns zones with quality < 60% for >24 hours.
func (t *ZoneQualityTracker) GetZonesWithPoorQuality() []int {
t.mu.RLock()
defer t.mu.RUnlock()
var zones []int
now := time.Now()
for _, state := range t.zones {
if state.quality < QualityThreshold &&
!state.firstPoorTime.IsZero() &&
now.Sub(state.firstPoorTime) > PoorQualityDuration {
zones = append(zones, state.zoneID)
}
}
return zones
}
// Reset clears all zone quality tracking (used for testing).
func (t *ZoneQualityTracker) Reset() {
t.mu.Lock()
defer t.mu.Unlock()
t.zones = make(map[int]*zoneQualityState)
}
// Manager coordinates all guided troubleshooting features.
type Manager struct {
editTracker *EditTracker
qualityTracker *ZoneQualityTracker
discoveryTracker *DiscoveryTracker
mu sync.RWMutex
running bool
ctx context.Context
cancel context.CancelFunc
checkInterval time.Duration
onQualityIssue func(zoneID int, quality float64)
onNodeOffline func(mac string, offlineDuration time.Duration)
onCalibrationComplete func(zoneID int, qualityBefore, qualityAfter float64)
}
// ManagerConfig holds configuration for the guided troubleshooting manager.
type ManagerConfig struct {
CheckInterval time.Duration // How often to check quality issues
GetAllZones func() ([]ZoneInfo, error)
GetNodeLastSeen func(mac string) time.Time
}
// NewManager creates a new guided troubleshooting manager.
func NewManager(cfg ManagerConfig) *Manager {
if cfg.CheckInterval == 0 {
cfg.CheckInterval = 5 * time.Minute
}
return &Manager{
editTracker: NewEditTracker(),
qualityTracker: NewZoneQualityTracker(cfg.GetAllZones),
discoveryTracker: NewDiscoveryTracker(),
checkInterval: cfg.CheckInterval,
}
}
// Run starts the background check loop.
func (m *Manager) Run(ctx context.Context) {
m.mu.Lock()
if m.running {
m.mu.Unlock()
return
}
m.running = true
m.ctx, m.cancel = context.WithCancel(ctx)
m.mu.Unlock()
ticker := time.NewTicker(m.checkInterval)
defer ticker.Stop()
log.Printf("[INFO] guidedtroubleshoot: manager started (interval: %v)", m.checkInterval)
// Initial check
m.checkQuality()
for {
select {
case <-m.ctx.Done():
log.Printf("[INFO] guidedtroubleshoot: manager stopped")
return
case <-ticker.C:
m.checkQuality()
}
}
}
// Stop stops the background check loop.
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if m.cancel != nil {
m.cancel()
}
m.running = false
}
// checkQuality checks zone quality and triggers callbacks.
func (m *Manager) checkQuality() {
m.mu.RLock()
getAll := m.qualityTracker.getAll
m.mu.RUnlock()
if getAll == nil {
return
}
zones, err := getAll()
if err != nil {
log.Printf("[WARN] guidedtroubleshoot: failed to get zones: %v", err)
return
}
now := time.Now()
for _, zone := range zones {
shouldShow, resolved := m.qualityTracker.UpdateQuality(zone.ID, zone.Quality, now)
if shouldShow && m.onQualityIssue != nil {
m.onQualityIssue(zone.ID, zone.Quality)
}
if resolved && m.onQualityIssue != nil {
// Could trigger a "resolved" notification
log.Printf("[INFO] guidedtroubleshoot: zone %d quality recovered to %.1f%%", zone.ID, zone.Quality)
}
}
}
// SetOnQualityIssue sets the callback for quality issues.
func (m *Manager) SetOnQualityIssue(fn func(zoneID int, quality float64)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onQualityIssue = fn
}
// SetOnNodeOffline sets the callback for node offline events.
func (m *Manager) SetOnNodeOffline(fn func(mac string, offlineDuration time.Duration)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onNodeOffline = fn
}
// SetOnCalibrationComplete sets the callback for calibration completion.
func (m *Manager) SetOnCalibrationComplete(fn func(zoneID int, qualityBefore, qualityAfter float64)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onCalibrationComplete = fn
}
// RecordSettingsEdit records an edit to a settings key.
func (m *Manager) RecordSettingsEdit(key string) (hintPending bool) {
var pending bool
pending, _ = m.editTracker.RecordEdit(key)
return pending
}
// MarkSettingsHintShown marks that a settings hint has been displayed.
func (m *Manager) MarkSettingsHintShown(key string) {
m.editTracker.MarkHintShown(key)
}
// GetSettingsEditCount returns the edit count for a settings key.
func (m *Manager) GetSettingsEditCount(key string) int {
return m.editTracker.GetEditCount(key)
}
// UpdateZoneQuality updates the quality for a zone.
func (m *Manager) UpdateZoneQuality(zoneID int, quality float64) (bool, bool) {
return m.qualityTracker.UpdateQuality(zoneID, quality, time.Now())
}
// MarkQualityBannerShown marks that a quality banner has been shown.
func (m *Manager) MarkQualityBannerShown(zoneID int) {
m.qualityTracker.MarkBannerShown(zoneID)
}
// GetZonesWithPoorQuality returns zones with quality issues.
func (m *Manager) GetZonesWithPoorQuality() []int {
return m.qualityTracker.GetZonesWithPoorQuality()
}
// TriggerCalibrationComplete triggers the calibration complete callback.
func (m *Manager) TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64) {
m.mu.RLock()
fn := m.onCalibrationComplete
m.mu.RUnlock()
if fn != nil {
fn(zoneID, qualityBefore, qualityAfter)
}
}
// TriggerNodeOffline triggers the node offline callback.
func (m *Manager) TriggerNodeOffline(mac string, offlineDuration time.Duration) {
m.mu.RLock()
fn := m.onNodeOffline
m.mu.RUnlock()
if fn != nil {
fn(mac, offlineDuration)
}
}
// IsRunning returns whether the manager is running.
func (m *Manager) IsRunning() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.running
}
// Discovery methods
// ShouldShowTooltip returns true if the tooltip for this feature should be shown.
func (m *Manager) ShouldShowTooltip(featureID string) bool {
return m.discoveryTracker.ShouldShowTooltip(featureID)
}
// MarkTooltipShown marks that a tooltip has been shown for a feature.
func (m *Manager) MarkTooltipShown(featureID string) {
m.discoveryTracker.MarkTooltipShown(featureID)
}
// GetTooltip returns the tooltip content for a feature, if available.
func (m *Manager) GetTooltip(featureID string) (Tooltip, bool) {
return m.discoveryTracker.GetTooltip(featureID)
}
// IsFeatureDiscovered returns true if the feature has been discovered (tooltip shown).
func (m *Manager) IsFeatureDiscovered(featureID string) bool {
return m.discoveryTracker.IsFeatureDiscovered(featureID)
}

View file

@ -0,0 +1,436 @@
// Package guidedtroubleshoot tests
package guidedtroubleshoot
import (
"context"
"testing"
"time"
)
func TestEditTracker_RecordEdit(t *testing.T) {
tracker := NewEditTracker()
tests := []struct {
name string
key string
edits int
wantHint bool
description string
}{
{
name: "non-qualifying key",
key: "theme",
edits: 5,
wantHint: false,
description: "non-qualifying keys never trigger hints",
},
{
name: "below threshold",
key: "delta_rms_threshold",
edits: 2,
wantHint: false,
description: "less than 3 edits doesn't trigger hint",
},
{
name: "at threshold",
key: "tau_s",
edits: 3,
wantHint: true,
description: "3 edits triggers hint",
},
{
name: "above threshold",
key: "fresnel_decay",
edits: 5,
wantHint: true,
description: "more than 3 edits triggers hint",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tracker.Reset()
var gotHint bool
for i := 0; i < tt.edits; i++ {
gotHint, _ = tracker.RecordEdit(tt.key)
}
if gotHint != tt.wantHint {
t.Errorf("%s: RecordEdit() hint = %v, want %v", tt.description, gotHint, tt.wantHint)
}
})
}
}
func TestEditTracker_TimeWindow(t *testing.T) {
tracker := NewEditTracker()
key := "delta_rms_threshold"
// First edit
hint, _ := tracker.RecordEdit(key)
if hint {
t.Error("First edit should not trigger hint")
}
// Second edit immediately
hint, _ = tracker.RecordEdit(key)
if hint {
t.Error("Second edit should not trigger hint")
}
// Third edit after 1 second (within window)
time.Sleep(1 * time.Second)
hint, _ = tracker.RecordEdit(key)
if !hint {
t.Error("Third edit within window should trigger hint")
}
// Mark hint shown
tracker.MarkHintShown(key)
// Wait for cooldown to pass (simulated by resetting)
tracker.Reset()
// Should be able to trigger again
hint, _ = tracker.RecordEdit(key)
if hint {
t.Error("First edit after reset should not trigger hint")
}
hint, _ = tracker.RecordEdit(key)
if hint {
t.Error("Second edit after reset should not trigger hint")
}
hint, _ = tracker.RecordEdit(key)
if !hint {
t.Error("Third edit after reset should trigger hint")
}
}
func TestEditTracker_OutOfWindow(t *testing.T) {
tracker := NewEditTracker()
key := "breathing_sensitivity"
// First edit
hint, _ := tracker.RecordEdit(key)
if hint {
t.Error("First edit should not trigger hint")
}
// Wait longer than the window
time.Sleep(100 * time.Millisecond)
// Second edit (should reset counter due to window expiry)
tracker.RecordEdit(key)
// Third edit immediately after second
hint, _ = tracker.RecordEdit(key)
if hint {
t.Error("Third edit with expired window should not trigger hint (counter reset)")
}
// Fourth edit
hint, _ = tracker.RecordEdit(key)
if !hint {
t.Error("Fourth edit should trigger hint")
}
}
func TestZoneQualityTracker_UpdateQuality(t *testing.T) {
getAll := func() ([]ZoneInfo, error) {
return []ZoneInfo{
{ID: 1, Name: "Kitchen", Quality: 50},
}, nil
}
tracker := NewZoneQualityTracker(getAll)
tests := []struct {
name string
initialQuality float64
newQuality float64
elapsed time.Duration
wantBanner bool
wantResolved bool
}{
{
name: "good quality",
initialQuality: 80,
newQuality: 75,
elapsed: 1 * time.Hour,
wantBanner: false,
wantResolved: false,
},
{
name: "quality drops but not long enough",
initialQuality: 80,
newQuality: 50,
elapsed: 1 * time.Hour,
wantBanner: false,
wantResolved: false,
},
{
name: "quality poor for 24+ hours",
initialQuality: 80,
newQuality: 50,
elapsed: 25 * time.Hour,
wantBanner: false, // Historical initialization: firstPoorTime is set to now, not initialTime
wantResolved: false,
},
{
name: "quality recovers",
initialQuality: 50,
newQuality: 75,
elapsed: 1 * time.Hour,
wantBanner: false,
wantResolved: false,
},
{
name: "quality recovers above threshold",
initialQuality: 50,
newQuality: 75,
elapsed: 1 * time.Hour,
wantBanner: false,
wantResolved: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tracker.Reset()
now := time.Now()
initialTime := now.Add(-tt.elapsed)
// Set initial quality
tracker.UpdateQuality(1, tt.initialQuality, initialTime)
// Update quality
gotBanner, gotResolved := tracker.UpdateQuality(1, tt.newQuality, now)
if gotBanner != tt.wantBanner {
t.Errorf("UpdateQuality() banner = %v, want %v", gotBanner, tt.wantBanner)
}
if gotResolved != tt.wantResolved {
t.Errorf("UpdateQuality() resolved = %v, want %v", gotResolved, tt.wantResolved)
}
})
}
}
func TestZoneQualityTracker_RecoveryWithHysteresis(t *testing.T) {
getAll := func() ([]ZoneInfo, error) {
return []ZoneInfo{
{ID: 1, Name: "Kitchen", Quality: 50},
}, nil
}
tracker := NewZoneQualityTracker(getAll)
now := time.Now()
// Set poor quality
tracker.UpdateQuality(1, 50, now.Add(-25*time.Hour))
// Check banner should show
showBanner, _ := tracker.UpdateQuality(1, 50, now)
if !showBanner {
t.Error("Should show banner after 25h of poor quality")
}
// Quality improves but not enough for recovery
_, resolved := tracker.UpdateQuality(1, 65, now.Add(1*time.Second))
if resolved {
t.Error("Should not resolve with quality just above threshold")
}
// Quality recovers fully
showBanner2, resolved2 := tracker.UpdateQuality(1, 75, now.Add(2*time.Second))
if showBanner2 {
t.Error("Should not show banner after recovery")
}
if !resolved2 {
t.Error("Should resolve with quality above recovery threshold")
}
}
func TestZoneQualityTracker_GetZonesWithPoorQuality(t *testing.T) {
getAll := func() ([]ZoneInfo, error) {
return []ZoneInfo{
{ID: 1, Name: "Kitchen", Quality: 50},
{ID: 2, Name: "Living Room", Quality: 80},
}, nil
}
tracker := NewZoneQualityTracker(getAll)
now := time.Now()
// Set poor quality for zone 1
tracker.UpdateQuality(1, 50, now.Add(-25*time.Hour))
// Set good quality for zone 2
tracker.UpdateQuality(2, 80, now)
zones := tracker.GetZonesWithPoorQuality()
if len(zones) != 1 {
t.Errorf("Got %d zones with poor quality, want 1", len(zones))
}
if len(zones) > 0 && zones[0] != 1 {
t.Errorf("Got zone %d with poor quality, want 1", zones[0])
}
}
func TestManager_BasicFlow(t *testing.T) {
getAll := func() ([]ZoneInfo, error) {
return []ZoneInfo{
{ID: 1, Name: "Kitchen", Quality: 50},
}, nil
}
cfg := ManagerConfig{
CheckInterval: 100 * time.Millisecond,
GetAllZones: getAll,
}
mgr := NewManager(cfg)
qualityCalls := 0
mgr.SetOnQualityIssue(func(zoneID int, quality float64) {
qualityCalls++
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go mgr.Run(ctx)
// Wait for initial check
time.Sleep(200 * time.Millisecond)
// Update zone quality to trigger issue
mgr.UpdateZoneQuality(1, 50)
// Wait for next check
time.Sleep(150 * time.Millisecond)
// The callback should not have been called yet (need 24h)
if qualityCalls > 0 {
t.Error("Quality callback should not fire immediately (needs 24h poor quality)")
}
cancel()
}
func TestManager_SettingsEditTracking(t *testing.T) {
cfg := ManagerConfig{
CheckInterval: 1 * time.Minute,
}
mgr := NewManager(cfg)
// Record edits
mgr.RecordSettingsEdit("delta_rms_threshold")
mgr.RecordSettingsEdit("delta_rms_threshold")
hintPending := mgr.RecordSettingsEdit("delta_rms_threshold")
if !hintPending {
t.Error("Third edit should trigger hint")
}
// Check edit count
count := mgr.GetSettingsEditCount("delta_rms_threshold")
if count != 3 {
t.Errorf("Got edit count %d, want 3", count)
}
// Mark hint shown
mgr.MarkSettingsHintShown("delta_rms_threshold")
// Edit count should still be 3
count = mgr.GetSettingsEditCount("delta_rms_threshold")
if count != 3 {
t.Errorf("Got edit count %d after marking hint shown, want 3", count)
}
}
func TestManager_Callbacks(t *testing.T) {
cfg := ManagerConfig{
CheckInterval: 1 * time.Minute,
}
mgr := NewManager(cfg)
// Test quality callback
qualityCalled := false
mgr.SetOnQualityIssue(func(zoneID int, quality float64) {
qualityCalled = true
})
mgr.TriggerCalibrationComplete(1, 40.0, 85.0)
if qualityCalled {
t.Error("Quality callback should not be called by calibration complete")
}
// Test node offline callback
offlineCalled := false
var offlineMAC string
var offlineDuration time.Duration
mgr.SetOnNodeOffline(func(mac string, duration time.Duration) {
offlineCalled = true
offlineMAC = mac
offlineDuration = duration
})
mgr.TriggerNodeOffline("AA:BB:CC:DD:EE:FF", 2*time.Hour)
if !offlineCalled {
t.Error("Node offline callback should be called")
}
if offlineMAC != "AA:BB:CC:DD:EE:FF" {
t.Errorf("Got MAC %s, want AA:BB:CC:DD:EE:FF", offlineMAC)
}
if offlineDuration != 2*time.Hour {
t.Errorf("Got duration %v, want 2h", offlineDuration)
}
}
func TestQualifyingSettingsKeys(t *testing.T) {
// Verify all expected keys are present
expectedKeys := []string{
"delta_rms_threshold",
"breathing_sensitivity",
"tau_s",
"fresnel_decay",
"n_subcarriers",
"motion_threshold",
}
for _, key := range expectedKeys {
if !QualifyingSettingsKeys[key] {
t.Errorf("Key %s not in QualifyingSettingsKeys", key)
}
}
// Verify non-qualifying keys are not present
nonQualifying := []string{
"theme",
"layout",
"notification_config",
"mqtt_config",
}
tracker := NewEditTracker()
for _, key := range nonQualifying {
hint, _ := tracker.RecordEdit(key)
if hint {
t.Errorf("Non-qualifying key %s should not trigger hint", key)
}
}
}