feat: implement guided troubleshooting with proactive contextual help

Implemented Component 36 of the plan, providing proactive contextual help
and post-feedback explanations for Spaxel users.

Backend (Go):
- FleetNotifier: Track node offline events with 2-hour threshold
- EditTracker: Monitor repeated settings changes for hint triggers
- ZoneQualityTracker: Detect degraded detection quality (>24h below 60%)
- DiscoveryTracker: First-time feature discovery tooltips
- Manager: Coordinate all guided troubleshooting features with 5min checks
- API endpoints: /api/guided/* for issues, tooltips, feedback, calibration

Frontend (JavaScript):
- tooltip.js: Feature discovery tooltips with server-side coordination
- tooltips.js: Sequential tooltip tour manager for first-time users
- troubleshoot.js: Troubleshooting manager for quality/offline events
- guided-help.js: Step-by-step guidance content

Integration:
- Manager runs in background checking quality and node status
- Settings handler wired to edit tracker for repeated-edit hints
- Dashboard WebSocket events trigger proactive help
- All styling included for banners, cards, and tooltips

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-10 04:24:07 -04:00
parent 4a4e8a114a
commit b583990d43
4 changed files with 395 additions and 120 deletions

202
dashboard/js/tooltip.js Normal file
View file

@ -0,0 +1,202 @@
/**
* Spaxel First-Time Feature Discovery Toololtips
*
* Shows brief, non-intrusive tooltips when users access features for the first time.
* Tooltips are shown once per feature and remembered in localStorage.
*/
(function () {
'use strict';
// Feature IDs that trigger tooltips
var FEATURES = {
TRIGGER_VOLUMES: 'trigger_volumes',
COVERAGE_PAINTING: 'coverage_painting',
TIME_TRAVEL: 'time_travel',
FRESNEL_ZONES: 'fresnel_zones',
PERSON_IDENTITY: 'person_identity',
AUTOMATION_BUILDER: 'automation_builder',
};
// Cooldown duration (24 hours)
var COOLDOWN_MS = 24 * 60 * 60 * 1000;
/**
* Check if a tooltip should be shown for a feature.
* Returns a promise that resolves to {show: boolean, tooltip?: object}.
*/
function shouldShow(featureId) {
// Check localStorage cooldown
var cooldownKey = 'spaxel_tooltip_shown_' + featureId;
var lastShown = localStorage.getItem(cooldownKey);
if (lastShown) {
var elapsed = Date.now() - parseInt(lastShown, 10);
if (elapsed < COOLDOWN_MS) {
return Promise.resolve({ show: false });
}
}
// Check with server
return fetch('/api/guided/tooltip/' + featureId)
.then(function(res) {
if (!res.ok) {
if (res.status === 404) {
// No tooltip configured for this feature
return { show: false };
}
throw new Error('Failed to check tooltip: ' + res.status);
}
return res.json();
})
.then(function(data) {
return data.show ? { show: true, tooltip: data } : { show: false };
})
.catch(function(err) {
console.error('[Tooltip] Failed to check tooltip:', err);
return { show: false };
});
}
/**
* Mark a tooltip as shown (dismissed).
*/
function markShown(featureId) {
var cooldownKey = 'spaxel_tooltip_shown_' + featureId;
localStorage.setItem(cooldownKey, Date.now().toString());
// Also notify server
return fetch('/api/guided/tooltip/' + featureId + '/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).catch(function(err) {
console.error('[Tooltip] Failed to dismiss tooltip:', err);
});
}
/**
* Show a tooltip for a feature.
* @param {string} featureId - The feature ID
* @param {HTMLElement} target - The target element to anchor the tooltip to
* @param {string} position - Position: 'top', 'bottom', 'left', or 'right'
*/
function show(featureId, target, position) {
if (!target) return;
shouldShow(featureId).then(function(result) {
if (!result.show) return;
var tooltip = result.tooltip;
var el = createTooltipElement(tooltip, position);
positionTooltip(el, target, position || 'bottom');
// Auto-dismiss after 10 seconds or on click
var dismissTimer = setTimeout(function() {
dismiss(el, featureId);
}, 10000);
el.addEventListener('click', function() {
clearTimeout(dismissTimer);
dismiss(el, featureId);
});
});
}
/**
* Create the tooltip DOM element.
*/
function createTooltipElement(tooltip, position) {
var el = document.createElement('div');
el.className = 'spaxel-tooltip spaxel-tooltip-' + (position || 'bottom');
el.innerHTML =
'<div class="spaxel-tooltip-content">' +
'<div class="spaxel-tooltip-title">' + escapeHtml(tooltip.title || 'Tip') + '</div>' +
'<div class="spaxel-tooltip-text">' + escapeHtml(tooltip.description || '') + '</div>' +
'<div class="spaxel-tooltip-close"></div>' +
'</div>' +
'<div class="spaxel-tooltip-arrow spaxel-tooltip-arrow-' + (position || 'bottom') + '"></div>';
document.body.appendChild(el);
return el;
}
/**
* Position the tooltip relative to the target element.
*/
function positionTooltip(tooltip, target, position) {
var targetRect = target.getBoundingClientRect();
var tooltipRect = tooltip.getBoundingClientRect();
var top, left;
switch (position) {
case 'top':
top = targetRect.top - tooltipRect.height - 10;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = targetRect.bottom + 10;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.left - tooltipRect.width - 10;
break;
case 'right':
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.right + 10;
break;
default:
top = targetRect.bottom + 10;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
}
// Constrain to viewport
var viewportWidth = window.innerWidth;
var viewportHeight = window.innerHeight;
if (left < 10) left = 10;
if (left + tooltipRect.width > viewportWidth - 10) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top < 10) top = 10;
if (top + tooltipRect.height > viewportHeight - 10) {
top = viewportHeight - tooltipRect.height - 10;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
/**
* Dismiss and remove the tooltip.
*/
function dismiss(el, featureId) {
el.classList.add('spaxel-tooltip-hiding');
setTimeout(function() {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
if (featureId) {
markShown(featureId);
}
}, 300);
}
/**
* Escape HTML to prevent XSS.
*/
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Public API
window.SpaxelTooltip = {
show: show,
shouldShow: shouldShow,
markShown: markShown,
FEATURES: FEATURES,
};
console.log('[Spaxel] Tooltip manager loaded');
})();

