{% extends "base.twig" %}
{% block title %}{{ t('ui.admin.binkp_sessions.page_title', {}, locale, ['common']) }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-network-wired me-2"></i>{{ t('ui.admin.binkp_sessions.heading', {}, locale, ['common']) }}</h1>
<div class="btn-toolbar">
<button class="btn btn-outline-primary me-2" id="refreshSessions">
<i class="fas fa-sync-alt me-1"></i>{{ t('ui.common.refresh', {}, locale, ['common']) }}
</button>
<button type="button" class="btn btn-outline-secondary" onclick="goBackFromBinkpSessions()">
<i class="fas fa-arrow-left me-1"></i>{{ t('ui.common.back', {}, locale, ['common']) }}
</button>
</div>
</div>
<!-- Session Statistics -->
<div class="row mb-4">
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1" id="statTotalLabel">{{ t('ui.admin.binkp_sessions.total_24h', {}, locale, ['common']) }}</div>
<div class="h5 mb-0 font-weight-bold" id="statTotal">-</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">{{ t('ui.admin.binkp_sessions.secure', {}, locale, ['common']) }}</div>
<div class="h5 mb-0 font-weight-bold" id="statSecure">-</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">{{ t('ui.admin.binkp_sessions.insecure', {}, locale, ['common']) }}</div>
<div class="h5 mb-0 font-weight-bold" id="statInsecure">-</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">{{ t('ui.admin.binkp_sessions.crash_out', {}, locale, ['common']) }}</div>
<div class="h5 mb-0 font-weight-bold" id="statCrash">-</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">{{ t('ui.admin.binkp_sessions.successful', {}, locale, ['common']) }}</div>
<div class="h5 mb-0 font-weight-bold" id="statSuccessful">-</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-danger shadow h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">{{ t('ui.admin.binkp_sessions.failed', {}, locale, ['common']) }}</div>
<div class="h5 mb-0 font-weight-bold" id="statFailed">-</div>
</div>
</div>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-wrap justify-content-between align-items-center gap-2">
<h6 class="m-0 font-weight-bold text-primary" id="binkpChartTitle">{{ t('ui.admin.binkp_sessions.traffic_chart', {}, locale, ['common']) }}</h6>
<div class="d-flex flex-wrap align-items-center gap-2">
<label for="binkpChartType" class="form-label mb-0 small text-muted">{{ t('ui.admin.binkp_sessions.chart_type_label', {}, locale, ['common']) }}</label>
<select class="form-select form-select-sm" id="binkpChartType" style="width: auto;">
<option value="traffic">{{ t('ui.admin.binkp_sessions.traffic_chart', {}, locale, ['common']) }}</option>
<option value="uplinks">{{ t('ui.admin.binkp_sessions.uplink_connections_chart', {}, locale, ['common']) }}</option>
</select>
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('ui.admin.binkp_sessions.chart_period_label', {}, locale, ['common']) }}">
<button type="button" class="btn btn-outline-secondary active" data-period="day">{{ t('ui.admin.binkp_sessions.period_24h', {}, locale, ['common']) }}</button>
<button type="button" class="btn btn-outline-secondary" data-period="week">{{ t('ui.admin.binkp_sessions.period_7d', {}, locale, ['common']) }}</button>
<button type="button" class="btn btn-outline-secondary" data-period="month">{{ t('ui.admin.binkp_sessions.period_1m', {}, locale, ['common']) }}</button>
</div>
</div>
<div class="small text-muted d-flex flex-wrap gap-3" id="binkpChartLegend">
<span><span class="binkp-chart-legend binkp-chart-legend-in"></span>{{ t('ui.admin.binkp_sessions.files_in', {}, locale, ['common']) }}</span>
<span><span class="binkp-chart-legend binkp-chart-legend-out"></span>{{ t('ui.admin.binkp_sessions.files_out', {}, locale, ['common']) }}</span>
</div>
</div>
<div class="card-body">
<div id="binkpPrimaryChart" class="binkp-traffic-chart text-muted">{{ t('ui.common.loading', {}, locale, ['common']) }}</div>
</div>
</div>
<!-- Filter -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{ t('ui.admin.binkp_sessions.filter_sessions', {}, locale, ['common']) }}</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">{{ t('ui.admin.binkp_sessions.session_type', {}, locale, ['common']) }}</label>
<select class="form-select" id="filterType">
<option value="">{{ t('ui.common.all', {}, locale, ['common']) }}</option>
<option value="secure">{{ t('ui.admin.binkp_sessions.secure', {}, locale, ['common']) }}</option>
<option value="insecure">{{ t('ui.admin.binkp_sessions.insecure', {}, locale, ['common']) }}</option>
<option value="crash_outbound">{{ t('ui.admin.binkp_sessions.crash_outbound', {}, locale, ['common']) }}</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">{{ t('ui.common.status', {}, locale, ['common']) }}</label>
<select class="form-select" id="filterStatus">
<option value="">{{ t('ui.common.all', {}, locale, ['common']) }}</option>
<option value="success">{{ t('ui.admin.binkp_sessions.success', {}, locale, ['common']) }}</option>
<option value="failed">{{ t('ui.admin.binkp_sessions.failed', {}, locale, ['common']) }}</option>
<option value="rejected">{{ t('ui.admin.binkp_sessions.rejected', {}, locale, ['common']) }}</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">{{ t('ui.admin.binkp_sessions.remote_address', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="filterAddress" placeholder="{{ t('ui.admin.binkp_sessions.remote_address_placeholder', {}, locale, ['common']) }}">
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-primary" id="applyFilter">
<i class="fas fa-filter me-1"></i>{{ t('ui.admin.binkp_sessions.apply_filter', {}, locale, ['common']) }}
</button>
</div>
</div>
</div>
</div>
<!-- Sessions Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<h6 class="m-0 font-weight-bold text-primary">{{ t('ui.admin.binkp_sessions.recent_sessions', {}, locale, ['common']) }}</h6>
<div class="d-flex flex-wrap align-items-center gap-2">
<label for="sessionsPerPage" class="form-label mb-0 small text-muted">{{ t('ui.admin.binkp_sessions.rows_per_page', {}, locale, ['common']) }}</label>
<select class="form-select form-select-sm" id="sessionsPerPage" style="width: auto;">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover table-sm" id="sessionsTable">
<thead>
<tr>
<th>{{ t('ui.admin.binkp_sessions.time', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.remote_address', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.ip', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.pid', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.type', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.direction', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.files_in', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.files_out', {}, locale, ['common']) }}</th>
<th>{{ t('ui.common.status', {}, locale, ['common']) }}</th>
<th>{{ t('ui.admin.binkp_sessions.duration', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody id="sessionsTableBody">
<tr>
<td colspan="10" class="text-center">{{ t('ui.common.loading', {}, locale, ['common']) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
<div class="small text-muted" id="sessionsPaginationStatus">{{ t('ui.common.loading', {}, locale, ['common']) }}</div>
<nav aria-label="{{ t('ui.admin.binkp_sessions.pagination_aria', {}, locale, ['common']) }}">
<ul class="pagination pagination-sm mb-0">
<li class="page-item">
<button type="button" class="page-link" id="sessionsPrevPage">{{ t('ui.common.previous', {}, locale, ['common']) }}</button>
</li>
<li class="page-item disabled">
<span class="page-link" id="sessionsPageIndicator">1 / 1</span>
</li>
<li class="page-item">
<button type="button" class="page-link" id="sessionsNextPage">{{ t('ui.common.next', {}, locale, ['common']) }}</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
<div class="modal fade" id="binkpSessionLogModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="binkpSessionLogModalTitle">{{ t('ui.admin.binkp_sessions.log_modal_title', {}, locale, ['common']) }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close', {}, locale, ['common']) }}"></button>
</div>
<div class="modal-body">
<div id="binkpSessionLogMeta" class="small text-muted mb-3"></div>
<pre id="binkpSessionLogBody" class="small mb-0 bg-dark text-light p-3 rounded" style="min-height: 20rem;">{{ t('ui.admin.binkp_sessions.loading_log', {}, locale, ['common']) }}</pre>
</div>
</div>
</div>
</div>
<style>
.border-left-primary { border-left: 0.25rem solid #4e73df !important; }
.border-left-success { border-left: 0.25rem solid #1cc88a !important; }
.border-left-info { border-left: 0.25rem solid #36b9cc !important; }
.border-left-warning { border-left: 0.25rem solid #f6c23e !important; }
.border-left-danger { border-left: 0.25rem solid #e74a3b !important; }
#sessionsTableBody tr[data-session-id] { cursor: pointer; }
.binkp-traffic-chart { min-height: 260px; }
.binkp-traffic-chart svg { width: 100%; height: 260px; display: block; }
.binkp-chart-grid { stroke: rgba(108, 117, 125, 0.2); stroke-width: 1; }
.binkp-chart-axis { stroke: rgba(108, 117, 125, 0.35); stroke-width: 1; }
.binkp-chart-label { fill: currentColor; font-size: 12px; }
.binkp-chart-line-in { stroke: #36b9cc; stroke-width: 3; fill: none; }
.binkp-chart-line-out { stroke: #1cc88a; stroke-width: 3; fill: none; }
.binkp-chart-point-in { fill: #36b9cc; }
.binkp-chart-point-out { fill: #1cc88a; }
.binkp-chart-bar { fill: #4e73df; }
.binkp-chart-legend { display: inline-block; width: 12px; height: 12px; border-radius: 999px; margin-right: 0.35rem; vertical-align: -1px; }
.binkp-chart-legend-in { background: #36b9cc; }
.binkp-chart-legend-out { background: #1cc88a; }
</style>
<script>
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
document.addEventListener('DOMContentLoaded', function() {
loadI18nNamespaces(['common', 'errors']).then(function() {
initSessionPagination();
loadStats();
loadSessions();
initRealtime();
initSessionLogModal();
});
document.getElementById('refreshSessions').addEventListener('click', function() {
loadStats();
loadSessions();
});
document.getElementById('applyFilter').addEventListener('click', function() {
sessionsCurrentPage = 1;
loadSessions();
});
document.getElementById('sessionsPrevPage').addEventListener('click', function() {
if (sessionsCurrentPage > 1) {
sessionsCurrentPage--;
renderSessionsTable();
}
});
document.getElementById('sessionsNextPage').addEventListener('click', function() {
const totalPages = getSessionsTotalPages();
if (sessionsCurrentPage < totalPages) {
sessionsCurrentPage++;
renderSessionsTable();
}
});
document.getElementById('sessionsPerPage').addEventListener('change', function(event) {
const value = Number(event.target.value) || DEFAULT_SESSIONS_PER_PAGE;
sessionsPerPage = normalizeSessionsPerPage(value);
sessionsCurrentPage = 1;
persistSessionsPerPage();
renderSessionsTable();
});
document.getElementById('binkpChartType').addEventListener('change', function(event) {
selectedChartType = String(event.target.value || 'traffic');
renderSelectedChart();
});
document.querySelectorAll('[data-period]').forEach(function(button) {
button.addEventListener('click', function() {
const period = String(button.getAttribute('data-period') || 'day');
if (period === selectedStatsPeriod) {
return;
}
selectedStatsPeriod = period;
updatePeriodButtons();
loadStats();
});
});
});
const SESSIONS_PER_PAGE_KEY = 'admin.binkpSessions.perPage';
const DEFAULT_SESSIONS_PER_PAGE = 25;
const MAX_SESSION_FETCH = 250;
const ALLOWED_SESSION_PAGE_SIZES = [25, 50, 100, 250];
const PERIOD_LABELS = {
day: uiT('ui.admin.binkp_sessions.period_24h', '24 Hours'),
week: uiT('ui.admin.binkp_sessions.period_7d', '7 Days'),
month: uiT('ui.admin.binkp_sessions.period_1m', '1 Month')
};
let allSessions = [];
let sessionsCurrentPage = 1;
let sessionsPerPage = DEFAULT_SESSIONS_PER_PAGE;
let selectedStatsPeriod = 'day';
let selectedChartType = 'traffic';
let latestStatsPayload = null;
function goBackFromBinkpSessions() {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.href = '/binkp';
}
function initSessionPagination() {
sessionsPerPage = loadStoredSessionsPerPage();
const select = document.getElementById('sessionsPerPage');
if (select) {
select.value = String(sessionsPerPage);
}
}
function normalizeSessionsPerPage(value) {
return ALLOWED_SESSION_PAGE_SIZES.includes(value) ? value : DEFAULT_SESSIONS_PER_PAGE;
}
function loadStoredSessionsPerPage() {
try {
const storedValue = window.UserStorage ? window.UserStorage.getItem(SESSIONS_PER_PAGE_KEY) : null;
return normalizeSessionsPerPage(Number(storedValue));
} catch (_) {
return DEFAULT_SESSIONS_PER_PAGE;
}
}
function persistSessionsPerPage() {
try {
if (window.UserStorage) {
window.UserStorage.setItem(SESSIONS_PER_PAGE_KEY, String(sessionsPerPage));
}
} catch (_) {}
}
function getSessionsTotalPages() {
if (!allSessions.length) {
return 1;
}
return Math.max(1, Math.ceil(allSessions.length / sessionsPerPage));
}
function loadStats() {
updateStatsSummaryLabel();
fetch(`/admin/api/binkp-sessions/stats?period=${encodeURIComponent(selectedStatsPeriod)}`)
.then(response => response.json())
.then(data => {
latestStatsPayload = data || {};
document.getElementById('statTotal').textContent = data.total_sessions || 0;
document.getElementById('statSecure').textContent = data.secure_sessions || 0;
document.getElementById('statInsecure').textContent = data.insecure_sessions || 0;
document.getElementById('statCrash').textContent = data.crash_sessions || 0;
document.getElementById('statSuccessful').textContent = data.successful || 0;
document.getElementById('statFailed').textContent = data.failed || 0;
renderSelectedChart();
})
.catch(error => {
console.error(uiT('ui.admin.binkp_sessions.load_stats_failed', 'Error loading stats') + ':', error);
latestStatsPayload = null;
renderSelectedChart();
});
}
function renderTrafficChart(points) {
renderLineChart({
containerId: 'binkpPrimaryChart',
points,
emptyText: uiT('ui.admin.binkp_sessions.traffic_chart_empty', 'No file traffic recorded in the selected period.'),
ariaLabel: uiT('ui.admin.binkp_sessions.traffic_chart_aria', 'BinkP file traffic for the selected period'),
series: [
{ key: 'messages_received', lineClass: 'binkp-chart-line-in', pointClass: 'binkp-chart-point-in' },
{ key: 'messages_sent', lineClass: 'binkp-chart-line-out', pointClass: 'binkp-chart-point-out' }
]
});
}
function renderUplinkConnectionsChart(points) {
renderBarChart({
containerId: 'binkpPrimaryChart',
points,
valueKey: 'count',
emptyText: uiT('ui.admin.binkp_sessions.uplink_connections_chart_empty', 'No uplink connections recorded in the selected period.'),
ariaLabel: uiT('ui.admin.binkp_sessions.uplink_connections_chart_aria', 'BinkP uplink connections for the selected period')
});
}
function renderSelectedChart() {
updateChartHeader();
if (selectedChartType === 'uplinks') {
renderUplinkConnectionsChart(Array.isArray(latestStatsPayload?.uplink_connections) ? latestStatsPayload.uplink_connections : []);
return;
}
renderTrafficChart(Array.isArray(latestStatsPayload?.timeline) ? latestStatsPayload.timeline : []);
}
function renderLineChart(config) {
const container = document.getElementById(config.containerId);
if (!container) {
return;
}
const points = Array.isArray(config.points) ? config.points : [];
if (points.length === 0) {
container.innerHTML = `<div class="text-muted">${escapeHtml(config.emptyText)}</div>`;
return;
}
const chart = createChartFrame(points, point => config.series.map(series => Number(point[series.key] || 0)));
const xLabels = buildXAxisLabels(points, chart, formatTrafficChartLabel);
const seriesMarkup = config.series.map(series => {
const path = buildLinePath(points, chart, series.key);
const dots = points.map((point, index) => `<circle class="${series.pointClass}" cx="${chart.xFor(index)}" cy="${chart.yFor(point[series.key])}" r="3"></circle>`).join('');
return `<path class="${series.lineClass}" d="${path}"></path>${dots}`;
}).join('');
container.innerHTML = renderChartSvg(chart, config.ariaLabel, `${seriesMarkup}${xLabels}`);
}
function renderBarChart(config) {
const container = document.getElementById(config.containerId);
if (!container) {
return;
}
const points = Array.isArray(config.points) ? config.points : [];
if (points.length === 0) {
container.innerHTML = `<div class="text-muted">${escapeHtml(config.emptyText)}</div>`;
return;
}
const chart = createChartFrame(points, point => [Number(point[config.valueKey] || 0)], { bottom: 68 });
const barWidth = Math.max(16, Math.min(64, chart.chartWidth / Math.max(points.length * 1.8, 1)));
const bars = points.map((point, index) => {
const value = Number(point[config.valueKey] || 0);
const x = chart.xFor(index) - (barWidth / 2);
const y = chart.yFor(value);
const height = Math.max(0, chart.height - chart.padding.bottom - y);
const labelY = chart.height - 18 + ((index % 2) * 12);
return `
<rect class="binkp-chart-bar" x="${x}" y="${y}" width="${barWidth}" height="${height}"></rect>
<text class="binkp-chart-label" x="${chart.xFor(index)}" y="${labelY}" text-anchor="middle">${escapeHtml(point.label || '')}</text>
`;
}).join('');
container.innerHTML = renderChartSvg(chart, config.ariaLabel, bars);
}
function createChartFrame(points, valueExtractor, paddingOverrides = {}) {
const width = 960;
const height = 260;
const padding = Object.assign({ top: 18, right: 18, bottom: 34, left: 42 }, paddingOverrides);
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const maxValue = Math.max(1, ...points.flatMap(point => valueExtractor(point)));
const xFor = function(index) {
if (points.length === 1) {
return padding.left + chartWidth / 2;
}
return padding.left + (chartWidth * index / (points.length - 1));
};
const yFor = function(value) {
return padding.top + chartHeight - ((Number(value || 0) / maxValue) * chartHeight);
};
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(fraction => Math.round(maxValue * fraction));
const uniqueTicks = [...new Set(yTicks)].sort((a, b) => a - b);
const gridLines = uniqueTicks.map(value => {
const y = yFor(value);
return `<g><line class="binkp-chart-grid" x1="${padding.left}" y1="${y}" x2="${width - padding.right}" y2="${y}"></line><text class="binkp-chart-label" x="${padding.left - 8}" y="${y + 4}" text-anchor="end">${value}</text></g>`;
}).join('');
return { width, height, padding, chartWidth, chartHeight, xFor, yFor, gridLines };
}
function renderChartSvg(chart, ariaLabel, innerMarkup) {
return `
<svg viewBox="0 0 ${chart.width} ${chart.height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtmlAttr(ariaLabel)}">
${chart.gridLines}
<line class="binkp-chart-axis" x1="${chart.padding.left}" y1="${chart.padding.top + chart.chartHeight}" x2="${chart.width - chart.padding.right}" y2="${chart.padding.top + chart.chartHeight}"></line>
<line class="binkp-chart-axis" x1="${chart.padding.left}" y1="${chart.padding.top}" x2="${chart.padding.left}" y2="${chart.padding.top + chart.chartHeight}"></line>
${innerMarkup}
</svg>
`;
}
function buildLinePath(points, chart, key) {
return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${chart.xFor(index).toFixed(2)} ${chart.yFor(point[key]).toFixed(2)}`).join(' ');
}
function buildXAxisLabels(points, chart, formatter) {
return points.map((point, index) => {
if (index !== 0 && index !== points.length - 1 && (index % Math.max(1, Math.ceil(points.length / 6))) !== 0) {
return '';
}
return `<text class="binkp-chart-label" x="${chart.xFor(index)}" y="${chart.height - 10}" text-anchor="middle">${escapeHtml(formatter(point))}</text>`;
}).join('');
}
function updatePeriodButtons() {
document.querySelectorAll('[data-period]').forEach(function(button) {
const isActive = button.getAttribute('data-period') === selectedStatsPeriod;
button.classList.toggle('active', isActive);
button.classList.toggle('btn-outline-secondary', !isActive);
button.classList.toggle('btn-primary', isActive);
});
}
function updateStatsSummaryLabel() {
const label = document.getElementById('statTotalLabel');
if (!label) {
return;
}
label.textContent = uiT('ui.admin.binkp_sessions.total_period', 'Total ({period})', {
period: PERIOD_LABELS[selectedStatsPeriod] || PERIOD_LABELS.day
});
}
function updateChartHeader() {
const titleEl = document.getElementById('binkpChartTitle');
const legendEl = document.getElementById('binkpChartLegend');
if (titleEl) {
titleEl.textContent = selectedChartType === 'uplinks'
? uiT('ui.admin.binkp_sessions.uplink_connections_chart', 'Uplink Connections')
: uiT('ui.admin.binkp_sessions.traffic_chart', 'File Traffic');
}
if (!legendEl) {
return;
}
if (selectedChartType === 'uplinks') {
legendEl.innerHTML = `<span><span class="binkp-chart-legend binkp-chart-legend-in"></span>${escapeHtml(uiT('ui.admin.binkp_sessions.connections', 'Connections'))}</span>`;
return;
}
legendEl.innerHTML = `
<span><span class="binkp-chart-legend binkp-chart-legend-in"></span>${escapeHtml(uiT('ui.admin.binkp_sessions.files_in', 'Files In'))}</span>
<span><span class="binkp-chart-legend binkp-chart-legend-out"></span>${escapeHtml(uiT('ui.admin.binkp_sessions.files_out', 'Files Out'))}</span>
`;
}
function renderRemoteAddressCell(session) {
const remoteAddress = session && session.remote_address;
const value = String(remoteAddress || '').trim();
if (!value) {
return `<code>${escapeHtml(uiT('ui.common.unknown', 'Unknown'))}</code>`;
}
const resolvedDomain = String((session && session.remote_domain) || '').trim();
const atIndex = value.lastIndexOf('@');
if (atIndex > 0 && atIndex < value.length - 1) {
const nodeAddress = value.slice(0, atIndex);
const domain = value.slice(atIndex + 1);
return `
<div><code>${escapeHtml(nodeAddress)}</code></div>
<small class="text-muted">${escapeHtml(domain)}</small>
`;
}
if (resolvedDomain) {
return `
<div><code>${escapeHtml(value)}</code></div>
<small class="text-muted">${escapeHtml(resolvedDomain)}</small>
`;
}
return `<code>${escapeHtml(value)}</code>`;
}
function loadSessions() {
const sessionType = document.getElementById('filterType').value;
const status = document.getElementById('filterStatus').value;
const address = document.getElementById('filterAddress').value;
let url = `/admin/api/binkp-sessions?limit=${MAX_SESSION_FETCH}`;
if (sessionType) url += `&session_type=${sessionType}`;
if (status) url += `&status=${status}`;
if (address) url += `&remote_address=${encodeURIComponent(address)}`;
fetch(url)
.then(response => response.json())
.then(data => {
const previousFirstSessionId = allSessions.length > 0 ? allSessions[0].id : null;
allSessions = Array.isArray(data.sessions) ? data.sessions : [];
if (previousFirstSessionId !== null && allSessions.length > 0 && allSessions[0].id !== previousFirstSessionId) {
sessionsCurrentPage = 1;
}
const totalPages = getSessionsTotalPages();
if (sessionsCurrentPage > totalPages) {
sessionsCurrentPage = totalPages;
}
renderSessionsTable();
})
.catch(error => {
console.error(uiT('ui.admin.binkp_sessions.load_sessions_failed', 'Error loading sessions') + ':', error);
allSessions = [];
renderSessionsTable(true);
});
}
function renderSessionsTable(hasError = false) {
const tbody = document.getElementById('sessionsTableBody');
const prevButton = document.getElementById('sessionsPrevPage');
const nextButton = document.getElementById('sessionsNextPage');
const pageIndicator = document.getElementById('sessionsPageIndicator');
const paginationStatus = document.getElementById('sessionsPaginationStatus');
if (!tbody || !prevButton || !nextButton || !pageIndicator || !paginationStatus) {
return;
}
if (hasError) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-danger">' + uiT('ui.admin.binkp_sessions.load_sessions_failed', 'Error loading sessions') + '</td></tr>';
paginationStatus.textContent = uiT('ui.admin.binkp_sessions.load_sessions_failed', 'Error loading sessions');
pageIndicator.textContent = '1 / 1';
prevButton.disabled = true;
nextButton.disabled = true;
setPaginationDisabledState(prevButton, true);
setPaginationDisabledState(nextButton, true);
return;
}
if (!allSessions.length) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted">' + uiT('ui.admin.binkp_sessions.no_sessions_found', 'No sessions found') + '</td></tr>';
paginationStatus.textContent = uiT('ui.admin.binkp_sessions.page_status_empty', '0 sessions');
pageIndicator.textContent = '1 / 1';
prevButton.disabled = true;
nextButton.disabled = true;
setPaginationDisabledState(prevButton, true);
setPaginationDisabledState(nextButton, true);
return;
}
const totalPages = getSessionsTotalPages();
const currentPage = Math.min(Math.max(1, sessionsCurrentPage), totalPages);
sessionsCurrentPage = currentPage;
const startIndex = (currentPage - 1) * sessionsPerPage;
const endIndex = Math.min(startIndex + sessionsPerPage, allSessions.length);
const pageSessions = allSessions.slice(startIndex, endIndex);
tbody.innerHTML = pageSessions.map(session => `
<tr data-session-id="${session.id}">
<td><small>${formatLocalizedDateTime(session.started_at)}</small></td>
<td>${renderRemoteAddressCell(session)}</td>
<td><small>${escapeHtml(session.remote_ip || '-')}</small></td>
<td>
<div><code>${escapeHtml(String(session.process_id || '-'))}</code></div>
<small class="text-muted">${escapeHtml(session.log_file || '-')}</small>
</td>
<td>${getTypeBadge(session.session_type)}</td>
<td>${session.is_inbound
? '<span class="badge bg-info"><i class="fas fa-arrow-down"></i> ' + uiT('ui.admin.binkp_sessions.in', 'In') + '</span>'
: '<span class="badge bg-secondary"><i class="fas fa-arrow-up"></i> ' + uiT('ui.admin.binkp_sessions.out', 'Out') + '</span>'}</td>
<td>${session.messages_received || 0}</td>
<td>${session.messages_sent || 0}</td>
<td>${getStatusBadge(session.status)}
${session.error_message ? `<br><small class="text-danger">${escapeHtml(session.error_message.substring(0, 50))}</small>` : ''}</td>
<td>${session.duration_seconds ? formatDuration(session.duration_seconds) : '-'}</td>
</tr>
`).join('');
paginationStatus.textContent = uiT(
'ui.admin.binkp_sessions.page_status',
'Showing {start}-{end} of {total}',
{
start: startIndex + 1,
end: endIndex,
total: allSessions.length
}
);
pageIndicator.textContent = uiT(
'ui.admin.binkp_sessions.page_indicator',
'Page {page} of {pages}',
{
page: currentPage,
pages: totalPages
}
);
prevButton.disabled = currentPage <= 1;
nextButton.disabled = currentPage >= totalPages;
setPaginationDisabledState(prevButton, currentPage <= 1);
setPaginationDisabledState(nextButton, currentPage >= totalPages);
}
function setPaginationDisabledState(button, disabled) {
const item = button ? button.closest('.page-item') : null;
if (!item) {
return;
}
item.classList.toggle('disabled', disabled);
}
let binkpSessionRefreshTimer = null;
let binkpSessionRealtimeHandler = null;
function scheduleRealtimeRefresh() {
clearTimeout(binkpSessionRefreshTimer);
binkpSessionRefreshTimer = setTimeout(function() {
loadStats();
loadSessions();
}, 300);
}
function initRealtime() {
if (!window.BinkStream || typeof window.BinkStream.on !== 'function') {
return;
}
binkpSessionRealtimeHandler = function () {
scheduleRealtimeRefresh();
};
window.BinkStream.on('binkp_session', binkpSessionRealtimeHandler);
window.addEventListener('beforeunload', function cleanupRealtime() {
window.removeEventListener('beforeunload', cleanupRealtime);
if (window.BinkStream && typeof window.BinkStream.off === 'function' && binkpSessionRealtimeHandler) {
window.BinkStream.off('binkp_session', binkpSessionRealtimeHandler);
}
});
}
let binkpSessionLogModal = null;
function initSessionLogModal() {
const modalEl = document.getElementById('binkpSessionLogModal');
const tbody = document.getElementById('sessionsTableBody');
if (!modalEl || !tbody || typeof bootstrap === 'undefined') {
return;
}
binkpSessionLogModal = new bootstrap.Modal(modalEl);
tbody.addEventListener('click', function (event) {
const row = event.target.closest('tr[data-session-id]');
if (!row) {
return;
}
loadSessionLog(row.getAttribute('data-session-id'));
});
}
function loadSessionLog(sessionId) {
const titleEl = document.getElementById('binkpSessionLogModalTitle');
const metaEl = document.getElementById('binkpSessionLogMeta');
const bodyEl = document.getElementById('binkpSessionLogBody');
if (!titleEl || !metaEl || !bodyEl || !binkpSessionLogModal) {
return;
}
titleEl.textContent = uiT('ui.admin.binkp_sessions.log_modal_title', 'Session Log');
metaEl.textContent = '';
bodyEl.textContent = uiT('ui.admin.binkp_sessions.loading_log', 'Loading log...');
binkpSessionLogModal.show();
fetch(`/admin/api/binkp-sessions/${encodeURIComponent(sessionId)}/logs`)
.then(response => response.json())
.then(data => {
if (!data.success || !data.session) {
throw new Error(uiT('ui.admin.binkp_sessions.log_load_failed', 'Failed to load session log'));
}
const session = data.session;
titleEl.textContent = `${session.remote_address || uiT('ui.common.unknown', 'Unknown')} | PID ${session.process_id || '-'}`;
metaEl.textContent = `${uiT('ui.admin.binkp_sessions.log_file', 'Log File')}: ${session.log_file || '-'} | ${uiT('ui.admin.binkp_sessions.pid', 'PID')}: ${session.process_id || '-'}`;
const lines = data.logs && Array.isArray(data.logs.lines) ? data.logs.lines : [];
if (!session.process_id) {
bodyEl.textContent = uiT('ui.admin.binkp_sessions.log_unavailable', 'No PID was recorded for this session.');
return;
}
if (lines.length === 0) {
bodyEl.textContent = uiT('ui.admin.binkp_sessions.no_log_lines', 'No log lines found for this session.');
return;
}
bodyEl.textContent = lines.map(line => line.line || '').join('\n');
})
.catch(error => {
console.error(uiT('ui.admin.binkp_sessions.log_load_failed', 'Failed to load session log') + ':', error);
bodyEl.textContent = uiT('ui.admin.binkp_sessions.log_load_failed', 'Failed to load session log');
});
}
function getTypeBadge(type) {
const badges = {
'secure': '<span class="badge bg-success">' + uiT('ui.admin.binkp_sessions.secure', 'Secure') + '</span>',
'insecure': '<span class="badge bg-warning">' + uiT('ui.admin.binkp_sessions.insecure', 'Insecure') + '</span>',
'crash_outbound': '<span class="badge bg-info">' + uiT('ui.admin.binkp_sessions.crash', 'Crash') + '</span>'
};
return badges[type] || `<span class="badge bg-secondary">${type}</span>`;
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-primary">' + uiT('ui.admin.binkp_sessions.active', 'Active') + '</span>',
'success': '<span class="badge bg-success">' + uiT('ui.admin.binkp_sessions.success', 'Success') + '</span>',
'failed': '<span class="badge bg-danger">' + uiT('ui.admin.binkp_sessions.failed', 'Failed') + '</span>',
'rejected': '<span class="badge bg-warning">' + uiT('ui.admin.binkp_sessions.rejected', 'Rejected') + '</span>'
};
return badges[status] || `<span class="badge bg-secondary">${status}</span>`;
}
function formatDate(dateStr) {
return formatLocalizedDateTime(dateStr);
}
function normalizeServerDate(dateStr) {
if (!dateStr || typeof dateStr !== 'string') {
return null;
}
let normalized = dateStr.trim().replace(' ', 'T');
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
normalized = normalized.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
return normalized;
}
function formatLocalizedDateTime(dateStr) {
const parser = typeof window.parseAppDate === 'function'
? window.parseAppDate
: function(value) {
const normalized = normalizeServerDate(value);
if (!normalized) {
return null;
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const date = parser(dateStr);
if (!date) {
return '-';
}
const userTimezone = getUserTimezone();
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'
});
}
function getUserTimezone() {
return window.userSettings?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
}
function formatTrafficChartLabel(point) {
const epochSeconds = Number(point?.bucket_epoch || 0);
const userDateFormat = window.userSettings?.date_format || undefined;
if (epochSeconds > 0) {
const date = new Date(epochSeconds * 1000);
if (!Number.isNaN(date.getTime())) {
if (selectedStatsPeriod === 'day') {
return date.toLocaleTimeString(userDateFormat, {
hour: 'numeric',
minute: '2-digit'
});
}
return date.toLocaleDateString(userDateFormat, {
month: 'short',
day: 'numeric'
});
}
}
return point?.label || '';
}
function formatDuration(seconds) {
if (seconds < 1) return '<1s';
if (seconds < 60) return Math.round(seconds) + 's';
if (seconds < 3600) return Math.round(seconds / 60) + 'm';
return Math.round(seconds / 3600) + 'h';
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtmlAttr(text) {
return escapeHtml(text);
}
</script>
{% endblock %}
|