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 += '