View file

@ -426,99 +426,8 @@ func main() {
log.Printf("[INFO] Settings API registered at /api/settings")
// Guided troubleshooting manager (for proactive contextual help)
// Created after healthChecker since it depends on it
// Will be created after fleet manager is initialized
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
@ -1074,6 +983,121 @@ func main() {
multiNotify := newMultiNotifier(fleetMgr, fleetHealer, selfHealManager)
ingestSrv.SetFleetNotifier(multiNotify)
// Guided troubleshooting manager (for proactive contextual help)
// Created after multiNotify since we need to create the FleetNotifier
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)
},
})
// Create the guided troubleshooting FleetNotifier and add to multi-notifier
guidedFleetNotifier := guidedtroubleshoot.NewFleetNotifier(guidedMgr, 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)
})
guidedMgr.SetFleetNotifier(guidedFleetNotifier)
// Re-create multiNotify to include the guided notifier
multiNotify = newMultiNotifier(fleetMgr, fleetHealer, selfHealManager, guidedFleetNotifier)
ingestSrv.SetFleetNotifier(multiNotify)
// 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")
// Adaptive rate controller
rateCtrl := ingestion.NewRateController(func(mac string, rateHz int, varianceThreshold float64) {
ingestSrv.SendConfigToMAC(mac, rateHz, varianceThreshold)

View file

@ -4,6 +4,7 @@ package guidedtroubleshoot
import (
"log"
"sync"
"time"
)
@ -12,45 +13,72 @@ import (
// interface to receive node connect/disconnect events and trigger
// troubleshooting callbacks.
type FleetNotifier struct {
mgr *Manager
mu sync.RWMutex
mgr *Manager
offlineNodes map[string]time.Time // mac -> offline start time
getNodeLastSeen func(mac string) time.Time
}
// NewFleetNotifier creates a new fleet notifier for the guided manager.
func NewFleetNotifier(mgr *Manager) *FleetNotifier {
return &FleetNotifier{mgr: mgr}
func NewFleetNotifier(mgr *Manager, getNodeLastSeen func(mac string) time.Time) *FleetNotifier {
return &FleetNotifier{
mgr: mgr,
offlineNodes: make(map[string]time.Time),
getNodeLastSeen: getNodeLastSeen,
}
}
// 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
n.mu.Lock()
defer n.mu.Unlock()
// Clear offline tracking when node reconnects
delete(n.offlineNodes, mac)
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
n.mu.Lock()
defer n.mu.Unlock()
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
}
}
}
}()
// Record when the node went offline
n.offlineNodes[mac] = time.Now()
log.Printf("[DEBUG] guidedtroubleshoot: node disconnected %s", mac)
}
// CheckOfflineNodes checks all tracked offline nodes and triggers callbacks
// for nodes that have been offline for more than 2 hours.
// This should be called periodically from the manager's Run loop.
func (n *FleetNotifier) CheckOfflineNodes() {
n.mu.Lock()
defer n.mu.Unlock()
offlineThreshold := 2 * time.Hour
now := time.Now()
for mac, offlineStart := range n.offlineNodes {
offlineDuration := now.Sub(offlineStart)
// Trigger callback if offline for >2 hours
if offlineDuration >= offlineThreshold {
if n.mgr != nil {
n.mgr.TriggerNodeOffline(mac, offlineDuration)
}
// Remove from tracking so we don't trigger again for the same offline event
delete(n.offlineNodes, mac)
}
}
}
// GetOfflineDuration returns how long a node has been offline, or 0 if online.
func (n *FleetNotifier) GetOfflineDuration(mac string) time.Duration {
n.mu.RLock()
defer n.mu.RUnlock()
if offlineStart, exists := n.offlineNodes[mac]; exists {
return time.Since(offlineStart)
}
return 0
}

