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:
parent
7969920eb2
commit
4a4e8a114a
13 changed files with 2524 additions and 17 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">×</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">×</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">×</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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
391
mothership/internal/api/guided.go
Normal file
391
mothership/internal/api/guided.go
Normal 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})
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
137
mothership/internal/guidedtroubleshoot/discovery.go
Normal file
137
mothership/internal/guidedtroubleshoot/discovery.go
Normal 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
|
||||
}
|
||||
56
mothership/internal/guidedtroubleshoot/notifier.go
Normal file
56
mothership/internal/guidedtroubleshoot/notifier.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
480
mothership/internal/guidedtroubleshoot/quality.go
Normal file
480
mothership/internal/guidedtroubleshoot/quality.go
Normal 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)
|
||||
}
|
||||
436
mothership/internal/guidedtroubleshoot/quality_test.go
Normal file
436
mothership/internal/guidedtroubleshoot/quality_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue