{% extends "base.twig" %}
{% block title %}{{ t('ui.nodelist.index.heading', {}, locale, ['common']) }} - {{ system_name }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="/assets/leaflet/leaflet.css">
<link rel="stylesheet" href="/assets/leaflet/MarkerCluster.css">
<link rel="stylesheet" href="/assets/leaflet/MarkerCluster.Default.css">
<style>
.nodelist-map-shell {
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
border-color: rgba(148, 163, 184, 0.18);
color: #e2e8f0;
}
#nodelistMap {
height: 520px;
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 0.5rem;
background: #111827;
}
#nodelistMap .leaflet-tile-pane {
filter: grayscale(0.45) saturate(0.45) brightness(0.38) contrast(1.28);
}
#nodelistMap .leaflet-control-container .leaflet-bar a,
#nodelistMap .leaflet-control-zoom a {
background: rgba(15, 23, 42, 0.92);
color: #e2e8f0;
border-bottom-color: rgba(148, 163, 184, 0.18);
}
#nodelistMap .leaflet-control-container .leaflet-bar a:hover,
#nodelistMap .leaflet-control-zoom a:hover {
background: rgba(30, 41, 59, 0.98);
color: #fff;
}
#nodelistMap .leaflet-popup-content-wrapper,
#nodelistMap .leaflet-popup-tip {
background: #111827;
color: #e2e8f0;
}
#nodelistMap .leaflet-popup-content a {
color: #93c5fd;
}
.context-menu {
background-color: var(--bs-body-bg, #fff);
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0,0,0,.15);
min-width: 150px;
padding: 0.5rem 0;
}
.context-menu-item {
display: block;
padding: 0.5rem 1rem;
text-decoration: none;
color: var(--bs-body-color, #212529);
transition: background-color 0.15s ease;
}
.context-menu-item:hover { background-color: var(--bs-secondary-bg, #e9ecef); }
.context-menu-item i { width: 20px; margin-right: 0.5rem; }
</style>
{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-sitemap"></i> {{ t('ui.nodelist.index.heading', {}, locale, ['common']) }}</h2>
{% if user.is_admin %}
<a href="/nodelist/import" class="btn btn-primary">
<i class="fas fa-upload"></i> {{ t('ui.nodelist.index.import_nodelist', {}, locale, ['common']) }}
</a>
{% endif %}
</div>
{% if activeNodelists|length > 0 %}
<div class="alert alert-info py-2 mb-3 text-center small">
<strong>{{ stats.total_nodes|number_format }}</strong> {{ t('ui.nodelist.index.nodes', {}, locale, ['common']) }} · <strong>{{ stats.total_zones }}</strong> {{ t('ui.nodelist.index.zones', {}, locale, ['common']) }} · <strong>{{ stats.total_nets|number_format }}</strong> {{ t('ui.nodelist.index.nets', {}, locale, ['common']) }} · <strong>{{ stats.point_nodes|number_format }}</strong> {{ t('ui.nodelist.index.points', {}, locale, ['common']) }} · <strong>{{ stats.special_nodes|number_format }}</strong> {{ t('ui.nodelist.index.special', {}, locale, ['common']) }}
<br><span class="text-muted">{{ t('ui.nodelist.index.last_imported', {}, locale, ['common']) }}
{% for nodelist in activeNodelists %}<span class="badge bg-secondary fw-normal ms-1">{{ nodelist.domain|default(t('ui.common.unknown', {}, locale, ['common'])) }} {{ nodelist.imported_at|date('M d, Y') }}</span>{% endfor %}
</span>
</div>
{% elseif activeNodelist %}
<div class="alert alert-info py-2 mb-3 text-center small">
<strong>{{ stats.total_nodes|number_format }}</strong> {{ t('ui.nodelist.index.nodes', {}, locale, ['common']) }} · <strong>{{ stats.total_zones }}</strong> {{ t('ui.nodelist.index.zones', {}, locale, ['common']) }} · <strong>{{ stats.total_nets|number_format }}</strong> {{ t('ui.nodelist.index.nets', {}, locale, ['common']) }} · <strong>{{ stats.point_nodes|number_format }}</strong> {{ t('ui.nodelist.index.points', {}, locale, ['common']) }} · <strong>{{ stats.special_nodes|number_format }}</strong> {{ t('ui.nodelist.index.special', {}, locale, ['common']) }}
<br><span class="text-muted">{{ t('ui.nodelist.index.last_imported', {}, locale, ['common']) }} {{ activeNodelist.imported_at|date('M d, Y') }}</span>
</div>
{% endif %}
<!-- Tab Navigation -->
<ul class="nav nav-tabs mb-4" id="nodelistTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="nodelist-list-tab" data-bs-toggle="tab"
data-bs-target="#nodelist-list-pane" type="button" role="tab">
<i class="fas fa-list me-1"></i>{{ t('ui.nodelist.tab_list', {}, locale, ['common']) }}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="nodelist-map-tab" data-bs-toggle="tab"
data-bs-target="#nodelist-map-pane" type="button" role="tab">
<i class="fas fa-map-marked-alt me-1"></i>{{ t('ui.nodelist.tab_map', {}, locale, ['common']) }}
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="nodelist-list-pane" role="tabpanel">
<!-- Search Form -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/nodelist" class="row g-3">
<div class="col-md-4">
<label for="search" class="form-label">{{ t('ui.common.search', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="search" name="search"
value="{{ search }}" placeholder="{{ t('ui.nodelist.index.search_placeholder', {}, locale, ['common']) }}">
</div>
<div class="col-md-2">
<label for="zone" class="form-label">{{ t('ui.nodelist.zone', {}, locale, ['common']) }}</label>
<select class="form-select" id="zone" name="zone">
<option value="">{{ t('ui.nodelist.index.all_zones', {}, locale, ['common']) }}</option>
{% for z in zones %}
<option value="{{ z.zone }}" {% if selectedZone == z.zone %}selected{% endif %}>{{ t('ui.nodelist.index.zone_prefix', {}, locale, ['common']) }} {{ z.zone }}{% if z.domain %} ({{ z.domain }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label for="net" class="form-label">{{ t('ui.nodelist.net', {}, locale, ['common']) }}</label>
<select class="form-select" id="net" name="net">
<option value="">{{ t('ui.nodelist.index.all_nets', {}, locale, ['common']) }}</option>
{% for n in nets %}
<option value="{{ n }}" {% if selectedNet == n %}selected{% endif %}>{{ t('ui.nodelist.index.net_prefix', {}, locale, ['common']) }} {{ n }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label">{{ t('ui.nodelist.index.flag_filter_label', {}, locale, ['common']) }}</label>
<div class="dropdown" id="flagPickerWrap">
<button class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
type="button" id="flagPickerBtn"
data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<span id="flagPickerLabel">{{ selectedFlags|length > 0 ? selectedFlags|join(', ') : t('ui.nodelist.index.flag_filter_any', {}, locale, ['common']) }}</span>
</button>
{% set flagDescriptions = {
'IBN': 'Internet BinkP Node',
'INA': 'Internet Address',
'ITN': 'Internet Telnet Node',
'IFC': 'Internet File/Conference',
'IFT': 'Internet FTP',
'IVM': 'Internet VoIP/Messaging',
'IMI': 'Internet MIME email',
'ISE': 'Internet SMTP/Email',
'IEM': 'Internet Email',
'IP': 'Internet IP address (legacy)',
'BINKP': 'BinkP capable',
'CM': 'Continuous Mail',
'MO': 'Mail Only',
'LO': 'Listed Only',
'MN': 'Mail and News',
'FREQ': 'File Request',
'PING': 'Ping supported',
'TRACE': 'Netmail tracing supported',
'HOLD': 'Mail on hold',
'DOWN': 'System down',
'PVT': 'Private node',
'HUB': 'Hub node',
'HOST': 'Net host node',
'REGION': 'Regional coordinator',
'ZONE': 'Zone coordinator',
'NEC': 'No EchoMail',
'V21': 'V.21 modem (300 baud)',
'V22': 'V.22 modem (1200 baud)',
'V29': 'V.29 modem (9600 baud)',
'V32': 'V.32 modem (9600 baud)',
'V32T': 'V.32 Terbo modem (19200 baud)',
'V32B': 'V.32bis modem (14400 baud)',
'VFC': 'Rockwell V.FC modem (28800 baud)',
'V34': 'V.34 modem (28800 baud)',
'V42': 'V.42 error correction',
'V42B': 'V.42bis compression',
'V90C': 'V.90 modem ? client (56k download)',
'V90S': 'V.90 modem ? server (56k upload)',
'H96': 'HST 9600',
'H14': 'HST 14400',
'H16': 'HST 16800',
'MAX': 'MaxLink protocol',
'PEP': 'PEP high-speed protocol',
'ZYX': 'ZyXEL protocol',
'XA': 'Xmodem/ASCII',
'XB': 'Xmodem/CRC',
'XC': 'Xmodem/1K',
'XP': 'Xmodem/1K-G',
'ZMO': 'ZModem (original)',
'ZMB': 'ZModem with recovery',
'XW': 'WXmodem (windowed Xmodem)',
'XX': 'Xmodem/CRC (alternate)',
'ISDN': 'ISDN connection',
'ISDNA': 'ISDN async',
'X25': 'X.25 packet network',
'POINTS24': 'Points file 24 hours',
'ANUS': 'Allow anonymous sessions'
} %}
<ul class="dropdown-menu p-2" id="flagDropdown" style="min-width:280px;max-height:260px;overflow-y:auto;">
{% if availableFlags|length > 0 %}
{% for f in availableFlags %}
<li>
<label class="dropdown-item d-flex align-items-center gap-2 py-1 px-2">
<input type="checkbox" class="form-check-input flag-cb flex-shrink-0"
name="flag[]" value="{{ f }}"
{% if f in selectedFlags %}checked{% endif %}>
<code class="small flex-shrink-0">{{ f }}</code>
{% if flagDescriptions[f] is defined %}
<span class="text-muted small">? {{ flagDescriptions[f] }}</span>
{% endif %}
</label>
</li>
{% endfor %}
{% else %}
<li><span class="dropdown-item-text text-muted small">No flags available</span></li>
{% endif %}
</ul>
</div>
</div>
<div class="col-md-2">
<label class="form-label"> </label>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> {{ t('ui.common.search', {}, locale, ['common']) }}
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Results -->
{% if nodes %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ t('ui.nodelist.index.search_results', {'count': nodes|length}, locale, ['common']) }}</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr>
<th>{{ t('ui.nodelist.index.address', {}, locale, ['common']) }}</th>
<th>{{ t('ui.nodelist.index.type', {}, locale, ['common']) }}</th>
<th>{{ t('ui.nodelist.system_name', {}, locale, ['common']) }}</th>
<th>{{ t('ui.nodelist.sysop', {}, locale, ['common']) }}</th>
<th>{{ t('ui.nodelist.location', {}, locale, ['common']) }}</th>
<th>{{ t('ui.nodelist.index.phone_host', {}, locale, ['common']) }}</th>
<th>{{ t('ui.nodelist.index.speed', {}, locale, ['common']) }}</th>
<th>{{ t('ui.nodelist.index.actions', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody>
{% for node in nodes %}
<tr>
<td>
<strong>{{ node.full_address }}</strong>
</td>
<td>
{% if node.keyword_type %}
<span class="badge bg-primary">{{ node.keyword_type }}</span>
{% else %}
<span class="text-muted">{{ t('ui.nodelist.node', {}, locale, ['common']) }}</span>
{% endif %}
</td>
<td>{{ node.system_name|default('') }}</td>
<td>{{ node.sysop_name|default('') }}</td>
<td>{{ node.location|default('') }}</td>
<td>
{% if node.phone and node.phone != '-Unpublished-' %}
<small>{{ node.phone }}</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
{% if node.flags['INA'] is defined %}
<br><a href="telnet://{{ node.flags['INA'] }}" class="host-link" data-hostname="{{ node.flags['INA'] }}">{{ node.flags['INA'] }}</a>
{% endif %}
</td>
<td>
{% if node.baud_rate > 0 %}
{{ node.baud_rate }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="/nodelist/view?address={{ node.full_address|url_encode }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i>
</a>
{% if user %}
<a href="/compose/netmail?to={{ node.full_address|url_encode }}&to_name={{ node.sysop_name|url_encode }}" class="btn btn-outline-success btn-sm">
<i class="fas fa-envelope"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
{% if search or selectedZone or selectedNet or selectedFlags|length > 0 %}
{{ t('ui.nodelist.index.no_nodes_found_search', {}, locale, ['common']) }}
{% else %}
{{ t('ui.nodelist.index.use_search_form', {}, locale, ['common']) }}
{% endif %}
</div>
{% endif %}
</div>{# end list pane #}
<!-- Map Tab Pane -->
<div class="tab-pane fade" id="nodelist-map-pane" role="tabpanel">
<div class="card nodelist-map-shell" id="nodelistMapShell">
<div class="card-body p-2">
<div id="nodelistMapNotice" class="alert alert-secondary mb-2 d-none"></div>
<div id="nodelistMap"></div>
<div id="nodelistMapStatus" class="text-muted small mt-1 text-end"></div>
</div>
</div>
</div>
</div>{# end tab-content #}
</div>
</div>
</div>
<!-- Context Menu for Host Links -->
<div id="hostContextMenu" class="context-menu" style="display: none; position: absolute; z-index: 9999;">
<ul class="list-unstyled mb-0">
<li><a href="#" id="menuHttp" class="context-menu-item"><i class="fas fa-globe"></i> {{ t('ui.nodelist.http', {}, locale, ['common']) }}</a></li>
<li><a href="#" id="menuTelnet" class="context-menu-item"><i class="fas fa-terminal"></i> {{ t('ui.nodelist.telnet', {}, locale, ['common']) }}</a></li>
<li><a href="#" id="menuSsh" class="context-menu-item"><i class="fas fa-lock"></i> {{ t('ui.nodelist.ssh', {}, locale, ['common']) }}</a></li>
</ul>
</div>
{% endblock %}
{% block scripts %}
<script src="/assets/leaflet/leaflet.js"></script>
<script src="/assets/leaflet/leaflet.markercluster.js"></script>
<script>
(function() {
const mapTab = document.getElementById('nodelist-map-tab');
const mapShell = document.getElementById('nodelistMapShell');
const mapNotice = document.getElementById('nodelistMapNotice');
const mapStatus = document.getElementById('nodelistMapStatus');
let nodelistMap = null;
let mapLoaded = false;
// Network domain colour palette ? assigned dynamically as domains are encountered.
const DOMAIN_PALETTE = [
'#4a90d9', // blue
'#5cb85c', // green
'#d9534f', // red
'#9b59b6', // purple
'#1abc9c', // teal
'#e8a838', // amber
'#e67e22', // orange
'#e91e8c', // pink
'#00bcd4', // cyan
'#8bc34a', // lime
];
const COLOUR_MULTI = '#f39c12'; // gold ? node on multiple networks
const COLOUR_UNKNOWN = '#95a5a6'; // grey ? no network info
// Built during initMap from the data.
const domainColourMap = {};
function assignDomainColours(nodes) {
let idx = 0;
nodes.forEach(function(node) {
(node.networks || []).forEach(function(net) {
const d = (net.domain || '').toLowerCase();
if (d && !domainColourMap[d]) {
domainColourMap[d] = DOMAIN_PALETTE[idx % DOMAIN_PALETTE.length];
idx++;
}
});
});
}
function domainColour(networks) {
if (!networks || networks.length === 0) return COLOUR_UNKNOWN;
const domains = [...new Set(networks.map(function(n) { return (n.domain || '').toLowerCase(); }).filter(Boolean))];
if (domains.length === 0) return COLOUR_UNKNOWN;
if (domains.length > 1) return COLOUR_MULTI;
return domainColourMap[domains[0]] || COLOUR_UNKNOWN;
}
function makeIcon(colour) {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14">'
+ '<circle cx="7" cy="7" r="6" fill="' + colour + '" stroke="rgba(0,0,0,0.4)" stroke-width="1.5"/>'
+ '</svg>';
return L.divIcon({
className: '',
html: svg,
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -9]
});
}
function escapeHtml(v) {
return String(v || '').replace(/[&<>"']/g, function(ch) {
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch];
});
}
function addFullscreenControl() {
const FS = L.Control.extend({
options: { position: 'topleft' },
onAdd: function() {
const c = L.DomUtil.create('div', 'leaflet-bar');
const a = L.DomUtil.create('a', '', c);
a.href = '#'; a.title = 'Toggle fullscreen';
a.innerHTML = '<i class="fas fa-expand"></i>';
L.DomEvent.disableClickPropagation(c);
L.DomEvent.on(a, 'click', function(e) {
L.DomEvent.stop(e);
if (!document.fullscreenElement) {
mapShell && mapShell.requestFullscreen && mapShell.requestFullscreen();
} else {
document.exitFullscreen && document.exitFullscreen();
}
});
return c;
}
});
nodelistMap.addControl(new FS());
}
function addLegend() {
const Legend = L.Control.extend({
options: { position: 'bottomright' },
onAdd: function() {
const div = L.DomUtil.create('div');
div.style.cssText = 'background:rgba(17,24,39,0.92);padding:6px 9px;border-radius:4px;'
+ 'border:1px solid rgba(148,163,184,0.2);font-size:12px;line-height:1.9;'
+ 'color:#e2e8f0';
const dot = function(colour) {
return '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;'
+ 'background:' + colour + ';border:1px solid rgba(0,0,0,0.3);margin-right:5px;vertical-align:middle"></span>';
};
const entries = Object.keys(domainColourMap).sort().map(function(d) {
return dot(domainColourMap[d]) + escapeHtml(d);
});
if (Object.keys(domainColourMap).length > 1) {
entries.push(dot(COLOUR_MULTI) + 'Multiple networks');
}
entries.push(dot(COLOUR_UNKNOWN) + 'Unknown');
div.innerHTML = entries.join('<br>');
return div;
}
});
nodelistMap.addControl(new Legend());
}
function initMap(nodes) {
if (nodelistMap) {
nodelistMap.invalidateSize();
return;
}
if (nodes.length === 0) {
mapNotice.textContent = {{ t('ui.nodelist.map_no_coordinates', {}, locale, ['common'])|json_encode|raw }};
mapNotice.classList.remove('d-none');
document.getElementById('nodelistMap').style.display = 'none';
return;
}
assignDomainColours(nodes);
nodelistMap = L.map('nodelistMap', { scrollWheelZoom: true });
addFullscreenControl();
addLegend();
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(nodelistMap);
const cluster = L.markerClusterGroup({
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
maxClusterRadius: 40
});
const bounds = [];
const sendNetmailLabel = {{ t('ui.nodelist.map_send_netmail', {}, locale, ['common'])|json_encode|raw }};
const userLoggedIn = {{ user ? 'true' : 'false' }};
nodes.forEach(function(node) {
const lat = Number(node.latitude);
const lon = Number(node.longitude);
if (!isFinite(lat) || !isFinite(lon)) return;
const colour = domainColour(node.networks);
const parts = ['<div style="min-width:180px;max-width:260px">'];
parts.push('<strong>' + escapeHtml(node.system_name || '(unnamed)') + '</strong>');
if (node.sysop_name) parts.push('<div class="text-muted small">' + escapeHtml(node.sysop_name) + '</div>');
if (node.location) parts.push('<div class="small">' + escapeHtml(node.location) + '</div>');
// One row per network entry
parts.push('<hr style="margin:4px 0">');
(node.networks || []).forEach(function(net) {
const dot = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;'
+ 'background:' + (domainColourMap[(net.domain || '').toLowerCase()] || COLOUR_UNKNOWN) + ';border:1px solid rgba(0,0,0,0.3);'
+ 'margin-right:4px;vertical-align:middle"></span>';
let row = dot + '<code style="font-size:11px">' + escapeHtml(net.address) + '</code>';
if (net.inet_host) {
row += ' <a href="telnet://' + encodeURIComponent(net.inet_host) + '" style="font-size:11px">'
+ escapeHtml(net.inet_host) + '</a>';
}
if (userLoggedIn) {
row += ' <a href="/compose/netmail?to=' + encodeURIComponent(net.address)
+ '&to_name=' + encodeURIComponent(node.sysop_name || '')
+ '" title="' + escapeHtml(sendNetmailLabel) + '" style="font-size:11px">?</a>';
}
parts.push('<div>' + row + '</div>');
});
parts.push('</div>');
cluster.addLayer(
L.marker([lat, lon], { icon: makeIcon(colour) }).bindPopup(parts.join(''))
);
bounds.push([lat, lon]);
});
nodelistMap.addLayer(cluster);
if (bounds.length === 1) {
nodelistMap.setView(bounds[0], 6);
} else if (bounds.length > 1) {
nodelistMap.fitBounds(bounds, { padding: [30, 30] });
}
const countTpl = {{ t('ui.nodelist.map_node_count', {}, locale, ['common'])|json_encode|raw }};
if (mapStatus) {
mapStatus.textContent = countTpl.replace('{count}', nodes.length);
}
}
function loadMapData() {
if (mapLoaded) {
nodelistMap && nodelistMap.invalidateSize();
return;
}
mapLoaded = true;
if (mapNotice) {
mapNotice.textContent = {{ t('ui.nodelist.map_loading', {}, locale, ['common'])|json_encode|raw }};
mapNotice.classList.remove('d-none');
}
fetch('/api/nodelist/map-data')
.then(function(r) { return r.json(); })
.then(function(data) {
if (mapNotice) mapNotice.classList.add('d-none');
initMap(data.nodes || []);
})
.catch(function() {
if (mapNotice) {
mapNotice.textContent = 'Failed to load map data.';
mapNotice.classList.remove('d-none');
}
});
}
if (mapTab) {
mapTab.addEventListener('shown.bs.tab', function() {
loadMapData();
});
}
})();
</script>
<script>
function uiT(key, fallback, params = {}) {
if (window.t) { return window.t(key, params, fallback); }
return fallback;
}
document.addEventListener('DOMContentLoaded', function() {
const flagAnyLabel = {{ t('ui.nodelist.index.flag_filter_any', {}, locale, ['common'])|json_encode|raw }};
const flagLabel = document.getElementById('flagPickerLabel');
function updateFlagLabel() {
const checked = Array.from(document.querySelectorAll('.flag-cb:checked')).map(cb => cb.value);
flagLabel.textContent = checked.length > 0 ? checked.join(', ') : flagAnyLabel;
}
document.querySelectorAll('.flag-cb').forEach(function(cb) {
cb.addEventListener('change', updateFlagLabel);
});
const zoneSelect = document.getElementById('zone');
const netSelect = document.getElementById('net');
if (zoneSelect) {
zoneSelect.addEventListener('change', function() {
const selectedZone = this.value;
netSelect.innerHTML = '<option value="">' + uiT('ui.nodelist.index.all_nets', 'All Nets') + '</option>';
if (selectedZone) {
fetch(`/api/nodelist/nets?zone=${selectedZone}`)
.then(r => r.json())
.then(data => {
if (data.nets) {
data.nets.forEach(net => {
const opt = document.createElement('option');
opt.value = net;
opt.textContent = `${uiT('ui.nodelist.index.net_prefix', 'Net')} ${net}`;
netSelect.appendChild(opt);
});
}
})
.catch(err => console.error(uiT('ui.nodelist.index.error_fetching_nets', 'Error fetching nets:'), err));
}
});
}
});
</script>
<script>
// Context menu for host links
document.addEventListener('DOMContentLoaded', function() {
const contextMenu = document.getElementById('hostContextMenu');
const menuHttp = document.getElementById('menuHttp');
const menuTelnet = document.getElementById('menuTelnet');
const menuSsh = document.getElementById('menuSsh');
let currentHostname = '';
// Show context menu on right-click of host links
document.addEventListener('contextmenu', function(e) {
const hostLink = e.target.closest('.host-link');
if (hostLink) {
e.preventDefault();
currentHostname = hostLink.dataset.hostname;
// Position the menu at the cursor
contextMenu.style.left = e.pageX + 'px';
contextMenu.style.top = e.pageY + 'px';
contextMenu.style.display = 'block';
}
});
// Hide context menu on any click
document.addEventListener('click', function(e) {
if (!contextMenu.contains(e.target)) {
contextMenu.style.display = 'none';
}
});
// Hide context menu on scroll
document.addEventListener('scroll', function() {
contextMenu.style.display = 'none';
});
// Helper function to strip port number from hostname
function stripPort(hostname) {
// Handle bracketed IPv6 addresses: [::1]:24554 -> [::1]
if (hostname.startsWith('[')) {
const closeBracketIndex = hostname.indexOf(']');
if (closeBracketIndex !== -1) {
// Check if there's a :port after the ]
const afterBracket = hostname.substring(closeBracketIndex + 1);
if (afterBracket.match(/^:\d+$/)) {
return hostname.substring(0, closeBracketIndex + 1);
}
}
return hostname;
}
// Handle regular hostnames with port: example.com:24554 -> example.com
// Only strip if there's exactly one colon followed by digits (not IPv6)
const colonCount = (hostname.match(/:/g) || []).length;
if (colonCount === 1 && hostname.match(/:\d+$/)) {
return hostname.substring(0, hostname.lastIndexOf(':'));
}
// Return unchanged for IPv6 without brackets or hostnames without ports
return hostname;
}
// Handle menu item clicks
menuHttp.addEventListener('click', function(e) {
e.preventDefault();
window.open('http://' + stripPort(currentHostname), '_blank');
contextMenu.style.display = 'none';
});
menuTelnet.addEventListener('click', function(e) {
e.preventDefault();
window.location.href = 'telnet://' + stripPort(currentHostname);
contextMenu.style.display = 'none';
});
menuSsh.addEventListener('click', function(e) {
e.preventDefault();
window.location.href = 'ssh://' + stripPort(currentHostname);
contextMenu.style.display = 'none';
});
});
</script>
{% endblock %}
|