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:
parent
4a4e8a114a
commit
b583990d43
4 changed files with 395 additions and 120 deletions
202
dashboard/js/tooltip.js
Normal file
202
dashboard/js/tooltip.js
Normal 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');
|
||||
})();
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue