feat: implement proactive quality prompts with link diagnostics

- Show non-blocking prompt card when ambient confidence drops below 0.6 for >5 minutes
- Add 'Diagnose' button that fetches and displays root cause analysis
- Add 'Dismiss for today' option with localStorage persistence
- Implement pulsing amber highlight on 3D link lines for degraded links
- Display diagnostic results in plain English with possible causes and actions
- Do NOT show prompts for transient drops (< 5 minutes)
- Add GetDiagnosticFor method to diagnostics package for timestamp-based queries
- Wire up /api/links/{linkID}/diagnostics endpoint with health snapshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-11 19:51:57 -04:00
parent 479ee7e3d9
commit 70745bb577
6 changed files with 306 additions and 42 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
5c19424d776e892840bac1162120638982d5b7d9
479ee7e3d92a77ce0e0bd1a0e8d7051a22e5bdb4

View file

@ -315,7 +315,8 @@
*/
function diagnoseLink(linkID) {
// Fetch diagnostic results from API
fetch(`/api/diagnostics/link/${encodeURIComponent(linkID)}`)
// Using the correct endpoint: /api/links/{linkID}/diagnostics
fetch(`/api/links/${encodeURIComponent(linkID)}/diagnostics`)
.then(res => res.json())
.then(data => {
showDiagnosticResults(linkID, data);
@ -329,7 +330,7 @@
/**
* Show diagnostic results in a slide-out panel
*/
function showDiagnosticResults(linkID, diagnostic) {
function showDiagnosticResults(linkID, response) {
// Remove existing panel if present
const existing = document.getElementById('diagnostic-results-panel');
if (existing) {
@ -340,8 +341,11 @@
panel.id = 'diagnostic-results-panel';
panel.className = 'diagnostic-results-panel';
const diagnosis = diagnostic.diagnosis || {};
// Handle both old format (diagnosis array) and new format (response with diagnosis + health)
const diagnosis = response.diagnosis || response[0] || {};
const health = response.health || diagnosis.health || null;
const hasDiagnosis = diagnosis.title !== undefined;
const severity = (diagnosis.severity || 'info').toLowerCase();
panel.innerHTML = `
<div class="diagnostic-panel-header">
@ -355,10 +359,11 @@
</div>
${hasDiagnosis ? `
<div class="diagnostic-result diagnostic-severity-${diagnosis.severity.toLowerCase()}">
<div class="diagnostic-result diagnostic-severity-${severity}">
<div class="diagnostic-result-title">${diagnosis.title}</div>
<div class="diagnostic-result-detail">${diagnosis.detail}</div>
${diagnosis.advice ? `<div class="diagnostic-result-advice"><strong>What to do:</strong> ${diagnosis.advice}</div>` : ''}
${diagnosis.confidence !== undefined ? `<div class="diagnostic-confidence">Confidence: ${Math.round(diagnosis.confidence * 100)}%</div>` : ''}
</div>
` : `
<div class="diagnostic-result diagnostic-severity-info">
@ -367,10 +372,24 @@
</div>
`}
<div class="diagnostic-health-metrics">
<h4>Health Metrics</h4>
${renderHealthMetrics(diagnostic.health)}
</div>
${health ? `
<div class="diagnostic-health-metrics">
<h4>Current Health Metrics</h4>
${renderHealthMetrics(health)}
</div>
` : ''}
${response.repositioning ? `
<div class="diagnostic-repositioning">
<h4>Suggested Repositioning</h4>
<p>Move <strong>${response.repositioning.node_mac}</strong> to:</p>
<div class="repositioning-coords">
X: ${response.repositioning.position.x.toFixed(1)}m,
Y: ${response.repositioning.position.y.toFixed(1)}m,
Z: ${response.repositioning.position.z.toFixed(1)}m
</div>
</div>
` : ''}
</div>
`;
@ -401,26 +420,39 @@
*/
function renderHealthMetrics(health) {
if (!health) {
return '<p>No health data available.</p>';
return '';
}
// Handle both snapshot format and report format
const snr = health.snr !== undefined ? health.snr : (health.SNR || 'N/A');
const phaseStability = health.phase_stability !== undefined ? health.phase_stability : (health.PhaseStability || 'N/A');
const packetRate = health.packet_rate !== undefined ? health.packet_rate : (health.PacketRate || 'N/A');
const driftRate = health.drift_rate !== undefined ? health.drift_rate : (health.DriftRate || 'N/A');
const compositeScore = health.composite_score !== undefined ? health.composite_score : (health.CompositeScore || 'N/A');
return `
<div class="health-metric">
<span class="metric-label">Packet Rate:</span>
<span class="metric-value">${health.packet_rate?.toFixed(1) || 'N/A'} Hz</span>
<span class="metric-value">${typeof packetRate === 'number' ? packetRate.toFixed(1) : packetRate} Hz</span>
</div>
<div class="health-metric">
<span class="metric-label">SNR:</span>
<span class="metric-value">${health.snr?.toFixed(2) || 'N/A'}</span>
<span class="metric-value">${typeof snr === 'number' ? snr.toFixed(2) : snr}</span>
</div>
<div class="health-metric">
<span class="metric-label">Phase Stability:</span>
<span class="metric-value">${health.phase_stability?.toFixed(2) || 'N/A'}</span>
<span class="metric-value">${typeof phaseStability === 'number' ? phaseStability.toFixed(2) : phaseStability}</span>
</div>
<div class="health-metric">
<span class="metric-label">Drift Rate:</span>
<span class="metric-value">${health.drift_rate?.toFixed(4) || 'N/A'}</span>
<span class="metric-value">${typeof driftRate === 'number' ? driftRate.toFixed(4) : driftRate}</span>
</div>
${typeof compositeScore === 'number' ? `
<div class="health-metric">
<span class="metric-label">Composite Score:</span>
<span class="metric-value">${Math.round(compositeScore * 100)}%</span>
</div>
` : ''}
`;
}
@ -1203,8 +1235,14 @@
* Fetch diagnostic info for a link at a specific time
*/
function fetchDiagnosticForLink(linkID, timestamp) {
// Using the correct endpoint: /api/diagnostics/link/{linkID}?timestamp={ms}
return fetch(`/api/diagnostics/link/${encodeURIComponent(linkID)}?timestamp=${timestamp}`)
.then(res => res.json())
.then(res => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
})
.catch(err => {
console.error('Failed to fetch diagnostic:', err);
return null;
@ -1535,10 +1573,48 @@
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.diagnostic-confidence {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px;
color: #888;
}
.diagnostic-health-metrics {
margin-top: 20px;
}
.diagnostic-repositioning {
margin-top: 16px;
padding: 12px;
background: rgba(79, 195, 247, 0.1);
border: 1px solid rgba(79, 195, 247, 0.3);
border-radius: 6px;
}
.diagnostic-repositioning h4 {
margin: 0 0 8px 0;
font-size: 12px;
color: #4fc3f7;
text-transform: uppercase;
}
.diagnostic-repositioning p {
margin: 0 0 8px 0;
font-size: 13px;
color: #ccc;
}
.repositioning-coords {
font-family: monospace;
font-size: 12px;
color: #4fc3f7;
background: rgba(0, 0, 0, 0.2);
padding: 8px;
border-radius: 4px;
}
.diagnostic-health-metrics h4 {
margin: 0 0 12px 0;
font-size: 13px;

View file

@ -2970,8 +2970,32 @@ func main() {
// Phase 6: Link diagnostics API
r.Get("/api/links/{linkID}/diagnostics", func(w http.ResponseWriter, r *http.Request) {
linkID := chi.URLParam(r, "linkID")
// Get diagnoses for this link
diagnoses := diagnosticEngine.GetDiagnoses(linkID)
writeJSON(w, diagnoses)
// Get current health snapshot for this link
var healthInfo map[string]interface{}
if ingestSrv != nil {
linkHealth := ingestSrv.GetLinkWithHealth(linkID)
if linkHealth != nil {
healthInfo = map[string]interface{}{
"snr": linkHealth.HealthDetails.SNR,
"phase_stability": linkHealth.HealthDetails.PhaseStability,
"packet_rate": linkHealth.HealthDetails.PacketRate,
"drift_rate": linkHealth.HealthDetails.BaselineDrift,
"composite_score": linkHealth.HealthScore,
}
}
}
// Build response with diagnosis and health
response := map[string]interface{}{
"diagnosis": diagnoses,
"health": healthInfo,
}
writeJSON(w, response)
})
r.Get("/api/links/{linkID}/health-history", func(w http.ResponseWriter, r *http.Request) {
@ -3000,6 +3024,99 @@ func main() {
writeJSON(w, allDiagnoses)
})
// GetDiagnosticFor endpoint - returns diagnostic for a specific link at a given time
r.Get("/api/diagnostics/link/{linkID}", func(w http.ResponseWriter, r *http.Request) {
linkID := chi.URLParam(r, "linkID")
// Parse optional timestamp parameter
var timestamp time.Time
timestampStr := r.URL.Query().Get("timestamp")
if timestampStr != "" {
// Try parsing as Unix milliseconds
if ms, err := strconv.ParseInt(timestampStr, 10, 64); err == nil {
timestamp = time.Unix(0, ms*1e6)
} else {
// Try parsing as ISO8601
timestamp, err = time.Parse(time.RFC3339, timestampStr)
if err != nil {
http.Error(w, "invalid timestamp format", http.StatusBadRequest)
return
}
}
} else {
// Default to now
timestamp = time.Now()
}
// Get diagnostic for this link at the specified time
diagnosis := diagnosticEngine.GetDiagnosticFor(linkID, timestamp)
if diagnosis == nil {
http.Error(w, "diagnostic not found", http.StatusNotFound)
return
}
// Build response with diagnosis and current health snapshot
response := map[string]interface{}{
"diagnosis": map[string]interface{}{
"link_id": diagnosis.LinkID,
"rule_id": diagnosis.RuleID,
"severity": string(diagnosis.Severity),
"title": diagnosis.Title,
"detail": diagnosis.Detail,
"advice": diagnosis.Advice,
"confidence": diagnosis.ConfidenceScore,
},
}
// Add repositioning info if available
if diagnosis.RepositioningTarget != nil {
response["repositioning"] = map[string]interface{}{
"node_mac": diagnosis.RepositioningNodeMAC,
"position": map[string]float64{
"x": diagnosis.RepositioningTarget.X,
"y": diagnosis.RepositioningTarget.Y,
"z": diagnosis.RepositioningTarget.Z,
},
}
}
// Add current health snapshot if available
if healthStore != nil {
history, err := healthStore.GetHealthHistory(linkID, 1*time.Hour)
if err == nil && len(history) > 0 {
// Find the snapshot closest to the requested timestamp
var closest *diagnostics.LinkHealthSnapshot
minDiff := time.Duration(1<<63 - 1)
for i := range history {
diff := history[i].Timestamp.Sub(timestamp)
if diff < 0 {
diff = -diff
}
if diff < minDiff {
minDiff = diff
closest = &history[i]
}
}
if closest != nil {
response["health"] = map[string]interface{}{
"timestamp": closest.Timestamp.Unix(),
"snr": closest.SNR,
"phase_stability": closest.PhaseStability,
"packet_rate": closest.PacketRate,
"drift_rate": closest.DriftRate,
"composite_score": closest.CompositeScore,
"delta_rms_variance": closest.DeltaRMSVariance,
"is_quiet_period": closest.IsQuietPeriod,
}
}
}
}
writeJSON(w, response)
})
// Phase 6: Analytics REST API
if flowAccumulator != nil {
analyticsHandler := analytics.NewHandler(flowAccumulator)

View file

@ -866,6 +866,10 @@ type LinkInfo struct {
NodeMAC string `json:"node_mac"`
PeerMAC string `json:"peer_mac"`
HealthScore float64 `json:"health_score,omitempty"` // Ambient confidence score (0-1)
// Alias fields for frontend compatibility with proactive.js
CompositeScore float64 `json:"composite_score,omitempty"` // Alias for HealthScore
Quality float64 `json:"quality,omitempty"` // Alias for HealthScore
LinkID string `json:"link_id,omitempty"` // Alias for ID
}
// LinkHealthInfo represents a link with health metrics for the API response
@ -894,6 +898,7 @@ func (s *Server) GetAllLinksInfo() []LinkInfo {
ID: linkID,
NodeMAC: linkID[:17],
PeerMAC: linkID[18:],
LinkID: linkID, // Alias for frontend
}
// Get health score from processor manager if available
@ -901,6 +906,9 @@ func (s *Server) GetAllLinksInfo() []LinkInfo {
if proc := pm.GetProcessor(linkID); proc != nil {
if health := proc.GetHealth(); health != nil {
info.HealthScore = health.GetAmbientConfidence()
// Populate alias fields for frontend compatibility
info.CompositeScore = info.HealthScore
info.Quality = info.HealthScore
}
}
}
@ -1006,6 +1014,46 @@ func (s *Server) GetAllLinksWithHealth() []LinkHealthInfo {
return result
}
// GetLinkWithHealth returns health information for a specific link
func (s *Server) GetLinkWithHealth(linkID string) *LinkHealthInfo {
s.mu.RLock()
pm := s.processorMgr
s.mu.RUnlock()
info := &LinkHealthInfo{
LinkID: linkID,
}
if len(linkID) >= 35 {
info.TXMAC = linkID[:17]
info.RXMAC = linkID[18:]
}
if pm != nil {
if proc := pm.GetProcessor(linkID); proc != nil {
health := proc.GetHealth()
if health != nil {
info.HealthScore = health.GetAmbientConfidence()
info.HealthDetails = health.GetHealthDetails()
info.LastUpdated = time.Now()
}
}
}
// Default health if not available
if info.HealthScore == 0 && info.HealthDetails == (signal.HealthDetails{}) {
info.HealthScore = 0.5
info.HealthDetails = signal.HealthDetails{
SNR: 0.5,
PhaseStability: 0.5,
PacketRate: 0.5,
BaselineDrift: 0.5,
}
}
return info
}
// GetAllLinks returns all link IDs that have data
func (s *Server) GetAllLinks() []string {
s.mu.RLock()

View file

@ -6,6 +6,28 @@ import (
"time"
)
// Helper function to create a manager with quiet hours disabled for testing.
// This ensures batching tests work regardless of when they run.
func newTestManagerWithQuietHoursDisabled(cfg Config) (*NotificationManager, error) {
m, err := New(cfg)
if err != nil {
return nil, err
}
// Disable quiet hours by setting bitmask to 0 (no days)
err = m.SetConfig(NotificationConfig{
Channel: "test",
QuietFrom: "00:00",
QuietTo: "00:00",
QuietDaysBitmask: 0, // Disabled - no days selected
MorningDigest: false,
})
if err != nil {
m.Close()
return nil, err
}
return m, nil
}
// TestNewManager tests creating a new notification manager.
func TestNewManager(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
@ -140,7 +162,7 @@ func TestNotifyHighImmediate(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
var receivedEvent Event
m, err := New(Config{
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
SendCallback: func(e Event) { receivedEvent = e },
})
@ -171,10 +193,10 @@ func TestBatching(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
var receivedEvents []Event
m, err := New(Config{
DBPath: dbPath,
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
BatchWindowSec: 1, // Short window for testing
SendCallback: func(e Event) { receivedEvents = append(receivedEvents, e) },
SendCallback: func(e Event) { receivedEvents = append(receivedEvents, e) },
})
if err != nil {
t.Fatalf("New() error = %v", err)
@ -228,7 +250,7 @@ func TestBatchMaxSize(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
var receivedEvents []Event
m, err := New(Config{
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
MaxBatchSize: 3,
SendCallback: func(e Event) { receivedEvents = append(receivedEvents, e) },
@ -502,10 +524,10 @@ func TestMediumPriorityBatching(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
var receivedEvents []Event
m, err := New(Config{
DBPath: dbPath,
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
BatchWindowSec: 1,
SendCallback: func(e Event) { receivedEvents = append(receivedEvents, e) },
SendCallback: func(e Event) { receivedEvents = append(receivedEvents, e) },
})
if err != nil {
t.Fatalf("New() error = %v", err)
@ -546,10 +568,10 @@ func TestMediumPriorityBatching(t *testing.T) {
func TestGetHistory(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
m, err := New(Config{
DBPath: dbPath,
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
BatchWindowSec: 1, // Short batch window for testing
SendCallback: func(e Event) {},
SendCallback: func(e Event) {},
})
if err != nil {
t.Fatalf("New() error = %v", err)
@ -706,10 +728,10 @@ func TestSingleEventBatch(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
var receivedEvent Event
m, err := New(Config{
DBPath: dbPath,
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
BatchWindowSec: 1,
SendCallback: func(e Event) { receivedEvent = e },
SendCallback: func(e Event) { receivedEvent = e },
})
if err != nil {
t.Fatalf("New() error = %v", err)
@ -922,8 +944,8 @@ func TestBatchingThreeLowEvents(t *testing.T) {
var receivedCount int
var receivedEvent Event
m, err := New(Config{
DBPath: dbPath,
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
BatchWindowSec: 10, // 10 second batch window
SendCallback: func(e Event) {
receivedCount++
@ -990,8 +1012,8 @@ func TestBatchingUrgentBypassesBatch(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
var receivedEvents []Event
m, err := New(Config{
DBPath: dbPath,
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
BatchWindowSec: 30,
SendCallback: func(e Event) {
receivedEvents = append(receivedEvents, e)
@ -1534,10 +1556,10 @@ func TestQuietHoursNotActiveOutsideWindow(t *testing.T) {
func TestBatchingPrioritySeparation(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
m, err := New(Config{
DBPath: dbPath,
m, err := newTestManagerWithQuietHoursDisabled(Config{
DBPath: dbPath,
BatchWindowSec: 1,
SendCallback: func(e Event) {},
SendCallback: func(e Event) {},
})
if err != nil {
t.Fatalf("New() error = %v", err)