?{% extends "base.twig" %}
{% block title %}{{ t('ui.binkp.page_title') }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<h1>{{ t('ui.binkp.heading') }}</h1>
<p class="text-muted">{{ t('ui.binkp.subheading') }}</p>
</div>
</div>
<!-- Status Overview -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ t('ui.binkp.system_status') }}</h5>
<p class="card-text" id="system-status">{{ t('ui.common.loading') }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ t('ui.binkp.inbound_queue') }}</h5>
<p class="card-text" id="inbound-count">{{ t('ui.common.loading') }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ t('ui.binkp.outbound_queue') }}</h5>
<p class="card-text" id="outbound-count">{{ t('ui.common.loading') }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">{{ t('ui.binkp.uplinks') }}</h5>
<p class="card-text" id="uplink-count">{{ t('ui.common.loading') }}</p>
</div>
</div>
</div>
</div>
<!-- Navigation Tabs -->
<ul class="nav nav-tabs" id="binkpTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="status-tab" data-bs-toggle="tab" data-bs-target="#status" type="button" role="tab">{{ t('ui.binkp.status_tab') }}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="uplinks-tab" data-bs-toggle="tab" data-bs-target="#uplinks" type="button" role="tab">{{ t('ui.binkp.uplinks_tab') }}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="queues-tab" data-bs-toggle="tab" data-bs-target="#queues" type="button" role="tab">{{ t('ui.binkp.queues_tab') }}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="kept-packets-tab" data-bs-toggle="tab" data-bs-target="#kept-packets" type="button" role="tab"{% if license_valid %} onclick="onKeptPacketsTabOpen()"{% endif %}>
{{ t('ui.binkp.kept_packets_tab') }}{% if not license_valid %} <i class="fas fa-lock ms-1 text-warning small"></i>{% endif %}
</button>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="sessions-tab" href="/admin/binkp-sessions">{{ t('ui.admin.binkp_sessions.heading', {}, locale, ['common']) }}</a>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="logs-tab" data-bs-toggle="tab" data-bs-target="#logs" type="button" role="tab">{{ t('ui.binkp.logs_tab') }}</button>
</li>
</ul>
<div class="tab-content mt-3" id="binkpTabContent">
<!-- Status Tab -->
<div class="tab-pane fade show active" id="status" role="tabpanel">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>{{ t('ui.binkp.system_information') }}</h5>
<button class="btn btn-sm btn-outline-primary" onclick="refreshBinkpOverview()">
<i class="fas fa-refresh"></i> {{ t('ui.common.refresh') }}
</button>
</div>
<div class="card-body" id="system-info">
{{ t('ui.binkp.loading_system_information') }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>{{ t('ui.binkp.uplink_status') }}</h5>
<button class="btn btn-sm btn-success" onclick="pollAllUplinks()">
<i class="fas fa-sync"></i> {{ t('ui.binkp.poll_all') }}
</button>
</div>
<div class="card-body" id="uplink-status">
{{ t('ui.binkp.loading_uplink_status') }}
</div>
</div>
</div>
</div>
</div>
<!-- Uplinks Tab -->
<div class="tab-pane fade" id="uplinks" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5>{{ t('ui.binkp.configured_uplinks') }}</h5>
{#
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUplinkModal">
<i class="fas fa-plus"></i> {{ t('ui.binkp.add_uplink') }}
</button>#}
</div>
<div class="table-responsive">
<table class="table table-striped" id="uplinksTable">
<thead>
<tr>
<th>{{ t('ui.binkp.uplink') }}</th>
<th>{{ t('ui.common.hostname') }}</th>
<th>{{ t('ui.common.port') }}</th>
<th>{{ t('ui.binkp.status') }}</th>
<th>{{ t('ui.binkp.schedule') }}</th>
<th>{{ t('ui.binkp.actions') }}</th>
</tr>
</thead>
<tbody>
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
<!-- Queues Tab -->
<div class="tab-pane fade" id="queues" role="tabpanel">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>{{ t('ui.binkp.inbound_queue') }}</h5>
<button class="btn btn-sm btn-success" onclick="processInbound()">
<i class="fas fa-play"></i> {{ t('ui.binkp.process') }}
</button>
</div>
<div class="card-body">
<div id="inbound-files">{{ t('ui.common.loading') }}</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5>{{ t('ui.binkp.outbound_queue') }}</h5>
<button class="btn btn-sm btn-success" onclick="processOutbound()">
<i class="fas fa-paper-plane"></i> {{ t('ui.binkp.send') }}
</button>
</div>
<div class="card-body">
<div id="outbound-files">{{ t('ui.common.loading') }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Kept Packets Tab -->
<div class="tab-pane fade" id="kept-packets" role="tabpanel">
{% if license_valid %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5>{{ t('ui.binkp.kept_packets_tab') }}</h5>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary active" id="keptInboundBtn" onclick="switchKeptType('inbound')">
<i class="fas fa-inbox"></i> {{ t('ui.binkp.kept_inbound') }}
</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="keptOutboundBtn" onclick="switchKeptType('outbound')">
<i class="fas fa-paper-plane"></i> {{ t('ui.binkp.kept_outbound') }}
</button>
</div>
</div>
<div id="kept-packets-content">
<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>{{ t('ui.common.loading') }}</div>
</div>
{% else %}
<div style="position:relative;">
<!-- Blurred placeholder content -->
<div style="filter:blur(4px); pointer-events:none; user-select:none; opacity:0.6;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5>{{ t('ui.binkp.kept_packets_tab') }}</h5>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary active" disabled>
<i class="fas fa-inbox"></i> {{ t('ui.binkp.kept_inbound') }}
</button>
<button type="button" class="btn btn-sm btn-outline-primary" disabled>
<i class="fas fa-paper-plane"></i> {{ t('ui.binkp.kept_outbound') }}
</button>
</div>
</div>
<div class="accordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" disabled>
<strong>Mar-18-2026</strong><span class="badge bg-secondary ms-2">12</span>
</button>
</h2>
<div class="accordion-collapse collapse show">
<div class="accordion-body p-0">
<table class="table table-sm mb-0">
<thead><tr><th>Filename</th><th>From</th><th>To</th><th>Messages</th><th>Size</th><th>Date</th></tr></thead>
<tbody>
<tr><td><code>69ba718d.pkt</code></td><td>1:1/100</td><td>1:2/200</td><td>3</td><td>4.2 KB</td><td>2026-03-18 09:15:00</td></tr>
<tr><td><code>69ba93c4.pkt</code></td><td>1:1/100</td><td>1:3/300</td><td>7</td><td>8.1 KB</td><td>2026-03-18 09:22:00</td></tr>
<tr><td><code>69bab14d.pkt</code></td><td>1:2/200</td><td>1:1/100</td><td>1</td><td>1.9 KB</td><td>2026-03-18 09:30:00</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Lock overlay -->
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.75rem;">
<i class="fas fa-lock fa-3x text-warning"></i>
<h5 class="mb-0">{{ t('ui.binkp.kept_packets_tab') }}</h5>
<p class="text-muted mb-0">{{ t('ui.binkp.kept_packets_locked') }}</p>
<a href="/admin/licensing" class="btn btn-warning btn-sm mt-1">
<i class="fas fa-key me-1"></i>{{ t('ui.binkp.kept_packets_register') }}
</a>
</div>
</div>
{% endif %}
</div>
<!-- Logs Tab -->
<div class="tab-pane fade" id="logs" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5>{{ t('ui.binkp.logs_heading') }}</h5>
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="input-group input-group-sm" style="width: 220px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="logSearch" placeholder="{{ t('ui.common.search') }}" oninput="filterLogs()">
<button class="btn btn-outline-secondary" type="button" id="logSearchClear" onclick="clearLogSearch()" style="display:none;" title="{{ t('ui.common.clear_search') }}"><i class="fas fa-times"></i></button>
</div>
<small class="text-muted" id="logMatchCount" style="display:none;"></small>
<select class="form-select form-select-sm w-auto" id="logLines">
<option value="50">{{ t('ui.binkp.lines_option', {'count': 50}) }}</option>
<option value="100" selected>{{ t('ui.binkp.lines_option', {'count': 100}) }}</option>
<option value="200">{{ t('ui.binkp.lines_option', {'count': 200}) }}</option>
<option value="500">{{ t('ui.binkp.lines_option', {'count': 500}) }}</option>
</select>
<button class="btn btn-sm btn-outline-primary" onclick="refreshLogs()">
<i class="fas fa-refresh"></i> {{ t('ui.common.refresh') }}
</button>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<pre id="log-content" style="height: 400px; overflow-y: auto; margin: 0; padding: 10px; font-size: 0.8em;"></pre>
</div>
</div>
<!-- Advanced Log Search -->
<div class="mt-3">
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="toggleAdvancedLogSearch()">
<i class="fas fa-search-plus"></i> {{ t('ui.binkp.advanced_log_search') }}
</button>
</div>
<div id="logAdvancedSearchPanel" style="display:none;" class="mt-2">
<div class="card border-secondary">
<div class="card-header py-2 d-flex align-items-center gap-2">
<i class="fas fa-search"></i>
<strong>{{ t('ui.binkp.advanced_log_search') }}</strong>
<small class="text-muted ms-auto" id="logSearchSummary"></small>
</div>
<div class="card-body">
<p class="text-muted small mb-2">{{ t('ui.binkp.advanced_log_search_help') }}</p>
<div class="input-group mb-3">
<input type="text" class="form-control" id="logAdvancedQuery"
placeholder="{{ t('ui.binkp.advanced_log_search_placeholder') }}"
onkeydown="if(event.key==='Enter') runAdvancedLogSearch()">
<button class="btn btn-primary" type="button" id="logAdvancedSearchBtn" onclick="runAdvancedLogSearch()">
<i class="fas fa-search"></i> {{ t('ui.common.search') }}
</button>
</div>
<div id="logAdvancedResults" style="display:none;">
<small class="d-block mb-1">
<span>{{ t('ui.binkp.log_search_legend_match') }}</span>
·
<span style="opacity:0.45;">{{ t('ui.binkp.log_search_legend_context') }}</span>
</small>
<pre id="log-search-content" style="height: 500px; overflow-y: auto; margin: 0; padding: 10px; font-size: 0.8em;"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Packet Inspector Modal -->
<div class="modal fade" id="packetInspectorModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-file-code me-2"></i><span id="pktModalTitle"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="pktModalBody">
<div class="text-center py-4"><span class="spinner-border"></span></div>
</div>
<div class="modal-footer">
<a id="pktDownloadBtn" class="btn btn-outline-primary" href="#">
<i class="fas fa-download me-1"></i>{{ t('ui.files.download') }}
</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.close') }}</button>
</div>
</div>
</div>
</div>
<!-- Add Uplink Modal -->
<div class="modal fade" id="addUplinkModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('ui.binkp.add_new_uplink') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addUplinkForm">
<div class="mb-3">
<label class="form-label">{{ t('ui.binkp.ftn_address_required') }}</label>
<input type="text" class="form-control" name="address" required placeholder="{{ t('ui.binkp.ftn_address_placeholder') }}">
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.binkp.hostname_required') }}</label>
<input type="text" class="form-control" name="hostname" required placeholder="{{ t('ui.binkp.hostname_placeholder') }}">
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.common.port') }}</label>
<input type="number" class="form-control" name="port" value="24554" min="1" max="65535">
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.common.password') }}</label>
<input type="password" class="form-control" name="password">
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.binkp.poll_schedule_cron') }}</label>
<input type="text" class="form-control" id="pollScheduleInput" name="poll_schedule" value="0 */4 * * *" placeholder="{{ t('ui.binkp.poll_schedule_placeholder') }}">
<small class="form-text text-muted">{{ t('ui.binkp.default_every_4_hours') }}</small>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="enabled" checked>
<label class="form-check-label">{{ t('ui.binkp.enabled') }}</label>
</div>
</form>
</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-primary" onclick="addUplink()">{{ t('ui.binkp.add_uplink') }}</button>
</div>
</div>
</div>
</div>
<script>
let binkpData = {};
let binkpUplinks = [];
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
function apiError(payload, fallback) {
if (window.getApiErrorMessage) {
return window.getApiErrorMessage(payload, fallback);
}
return payload && payload.error ? payload.error : fallback;
}
function apiMessage(payload, fallback) {
if (window.getApiMessage) {
return window.getApiMessage(payload, fallback);
}
return payload && payload.message ? payload.message : fallback;
}
function formatLocalizedDateTime(value, fallback = null) {
if (!value || value === 'Never' || value === 'Unknown') {
return value || fallback || '?';
}
const parser = typeof window.parseAppDate === 'function'
? window.parseAppDate
: function(dateString) {
if (!dateString) {
return null;
}
let normalized = String(dateString).trim().replace(' ', 'T');
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(normalized)) {
normalized += 'Z';
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}$/.test(normalized)) {
normalized += ':00';
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}$/.test(normalized)) {
normalized = normalized.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const date = parser(value);
if (!date) {
return fallback !== null ? fallback : String(value);
}
const userTimezone = window.userSettings?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const userDateFormat = window.userSettings?.date_format || undefined;
return date.toLocaleString(userDateFormat, {
timeZone: userTimezone,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
});
}
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
loadI18nNamespaces(['common', 'errors']).then(function() {
refreshBinkpOverview();
refreshQueues();
refreshLogs();
CronBuilder.init('pollScheduleInput');
setInterval(function() {
refreshQueues();
refreshBinkpOverview();
}, 15000);
});
document.getElementById('addUplinkModal').addEventListener('shown.bs.modal', function () {
CronBuilder.refresh('pollScheduleInput');
});
});
function refreshBinkpOverview() {
refreshStatus();
refreshUplinks();
}
function refreshStatus() {
console.log('Calling /api/binkp/status...');
fetch('/api/binkp/status')
.then(response => {
console.log('Status response:', response.status, response.statusText);
if (!response.ok) {
throw new Error(uiT('ui.binkp.http_status_error', 'HTTP {code}: {text}', { code: response.status, text: response.statusText }));
}
return response.json();
})
.then(data => {
console.log('Status data received:', data);
binkpData = data;
updateStatusDisplay(data);
})
.catch(error => {
console.error('Error fetching status:', error);
document.getElementById('system-status').textContent = `${uiT('ui.common.error', 'Error')}: ${error.message}`;
document.getElementById('system-info').innerHTML = `<div class="alert alert-danger">${uiT('ui.binkp.error_loading_status_prefix', 'Error loading status:')} ${escapeHtml(error.message)}</div>`;
});
}
function updateStatusDisplay(data) {
// Update cards
document.getElementById('system-status').textContent = data.system.address;
document.getElementById('inbound-count').textContent = uiT('ui.binkp.file_count', '{count} files', { count: data.queues.inbound.pending_files });
document.getElementById('outbound-count').textContent = uiT('ui.binkp.file_count', '{count} files', { count: data.queues.outbound.pending_files });
document.getElementById('uplink-count').textContent = uiT('ui.binkp.configured_count', '{count} configured', { count: binkpUplinks.length });
// Update system info
const systemInfo = document.getElementById('system-info');
systemInfo.innerHTML = `
<dl class="row">
<dt class="col-sm-4">${uiT('ui.binkp.address', 'Address')}:</dt>
<dd class="col-sm-8">${data.system.address}</dd>
<dt class="col-sm-4">${uiT('ui.binkp.sysop', 'Sysop')}:</dt>
<dd class="col-sm-8">${data.system.sysop}</dd>
<dt class="col-sm-4">${uiT('ui.binkp.location', 'Location')}:</dt>
<dd class="col-sm-8">${data.system.location}</dd>
<dt class="col-sm-4">${uiT('ui.common.port', 'Port')}:</dt>
<dd class="col-sm-8">${data.system.port}</dd>
<dt class="col-sm-4">${uiT('ui.common.hostname', 'Hostname')}:</dt>
<dd class="col-sm-8">${data.system.hostname}</dd>
</dl>
`;
}
function refreshUplinks() {
console.log('Calling /api/binkp/uplinks...');
fetch('/api/binkp/uplinks')
.then(response => {
console.log('Uplinks response:', response.status, response.statusText);
if (!response.ok) {
throw new Error(uiT('ui.binkp.http_status_error', 'HTTP {code}: {text}', { code: response.status, text: response.statusText }));
}
return response.json();
})
.then(data => {
console.log('Uplinks data received:', data);
binkpUplinks = Array.isArray(data.uplinks) ? data.uplinks : [];
document.getElementById('uplink-count').textContent = uiT('ui.binkp.configured_count', '{count} configured', { count: binkpUplinks.length });
renderUplinkStatusList(binkpUplinks);
updateUplinksTable(data.uplinks);
})
.catch(error => {
console.error('Error fetching uplinks:', error);
document.getElementById('uplink-status').innerHTML = `<div class="alert alert-danger">${uiT('ui.binkp.error_loading_uplinks_prefix', 'Error loading uplinks:')} ${escapeHtml(error.message)}</div>`;
const tbody = document.querySelector('#uplinksTable tbody');
if (tbody) {
tbody.innerHTML = `<tr><td colspan="6" class="text-danger">${uiT('ui.binkp.error_loading_uplinks_prefix', 'Error loading uplinks:')} ${escapeHtml(error.message)}</td></tr>`;
}
});
}
function renderUplinkStatusList(uplinks) {
const uplinkStatus = document.getElementById('uplink-status');
if (!Array.isArray(uplinks) || uplinks.length === 0) {
uplinkStatus.innerHTML = `<p class="text-muted">${uiT('ui.binkp.no_uplinks_configured', 'No uplinks configured')}</p>`;
return;
}
let html = '';
uplinks.forEach(uplink => {
const address = String(uplink.address || '');
const escapedAddress = escapeHtml(address);
const enabledBadge = uplink.enabled
? `<span class="badge bg-success ms-2">${uiT('ui.binkp.enabled', 'Enabled')}</span>`
: `<span class="badge bg-secondary ms-2">${uiT('ui.binkp.disabled', 'Disabled')}</span>`;
const initialStatus = getInitialUplinkConnectivityResult(uplink);
const statusHtml = initialStatus !== null
? renderUplinkConnectivityHtml(initialStatus)
: renderCheckStatusIcon(address);
html += `
<div class="d-flex justify-content-between align-items-center mb-2" data-uplink-list-row="${escapeHtmlAttr(address)}">
<div>
<span>${escapedAddress}</span>${enabledBadge}
</div>
<div data-uplink-list-status="${escapeHtmlAttr(address)}">
${statusHtml}
</div>
</div>
`;
});
uplinkStatus.innerHTML = html;
}
function updateUplinksTable(uplinks) {
const tbody = document.querySelector('#uplinksTable tbody');
tbody.innerHTML = '';
uplinks.forEach(uplink => {
const enabledBadge = uplink.enabled
? `<span class="badge bg-success">${uiT('ui.binkp.enabled', 'Enabled')}</span>`
: `<span class="badge bg-secondary">${uiT('ui.binkp.disabled', 'Disabled')}</span>`;
const address = String(uplink.address || '');
const initialStatus = getInitialUplinkConnectivityResult(uplink);
const canPoll = Boolean(address) && Boolean(uplink.enabled);
const tableStatusHtml = initialStatus !== null
? renderUplinkConnectivityHtml(initialStatus)
: renderCheckStatusIcon(address);
const row = tbody.insertRow();
row.innerHTML = `
<td>
<div>${escapeHtml(address)}</div>
<div class="small mt-1">${enabledBadge}</div>
</td>
<td>${escapeHtml(String(uplink.hostname || ''))}</td>
<td>${uplink.port}</td>
<td data-uplink-table-status="${escapeHtmlAttr(address)}">${tableStatusHtml}</td>
<td><code>${escapeHtml(String(uplink.poll_schedule || ''))}</code></td>
<td>
<button class="btn btn-sm btn-primary" onclick="pollUplink('${escapeJsString(address)}')"${canPoll ? '' : ' disabled'}>${uiT('ui.binkp.poll', 'Poll')}</button>
<!-- <button class="btn btn-sm btn-danger" onclick="removeUplink('${uplink.address}')">Remove</button> -->
</td>
`;
});
}
function checkUplinkConnectivity(address) {
updateUplinkConnectivityDisplay(address, {checking: true});
fetch(`/api/binkp/uplink-status?address=${encodeURIComponent(address)}`)
.then(response => {
if (!response.ok) {
throw new Error(uiT('ui.binkp.http_status_error', 'HTTP {code}: {text}', {
code: response.status,
text: response.statusText
}));
}
return response.json();
})
.then(result => {
updateUplinkConnectivityDisplay(address, result);
})
.catch(error => {
updateUplinkConnectivityDisplay(address, {
success: false,
error: error.message
});
});
}
function renderCheckStatusIcon(address) {
const title = uiT('ui.binkp.check_status_title', 'Check connectivity status');
const label = uiT('ui.binkp.check_status', 'Check');
return `<button class="btn btn-link btn-sm p-0 text-muted" onclick="checkUplinkConnectivity('${escapeJsString(address)}')" title="${escapeHtmlAttr(title)}"><i class="fas fa-circle-question me-1"></i><span class="small">${escapeHtml(label)}</span></button>`;
}
function getInitialUplinkConnectivityResult(uplink) {
const address = String(uplink && uplink.address ? uplink.address : '');
if (!address) {
return {
success: false,
skipped: true,
not_configured: true
};
}
if (!uplink.enabled) {
return {
success: false,
skipped: true,
disabled: true
};
}
return null;
}
function updateUplinkConnectivityDisplay(address, result) {
const html = renderUplinkConnectivityHtml(result);
const listNode = document.querySelector(`[data-uplink-list-status="${cssEscape(address)}"]`);
if (listNode) {
listNode.innerHTML = html;
}
const tableNode = document.querySelector(`[data-uplink-table-status="${cssEscape(address)}"]`);
if (tableNode) {
tableNode.innerHTML = html;
}
}
function renderPendingConnectivityBadge() {
return `<span class="text-muted"><span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>${uiT('ui.binkp.checking', 'Checking...')}</span>`;
}
function renderUplinkConnectivityHtml(result) {
if (!result) {
return `<span class="text-muted">?</span>`;
}
if (result.checking) {
return renderPendingConnectivityBadge();
}
if (result && result.disabled) {
return `<span class="badge bg-secondary">${uiT('ui.binkp.disabled', 'Disabled')}</span>`;
}
if (result && result.not_configured) {
return `<span class="badge bg-secondary">${uiT('ui.common.not_configured', 'Not configured')}</span>`;
}
if (result && result.success) {
return `<span class="badge bg-success">${uiT('ui.binkp.online', 'Online')}</span>`;
}
const errorText = result && result.error ? escapeHtml(String(result.error)) : uiT('ui.binkp.offline', 'Offline');
return `<div><span class="badge bg-danger">${uiT('ui.binkp.offline', 'Offline')}</span><div class="small text-muted mt-1">${errorText}</div></div>`;
}
function refreshQueues() {
console.log('Calling /api/binkp/files/inbound and /api/binkp/files/outbound...');
Promise.all([
fetch('/api/binkp/files/inbound').then(r => {
console.log('Inbound response:', r.status, r.statusText);
if (!r.ok) throw new Error(uiT('ui.binkp.inbound_api_http_error', 'Inbound API: HTTP {code}', { code: r.status }));
return r.json();
}),
fetch('/api/binkp/files/outbound').then(r => {
console.log('Outbound response:', r.status, r.statusText);
if (!r.ok) throw new Error(uiT('ui.binkp.outbound_api_http_error', 'Outbound API: HTTP {code}', { code: r.status }));
return r.json();
})
])
.then(([inboundData, outboundData]) => {
console.log('Queue data received:', {inboundData, outboundData});
updateInboundFiles(inboundData);
updateOutboundFiles(outboundData);
})
.catch(error => {
console.error('Error fetching queues:', error);
document.getElementById('inbound-files').innerHTML = `<div class="text-danger">${uiT('ui.common.error', 'Error')}: ${escapeHtml(error.message)}</div>`;
document.getElementById('outbound-files').innerHTML = `<div class="text-danger">${uiT('ui.common.error', 'Error')}: ${escapeHtml(error.message)}</div>`;
});
}
function updateInboundFiles(data) {
const container = document.getElementById('inbound-files');
let html = `<p><strong>${uiT('ui.binkp.pending', 'Pending')}:</strong> ${uiT('ui.binkp.file_count', '{count} files', { count: data.pending.length })}</p>`;
if (data.pending.length > 0) {
html += '<ul class="list-group list-group-flush">';
data.pending.forEach(file => {
const enc = encodeURIComponent(file.filename);
const isPkt = file.filename.toLowerCase().endsWith('.pkt');
const nameHtml = isPkt
? `<a href="#" class="font-monospace text-decoration-none" onclick="openQueuePacketInspector('inbound', '${enc}'); return false;">${escapeHtml(file.filename)}</a>`
: `<span class="font-monospace">${escapeHtml(file.filename)}</span>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">
${nameHtml} <small class="text-muted">${formatBytes(file.size)}</small>
</li>`;
});
html += '</ul>';
}
if (data.errors.length > 0) {
html += `<p class="mt-3"><strong>${uiT('ui.binkp.errors', 'Errors')}:</strong> ${uiT('ui.binkp.file_count', '{count} files', { count: data.errors.length })}</p>`;
}
container.innerHTML = html;
}
function updateOutboundFiles(data) {
const container = document.getElementById('outbound-files');
let html = `<p><strong>${uiT('ui.binkp.pending', 'Pending')}:</strong> ${uiT('ui.binkp.file_count', '{count} files', { count: data.files.length })}</p>`;
if (data.files.length > 0) {
html += '<ul class="list-group list-group-flush">';
data.files.forEach(file => {
const enc = encodeURIComponent(file.filename);
const isPkt = file.filename.toLowerCase().endsWith('.pkt');
const nameHtml = isPkt
? `<a href="#" class="font-monospace text-decoration-none" onclick="openQueuePacketInspector('outbound', '${enc}'); return false;">${escapeHtml(file.filename)}</a>`
: `<span class="font-monospace">${escapeHtml(file.filename)}</span>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">
${nameHtml} <small class="text-muted">${formatBytes(file.size)} ? ${uiT('ui.binkp.message_count', '{count} msgs', { count: file.message_count })}</small>
</li>`;
});
html += '</ul>';
}
container.innerHTML = html;
}
function openQueuePacketInspector(type, filename) {
const decoded = decodeURIComponent(filename);
document.getElementById('pktModalTitle').textContent = decoded;
document.getElementById('pktModalBody').innerHTML =
'<div class="text-center py-4"><span class="spinner-border"></span></div>';
document.getElementById('pktDownloadBtn').href =
`/api/binkp/queue/download?type=${type}&filename=${filename}`;
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('packetInspectorModal'));
modal.show();
fetch(`/api/binkp/queue/inspect?type=${type}&filename=${filename}`)
.then(r => r.json())
.then(data => {
if (!data.success) {
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${data.error || 'Failed to load packet.'}</div>`;
return;
}
renderPacketInspector(data);
})
.catch(err => {
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${err.message}</div>`;
});
}
let _logLines = [];
function refreshLogs() {
const lines = document.getElementById('logLines').value;
fetch(`/api/binkp/logs?lines=${lines}`)
.then(response => response.json())
.then(data => {
_logLines = data.logs || [];
filterLogs();
})
.catch(error => console.error('Error fetching logs:', error));
}
function filterLogs() {
const query = document.getElementById('logSearch').value.trim();
const logContent = document.getElementById('log-content');
const matchCount = document.getElementById('logMatchCount');
const clearBtn = document.getElementById('logSearchClear');
if (!query) {
logContent.textContent = _logLines.join('\n');
logContent.scrollTop = logContent.scrollHeight;
matchCount.style.display = 'none';
clearBtn.style.display = 'none';
return;
}
clearBtn.style.display = '';
// Case-insensitive filter + highlight
const lowerQuery = query.toLowerCase();
const matched = _logLines.filter(line => line.toLowerCase().includes(lowerQuery));
// Build highlighted HTML
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${escaped})`, 'gi');
logContent.innerHTML = matched
.map(line => line
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(re, '<mark>$1</mark>')
)
.join('\n');
matchCount.textContent = window.t('ui.binkp.log_matches', {count: matched.length, total: _logLines.length}, `${matched.length} / ${_logLines.length} lines`);
matchCount.style.display = '';
logContent.scrollTop = 0;
}
function clearLogSearch() {
document.getElementById('logSearch').value = '';
filterLogs();
}
// ?? Kept Packets ?????????????????????????????????????????????????????????????
let _keptType = 'inbound';
let _keptLoaded = { inbound: false, outbound: false };
function onKeptPacketsTabOpen() {
if (!_keptLoaded[_keptType]) {
loadKeptPackets(_keptType);
}
}
function switchKeptType(type) {
_keptType = type;
document.getElementById('keptInboundBtn').classList.toggle('active', type === 'inbound');
document.getElementById('keptOutboundBtn').classList.toggle('active', type === 'outbound');
if (!_keptLoaded[type]) {
loadKeptPackets(type);
} else {
// Already loaded ? just re-render from cache
renderKeptPackets(type, window._keptData[type]);
}
}
window._keptData = { inbound: null, outbound: null };
function loadKeptPackets(type) {
const container = document.getElementById('kept-packets-content');
container.innerHTML = '<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>' + window.t('ui.common.loading', {}, 'Loading...') + '</div>';
fetch(`/api/binkp/kept-packets?type=${type}`)
.then(r => r.json())
.then(data => {
_keptLoaded[type] = true;
window._keptData[type] = data;
renderKeptPackets(type, data);
})
.catch(err => {
container.innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
});
}
function renderKeptPackets(type, data) {
const container = document.getElementById('kept-packets-content');
if (!data || !data.success) {
container.innerHTML = `<div class="alert alert-warning">${data?.error || 'Failed to load.'}</div>`;
return;
}
if (!data.groups || !data.groups.length) {
container.innerHTML = `<p class="text-muted">${window.t('ui.binkp.kept_packets_empty', {}, 'No kept packets found.')}</p>`;
return;
}
const totalLabel = window.t('ui.binkp.kept_packets_total', { count: data.total }, `${data.total} packet(s)`);
let html = `<p class="text-muted small mb-3">${totalLabel}</p><div class="accordion" id="keptAccordion">`;
data.groups.forEach((group, gi) => {
const groupId = `keptGroup${gi}`;
const collapseId = `keptCollapse${gi}`;
const dateLabel = group.date || '?';
const count = group.packets.length;
html += `
<div class="accordion-item">
<h2 class="accordion-header" id="${groupId}">
<button class="accordion-button${gi > 0 ? ' collapsed' : ''}" type="button"
data-bs-toggle="collapse" data-bs-target="#${collapseId}"
aria-expanded="${gi === 0 ? 'true' : 'false'}" aria-controls="${collapseId}">
<strong>${dateLabel}</strong>
<span class="badge bg-secondary ms-2">${count}</span>
</button>
</h2>
<div id="${collapseId}" class="accordion-collapse collapse${gi === 0 ? ' show' : ''}" data-bs-parent="#keptAccordion">
<div class="accordion-body p-0">
<table class="table table-sm table-hover mb-0">
<thead><tr>
<th>{{ t('ui.common.filename') }}</th>
<th>{{ t('ui.binkp.from_address') }}</th>
<th>{{ t('ui.binkp.to_address') }}</th>
<th>{{ t('ui.binkp.message_count') }}</th>
<th>{{ t('ui.common.size') }}</th>
<th>{{ t('ui.common.date') }}</th>
</tr></thead>
<tbody>`;
group.packets.forEach(pkt => {
const size = pkt.size < 1024 ? pkt.size + ' B'
: pkt.size < 1048576 ? (pkt.size / 1024).toFixed(1) + ' KB'
: (pkt.size / 1048576).toFixed(2) + ' MB';
const escDate = encodeURIComponent(group.date);
const escFile = encodeURIComponent(pkt.filename);
if (pkt.file_type === 'bundle') {
html += `<tr>
<td><a href="#" class="font-monospace text-decoration-none" onclick="openBundleInspector('${type}','${escDate}','${escFile}');return false;"><i class="fas fa-file-archive me-1 text-warning"></i>${pkt.filename}</a></td>
<td colspan="2"><span class="text-muted small">{{ t('ui.binkp.bundle_arcmail', {}, 'Arcmail bundle') }}</span></td>
<td>?</td>
<td>${size}</td>
<td><small class="text-muted">${escapeHtml(formatLocalizedDateTime(pkt.modified))}</small></td>
</tr>`;
} else {
const msgs = pkt.message_count > 0 ? pkt.message_count : '?';
html += `<tr>
<td><a href="#" class="font-monospace text-decoration-none" onclick="openPacketInspector('${type}','${escDate}','${escFile}');return false;">${pkt.filename}</a></td>
<td>${pkt.orig_address}</td>
<td>${pkt.dest_address}</td>
<td>${msgs}</td>
<td>${size}</td>
<td><small class="text-muted">${escapeHtml(formatLocalizedDateTime(pkt.modified))}</small></td>
</tr>`;
}
});
html += `</tbody></table></div></div></div>`;
});
html += '</div>';
container.innerHTML = html;
}
function openPacketInspector(type, date, filename) {
document.getElementById('pktModalTitle').textContent = decodeURIComponent(filename);
document.getElementById('pktModalBody').innerHTML =
'<div class="text-center py-4"><span class="spinner-border"></span></div>';
document.getElementById('pktDownloadBtn').href =
`/api/binkp/kept-packets/download?type=${type}&date=${date}&filename=${filename}`;
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('packetInspectorModal'));
modal.show();
fetch(`/api/binkp/kept-packets/inspect?type=${type}&date=${date}&filename=${filename}`)
.then(r => r.json())
.then(data => {
if (!data.success) {
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${data.error || 'Failed to load packet.'}</div>`;
return;
}
renderPacketInspector(data);
})
.catch(err => {
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${err.message}</div>`;
});
}
function openBundleInspector(type, date, filename) {
const decoded = decodeURIComponent(filename);
document.getElementById('pktModalTitle').innerHTML =
`<i class="fas fa-file-archive me-2 text-warning"></i>${decoded}`;
document.getElementById('pktModalBody').innerHTML =
'<div class="text-center py-4"><span class="spinner-border"></span></div>';
document.getElementById('pktDownloadBtn').href =
`/api/binkp/kept-packets/bundle/download?type=${type}&date=${date}&filename=${filename}`;
document.getElementById('pktDownloadBtn').style.display = '';
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('packetInspectorModal'));
modal.show();
fetch(`/api/binkp/kept-packets/bundle/list?type=${type}&date=${date}&filename=${filename}`)
.then(r => r.json())
.then(data => {
if (!data.success) {
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${data.error || window.t('ui.binkp.bundle_open_failed', {}, 'Failed to open bundle.')}</div>`;
return;
}
renderBundleContents(type, date, filename, data);
})
.catch(err => {
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${err.message}</div>`;
});
}
function renderBundleContents(type, date, bundleFilename, data) {
const bundleSizeKb = data.bundle_size < 1024 ? data.bundle_size + ' B'
: data.bundle_size < 1048576 ? (data.bundle_size / 1024).toFixed(1) + ' KB'
: (data.bundle_size / 1048576).toFixed(2) + ' MB';
let html = `<p class="text-muted small mb-3">`;
if (!data.packets || !data.packets.length) {
html += window.t('ui.binkp.bundle_empty', {}, 'No .pkt files found in this bundle.') + `</p>`;
document.getElementById('pktModalBody').innerHTML = html;
return;
}
const countLabel = window.t('ui.binkp.bundle_packet_count', { count: data.packets.length }, `${data.packets.length} packet(s) in bundle`);
html += `${countLabel} — ${bundleSizeKb}</p>`;
html += `<div class="table-responsive"><table class="table table-sm table-hover mb-0">
<thead><tr>
<th>{{ t('ui.common.filename') }}</th>
<th>{{ t('ui.common.size') }}</th>
<th></th>
</tr></thead><tbody>`;
data.packets.forEach(pkt => {
const pktSize = pkt.size < 1024 ? pkt.size + ' B'
: pkt.size < 1048576 ? (pkt.size / 1024).toFixed(1) + ' KB'
: (pkt.size / 1048576).toFixed(2) + ' MB';
const escBundle = encodeURIComponent(bundleFilename);
const escPkt = encodeURIComponent(pkt.filename);
html += `<tr>
<td><code>${pkt.filename}</code></td>
<td><small class="text-muted">${pktSize}</small></td>
<td><a href="#" class="btn btn-sm btn-outline-primary py-0"
onclick="openBundlePacketInspector('${type}','${encodeURIComponent(date)}','${escBundle}','${escPkt}');return false;">
<i class="fas fa-search me-1"></i>{{ t('ui.binkp.bundle_view_packet') }}</a></td>
</tr>`;
});
html += `</tbody></table></div>`;
document.getElementById('pktModalBody').innerHTML = html;
}
function openBundlePacketInspector(type, date, bundleFilename, pktFilename) {
const decodedBundle = decodeURIComponent(bundleFilename);
const decodedPkt = decodeURIComponent(pktFilename);
document.getElementById('pktModalTitle').innerHTML =
`<a href="#" class="text-decoration-none me-1" onclick="openBundleInspector('${type}','${date}','${bundleFilename}');return false;" title="{{ t('ui.binkp.bundle_back') }}"><i class="fas fa-arrow-left"></i></a><i class="fas fa-file-archive me-1 text-warning"></i>${decodedBundle} / <span class="font-monospace">${decodedPkt}</span>`;
document.getElementById('pktModalBody').innerHTML =
'<div class="text-center py-4"><span class="spinner-border"></span></div>';
document.getElementById('pktDownloadBtn').style.display = 'none';
fetch(`/api/binkp/kept-packets/bundle/inspect?type=${type}&date=${date}&bundle=${bundleFilename}&pkt=${pktFilename}`)
.then(r => r.json())
.then(data => {
document.getElementById('pktDownloadBtn').style.display = '';
if (!data.success) {
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${data.error || window.t('ui.binkp.pkt_load_failed', {}, 'Failed to load packet.')}</div>`;
return;
}
renderPacketInspector(data);
})
.catch(err => {
document.getElementById('pktDownloadBtn').style.display = '';
document.getElementById('pktModalBody').innerHTML =
`<div class="alert alert-danger">${err.message}</div>`;
});
}
function renderPacketInspector(data) {
const p = data.packet;
const sizeKb = p.file_size < 1024 ? p.file_size + ' B'
: p.file_size < 1048576 ? (p.file_size / 1024).toFixed(1) + ' KB'
: (p.file_size / 1048576).toFixed(2) + ' MB';
let html = `
<h6 class="text-muted mb-3">{{ t('ui.binkp.pkt_header') }}</h6>
<table class="table table-sm table-bordered mb-4">
<tbody>
<tr><th style="width:35%">{{ t('ui.binkp.from_address') }}</th><td><code>${p.orig_address}</code></td></tr>
<tr><th>{{ t('ui.binkp.to_address') }}</th><td><code>${p.dest_address}</code></td></tr>
<tr><th>{{ t('ui.common.date') }}</th><td>${escapeHtml(String(p.created || '?'))}</td></tr>
<tr><th>{{ t('ui.common.size') }}</th><td>${sizeKb}</td></tr>
<tr><th>{{ t('ui.binkp.pkt_version') }}</th><td>${p.packet_version}</td></tr>
<tr><th>{{ t('ui.binkp.pkt_product') }}</th><td>${p.product_code}</td></tr>
<tr><th>{{ t('ui.binkp.pkt_password') }}</th><td>${p.has_password ? '<span class="badge bg-warning text-dark">{{ t("ui.binkp.pkt_password_set") }}</span>' : '<span class="text-muted">{{ t("ui.binkp.pkt_no_password") }}</span>'}</td></tr>
</tbody>
</table>`;
if (!data.messages || !data.messages.length) {
html += `<p class="text-muted">{{ t('ui.binkp.pkt_no_messages') }}</p>`;
} else {
html += `<h6 class="text-muted mb-2">{{ t('ui.binkp.pkt_messages') }} <span class="badge bg-secondary">${data.messages.length}</span></h6>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead><tr>
<th>#</th>
<th>{{ t('ui.binkp.pkt_from') }}</th>
<th>{{ t('ui.binkp.pkt_to') }}</th>
<th>{{ t('ui.binkp.pkt_subject') }}</th>
<th>{{ t('ui.common.date') }}</th>
<th>{{ t('ui.binkp.pkt_flags') }}</th>
</tr></thead>
<tbody>`;
data.messages.forEach((msg, i) => {
const flags = msg.flags.length
? msg.flags.map(f => `<span class="badge bg-secondary me-1">${f}</span>`).join('')
: '?';
const addrInfo = (msg.orig_addr !== '0:0' ? `<br><small class="text-muted">${msg.orig_addr} ? ${msg.dest_addr}</small>` : '');
html += `<tr>
<td class="text-muted">${i + 1}</td>
<td>${msg.from}${addrInfo}</td>
<td>${msg.to}</td>
<td>${msg.subject || '<span class="text-muted">?</span>'}</td>
<td><small class="text-muted">${escapeHtml(String(msg.date || '?'))}</small></td>
<td>${flags}</td>
</tr>`;
});
html += `</tbody></table></div>`;
}
document.getElementById('pktModalBody').innerHTML = html;
}
// ?????????????????????????????????????????????????????????????????????????????
function toggleAdvancedLogSearch() {
const panel = document.getElementById('logAdvancedSearchPanel');
const isHidden = panel.style.display === 'none';
panel.style.display = isHidden ? '' : 'none';
if (isHidden) {
document.getElementById('logAdvancedQuery').focus();
}
}
function runAdvancedLogSearch() {
const q = document.getElementById('logAdvancedQuery').value.trim();
if (q.length < 2) return;
const btn = document.getElementById('logAdvancedSearchBtn');
const resultsDiv = document.getElementById('logAdvancedResults');
const content = document.getElementById('log-search-content');
const summary = document.getElementById('logSearchSummary');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
resultsDiv.style.display = '';
content.textContent = '';
summary.textContent = '';
fetch(`/api/binkp/logs/search?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search"></i> ' + window.t('ui.common.search', {}, 'Search');
if (!data.success) {
content.textContent = data.error || 'Search failed.';
return;
}
if (!data.lines || !data.lines.length) {
content.textContent = window.t('ui.binkp.log_search_no_results', {}, 'No results found.');
summary.textContent = '';
return;
}
summary.textContent = window.t('ui.binkp.log_search_summary',
{matches: data.match_count, sessions: data.pid_count},
`${data.match_count} matches across ${data.pid_count} session(s)`);
// Build highlighted HTML ? match lines normal weight, context lines muted
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${escaped})`, 'gi');
content.innerHTML = data.lines.map(item => {
const safe = item.line
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(re, '<mark>$1</mark>');
if (item.is_match) {
return `<span style="display:block;">${safe}</span>`;
}
return `<span style="display:block;opacity:0.55;">${safe}</span>`;
}).join('');
content.scrollTop = 0;
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search"></i> ' + window.t('ui.common.search', {}, 'Search');
content.textContent = 'Error: ' + err.message;
});
}
function pollUplink(address) {
console.log('Polling uplink:', address);
fetch('/api/binkp/poll', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ address: address })
})
.then(response => {
console.log('Poll response status:', response.status, response.statusText);
if (!response.ok) {
throw new Error(uiT('ui.binkp.http_status_error', 'HTTP {code}: {text}', { code: response.status, text: response.statusText }));
}
return response.json();
})
.then(data => {
console.log('Poll response data:', data);
if (data.success) {
const result = data.result || {};
const exitCode = result.exit_code ?? null;
const details = formatPollDetails(result);
const message = exitCode !== null
? uiT('ui.binkp.poll_completed_exit', 'Poll completed (exit {code})', { code: exitCode })
: apiMessage(data, uiT('ui.api.binkp.poll_triggered', 'BinkP poll triggered'));
showAlert(message + details, 'success', { persistent: true });
if (exitCode === 0 || exitCode === '0') {
triggerPacketProcessing();
}
} else {
showAlert(uiT('ui.binkp.poll_failed_prefix', 'Poll failed: ') + apiError(data, uiT('ui.binkp.poll_failed', 'Poll failed')), 'danger', { persistent: true });
}
refreshBinkpOverview();
})
.catch(error => {
console.error('Poll error:', error);
showAlert(uiT('ui.binkp.error_polling_uplink_prefix', 'Error polling uplink: ') + error.message, 'danger', { persistent: true });
});
}
function pollAllUplinks() {
const warning = uiT('ui.binkp.poll_all_confirm_warning', 'Warning: Excessive polling may lead to rate limiting or IP bans by remote systems.');
const msg = uiT('ui.binkp.poll_all_confirm', 'Poll all uplinks now?') + '\n\n' + warning;
if (!confirm(msg)) return;
fetch('/api/binkp/poll-all', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
const result = data.result || {};
const exitCode = result.exit_code ?? null;
const details = formatPollDetails(result);
const message = exitCode !== null
? uiT('ui.binkp.all_uplinks_polled_exit', 'All uplinks polled (exit {code})', { code: exitCode })
: apiMessage(data, uiT('ui.api.binkp.poll_all_triggered', 'BinkP poll-all triggered'));
showAlert(message + details, 'success', { persistent: true });
if (exitCode === 0 || exitCode === '0') {
triggerPacketProcessing();
}
} else {
showAlert(uiT('ui.binkp.polling_failed_prefix', 'Polling failed: ') + apiError(data, uiT('ui.binkp.polling_failed', 'Polling failed')), 'danger', { persistent: true });
}
refreshBinkpOverview();
})
.catch(error => {
showAlert(uiT('ui.binkp.error_polling_uplinks_prefix', 'Error polling uplinks: ') + error.message, 'danger', { persistent: true });
});
}
function processInbound() {
fetch('/api/binkp/process/inbound', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(apiMessage(data, uiT('ui.binkp.inbound_processing_completed', 'Inbound processing completed')), 'success');
refreshQueues();
refreshBinkpOverview();
} else {
showAlert(uiT('ui.binkp.processing_failed_prefix', 'Processing failed: ') + apiError(data, uiT('ui.binkp.processing_failed', 'Processing failed')), 'danger');
}
})
.catch(error => {
showAlert(uiT('ui.binkp.error_processing_inbound_prefix', 'Error processing inbound: ') + error.message, 'danger');
});
}
function processOutbound() {
fetch('/api/binkp/process/outbound', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(apiMessage(data, uiT('ui.binkp.outbound_processing_completed', 'Outbound processing completed')), 'success');
refreshQueues();
refreshBinkpOverview();
} else {
showAlert(uiT('ui.binkp.processing_failed_prefix', 'Processing failed: ') + apiError(data, uiT('ui.binkp.processing_failed', 'Processing failed')), 'danger');
}
})
.catch(error => {
showAlert(uiT('ui.binkp.error_processing_outbound_prefix', 'Error processing outbound: ') + error.message, 'danger');
});
}
function addUplink() {
const form = document.getElementById('addUplinkForm');
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
data.enabled = formData.has('enabled');
fetch('/api/binkp/uplinks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showAlert(apiMessage(result, uiT('ui.binkp.uplink_added_success', 'Uplink added successfully')), 'success');
document.getElementById('addUplinkModal').querySelector('.btn-close').click();
form.reset();
refreshBinkpOverview();
} else {
showAlert(uiT('ui.binkp.add_uplink_failed_prefix', 'Failed to add uplink: ') + apiError(result, uiT('ui.binkp.add_uplink_failed', 'Failed to add uplink')), 'danger');
}
})
.catch(error => {
showAlert(uiT('ui.binkp.error_adding_uplink_prefix', 'Error adding uplink: ') + error.message, 'danger');
});
}
function removeUplink(address) {
if (!confirm(uiT('ui.binkp.remove_uplink_confirm', 'Remove uplink {address}?', { address }))) return;
fetch(`/api/binkp/uplinks/${address}`, { method: 'DELETE' })
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(apiMessage(data, uiT('ui.binkp.uplink_removed_success', 'Uplink removed successfully')), 'success');
refreshBinkpOverview();
} else {
showAlert(uiT('ui.binkp.remove_uplink_failed_prefix', 'Failed to remove uplink: ') + apiError(data, uiT('ui.binkp.remove_uplink_failed', 'Failed to remove uplink')), 'danger');
}
})
.catch(error => {
showAlert(uiT('ui.binkp.error_removing_uplink_prefix', 'Error removing uplink: ') + error.message, 'danger');
});
}
function showAlert(message, type, options = {}) {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
if (!options.persistent) {
setTimeout(() => {
alertDiv.remove();
}, 5000);
}
}
function formatBytes(bytes) {
if (bytes === 0) return uiT('ui.binkp.zero_bytes', '0 Bytes');
const k = 1024;
const sizes = [
uiT('ui.binkp.byte_units.bytes', 'Bytes'),
uiT('ui.binkp.byte_units.kb', 'KB'),
uiT('ui.binkp.byte_units.mb', 'MB'),
uiT('ui.binkp.byte_units.gb', 'GB')
];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatPollDetails(result) {
if (!result || (!result.stdout && !result.stderr)) {
return '';
}
const stdout = (result.stdout || '').trim();
const stderr = (result.stderr || '').trim();
let details = '';
if (stdout) {
details += `<br><div class="mt-1"><small class="text-muted">${uiT('ui.binkp.stdout', 'STDOUT')}:</small><pre class="mt-1 mb-0">${escapeHtml(stdout)}</pre></div>`;
}
if (stderr) {
details += `<br><div class="mt-1"><small class="text-warning">${uiT('ui.binkp.stderr', 'STDERR')}:</small><pre class="mt-1 mb-0">${escapeHtml(stderr)}</pre></div>`;
}
return details;
}
function triggerPacketProcessing() {
fetch('/api/binkp/process-packets', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (!data.success) {
showAlert(uiT('ui.binkp.packet_processing_failed_prefix', 'Packet processing failed: ') + apiError(data, uiT('ui.binkp.unknown_error', 'Unknown error')), 'danger', { persistent: true });
return;
}
const result = data.result || {};
const exitCode = result.exit_code ?? null;
const details = formatPollDetails(result);
const message = exitCode !== null
? uiT('ui.binkp.packet_processing_completed_exit', 'Packet processing completed (exit {code})', { code: exitCode })
: apiMessage(data, uiT('ui.api.binkp.process_packets_started', 'Packet processing started'));
showAlert(message + details, 'success', { persistent: true });
})
.catch(error => {
showAlert(uiT('ui.binkp.error_processing_packets_prefix', 'Error processing packets: ') + error.message, 'danger', { persistent: true });
});
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function escapeHtmlAttr(value) {
return escapeHtml(value);
}
function escapeJsString(value) {
return String(value)
.replaceAll('\\', '\\\\')
.replaceAll("'", "\\'");
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(String(value));
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, function(char) {
return '\\' + char;
});
}
</script>
<script src="/js/cron-builder.js"></script>
{% endblock %}
|