diff --git a/http.go b/http.go index 8eb4e83..44cb7a1 100644 --- a/http.go +++ b/http.go @@ -49,6 +49,7 @@ func (t *Tendrils) startHTTPServer() { mux := http.NewServeMux() mux.HandleFunc("/tendrils/api/status", t.handleAPIStatus) mux.HandleFunc("/tendrils/api/errors/clear", t.handleClearError) + mux.HandleFunc("/tendrils/api/nodes/remove", t.handleRemoveNode) mux.Handle("/", noCacheHandler(http.FileServer(http.Dir("static")))) log.Printf("[https] listening on :443") @@ -181,6 +182,28 @@ func (t *Tendrils) handleClearError(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func (t *Tendrils) handleRemoveNode(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "missing id parameter", http.StatusBadRequest) + return + } + + if err := t.nodes.RemoveNodeByID(id); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.Printf("removed node %s", id) + t.NotifyUpdate() + w.WriteHeader(http.StatusOK) +} + func (t *Tendrils) handleAPIStatusStream(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { diff --git a/nodes.go b/nodes.go index bb4e123..2390dad 100644 --- a/nodes.go +++ b/nodes.go @@ -441,6 +441,56 @@ func (n *Nodes) removeNode(node *Node) { } } +func (n *Nodes) RemoveNodeByID(nodeID string) error { + n.mu.Lock() + defer n.mu.Unlock() + + var node *Node + for _, nd := range n.nodes { + if nd.ID == nodeID { + node = nd + break + } + } + + if node == nil { + return fmt.Errorf("node not found") + } + + if !node.Unreachable { + return fmt.Errorf("node is reachable") + } + + if node.InConfig { + return fmt.Errorf("node is in config") + } + + for name := range node.Names { + delete(n.nameIndex, name) + } + + for _, iface := range node.Interfaces { + if iface.MAC != "" { + delete(n.macIndex, string(iface.MAC)) + } + for ipStr := range iface.IPs { + delete(n.ipIndex, ipStr) + } + } + + if node.cancelFunc != nil { + node.cancelFunc() + } + + n.removeNode(node) + + if n.t != nil && n.t.errors != nil { + n.t.errors.RemoveUnreachable(node) + } + + return nil +} + func (n *Nodes) GetByIP(ip net.IP) *Node { n.mu.RLock() defer n.mu.RUnlock() @@ -702,6 +752,10 @@ func (n *Nodes) applyNodeConfig(nc *NodeConfig) { if nc.Avoid { n.setAvoid(target) } + + n.mu.Lock() + target.InConfig = true + n.mu.Unlock() } func (n *Nodes) setAvoid(node *Node) { diff --git a/static/js/components.js b/static/js/components.js index 43bda6d..fa10e58 100644 --- a/static/js/components.js +++ b/static/js/components.js @@ -1,5 +1,5 @@ import { getLabel, getShortLabel, getFirstName, isSwitch, getSpeedClass } from './nodes.js'; -import { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList } from './ui.js'; +import { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList, removeNode } from './ui.js'; import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js'; export function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable) { @@ -307,6 +307,24 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn if (container) container.remove(); } + if (node.unreachable && !node.in_config) { + let removeBtn = div.querySelector(':scope > .remove-node-btn'); + if (!removeBtn) { + removeBtn = document.createElement('button'); + removeBtn.className = 'remove-node-btn'; + removeBtn.textContent = '×'; + removeBtn.title = 'Remove node'; + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + removeNode(node.id); + }); + div.appendChild(removeBtn); + } + } else { + const removeBtn = div.querySelector(':scope > .remove-node-btn'); + if (removeBtn) removeBtn.remove(); + } + return div; } diff --git a/static/js/table.js b/static/js/table.js index 0d2360c..3833d92 100644 --- a/static/js/table.js +++ b/static/js/table.js @@ -2,6 +2,7 @@ import { getLabel, getFirstName, isSwitch, getInterfaceSpeed, getInterfaceErrors import { buildSwitchUplinks } from './topology.js'; import { escapeHtml, formatUniverse } from './format.js'; import { tableData, tableSortKeys, setTableSortKeys } from './state.js'; +import { removeNode } from './ui.js'; export function sortTable(column) { const existingIdx = tableSortKeys.findIndex(k => k.column === column); @@ -79,6 +80,12 @@ export function renderTable() { th.classList.add(primarySort.asc ? 'sorted-asc' : 'sorted-desc'); } }); + + scrollDiv.querySelectorAll('.remove-node-btn').forEach(btn => { + btn.addEventListener('click', () => { + removeNode(btn.dataset.nodeId); + }); + }); } export function renderNetworkTable() { @@ -223,6 +230,7 @@ export function renderNetworkTable() { const outRateVal = rates == null ? null : (useLocalPerspective ? rates.outBytes : rates.inBytes); return { + nodeId: node.id, name, ip: ips[0] || '', upstream, @@ -240,7 +248,8 @@ export function renderNetworkTable() { uptime, uptimeStr: formatUptime(uptime), lastErrorTime, - lastErrorStr: formatTimeSince(lastErrorTime) + lastErrorStr: formatTimeSince(lastErrorTime), + removable: node.unreachable && !node.in_config }; }); @@ -251,7 +260,7 @@ export function renderNetworkTable() { html += ''; html += 'In'; html += 'Out'; - html += ''; + html += ''; html += ''; html += 'Name'; html += 'IP'; @@ -268,6 +277,7 @@ export function renderNetworkTable() { html += 'Uptime'; html += 'Last Err'; html += 'Status'; + html += ''; html += ''; rows.forEach(r => { @@ -288,6 +298,11 @@ export function renderNetworkTable() { html += '' + r.uptimeStr + ''; html += '' + r.lastErrorStr + ''; html += '' + r.status + ''; + html += ''; + if (r.removable) { + html += ''; + } + html += ''; html += ''; }); diff --git a/static/js/ui.js b/static/js/ui.js index 9720e92..ee4a5eb 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -1,6 +1,6 @@ import { formatBytes, formatPackets, formatMbps, formatPps, formatLinkSpeed } from './format.js'; import { openFlowHash } from './flow.js'; -import { portErrors, setErrorPanelCollapsed, errorPanelCollapsed } from './state.js'; +import { portErrors, setErrorPanelCollapsed, errorPanelCollapsed, tableData } from './state.js'; export function addClickableValue(container, label, value, plainLines, plainFormat) { const lbl = document.createElement('span'); @@ -193,6 +193,10 @@ export async function clearAllErrors() { await fetch('/tendrils/api/errors/clear?all=true', { method: 'POST' }); } +export async function removeNode(nodeId) { + await fetch('/tendrils/api/nodes/remove?id=' + encodeURIComponent(nodeId), { method: 'POST' }); +} + function formatLocalTime(utcString) { if (!utcString) return ''; const date = new Date(utcString); @@ -285,6 +289,15 @@ export function updateErrorPanel() { timestampEl.textContent = 'First: ' + formatLocalTime(err.first_seen) + ' / Last: ' + formatLocalTime(err.last_seen); item.appendChild(timestampEl); + const node = tableData?.nodes?.find(n => n.id === err.node_id); + if (node && node.unreachable && !node.in_config) { + const removeBtn = document.createElement('button'); + removeBtn.className = 'remove-btn'; + removeBtn.textContent = 'Remove node'; + removeBtn.addEventListener('click', () => removeNode(err.node_id)); + item.appendChild(removeBtn); + } + const dismissBtn = document.createElement('button'); dismissBtn.textContent = 'Dismiss'; dismissBtn.addEventListener('click', () => clearError(err.id)); diff --git a/static/style.css b/static/style.css index a67e662..40501dc 100644 --- a/static/style.css +++ b/static/style.css @@ -1172,6 +1172,14 @@ body.sacn-mode .node:not(.sacn-out):not(.sacn-in):hover .node-info-wrapper { background: #666; } +.error-item button.remove-btn { + background: #833; +} + +.error-item button.remove-btn:hover { + background: #a44; +} + .node.scroll-highlight { outline: 3px solid white; } @@ -1243,3 +1251,31 @@ body.sacn-mode .node:not(.sacn-out):not(.sacn-in):hover .node-info-wrapper { #broadcast-stats .bucket-rate { color: #eee; } + +.remove-node-btn { + padding: 0; + width: 16px; + height: 16px; + font-size: 14px; + line-height: 14px; + border: none; + border-radius: 3px; + cursor: pointer; + background: #833; + color: white; +} + +.remove-node-btn:hover { + background: #a44; +} + +.node .remove-node-btn { + position: absolute; + bottom: 2px; + right: 2px; +} + +.data-table .remove-node-btn { + width: 18px; + height: 18px; +} diff --git a/types.go b/types.go index eb96fa1..59f80a2 100644 --- a/types.go +++ b/types.go @@ -476,6 +476,7 @@ type Node struct { ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"` Unreachable bool `json:"unreachable,omitempty"` Avoid bool `json:"avoid,omitempty"` + InConfig bool `json:"in_config,omitempty"` errors *ErrorTracker pollTrigger chan struct{} cancelFunc context.CancelFunc