Add remove button for unreachable nodes not in config
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
23
http.go
23
http.go
@@ -49,6 +49,7 @@ func (t *Tendrils) startHTTPServer() {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/tendrils/api/status", t.handleAPIStatus)
|
mux.HandleFunc("/tendrils/api/status", t.handleAPIStatus)
|
||||||
mux.HandleFunc("/tendrils/api/errors/clear", t.handleClearError)
|
mux.HandleFunc("/tendrils/api/errors/clear", t.handleClearError)
|
||||||
|
mux.HandleFunc("/tendrils/api/nodes/remove", t.handleRemoveNode)
|
||||||
mux.Handle("/", noCacheHandler(http.FileServer(http.Dir("static"))))
|
mux.Handle("/", noCacheHandler(http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
log.Printf("[https] listening on :443")
|
log.Printf("[https] listening on :443")
|
||||||
@@ -181,6 +182,28 @@ func (t *Tendrils) handleClearError(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
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) {
|
func (t *Tendrils) handleAPIStatusStream(w http.ResponseWriter, r *http.Request) {
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
54
nodes.go
54
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 {
|
func (n *Nodes) GetByIP(ip net.IP) *Node {
|
||||||
n.mu.RLock()
|
n.mu.RLock()
|
||||||
defer n.mu.RUnlock()
|
defer n.mu.RUnlock()
|
||||||
@@ -702,6 +752,10 @@ func (n *Nodes) applyNodeConfig(nc *NodeConfig) {
|
|||||||
if nc.Avoid {
|
if nc.Avoid {
|
||||||
n.setAvoid(target)
|
n.setAvoid(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n.mu.Lock()
|
||||||
|
target.InConfig = true
|
||||||
|
n.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Nodes) setAvoid(node *Node) {
|
func (n *Nodes) setAvoid(node *Node) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getLabel, getShortLabel, getFirstName, isSwitch, getSpeedClass } from './nodes.js';
|
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';
|
import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js';
|
||||||
|
|
||||||
export function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable) {
|
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 (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;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getLabel, getFirstName, isSwitch, getInterfaceSpeed, getInterfaceErrors
|
|||||||
import { buildSwitchUplinks } from './topology.js';
|
import { buildSwitchUplinks } from './topology.js';
|
||||||
import { escapeHtml, formatUniverse } from './format.js';
|
import { escapeHtml, formatUniverse } from './format.js';
|
||||||
import { tableData, tableSortKeys, setTableSortKeys } from './state.js';
|
import { tableData, tableSortKeys, setTableSortKeys } from './state.js';
|
||||||
|
import { removeNode } from './ui.js';
|
||||||
|
|
||||||
export function sortTable(column) {
|
export function sortTable(column) {
|
||||||
const existingIdx = tableSortKeys.findIndex(k => k.column === 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');
|
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() {
|
export function renderNetworkTable() {
|
||||||
@@ -223,6 +230,7 @@ export function renderNetworkTable() {
|
|||||||
const outRateVal = rates == null ? null : (useLocalPerspective ? rates.outBytes : rates.inBytes);
|
const outRateVal = rates == null ? null : (useLocalPerspective ? rates.outBytes : rates.inBytes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
nodeId: node.id,
|
||||||
name,
|
name,
|
||||||
ip: ips[0] || '',
|
ip: ips[0] || '',
|
||||||
upstream,
|
upstream,
|
||||||
@@ -240,7 +248,8 @@ export function renderNetworkTable() {
|
|||||||
uptime,
|
uptime,
|
||||||
uptimeStr: formatUptime(uptime),
|
uptimeStr: formatUptime(uptime),
|
||||||
lastErrorTime,
|
lastErrorTime,
|
||||||
lastErrorStr: formatTimeSince(lastErrorTime)
|
lastErrorStr: formatTimeSince(lastErrorTime),
|
||||||
|
removable: node.unreachable && !node.in_config
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +260,7 @@ export function renderNetworkTable() {
|
|||||||
html += '<th colspan="4"></th>';
|
html += '<th colspan="4"></th>';
|
||||||
html += '<th colspan="4" class="group-in">In</th>';
|
html += '<th colspan="4" class="group-in">In</th>';
|
||||||
html += '<th colspan="4" class="group-out">Out</th>';
|
html += '<th colspan="4" class="group-out">Out</th>';
|
||||||
html += '<th colspan="3"></th>';
|
html += '<th colspan="4"></th>';
|
||||||
html += '</tr><tr>';
|
html += '</tr><tr>';
|
||||||
html += '<th data-sort="name">Name</th>';
|
html += '<th data-sort="name">Name</th>';
|
||||||
html += '<th data-sort="ip">IP</th>';
|
html += '<th data-sort="ip">IP</th>';
|
||||||
@@ -268,6 +277,7 @@ export function renderNetworkTable() {
|
|||||||
html += '<th data-sort="uptime">Uptime</th>';
|
html += '<th data-sort="uptime">Uptime</th>';
|
||||||
html += '<th data-sort="lastErrorTime">Last Err</th>';
|
html += '<th data-sort="lastErrorTime">Last Err</th>';
|
||||||
html += '<th data-sort="status">Status</th>';
|
html += '<th data-sort="status">Status</th>';
|
||||||
|
html += '<th></th>';
|
||||||
html += '</tr></thead><tbody>';
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
@@ -288,6 +298,11 @@ export function renderNetworkTable() {
|
|||||||
html += '<td class="numeric">' + r.uptimeStr + '</td>';
|
html += '<td class="numeric">' + r.uptimeStr + '</td>';
|
||||||
html += '<td class="numeric">' + r.lastErrorStr + '</td>';
|
html += '<td class="numeric">' + r.lastErrorStr + '</td>';
|
||||||
html += '<td class="' + statusClass + '">' + r.status + '</td>';
|
html += '<td class="' + statusClass + '">' + r.status + '</td>';
|
||||||
|
html += '<td>';
|
||||||
|
if (r.removable) {
|
||||||
|
html += '<button class="remove-node-btn" data-node-id="' + escapeHtml(r.nodeId) + '" title="Remove node">×</button>';
|
||||||
|
}
|
||||||
|
html += '</td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { formatBytes, formatPackets, formatMbps, formatPps, formatLinkSpeed } from './format.js';
|
import { formatBytes, formatPackets, formatMbps, formatPps, formatLinkSpeed } from './format.js';
|
||||||
import { openFlowHash } from './flow.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) {
|
export function addClickableValue(container, label, value, plainLines, plainFormat) {
|
||||||
const lbl = document.createElement('span');
|
const lbl = document.createElement('span');
|
||||||
@@ -193,6 +193,10 @@ export async function clearAllErrors() {
|
|||||||
await fetch('/tendrils/api/errors/clear?all=true', { method: 'POST' });
|
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) {
|
function formatLocalTime(utcString) {
|
||||||
if (!utcString) return '';
|
if (!utcString) return '';
|
||||||
const date = new Date(utcString);
|
const date = new Date(utcString);
|
||||||
@@ -285,6 +289,15 @@ export function updateErrorPanel() {
|
|||||||
timestampEl.textContent = 'First: ' + formatLocalTime(err.first_seen) + ' / Last: ' + formatLocalTime(err.last_seen);
|
timestampEl.textContent = 'First: ' + formatLocalTime(err.first_seen) + ' / Last: ' + formatLocalTime(err.last_seen);
|
||||||
item.appendChild(timestampEl);
|
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');
|
const dismissBtn = document.createElement('button');
|
||||||
dismissBtn.textContent = 'Dismiss';
|
dismissBtn.textContent = 'Dismiss';
|
||||||
dismissBtn.addEventListener('click', () => clearError(err.id));
|
dismissBtn.addEventListener('click', () => clearError(err.id));
|
||||||
|
|||||||
@@ -1172,6 +1172,14 @@ body.sacn-mode .node:not(.sacn-out):not(.sacn-in):hover .node-info-wrapper {
|
|||||||
background: #666;
|
background: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-item button.remove-btn {
|
||||||
|
background: #833;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-item button.remove-btn:hover {
|
||||||
|
background: #a44;
|
||||||
|
}
|
||||||
|
|
||||||
.node.scroll-highlight {
|
.node.scroll-highlight {
|
||||||
outline: 3px solid white;
|
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 {
|
#broadcast-stats .bucket-rate {
|
||||||
color: #eee;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
1
types.go
1
types.go
@@ -476,6 +476,7 @@ type Node struct {
|
|||||||
ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"`
|
ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"`
|
||||||
Unreachable bool `json:"unreachable,omitempty"`
|
Unreachable bool `json:"unreachable,omitempty"`
|
||||||
Avoid bool `json:"avoid,omitempty"`
|
Avoid bool `json:"avoid,omitempty"`
|
||||||
|
InConfig bool `json:"in_config,omitempty"`
|
||||||
errors *ErrorTracker
|
errors *ErrorTracker
|
||||||
pollTrigger chan struct{}
|
pollTrigger chan struct{}
|
||||||
cancelFunc context.CancelFunc
|
cancelFunc context.CancelFunc
|
||||||
|
|||||||
Reference in New Issue
Block a user