// 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}) }