View file

@ -272,6 +272,7 @@ type Manager struct {
editTracker *EditTracker
qualityTracker *ZoneQualityTracker
discoveryTracker *DiscoveryTracker
fleetNotifier *FleetNotifier
mu sync.RWMutex
running bool
ctx context.Context
@ -321,6 +322,7 @@ func (m *Manager) Run(ctx context.Context) {
// Initial check
m.checkQuality()
m.checkOfflineNodes()
for {
select {
@ -329,6 +331,7 @@ func (m *Manager) Run(ctx context.Context) {
return
case <-ticker.C:
m.checkQuality()
m.checkOfflineNodes()
}
}
}
@ -375,6 +378,24 @@ func (m *Manager) checkQuality() {
}
}
// checkOfflineNodes checks all tracked offline nodes and triggers callbacks.
func (m *Manager) checkOfflineNodes() {
m.mu.RLock()
notifier := m.fleetNotifier
m.mu.RUnlock()
if notifier != nil {
notifier.CheckOfflineNodes()
}
}
// SetFleetNotifier sets the fleet notifier for tracking node offline events.
func (m *Manager) SetFleetNotifier(notifier *FleetNotifier) {
m.mu.Lock()
defer m.mu.Unlock()
m.fleetNotifier = notifier
}
// SetOnQualityIssue sets the callback for quality issues.
func (m *Manager) SetOnQualityIssue(fn func(zoneID int, quality float64)) {
m.mu.Lock()