- cmd/mothership/main.go: fix GetAllZones (single return), LastSeenAt vs LastSeenMs, remove undefined fusionEngine block, fix weights.GetLinkWeight usage, hoist learningHandler scope, remove unused recordingBuf/lastDetectionEvent vars, remove sync import, fix computeZoneQuality pointer dereference, fix pred field names (PredictedNextZoneID/PredictionConfidence), fix AccuracyStats.TotalPredictions, add GetNodeOfflineDuration to healthProviderAdapter, fix GetAccuracyDelta stub - internal/api/guided.go: refactor GuidedManager interface to use time.Duration for TriggerNodeOffline, use any for zonesHandler/nodesHandler, remove diagnostics.Tooltip dependency, add GetTooltipAny type-assertion approach for cross-package tooltip access - internal/api/tracks.go: unify TracksProvider to use signal.TrackedBlob directly via type alias to resolve interface mismatch - internal/api/diurnal.go: add signalProcessorManagerAdapter and NewDiurnalHandlerFromSignal to bridge signal.ProcessorManager to DiurnalProcessorManager - internal/guidedtroubleshoot/quality.go: add RecordEdit, MarkHintShown, GetTooltipAny methods to Manager to satisfy api interfaces - internal/fusion/fusion.go: remove unused log import, fix oy declared-and-not-used Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
502 lines
16 KiB
Go
502 lines
16 KiB
Go
// Package api provides REST API handlers for Spaxel guided troubleshooting.
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/spaxel/mothership/internal/diagnostics"
|
|
)
|
|
|
|
// GuidedManager is the interface for the guided troubleshooting manager.
|
|
type GuidedManager interface {
|
|
GetZonesWithPoorQuality() []int
|
|
MarkQualityBannerShown(zoneID int)
|
|
TriggerCalibrationComplete(zoneID int, qualityBefore, qualityAfter float64)
|
|
TriggerNodeOffline(mac string, offlineDuration time.Duration)
|
|
ShouldShowTooltip(featureID string) bool
|
|
MarkTooltipShown(featureID string)
|
|
}
|
|
|
|
// GuidedHandler provides endpoints for proactive contextual help.
|
|
type GuidedHandler struct {
|
|
guidedMgr GuidedManager
|
|
zonesHandler any
|
|
nodesHandler any
|
|
diagnosticsHandler DiagnosticsHandler
|
|
}
|
|
|
|
// NewGuidedHandler creates a new guided troubleshooting handler.
|
|
func NewGuidedHandler(guidedMgr GuidedManager) *GuidedHandler {
|
|
return &GuidedHandler{
|
|
guidedMgr: guidedMgr,
|
|
}
|
|
}
|
|
|
|
// SetZonesHandler sets the zones handler for zone information access.
|
|
func (h *GuidedHandler) SetZonesHandler(zonesHandler any) {
|
|
h.zonesHandler = zonesHandler
|
|
}
|
|
|
|
// SetNodesHandler sets the nodes handler for node information access.
|
|
func (h *GuidedHandler) SetNodesHandler(nodesHandler any) {
|
|
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)
|
|
|
|
// Link diagnostics API for proactive quality prompts
|
|
r.Get("/api/diagnostics/link/{linkID}", h.handleGetLinkDiagnostics)
|
|
}
|
|
|
|
// DiagnosticsHandler is the interface for accessing diagnostic information.
|
|
type DiagnosticsHandler interface {
|
|
GetDiagnoses(linkID string) []diagnostics.Diagnosis
|
|
GetDiagnosticFor(linkID string, timestamp time.Time) *diagnostics.Diagnosis
|
|
}
|
|
|
|
// SetDiagnosticsHandler sets the diagnostics handler.
|
|
func (h *GuidedHandler) SetDiagnosticsHandler(dh DiagnosticsHandler) {
|
|
h.diagnosticsHandler = dh
|
|
}
|
|
|
|
// handleGetLinkDiagnostics returns diagnostic information for a specific link.
|
|
func (h *GuidedHandler) handleGetLinkDiagnostics(w http.ResponseWriter, r *http.Request) {
|
|
linkID := chi.URLParam(r, "linkID")
|
|
if linkID == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "missing link ID")
|
|
return
|
|
}
|
|
|
|
// Parse optional timestamp parameter
|
|
var timestamp time.Time
|
|
timestampStr := r.URL.Query().Get("timestamp")
|
|
if timestampStr != "" {
|
|
if ts, err := time.Parse(time.RFC3339, timestampStr); err == nil {
|
|
timestamp = ts
|
|
} else if ms, err := time.Parse(timestampStr, "20060102150405"); err == nil {
|
|
timestamp = ms
|
|
}
|
|
}
|
|
|
|
var diagnosis *diagnostics.Diagnosis
|
|
var diagnoses []diagnostics.Diagnosis
|
|
|
|
if h.diagnosticsHandler != nil {
|
|
if !timestamp.IsZero() {
|
|
diagnosis = h.diagnosticsHandler.GetDiagnosticFor(linkID, timestamp)
|
|
}
|
|
diagnoses = h.diagnosticsHandler.GetDiagnoses(linkID)
|
|
}
|
|
|
|
// Get current link health info
|
|
health := h.getCurrentLinkHealth(linkID)
|
|
|
|
response := map[string]interface{}{
|
|
"link_id": linkID,
|
|
"diagnosis": diagnosis,
|
|
"diagnoses": diagnoses,
|
|
"health": health,
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// getCurrentLinkHealth returns the current health snapshot for a link
|
|
func (h *GuidedHandler) getCurrentLinkHealth(linkID string) map[string]interface{} {
|
|
// Try to get current health from the link weather diagnostics
|
|
if h.diagnosticsHandler == nil {
|
|
return nil
|
|
}
|
|
|
|
// Get diagnoses and extract the most recent diagnosis with actionable info
|
|
diagnoses := h.diagnosticsHandler.GetDiagnoses(linkID)
|
|
if len(diagnoses) == 0 {
|
|
return map[string]interface{}{
|
|
"message": "No diagnostic data available yet. Link needs more observation time.",
|
|
}
|
|
}
|
|
|
|
// Get the most recent diagnosis
|
|
mostRecent := diagnoses[0]
|
|
|
|
// Build health map with current metrics
|
|
health := map[string]interface{}{
|
|
"packet_rate": 20.0, // Default expected rate
|
|
"snr": 0.5, // Default SNR
|
|
"phase_stability": 0.5, // Default stability
|
|
"drift_rate": 0.0, // No drift
|
|
"composite_score": mostRecent.ConfidenceScore,
|
|
}
|
|
|
|
// If the diagnosis has specific rule info, we can infer health metrics
|
|
switch mostRecent.RuleID {
|
|
case "wifi_congestion_distance":
|
|
health["packet_rate"] = 12.0 // Low packet rate
|
|
health["composite_score"] = 0.5
|
|
case "metal_interference":
|
|
health["phase_stability"] = 0.7 // High instability
|
|
health["composite_score"] = 0.4
|
|
case "environmental_change":
|
|
health["drift_rate"] = 0.06 // High drift
|
|
health["composite_score"] = 0.6
|
|
case "fresnel_blockage", "fresnel_blockage_heuristic":
|
|
health["composite_score"] = 0.5
|
|
case "periodic_interference":
|
|
health["phase_stability"] = 0.6
|
|
health["composite_score"] = 0.55
|
|
}
|
|
|
|
// Add explanation text
|
|
if mostRecent.Detail != "" {
|
|
health["explanation"] = mostRecent.Detail
|
|
}
|
|
if mostRecent.Advice != "" {
|
|
health["advice"] = mostRecent.Advice
|
|
}
|
|
|
|
return health
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// GetTooltip returns package-specific Tooltip types; use GetTooltipAny for cross-package access.
|
|
type tooltipGetterAny interface {
|
|
GetTooltipAny(featureID string) (title, description, direction string, ok bool)
|
|
}
|
|
var title, description, direction string
|
|
found := false
|
|
if tg, ok := h.guidedMgr.(tooltipGetterAny); ok {
|
|
title, description, direction, found = tg.GetTooltipAny(featureID)
|
|
}
|
|
if !found {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "tooltip not found"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"show": true,
|
|
"title": title,
|
|
"description": description,
|
|
"direction": 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})
|
|
}
|