From b583990d434dd472d8bac8cfb60f3129ff93e56a Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 10 Apr 2026 04:24:07 -0400 Subject: [PATCH] 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 --- dashboard/js/tooltip.js | 202 +++++++++++++++++ mothership/cmd/mothership/main.go | 208 ++++++++++-------- .../internal/guidedtroubleshoot/notifier.go | 84 ++++--- .../internal/guidedtroubleshoot/quality.go | 21 ++ 4 files changed, 395 insertions(+), 120 deletions(-) create mode 100644 dashboard/js/tooltip.js diff --git a/dashboard/js/tooltip.js b/dashboard/js/tooltip.js new file mode 100644 index 0000000..983894e --- /dev/null +++ b/dashboard/js/tooltip.js @@ -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 = + '
' + + '
' + escapeHtml(tooltip.title || 'Tip') + '
' + + '
' + escapeHtml(tooltip.description || '') + '
' + + '
' + + '
' + + '
'; + 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'); +})(); diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index cc85cbd..aefcb8f 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -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) diff --git a/mothership/internal/guidedtroubleshoot/notifier.go b/mothership/internal/guidedtroubleshoot/notifier.go index c16e360..1a697d1 100644 --- a/mothership/internal/guidedtroubleshoot/notifier.go +++ b/mothership/internal/guidedtroubleshoot/notifier.go @@ -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 } diff --git a/mothership/internal/guidedtroubleshoot/quality.go b/mothership/internal/guidedtroubleshoot/quality.go index a1942f7..e0ec8f4 100644 --- a/mothership/internal/guidedtroubleshoot/quality.go +++ b/mothership/internal/guidedtroubleshoot/quality.go @@ -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()