{% extends "base.twig" %}
{% block title %}{{ t('ui.nodelist.node_details', {}, locale, ['common']) }} - {{ system_name }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="/assets/leaflet/leaflet.css">
<style>
#nodeMap {
height: 300px;
width: 100%;
border-radius: 0.375rem;
background: #111827;
}
#nodeMap .leaflet-tile-pane {
filter: grayscale(0.45) saturate(0.45) brightness(0.38) contrast(1.28);
}
#nodeMap .leaflet-control-container .leaflet-bar a,
#nodeMap .leaflet-control-zoom a {
background: rgba(15, 23, 42, 0.92);
color: #e2e8f0;
border-bottom-color: rgba(148, 163, 184, 0.18);
}
#nodeMap .leaflet-control-container .leaflet-bar a:hover,
#nodeMap .leaflet-control-zoom a:hover {
background: rgba(30, 41, 59, 0.98);
color: #fff;
}
#nodeMap .leaflet-popup-content-wrapper,
#nodeMap .leaflet-popup-tip {
background: #111827;
color: #e2e8f0;
}
</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-server"></i> {{ t('ui.nodelist.node_details') }}</h2>
<a href="/nodelist" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {{ t('ui.nodelist.back_to_list') }}
</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
{{ node.full_address }}
{% if node.keyword_type %}
<span class="badge bg-primary ms-2">{{ node.keyword_type }}</span>
{% endif %}
</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>{{ t('ui.nodelist.system_information') }}</h6>
<table class="table table-sm">
<tr>
<th width="120">{{ t('ui.nodelist.system_name') }}:</th>
<td>{{ node.system_name|default(t('ui.nodelist.not_specified')) }}</td>
</tr>
<tr>
<th>{{ t('ui.nodelist.sysop') }}:</th>
<td>{{ node.sysop_name|default(t('ui.nodelist.not_specified')) }}</td>
</tr>
<tr>
<th>{{ t('ui.nodelist.location') }}:</th>
<td>{{ node.location|default(t('ui.nodelist.not_specified')) }}</td>
</tr>
<tr>
<th>{{ t('ui.nodelist.phone') }}:</th>
<td>
{% if node.phone and node.phone != '-Unpublished-' %}
{{ node.phone }}
{% else %}
<span class="text-muted">{{ t('ui.nodelist.unpublished') }}</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>
</tr>
<tr>
<th>{{ t('ui.nodelist.baud_rate') }}:</th>
<td>
{% if node.baud_rate > 0 %}
{{ node.baud_rate }}
{% else %}
<span class="text-muted">{{ t('ui.nodelist.not_specified') }}</span>
{% endif %}
</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h6>{{ t('ui.nodelist.address_components') }}</h6>
<table class="table table-sm">
<tr>
<th width="80">{{ t('ui.nodelist.zone') }}:</th>
<td>{{ node.zone }}</td>
</tr>
<tr>
<th>{{ t('ui.nodelist.net') }}:</th>
<td>{{ node.net }}</td>
</tr>
<tr>
<th>{{ t('ui.nodelist.node') }}:</th>
<td>{{ node.node }}</td>
</tr>
<tr>
<th>{{ t('ui.nodelist.point') }}:</th>
<td>{{ node.point }}</td>
</tr>
<tr>
<th>{{ t('ui.nodelist.full') }}:</th>
<td><strong>{{ node.full_address }}</strong></td>
</tr>
</table>
</div>
</div>
{% if node.flags and node.flags|length > 0 %}
<hr>
<h6>{{ t('ui.nodelist.capabilities_flags') }}</h6>
{% set flagDescriptions = {
'IBN': 'Internet BinkP Node ? accepts binkp connections',
'INA': 'Internet Address ? hostname or IP for internet connections',
'ITN': 'Internet Telnet Node ? accepts telnet connections',
'IFC': 'Internet File/Conference ? internet file sharing',
'IFT': 'Internet FTP ? accepts FTP connections',
'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 ? accepts mail 24 hours a day',
'MO': 'Mail Only ? does not accept human callers',
'LO': 'Listed Only ? only accepts calls from listed nodes',
'MN': 'Mail and News',
'FREQ': 'File Request ? will serve files on request',
'PING': 'Responds to ping requests',
'TRACE': 'Netmail tracing supported',
'HOLD': 'Mail on hold ? not currently accepting connections',
'DOWN': 'System down ? temporarily unavailable',
'PVT': 'Private node ? address not published',
'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 mode (56k download)',
'V90S': 'V.90 modem ? server mode (56k upload)',
'H96': 'HST 9600',
'H14': 'HST 14400',
'H16': 'HST 16800',
'MAX': 'MaxLink protocol',
'PEP': 'PEP high-speed protocol',
'ZYX': 'ZyXEL proprietary protocol',
'XA': 'Xmodem/ASCII transfer',
'XB': 'Xmodem/CRC transfer',
'XC': 'Xmodem/1K transfer',
'XP': 'Xmodem/1K-G transfer',
'ZMO': 'ZModem (original)',
'ZMB': 'ZModem with crash recovery',
'XW': 'WXmodem (windowed Xmodem)',
'XX': 'Xmodem/CRC (alternate)',
'ISDN': 'ISDN connection',
'ISDNA': 'ISDN async',
'X25': 'X.25 packet network',
'POINTS24': 'Points file available 24 hours',
'ANUS': 'Allow Null User Session ? accepts anonymous/insecure sessions'
} %}
<div class="row">
{% for flag_name, flag_value in node.flags %}
<div class="col-md-4 mb-2">
<span class="badge bg-info me-1"
{% if flagDescriptions[flag_name] is defined %}
data-bs-toggle="tooltip"
data-bs-title="{{ flagDescriptions[flag_name] }}"
{% endif %}>{{ flag_name }}</span>
{% if flag_value != true and flag_value != '1' %}
<small class="text-muted">{{ flag_value }}</small>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Actions Card -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.nodelist.quick_actions') }}</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
{% if user %}
<a href="/compose/netmail?to={{ node.full_address|url_encode }}&to_name={{ node.sysop_name|url_encode }}" class="btn btn-success">
<i class="fas fa-envelope"></i> {{ t('ui.nodelist.send_netmail') }}
</a>
{% else %}
<a href="/login" class="btn btn-outline-primary">
<i class="fas fa-sign-in-alt"></i> {{ t('ui.nodelist.login_to_send_netmail') }}
</a>
{% endif %}
{% if freq_experimental_enabled and user and user.is_admin %}
<button class="btn btn-info" onclick="showAllfilesModal()">
<i class="fas fa-file-download"></i> {{ t('ui.nodelist.request_allfiles') }}
</button>
{% endif %}
</div>
</div>
</div>
<!-- Connection Info Card -->
{% if node.flags and node.flags|length > 0 %}
<div class="card">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.nodelist.connection_information') }}</h6>
</div>
<div class="card-body">
{% set connection_info = [] %}
{% if node.flags['IBN'] is defined %}
{% set connection_info = connection_info|merge([t('ui.nodelist.internet_binkp')]) %}
{% endif %}
{% if node.flags['INA'] is defined %}
{% set connection_info = connection_info|merge([t('ui.nodelist.internet_telnet')]) %}
{% endif %}
{% if node.flags['ITN'] is defined %}
{% set connection_info = connection_info|merge([t('ui.nodelist.internet_telnet')]) %}
{% endif %}
{% if node.phone and node.phone != '-Unpublished-' %}
{% set connection_info = connection_info|merge([t('ui.nodelist.dialup_pots')]) %}
{% endif %}
{% if connection_info|length > 0 %}
<ul class="list-unstyled mb-0">
{% for info in connection_info %}
<li><i class="fas fa-check text-success"></i> {{ info }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">{{ t('ui.nodelist.no_connection_info') }}</p>
{% endif %}
{% if node.flags['CM'] is defined %}
<div class="alert alert-info mt-2 mb-0 py-2">
<small><i class="fas fa-clock"></i> {{ t('ui.nodelist.continuous_mail') }}</small>
</div>
{% endif %}
{% if node.flags['HOLD'] is defined %}
<div class="alert alert-warning mt-2 mb-0 py-2">
<small><i class="fas fa-pause"></i> {{ t('ui.nodelist.mail_on_hold') }}</small>
</div>
{% endif %}
{% if node.flags['DOWN'] is defined %}
<div class="alert alert-danger mt-2 mb-0 py-2">
<small><i class="fas fa-times"></i> {{ t('ui.nodelist.system_down') }}</small>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% if node.latitude and node.longitude %}
<div class="row mt-4">
<div class="col-12">
<div class="card" style="background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: rgba(148,163,184,0.18);">
<div class="card-header" style="border-color: rgba(148,163,184,0.18);">
<h6 class="mb-0" style="color:#e2e8f0;"><i class="fas fa-map-marker-alt me-2"></i>{{ node.location|default(node.full_address) }}</h6>
</div>
<div class="card-body p-2">
<div id="nodeMap"></div>
</div>
</div>
</div>
</div>
{% else %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body text-muted small">
<i class="fas fa-map-marker-alt me-1"></i> {{ t('ui.nodelist.no_coordinates') }}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script src="/assets/leaflet/leaflet.js"></script>
{% if node.latitude and node.longitude %}
<script>
(function() {
const lat = {{ node.latitude|json_encode|raw }};
const lng = {{ node.longitude|json_encode|raw }};
const name = {{ node.system_name|default('')|json_encode|raw }};
const addr = {{ node.full_address|json_encode|raw }};
const loc = {{ node.location|default('')|json_encode|raw }};
const map = L.map('nodeMap', { scrollWheelZoom: false }).setView([lat, lng], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18
}).addTo(map);
L.marker([lat, lng])
.addTo(map)
.bindPopup('<strong>' + name + '</strong><br>' + addr + (loc ? '<br><small>' + loc + '</small>' : ''))
.openPopup();
})();
</script>
{% endif %}
{% if freq_experimental_enabled and user and user.is_admin %}
<!-- ALLFILES FREQ Modal -->
<div class="modal fade" id="allfilesModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('ui.nodelist.request_allfiles_title', {'node': node.full_address}) }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>{{ t('ui.nodelist.request_allfiles_body') }}</p>
{% if not (node.flags and node.flags['FREQ'] is defined) %}
<div class="alert alert-warning py-2 small">
<i class="fas fa-exclamation-triangle me-1"></i>{{ t('ui.nodelist.freq_not_advertised') }}
</div>
{% endif %}
<div class="mb-3">
<label for="allfilesFilename" class="form-label">{{ t('ui.nodelist.request_allfiles_filename') }}</label>
<select class="form-select" id="allfilesFilename" onchange="toggleAllfilesFilenameOther()">
<option value="ALLFILES">ALLFILES</option>
<option value="FILES">FILES</option>
<option value="FILELIST">FILELIST</option>
<option value="NODELIST">NODELIST</option>
<option value="NODEDIFF">NODEDIFF</option>
<option value="">{{ t('ui.nodelist.request_allfiles_to_other') }}</option>
</select>
<input type="text" class="form-control mt-2 d-none" id="allfilesFilenameOther" placeholder="{{ t('ui.nodelist.request_allfiles_filename_placeholder') }}" autocomplete="off">
</div>
<div class="mb-3">
<label for="allfilesToName" class="form-label">{{ t('ui.nodelist.request_allfiles_to') }}</label>
<select class="form-select" id="allfilesToName" onchange="toggleAllfilesToNameOther()">
<option value="AllFix">AllFix</option>
<option value="FileFix">FileFix</option>
<option value="FileMgr">FileMgr</option>
<option value="FileReq">FileReq</option>
<option value="Files">Files</option>
<option value="Sysop">Sysop</option>
<option value="">{{ t('ui.nodelist.request_allfiles_to_other') }}</option>
</select>
<input type="text" class="form-control mt-2 d-none" id="allfilesToNameOther" placeholder="{{ t('ui.nodelist.request_allfiles_to_other_placeholder') }}" autocomplete="off">
</div>
<div class="mb-3">
<label for="allfilesPassword" class="form-label">{{ t('ui.nodelist.request_allfiles_password') }}</label>
<input type="text" class="form-control" id="allfilesPassword" autocomplete="off">
<div class="form-text">{{ t('ui.nodelist.request_allfiles_password_help') }}</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="allfilesCrashmail" checked>
<label class="form-check-label" for="allfilesCrashmail">
{{ t('ui.nodelist.request_allfiles_crashmail') }}
</label>
</div>
<div class="form-text">{{ t('ui.nodelist.request_allfiles_crashmail_help') }}</div>
</div>
<div id="allfilesError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel') }}</button>
<button type="button" class="btn btn-info" id="allfilesSendBtn" onclick="sendAllfilesFreq()">
<i class="fas fa-file-download"></i> {{ t('ui.nodelist.send_request') }}
</button>
</div>
</div>
</div>
</div>
{% endif %}
<!-- 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') }}</a></li>
<li><a href="#" id="menuTelnet" class="context-menu-item"><i class="fas fa-terminal"></i> {{ t('ui.nodelist.telnet') }}</a></li>
<li><a href="#" id="menuSsh" class="context-menu-item"><i class="fas fa-lock"></i> {{ t('ui.nodelist.ssh') }}</a></li>
</ul>
</div>
<style>
.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, 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);
color: var(--bs-body-color, #212529);
}
.context-menu-item i {
width: 20px;
margin-right: 0.5rem;
}
</style>
<script>
// Context menu for host links
document.addEventListener('DOMContentLoaded', function() {
// Initialise Bootstrap tooltips for flag descriptions
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el) {
new bootstrap.Tooltip(el);
});
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) {
// Remove port number if present (e.g., "example.com:24554" -> "example.com")
return hostname.split(':')[0];
}
// ALLFILES FREQ functions
window.showAllfilesModal = function() {
document.getElementById('allfilesFilename').value = 'ALLFILES';
document.getElementById('allfilesFilenameOther').value = '';
document.getElementById('allfilesFilenameOther').classList.add('d-none');
document.getElementById('allfilesToName').value = 'FileFix';
document.getElementById('allfilesToNameOther').value = '';
document.getElementById('allfilesToNameOther').classList.add('d-none');
document.getElementById('allfilesPassword').value = '';
document.getElementById('allfilesCrashmail').checked = true;
document.getElementById('allfilesError').classList.add('d-none');
document.getElementById('allfilesSendBtn').disabled = false;
new bootstrap.Modal(document.getElementById('allfilesModal')).show();
};
window.toggleAllfilesFilenameOther = function() {
const sel = document.getElementById('allfilesFilename');
const other = document.getElementById('allfilesFilenameOther');
if (sel.value === '') {
other.classList.remove('d-none');
other.focus();
} else {
other.classList.add('d-none');
}
};
window.toggleAllfilesToNameOther = function() {
const sel = document.getElementById('allfilesToName');
const other = document.getElementById('allfilesToNameOther');
if (sel.value === '') {
other.classList.remove('d-none');
other.focus();
} else {
other.classList.add('d-none');
}
};
window.sendAllfilesFreq = function() {
const filenameSel = document.getElementById('allfilesFilename');
const filename = (filenameSel.value === '' ? document.getElementById('allfilesFilenameOther').value.trim() : filenameSel.value) || 'ALLFILES';
const toNameSel = document.getElementById('allfilesToName');
const toName = (toNameSel.value === '' ? document.getElementById('allfilesToNameOther').value.trim() : toNameSel.value) || 'FileFix';
const password = document.getElementById('allfilesPassword').value.trim();
const crashmail = document.getElementById('allfilesCrashmail').checked;
const btn = document.getElementById('allfilesSendBtn');
const errorBox = document.getElementById('allfilesError');
btn.disabled = true;
errorBox.classList.add('d-none');
const subject = password ? filename + ' ' + password : filename;
fetch('/api/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'netmail',
to_address: '{{ node.full_address|e('js') }}',
to_name: toName,
subject: subject,
message_text: '',
is_freq: true,
crashmail: crashmail
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('allfilesModal')).hide();
if (window.showSuccess) {
showSuccess('{{ t('ui.nodelist.request_allfiles_sent')|e('js') }}');
}
} else {
errorBox.textContent = (window.getApiErrorMessage ? window.getApiErrorMessage(data, '') : '') || data.error || '{{ t('ui.nodelist.request_allfiles_failed')|e('js') }}';
errorBox.classList.remove('d-none');
btn.disabled = false;
}
})
.catch(() => {
errorBox.textContent = '{{ t('ui.nodelist.request_allfiles_failed')|e('js') }}';
errorBox.classList.remove('d-none');
btn.disabled = false;
});
};
// 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 %}
|