diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..1db85fb --- /dev/null +++ b/errors.go @@ -0,0 +1,152 @@ +package tendrils + +import ( + "fmt" + "sync" + "time" +) + +type PortErrorType string + +const ( + ErrorTypeStartup PortErrorType = "startup" + ErrorTypeNew PortErrorType = "new" +) + +type PortError struct { + ID string `json:"id"` + NodeTypeID string `json:"node_typeid"` + NodeName string `json:"node_name"` + PortName string `json:"port_name"` + ErrorType PortErrorType `json:"error_type"` + InErrors uint64 `json:"in_errors"` + OutErrors uint64 `json:"out_errors"` + InDelta uint64 `json:"in_delta,omitempty"` + OutDelta uint64 `json:"out_delta,omitempty"` + FirstSeen time.Time `json:"first_seen"` + LastUpdated time.Time `json:"last_updated"` +} + +type portErrorBaseline struct { + InErrors uint64 + OutErrors uint64 + HasData bool +} + +type ErrorTracker struct { + mu sync.RWMutex + errors map[string]*PortError + baselines map[string]*portErrorBaseline + nextID int +} + +func NewErrorTracker() *ErrorTracker { + return &ErrorTracker{ + errors: map[string]*PortError{}, + baselines: map[string]*portErrorBaseline{}, + } +} + +func (e *ErrorTracker) CheckPort(node *Node, portName string, stats *InterfaceStats) { + if stats == nil { + return + } + + e.mu.Lock() + defer e.mu.Unlock() + + key := node.TypeID + ":" + portName + baseline := e.baselines[key] + + now := time.Now() + + if baseline == nil || !baseline.HasData { + e.baselines[key] = &portErrorBaseline{ + InErrors: stats.InErrors, + OutErrors: stats.OutErrors, + HasData: true, + } + if stats.InErrors > 0 || stats.OutErrors > 0 { + e.nextID++ + e.errors[key] = &PortError{ + ID: fmt.Sprintf("err-%d", e.nextID), + NodeTypeID: node.TypeID, + NodeName: node.DisplayName(), + PortName: portName, + ErrorType: ErrorTypeStartup, + InErrors: stats.InErrors, + OutErrors: stats.OutErrors, + FirstSeen: now, + LastUpdated: now, + } + } + return + } + + inDelta := uint64(0) + outDelta := uint64(0) + if stats.InErrors > baseline.InErrors { + inDelta = stats.InErrors - baseline.InErrors + } + if stats.OutErrors > baseline.OutErrors { + outDelta = stats.OutErrors - baseline.OutErrors + } + + if inDelta > 0 || outDelta > 0 { + if existing, ok := e.errors[key]; ok { + existing.InErrors = stats.InErrors + existing.OutErrors = stats.OutErrors + existing.InDelta += inDelta + existing.OutDelta += outDelta + existing.LastUpdated = now + } else { + e.nextID++ + e.errors[key] = &PortError{ + ID: fmt.Sprintf("err-%d", e.nextID), + NodeTypeID: node.TypeID, + NodeName: node.DisplayName(), + PortName: portName, + ErrorType: ErrorTypeNew, + InErrors: stats.InErrors, + OutErrors: stats.OutErrors, + InDelta: inDelta, + OutDelta: outDelta, + FirstSeen: now, + LastUpdated: now, + } + } + } + + e.baselines[key].InErrors = stats.InErrors + e.baselines[key].OutErrors = stats.OutErrors +} + +func (e *ErrorTracker) ClearError(errorID string) { + e.mu.Lock() + defer e.mu.Unlock() + + for key, err := range e.errors { + if err.ID == errorID { + delete(e.errors, key) + return + } + } +} + +func (e *ErrorTracker) ClearAllErrors() { + e.mu.Lock() + defer e.mu.Unlock() + + e.errors = map[string]*PortError{} +} + +func (e *ErrorTracker) GetErrors() []*PortError { + e.mu.RLock() + defer e.mu.RUnlock() + + errors := make([]*PortError, 0, len(e.errors)) + for _, err := range e.errors { + errors = append(errors, err) + } + return errors +} diff --git a/http.go b/http.go index 49f4df0..f54f83c 100644 --- a/http.go +++ b/http.go @@ -29,6 +29,7 @@ type StatusResponse struct { MulticastGroups []*MulticastGroupMembers `json:"multicast_groups"` ArtNetNodes []*ArtNetNode `json:"artnet_nodes"` DanteFlows []*DanteFlow `json:"dante_flows"` + PortErrors []*PortError `json:"port_errors"` } func (t *Tendrils) startHTTPServer() { @@ -40,6 +41,7 @@ func (t *Tendrils) startHTTPServer() { mux := http.NewServeMux() mux.HandleFunc("/api/status", t.handleAPIStatus) mux.HandleFunc("/api/config", t.handleAPIConfig) + mux.HandleFunc("/api/errors/clear", t.handleClearError) mux.Handle("/", http.FileServer(http.Dir("static"))) log.Printf("[https] listening on :443") @@ -133,9 +135,28 @@ func (t *Tendrils) GetStatus() *StatusResponse { MulticastGroups: t.getMulticastGroups(), ArtNetNodes: t.getArtNetNodes(), DanteFlows: t.getDanteFlows(), + PortErrors: t.errors.GetErrors(), } } +func (t *Tendrils) handleClearError(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if r.URL.Query().Get("all") == "true" { + t.errors.ClearAllErrors() + } else if id := r.URL.Query().Get("id"); id != "" { + t.errors.ClearError(id) + } else { + http.Error(w, "missing id or all parameter", http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) +} + func (t *Tendrils) getNodes() []*Node { t.nodes.mu.RLock() defer t.nodes.mu.RUnlock() diff --git a/snmp.go b/snmp.go index 0909d0a..6a98ade 100644 --- a/snmp.go +++ b/snmp.go @@ -221,6 +221,7 @@ func (t *Tendrils) queryInterfaceStats(snmp *gosnmp.GoSNMP, node *Node, ifNames } iface.Stats = stats + t.errors.CheckPort(node, name, stats) } } diff --git a/static/index.html b/static/index.html index 98cdaaa..7e10f29 100644 --- a/static/index.html +++ b/static/index.html @@ -271,6 +271,126 @@ top: auto; bottom: -8px; } + + .node.has-error { + box-shadow: 0 0 0 3px #f66; + animation: error-pulse 2s infinite; + } + + @keyframes error-pulse { + 0%, 100% { box-shadow: 0 0 0 3px #f66; } + 50% { box-shadow: 0 0 0 3px #f00; } + } + + #error-panel { + position: fixed; + top: 50px; + right: 10px; + z-index: 1000; + background: #2a1a1a; + border: 1px solid #f66; + border-radius: 6px; + max-width: 500px; + max-height: 400px; + overflow: hidden; + display: none; + } + + #error-panel.has-errors { + display: block; + } + + #error-panel.collapsed #error-list { + display: none; + } + + #error-header { + padding: 8px 12px; + background: #3a2a2a; + display: flex; + gap: 8px; + align-items: center; + } + + #error-count { + flex: 1; + color: #f99; + font-weight: bold; + } + + #error-header button { + padding: 4px 8px; + border: none; + background: #444; + color: #ccc; + cursor: pointer; + border-radius: 4px; + font-size: 11px; + } + + #error-header button:hover { + background: #555; + } + + #error-list { + max-height: 300px; + overflow-y: auto; + padding: 8px; + } + + .error-item { + background: #3a2a2a; + border-radius: 4px; + padding: 8px; + margin-bottom: 6px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .error-item .error-node { + color: #faa; + font-weight: bold; + cursor: pointer; + } + + .error-item .error-node:hover { + text-decoration: underline; + } + + .error-item .error-port { + color: #ccc; + font-size: 11px; + } + + .error-item .error-counts { + color: #f66; + font-size: 11px; + } + + .error-item .error-type { + font-size: 9px; + color: #888; + } + + .error-item button { + align-self: flex-end; + padding: 2px 6px; + border: none; + background: #555; + color: #ccc; + cursor: pointer; + border-radius: 3px; + font-size: 10px; + } + + .error-item button:hover { + background: #666; + } + + .node.scroll-highlight { + outline: 3px solid white; + }
@@ -278,6 +398,14 @@ +