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()