p2ns.admin updates
This commit is contained in:
@@ -73,7 +73,7 @@ function getAdminHealthPayload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildStatsPageSnapshot(minutes = 1440) {
|
async function buildStatsPageSnapshot(minutes = 5) {
|
||||||
const [stats, historical] = await Promise.all([
|
const [stats, historical] = await Promise.all([
|
||||||
collectAdminStats(),
|
collectAdminStats(),
|
||||||
Promise.resolve(collectHistoricalMinutes(minutes))
|
Promise.resolve(collectHistoricalMinutes(minutes))
|
||||||
|
|||||||
@@ -1,8 +1,48 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const { trackRequestWithTiming, trackRequest } = require('../../../maintenance/metrics');
|
const { trackRequestWithTiming, trackRequest } = require('../../../maintenance/metrics');
|
||||||
const { logError } = require('../../../infrastructure/logger');
|
const { logError } = require('../../../infrastructure/logger');
|
||||||
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
const { collectAdminStats, collectHistoricalMinutes } = require('../stats-collector');
|
const { collectAdminStats, collectHistoricalMinutes } = require('../stats-collector');
|
||||||
|
|
||||||
|
const statsUIPrefsFile = './cache/statsUIPreferences.json';
|
||||||
|
const VALID_STATS_MINUTES = [1, 5, 15, 30, 60, 360, 1440, 2880];
|
||||||
|
const VALID_STATS_INTERVALS = [1000, 2000, 5000, 10000, 30000];
|
||||||
|
const DEFAULT_STATS_UI_PREFS = {
|
||||||
|
minutes: 5,
|
||||||
|
intervalMs: 2000,
|
||||||
|
autoRefresh: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeStatsUIPrefs(input) {
|
||||||
|
const prefs = {
|
||||||
|
...DEFAULT_STATS_UI_PREFS,
|
||||||
|
...(input && typeof input === 'object' ? input : {})
|
||||||
|
};
|
||||||
|
const minutes = Number(prefs.minutes);
|
||||||
|
const intervalMs = Number(prefs.intervalMs);
|
||||||
|
prefs.minutes = VALID_STATS_MINUTES.includes(minutes) ? minutes : DEFAULT_STATS_UI_PREFS.minutes;
|
||||||
|
prefs.intervalMs = VALID_STATS_INTERVALS.includes(intervalMs) ? intervalMs : DEFAULT_STATS_UI_PREFS.intervalMs;
|
||||||
|
prefs.autoRefresh = prefs.autoRefresh !== false;
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStatsUIPrefsFromDisk() {
|
||||||
|
if (!fs.existsSync(statsUIPrefsFile)) {
|
||||||
|
return { ...DEFAULT_STATS_UI_PREFS };
|
||||||
|
}
|
||||||
|
const data = fs.readFileSync(statsUIPrefsFile, 'utf8');
|
||||||
|
return normalizeStatsUIPrefs(JSON.parse(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStatsUIPrefsToDisk(prefs) {
|
||||||
|
const cacheDir = path.dirname(statsUIPrefsFile);
|
||||||
|
if (!fs.existsSync(cacheDir)) {
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(statsUIPrefsFile, JSON.stringify(normalizeStatsUIPrefs(prefs), null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStatsRoutes(req, res) {
|
async function handleStatsRoutes(req, res) {
|
||||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
const method = req.method;
|
const method = req.method;
|
||||||
@@ -27,7 +67,7 @@ async function handleStatsRoutes(req, res) {
|
|||||||
|
|
||||||
if (method === 'GET' && urlPath === '/api/stats/historical') {
|
if (method === 'GET' && urlPath === '/api/stats/historical') {
|
||||||
try {
|
try {
|
||||||
let minutes = 60;
|
let minutes = 5;
|
||||||
if (req.url.includes('?')) {
|
if (req.url.includes('?')) {
|
||||||
const queryString = req.url.split('?')[1];
|
const queryString = req.url.split('?')[1];
|
||||||
const params = new URLSearchParams(queryString);
|
const params = new URLSearchParams(queryString);
|
||||||
@@ -52,6 +92,42 @@ async function handleStatsRoutes(req, res) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/stats-ui-preferences') {
|
||||||
|
try {
|
||||||
|
const prefs = readStatsUIPrefsFromDisk();
|
||||||
|
trackRequest('/api/stats-ui-preferences', true);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(prefs));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error in /api/stats-ui-preferences: ${err.message}`, err);
|
||||||
|
trackRequest('/api/stats-ui-preferences', false);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(DEFAULT_STATS_UI_PREFS));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/save-stats-ui-preferences') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const prefs = normalizeStatsUIPrefs(JSON.parse(body || '{}'));
|
||||||
|
writeStatsUIPrefsToDisk(prefs);
|
||||||
|
trackRequest('/api/save-stats-ui-preferences', true);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(prefs));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error in /api/save-stats-ui-preferences: ${err.message}`, err);
|
||||||
|
trackRequest('/api/save-stats-ui-preferences', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,8 +322,8 @@ async function collectAdminStats() {
|
|||||||
|
|
||||||
function collectHistoricalMinutes(minutes) {
|
function collectHistoricalMinutes(minutes) {
|
||||||
let m = parseInt(minutes, 10);
|
let m = parseInt(minutes, 10);
|
||||||
if (isNaN(m) || m < 1) m = 60;
|
if (isNaN(m) || m < 1) m = 5;
|
||||||
if (m > 1440) m = 1440;
|
if (m > 2880) m = 2880;
|
||||||
return getHistoricalData(m);
|
return getHistoricalData(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ let statsBroadcastInterval = null;
|
|||||||
let statsBroadcastInFlight = false;
|
let statsBroadcastInFlight = false;
|
||||||
let statsBroadcastIntervalMs = 0;
|
let statsBroadcastIntervalMs = 0;
|
||||||
|
|
||||||
const DEFAULT_MINUTES = 1440;
|
const DEFAULT_MINUTES = 5;
|
||||||
const DEFAULT_INTERVAL_MS = 5000;
|
const MAX_HISTORICAL_MINUTES = 2880;
|
||||||
|
const DEFAULT_INTERVAL_MS = 2000;
|
||||||
const MIN_INTERVAL_MS = 1000;
|
const MIN_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
function normalizeIntervalMs(value) {
|
function normalizeIntervalMs(value) {
|
||||||
@@ -37,7 +38,7 @@ function getMinSubscriberIntervalMs() {
|
|||||||
function parseMinutes(value) {
|
function parseMinutes(value) {
|
||||||
const m = parseInt(value, 10);
|
const m = parseInt(value, 10);
|
||||||
if (isNaN(m) || m < 1) return DEFAULT_MINUTES;
|
if (isNaN(m) || m < 1) return DEFAULT_MINUTES;
|
||||||
if (m > 1440) return 1440;
|
if (m > MAX_HISTORICAL_MINUTES) return MAX_HISTORICAL_MINUTES;
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ if (location.hash === '#domains') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
function initializeApp() {
|
async function initializeApp() {
|
||||||
const SCROLLABLE_TABS = ['stats', 'plugins', 'settings'];
|
const SCROLLABLE_TABS = ['stats', 'plugins', 'settings'];
|
||||||
|
|
||||||
function updateAdminContentMode(tabId) {
|
function updateAdminContentMode(tabId) {
|
||||||
@@ -265,6 +265,10 @@ function initializeApp() {
|
|||||||
document.body.classList.add('no-scroll');
|
document.body.classList.add('no-scroll');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.initStatsToolbar) {
|
||||||
|
await window.initStatsToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
showTab(tabId);
|
showTab(tabId);
|
||||||
|
|
||||||
if (window.startStatusUpdates) window.startStatusUpdates();
|
if (window.startStatusUpdates) window.startStatusUpdates();
|
||||||
@@ -274,41 +278,6 @@ function initializeApp() {
|
|||||||
window.genericFetch('local-dns', false);
|
window.genericFetch('local-dns', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup stats auto-refresh event listeners
|
|
||||||
const autoRefreshCheckbox = document.getElementById('auto-refresh-stats');
|
|
||||||
if (autoRefreshCheckbox) {
|
|
||||||
autoRefreshCheckbox.addEventListener('change', (e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
if (window.startStatsUpdates) window.startStatsUpdates();
|
|
||||||
} else {
|
|
||||||
if (window.stopStatsPollingFallback) window.stopStatsPollingFallback();
|
|
||||||
if (window.unsubscribeStatsWebSocket) window.unsubscribeStatsWebSocket();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshIntervalSelector = document.getElementById('refresh-interval-selector');
|
|
||||||
if (refreshIntervalSelector) {
|
|
||||||
refreshIntervalSelector.addEventListener('change', () => {
|
|
||||||
if (autoRefreshCheckbox && autoRefreshCheckbox.checked && window.startStatsUpdates) {
|
|
||||||
window.startStatsUpdates();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeRangeSelector = document.getElementById('time-range-selector');
|
|
||||||
if (timeRangeSelector) {
|
|
||||||
timeRangeSelector.addEventListener('change', () => {
|
|
||||||
if (window.activeTab === 'stats') {
|
|
||||||
if (window.requestStatsSnapshotViaWebSocket) {
|
|
||||||
window.requestStatsSnapshotViaWebSocket();
|
|
||||||
} else if (window.renderStats) {
|
|
||||||
window.renderStats();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.shouldAutoRefreshStats && window.shouldAutoRefreshStats() && window.startStatsUpdates) {
|
if (window.shouldAutoRefreshStats && window.shouldAutoRefreshStats() && window.startStatsUpdates) {
|
||||||
window.startStatsUpdates();
|
window.startStatsUpdates();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -72,8 +72,46 @@ function showCertsListEnd() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePeersListChrome() {
|
||||||
|
const totalCount = (window.peersData || []).length;
|
||||||
|
const filteredCount = (window.filteredPeers || window.peersData || []).length;
|
||||||
|
const searchEl = document.getElementById('search-peers');
|
||||||
|
const hasFilter = Boolean(searchEl?.value?.trim());
|
||||||
|
|
||||||
|
const emptyEl = document.getElementById('peersEmpty');
|
||||||
|
const filteredEmptyEl = document.getElementById('peersFilteredEmpty');
|
||||||
|
const listEl = document.getElementById('peersList');
|
||||||
|
const endEl = document.getElementById('peersEnd');
|
||||||
|
|
||||||
|
const trulyEmpty = totalCount === 0;
|
||||||
|
const filteredEmpty = !trulyEmpty && filteredCount === 0;
|
||||||
|
|
||||||
|
if (emptyEl) emptyEl.classList.toggle('peer-empty--visible', trulyEmpty);
|
||||||
|
if (filteredEmptyEl) filteredEmptyEl.classList.toggle('peer-empty--visible', filteredEmpty);
|
||||||
|
if (listEl) listEl.classList.toggle('peer-list--hidden', trulyEmpty || filteredEmpty);
|
||||||
|
if (endEl) endEl.classList.remove('peer-list-end--visible');
|
||||||
|
|
||||||
|
const countEl = document.getElementById('peersCount');
|
||||||
|
if (countEl) {
|
||||||
|
if (trulyEmpty) countEl.textContent = 'No peers connected';
|
||||||
|
else if (hasFilter) countEl.textContent = `${filteredCount} of ${totalCount} shown`;
|
||||||
|
else countEl.textContent = `${totalCount} peer${totalCount === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPeersListEnd() {
|
||||||
|
const totalCount = (window.peersData || []).length;
|
||||||
|
const visibleCount = (window.filteredPeers || window.peersData || []).length;
|
||||||
|
const endEl = document.getElementById('peersEnd');
|
||||||
|
if (endEl && totalCount > 0 && visibleCount > 0) {
|
||||||
|
endEl.classList.add('peer-list-end--visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.updateCertsListChrome = updateCertsListChrome;
|
window.updateCertsListChrome = updateCertsListChrome;
|
||||||
window.showCertsListEnd = showCertsListEnd;
|
window.showCertsListEnd = showCertsListEnd;
|
||||||
|
window.updatePeersListChrome = updatePeersListChrome;
|
||||||
|
window.showPeersListEnd = showPeersListEnd;
|
||||||
|
|
||||||
window.chartColors = {
|
window.chartColors = {
|
||||||
primary: 'rgb(59, 130, 246)',
|
primary: 'rgb(59, 130, 246)',
|
||||||
@@ -160,16 +198,39 @@ window.tabs = {
|
|||||||
containerId: 'entriesTable',
|
containerId: 'entriesTable',
|
||||||
paginationId: 'entriesPagination',
|
paginationId: 'entriesPagination',
|
||||||
sentinelId: 'entriesScrollSentinel',
|
sentinelId: 'entriesScrollSentinel',
|
||||||
countId: 'entries-count',
|
|
||||||
useLazyScroll: true,
|
|
||||||
sort: (a, b) => a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }),
|
sort: (a, b) => a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }),
|
||||||
filter: (item, query) => item.key.toLowerCase().includes(query) || item.value.toLowerCase().includes(query),
|
filter: (item, query) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const key = (item.key || '').toLowerCase();
|
||||||
|
const value = (item.value || '').toLowerCase();
|
||||||
|
const type = key.startsWith('claim:') ? 'claim' : key.startsWith('vote:') ? 'vote' : 'other';
|
||||||
|
return key.includes(q) || value.includes(q) || type.includes(q);
|
||||||
|
},
|
||||||
renderItem: (item) => {
|
renderItem: (item) => {
|
||||||
|
const esc = window.escapeHtml || ((value) => String(value));
|
||||||
|
const classify = window.classifyEntryKey || ((key) => ({ label: 'Other', className: 'entry-badge--muted' }));
|
||||||
|
const type = classify(item.key);
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
tr.className = 'entry-row';
|
||||||
tr.innerHTML = `<td class="p-3 break-all">${item.key}</td>
|
tr.innerHTML = `
|
||||||
<td class="p-3 break-all">${item.value}</td>`;
|
<td class="p-3">
|
||||||
|
<span class="entry-badge ${type.className}">${esc(type.label)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3"><code class="entry-key">${esc(item.key)}</code></td>
|
||||||
|
<td class="p-3"><span class="entry-value">${esc(item.value)}</span></td>
|
||||||
|
<td class="p-3 text-right">
|
||||||
|
<button type="button" class="admin-btn admin-btn--secondary admin-btn--sm" data-entry-value="${esc(item.value)}" onclick="copyEntryValue(this.getAttribute('data-entry-value'))" title="Copy value">
|
||||||
|
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
return tr;
|
return tr;
|
||||||
|
},
|
||||||
|
preRender: (visibleCount) => {
|
||||||
|
if (window.updateEntriesChrome) window.updateEntriesChrome(visibleCount);
|
||||||
|
},
|
||||||
|
onAllItemsLoaded: () => {
|
||||||
|
if (window.showEntriesListEnd) window.showEntriesListEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
peers: {
|
peers: {
|
||||||
@@ -179,51 +240,76 @@ window.tabs = {
|
|||||||
filteredKey: 'filteredPeers',
|
filteredKey: 'filteredPeers',
|
||||||
containerId: 'peersList',
|
containerId: 'peersList',
|
||||||
paginationId: 'peersPagination',
|
paginationId: 'peersPagination',
|
||||||
|
sentinelId: 'peersScrollSentinel',
|
||||||
sort: (a, b) => (a.id || '').localeCompare(b.id || '', undefined, { sensitivity: 'base' }),
|
sort: (a, b) => (a.id || '').localeCompare(b.id || '', undefined, { sensitivity: 'base' }),
|
||||||
filter: (item, query) => {
|
filter: (item, query) => {
|
||||||
const queryLower = query.toLowerCase();
|
const queryLower = query.toLowerCase();
|
||||||
return (item.id || '').toLowerCase().includes(queryLower) ||
|
return (item.id || '').toLowerCase().includes(queryLower) ||
|
||||||
(item.connected ? 'connected' : 'disconnected').includes(queryLower);
|
(item.connected ? 'connected' : 'disconnected').includes(queryLower) ||
|
||||||
|
(item.isBlocked ? 'blocked' : '').includes(queryLower);
|
||||||
},
|
},
|
||||||
renderItem: (peer) => {
|
renderItem: (peer) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow';
|
li.className = 'peer-row';
|
||||||
|
const esc = window.escapeHtml || ((value) => String(value));
|
||||||
|
const peerIdAttr = String(peer.id || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
|
const peerId = esc(peer.id || '');
|
||||||
|
|
||||||
const uptime = peer.uptime ? (window.sdk?.utils?.format?.formatUptime ? window.sdk.utils.format.formatUptime(peer.uptime) : `${Math.floor(peer.uptime / 1000)}s`) : 'N/A';
|
const uptime = peer.uptime
|
||||||
const statusBadge = peer.connected
|
? (window.sdk?.utils?.format?.formatUptime
|
||||||
? '<span class="px-2 py-1 bg-green-500 rounded text-sm" style="color: var(--text-primary);">Connected</span>'
|
? window.sdk.utils.format.formatUptime(peer.uptime)
|
||||||
: '<span class="px-2 py-1 bg-gray-500 rounded text-sm" style="color: var(--text-primary);">Disconnected</span>';
|
: `${Math.floor(peer.uptime / 1000)}s`)
|
||||||
|
: 'N/A';
|
||||||
|
const avgDuration = peer.metrics?.avgDuration
|
||||||
|
? (window.sdk?.utils?.format?.formatDuration
|
||||||
|
? window.sdk.utils.format.formatDuration(peer.metrics.avgDuration)
|
||||||
|
: `${Math.floor(peer.metrics.avgDuration / 1000)}s`)
|
||||||
|
: 'N/A';
|
||||||
|
const statusClass = peer.connected ? 'peer-badge--ok' : 'peer-badge--muted';
|
||||||
|
const statusLabel = peer.connected ? 'Connected' : 'Disconnected';
|
||||||
const blockedBadge = peer.isBlocked
|
const blockedBadge = peer.isBlocked
|
||||||
? '<span class="px-2 py-1 bg-red-500 rounded text-sm ml-2" style="color: var(--text-primary);">Blocked</span>'
|
? '<span class="peer-badge peer-badge--danger">Blocked</span>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<div class="flex justify-between items-center">
|
<div class="peer-row-main">
|
||||||
<div class="flex-1 min-w-0">
|
<span class="peer-row-icon" aria-hidden="true"><i class="fas fa-server"></i></span>
|
||||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
<div class="peer-row-body">
|
||||||
<span class="font-mono text-sm break-all cursor-pointer text-blue-500 hover:underline" onclick="showPeerDetails('${peer.id}')">${peer.id}</span>
|
<div class="peer-row-head">
|
||||||
${statusBadge}
|
<button type="button" class="peer-row-id" onclick="showPeerDetails('${peerIdAttr}')">${peerId}</button>
|
||||||
|
<div class="peer-row-badges">
|
||||||
|
<span class="peer-badge ${statusClass}">${statusLabel}</span>
|
||||||
${blockedBadge}
|
${blockedBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm theme-text-secondary">
|
</div>
|
||||||
<div>Uptime: ${uptime}</div>
|
<dl class="peer-row-meta">
|
||||||
<div>Connections: ${peer.metrics?.connections || 0} | Avg Duration: ${peer.metrics?.avgDuration ? (window.sdk?.utils?.format?.formatDuration ? window.sdk.utils.format.formatDuration(peer.metrics.avgDuration) : `${Math.floor(peer.metrics.avgDuration / 1000)}s`) : 'N/A'}</div>
|
<div><dt>Uptime</dt><dd>${esc(uptime)}</dd></div>
|
||||||
|
<div><dt>Connections</dt><dd>${peer.metrics?.connections || 0}</dd></div>
|
||||||
|
<div><dt>Avg duration</dt><dd>${esc(avgDuration)}</dd></div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 ml-4 flex-shrink-0">
|
<div class="peer-row-actions">
|
||||||
<button onclick="showPeerDetails('${peer.id}')" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm whitespace-nowrap">Details</button>
|
<button type="button" class="admin-btn admin-btn--secondary admin-btn--sm" onclick="showPeerDetails('${peerIdAttr}')" title="View peer details">
|
||||||
|
<i class="fas fa-circle-info" aria-hidden="true"></i><span class="peer-row-action-label">Details</span>
|
||||||
|
</button>
|
||||||
${peer.isBlocked
|
${peer.isBlocked
|
||||||
? `<button onclick="unblockPeer('${peer.id}')" class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 text-sm whitespace-nowrap">Unblock</button>`
|
? `<button type="button" class="admin-btn admin-btn--success admin-btn--sm" onclick="unblockPeer('${peerIdAttr}')" title="Unblock peer">
|
||||||
: `<button onclick="blockPeer('${peer.id}')" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 text-sm whitespace-nowrap">Block</button>`
|
<i class="fas fa-unlock" aria-hidden="true"></i><span class="peer-row-action-label">Unblock</span>
|
||||||
|
</button>`
|
||||||
|
: `<button type="button" class="admin-btn admin-btn--danger admin-btn--sm" onclick="blockPeer('${peerIdAttr}')" title="Block peer">
|
||||||
|
<i class="fas fa-ban" aria-hidden="true"></i><span class="peer-row-action-label">Block</span>
|
||||||
|
</button>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
return li;
|
return li;
|
||||||
},
|
},
|
||||||
preRender: (total) => {
|
preRender: () => {
|
||||||
const el = document.getElementById('peers-count');
|
updatePeersListChrome();
|
||||||
if (el) el.textContent = `(${total})`;
|
},
|
||||||
|
onAllItemsLoaded: () => {
|
||||||
|
showPeersListEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
certs: {
|
certs: {
|
||||||
|
|||||||
@@ -330,26 +330,6 @@ function setupInfiniteScrollObserver(tabId, container) {
|
|||||||
|
|
||||||
// Legacy entries lazy scroll - now uses generic infinite scroll
|
// Legacy entries lazy scroll - now uses generic infinite scroll
|
||||||
function renderEntriesLazy() {
|
function renderEntriesLazy() {
|
||||||
const config = window.tabs.entries;
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
const data = window[config.filteredKey] || window[config.dataKey];
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
// Update count display
|
|
||||||
const countEl = document.getElementById(config.countId);
|
|
||||||
if (countEl) {
|
|
||||||
const totalCount = data.length;
|
|
||||||
const searchEl = document.getElementById(config.searchId);
|
|
||||||
const searchQuery = searchEl ? searchEl.value : '';
|
|
||||||
if (searchQuery) {
|
|
||||||
countEl.textContent = `${totalCount.toLocaleString()} (filtered)`;
|
|
||||||
} else {
|
|
||||||
countEl.textContent = totalCount.toLocaleString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use generic infinite scroll
|
|
||||||
genericRenderInfiniteScroll('entries');
|
genericRenderInfiniteScroll('entries');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Autopass entries tab — ledger list chrome and overview stats
|
||||||
|
|
||||||
|
function classifyEntryKey(key) {
|
||||||
|
const text = String(key || '');
|
||||||
|
if (text.startsWith('claim:')) {
|
||||||
|
return { label: 'Claim', className: 'entry-badge--claim' };
|
||||||
|
}
|
||||||
|
if (text.startsWith('vote:')) {
|
||||||
|
return { label: 'Vote', className: 'entry-badge--vote' };
|
||||||
|
}
|
||||||
|
return { label: 'Other', className: 'entry-badge--muted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeEntryStats(data) {
|
||||||
|
const entries = Array.isArray(data) ? data : [];
|
||||||
|
let claims = 0;
|
||||||
|
let votes = 0;
|
||||||
|
let other = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = entry?.key || '';
|
||||||
|
if (key.startsWith('claim:')) claims += 1;
|
||||||
|
else if (key.startsWith('vote:')) votes += 1;
|
||||||
|
else other += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: entries.length,
|
||||||
|
claims,
|
||||||
|
votes,
|
||||||
|
other
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntriesChrome(filteredCount) {
|
||||||
|
const totalCount = (window.entriesData || []).length;
|
||||||
|
const visibleCount = filteredCount != null
|
||||||
|
? filteredCount
|
||||||
|
: (window.filteredEntries || window.entriesData || []).length;
|
||||||
|
const searchEl = document.getElementById('search-entries');
|
||||||
|
const hasSearch = Boolean(searchEl?.value?.trim());
|
||||||
|
|
||||||
|
const emptyEl = document.getElementById('entriesEmpty');
|
||||||
|
const filteredEmptyEl = document.getElementById('entriesFilteredEmpty');
|
||||||
|
const endEl = document.getElementById('entriesEnd');
|
||||||
|
const tableEl = document.querySelector('.entries-table');
|
||||||
|
const countEl = document.getElementById('entriesCount');
|
||||||
|
|
||||||
|
const trulyEmpty = totalCount === 0;
|
||||||
|
const filteredToZero = !trulyEmpty && visibleCount === 0 && hasSearch;
|
||||||
|
|
||||||
|
if (emptyEl) emptyEl.classList.toggle('entry-empty--visible', trulyEmpty);
|
||||||
|
if (filteredEmptyEl) filteredEmptyEl.classList.toggle('entry-empty--visible', filteredToZero);
|
||||||
|
if (tableEl) tableEl.classList.toggle('entries-table--hidden', trulyEmpty || filteredToZero);
|
||||||
|
if (endEl) endEl.classList.remove('entry-list-end--visible');
|
||||||
|
|
||||||
|
if (countEl) {
|
||||||
|
if (trulyEmpty) {
|
||||||
|
countEl.textContent = 'No ledger entries';
|
||||||
|
} else if (hasSearch && visibleCount !== totalCount) {
|
||||||
|
countEl.textContent = `${visibleCount.toLocaleString()} of ${totalCount.toLocaleString()} shown`;
|
||||||
|
} else {
|
||||||
|
countEl.textContent = `${totalCount.toLocaleString()} entr${totalCount === 1 ? 'y' : 'ies'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEntriesSummary(window.entriesData || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEntriesListEnd() {
|
||||||
|
const totalCount = (window.entriesData || []).length;
|
||||||
|
const visibleCount = (window.filteredEntries || window.entriesData || []).length;
|
||||||
|
const endEl = document.getElementById('entriesEnd');
|
||||||
|
if (endEl && totalCount > 0 && visibleCount > 0) {
|
||||||
|
endEl.classList.add('entry-list-end--visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntriesSummary(data) {
|
||||||
|
const stats = computeEntryStats(data);
|
||||||
|
const setStat = (id, value) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
setStat('entryStatTotal', stats.total);
|
||||||
|
setStat('entryStatClaims', stats.claims);
|
||||||
|
setStat('entryStatVotes', stats.votes);
|
||||||
|
setStat('entryStatOther', stats.other);
|
||||||
|
|
||||||
|
const overviewEl = document.getElementById('entriesOverviewLine');
|
||||||
|
if (overviewEl) {
|
||||||
|
overviewEl.textContent = stats.total === 0
|
||||||
|
? 'Ledger is empty'
|
||||||
|
: `${stats.claims} claims · ${stats.votes} votes`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyEntryValue(value) {
|
||||||
|
const text = String(value ?? '');
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (window.showNotification) window.showNotification('Value copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy entry value:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to copy value', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.classifyEntryKey = classifyEntryKey;
|
||||||
|
window.updateEntriesChrome = updateEntriesChrome;
|
||||||
|
window.showEntriesListEnd = showEntriesListEnd;
|
||||||
|
window.renderEntriesSummary = renderEntriesSummary;
|
||||||
|
window.copyEntryValue = copyEntryValue;
|
||||||
@@ -53,8 +53,8 @@ function updateHealthStatus(data) {
|
|||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded';
|
statusEl.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded';
|
||||||
statusEl.className = data.status === 'healthy'
|
statusEl.className = data.status === 'healthy'
|
||||||
? 'text-2xl font-bold text-green-600 dark:text-green-400'
|
? 'stats-metric-value stats-health-status stats-health-status--ok'
|
||||||
: 'text-2xl font-bold text-yellow-600 dark:text-yellow-400';
|
: 'stats-metric-value stats-health-status stats-health-status--warn';
|
||||||
}
|
}
|
||||||
|
|
||||||
const uptimeEl = document.getElementById('health-uptime');
|
const uptimeEl = document.getElementById('health-uptime');
|
||||||
@@ -117,20 +117,20 @@ function formatDetailEntries(details) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureServiceCards(container) {
|
function ensureServiceCards(container) {
|
||||||
if (container.dataset.initialized === '1') return;
|
if (container.dataset.initialized === '3') return;
|
||||||
|
|
||||||
container.dataset.initialized = '1';
|
container.dataset.initialized = '3';
|
||||||
container.innerHTML = SERVICE_DEFS.map((service) => `
|
container.innerHTML = SERVICE_DEFS.map((service) => `
|
||||||
<div class="admin-card theme-card flex flex-col health-service-card" data-service="${service.key}">
|
<div class="stats-service-card health-service-card" data-service="${service.key}">
|
||||||
<div class="flex items-center justify-between mb-3 gap-2">
|
<div class="health-service-top">
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
<div class="health-service-title">
|
||||||
<span class="health-service-icon" aria-hidden="true"><i class="fas ${service.icon}"></i></span>
|
<span class="health-service-icon" aria-hidden="true"><i class="fas ${service.icon}"></i></span>
|
||||||
<h3 class="text-sm font-semibold truncate">${service.name}</h3>
|
<h3 class="health-service-name">${service.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class="health-service-badge health-service-badge--neutral">—</span>
|
<span class="health-service-badge health-service-badge--neutral">—</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="health-service-enabled text-xs theme-text-tertiary mb-2">—</p>
|
<p class="health-service-enabled theme-text-tertiary">—</p>
|
||||||
<dl class="health-service-details text-xs theme-text-tertiary space-y-1 min-h-[3rem] tabular-nums"></dl>
|
<dl class="health-service-details"></dl>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -143,22 +143,13 @@ function updateServiceCardElement(card, serviceData) {
|
|||||||
|
|
||||||
const badge = card.querySelector('.health-service-badge');
|
const badge = card.querySelector('.health-service-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
const statusText = healthy ? 'Healthy' : 'Unhealthy';
|
badge.textContent = healthy ? 'Healthy' : 'Unhealthy';
|
||||||
if (badge.textContent !== statusText) {
|
badge.className = `health-service-badge health-service-badge--${healthy ? 'ok' : 'error'}`;
|
||||||
badge.textContent = statusText;
|
|
||||||
}
|
|
||||||
const nextClass = `health-service-badge health-service-badge--${healthy ? 'ok' : 'error'}`;
|
|
||||||
if (badge.className !== nextClass) {
|
|
||||||
badge.className = nextClass;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledEl = card.querySelector('.health-service-enabled');
|
const enabledEl = card.querySelector('.health-service-enabled');
|
||||||
if (enabledEl) {
|
if (enabledEl) {
|
||||||
const enabledText = enabled ? 'Enabled' : 'Disabled';
|
enabledEl.textContent = enabled ? 'Enabled' : 'Disabled';
|
||||||
if (enabledEl.textContent !== enabledText) {
|
|
||||||
enabledEl.textContent = enabledText;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const detailsEl = card.querySelector('.health-service-details');
|
const detailsEl = card.querySelector('.health-service-details');
|
||||||
@@ -174,15 +165,15 @@ function updateServiceCardElement(card, serviceData) {
|
|||||||
let row = existingRows.get(key);
|
let row = existingRows.get(key);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
row = document.createElement('div');
|
row = document.createElement('div');
|
||||||
row.className = 'flex justify-between gap-3';
|
row.className = 'health-service-detail-row';
|
||||||
row.dataset.detailKey = key;
|
row.dataset.detailKey = key;
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'truncate opacity-80';
|
label.className = 'health-service-detail-key';
|
||||||
label.textContent = key;
|
label.textContent = key;
|
||||||
|
|
||||||
const valueEl = document.createElement('span');
|
const valueEl = document.createElement('span');
|
||||||
valueEl.className = 'health-detail-value shrink-0 text-right';
|
valueEl.className = 'health-detail-value';
|
||||||
valueEl.textContent = value;
|
valueEl.textContent = value;
|
||||||
|
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
@@ -197,7 +188,6 @@ function updateServiceCardElement(card, serviceData) {
|
|||||||
existingRows.delete(key);
|
existingRows.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only remove rows when the payload explicitly includes a new details object
|
|
||||||
for (const row of existingRows.values()) {
|
for (const row of existingRows.values()) {
|
||||||
row.remove();
|
row.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const infoContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
content: 'Use the search box to find specific entries by key or value.'
|
content: 'Use the search field to filter by key, value, or entry type (claim, vote, other). The overview panel summarizes ledger composition.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -136,7 +136,7 @@ const infoContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Connection Status',
|
title: 'Connection Status',
|
||||||
content: 'The count next to "Connected Peers" shows how many active peer connections you currently have.'
|
content: 'The overview panel shows connected, disconnected, and blocked counts. The peer list subtitle reflects the current search filter.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -128,12 +128,20 @@ function writelnToTerminal(line, channel) {
|
|||||||
window.term.writeln(line);
|
window.term.writeln(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fitLogTerminal() {
|
||||||
|
if (!window.fitAddon) return;
|
||||||
|
window.fitAddon.fit();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (window.fitAddon) window.fitAddon.fit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function paintLogTerminal(channel) {
|
function paintLogTerminal(channel) {
|
||||||
if (!window.term) return;
|
if (!window.term) return;
|
||||||
window.term.reset();
|
window.term.reset();
|
||||||
const { lines } = getFilteredLogLines(channel);
|
const { lines } = getFilteredLogLines(channel);
|
||||||
lines.forEach((line) => window.term.writeln(line));
|
lines.forEach((line) => window.term.writeln(line));
|
||||||
if (window.fitAddon) window.fitAddon.fit();
|
fitLogTerminal();
|
||||||
updateLogFilterStats(channel);
|
updateLogFilterStats(channel);
|
||||||
updateLogFilterClearButton();
|
updateLogFilterClearButton();
|
||||||
}
|
}
|
||||||
@@ -218,8 +226,8 @@ function applyFileLog(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (window.activeTab === 'logs' && window.fitAddon) {
|
if (window.activeTab === 'logs') {
|
||||||
window.fitAddon.fit();
|
fitLogTerminal();
|
||||||
}
|
}
|
||||||
const holesailLogModal = document.getElementById('holesailLogModal');
|
const holesailLogModal = document.getElementById('holesailLogModal');
|
||||||
if (holesailLogModal && holesailLogModal.open && window.holesailFitAddon) {
|
if (holesailLogModal && holesailLogModal.open && window.holesailFitAddon) {
|
||||||
|
|||||||
@@ -1,57 +1,86 @@
|
|||||||
// Notification and confirmation dialog functions
|
// Notification and confirmation dialog functions
|
||||||
function showNotification(message, type = 'success') {
|
|
||||||
// Check if any dialog is open
|
|
||||||
const openDialog = document.querySelector('dialog[open]');
|
|
||||||
|
|
||||||
// If a dialog is open, append notifications directly to the dialog element
|
const TOAST_DURATION_MS = 4000;
|
||||||
// This ensures they appear above the backdrop since they're children of the dialog
|
|
||||||
// Otherwise, use the regular notifications container
|
const TOAST_META = {
|
||||||
let container;
|
success: { tone: 'success', icon: 'fa-circle-check', label: 'Success' },
|
||||||
|
error: { tone: 'error', icon: 'fa-circle-xmark', label: 'Error' },
|
||||||
|
warning: { tone: 'warning', icon: 'fa-triangle-exclamation', label: 'Warning' },
|
||||||
|
info: { tone: 'info', icon: 'fa-circle-info', label: 'Info' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function getToastMeta(type) {
|
||||||
|
return TOAST_META[type] || TOAST_META.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationContainer() {
|
||||||
|
const openDialog = document.querySelector('dialog[open]');
|
||||||
if (openDialog) {
|
if (openDialog) {
|
||||||
// Check if notification container already exists in the dialog
|
|
||||||
let dialogNotificationContainer = openDialog.querySelector('.dialog-notifications');
|
let dialogNotificationContainer = openDialog.querySelector('.dialog-notifications');
|
||||||
if (!dialogNotificationContainer) {
|
if (!dialogNotificationContainer) {
|
||||||
dialogNotificationContainer = document.createElement('div');
|
dialogNotificationContainer = document.createElement('div');
|
||||||
dialogNotificationContainer.className = 'dialog-notifications';
|
dialogNotificationContainer.className = 'dialog-notifications admin-toast-stack';
|
||||||
// Append directly to dialog element (not inside content)
|
|
||||||
// This ensures it's in the dialog's stacking context above the backdrop
|
|
||||||
openDialog.appendChild(dialogNotificationContainer);
|
openDialog.appendChild(dialogNotificationContainer);
|
||||||
}
|
}
|
||||||
container = dialogNotificationContainer;
|
return { container: dialogNotificationContainer, openDialog };
|
||||||
} else {
|
}
|
||||||
container = document.getElementById('notifications');
|
return { container: document.getElementById('notifications'), openDialog: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!container) return;
|
function dismissToast(notification, container, openDialog) {
|
||||||
const notification = document.createElement('div');
|
if (!notification || notification.dataset.dismissed === 'true') return;
|
||||||
let bgColor = 'bg-green-500';
|
notification.dataset.dismissed = 'true';
|
||||||
if (type === 'error') bgColor = 'bg-red-500';
|
|
||||||
else if (type === 'warning') bgColor = 'bg-yellow-500';
|
|
||||||
else if (type === 'info') bgColor = 'bg-blue-500';
|
|
||||||
|
|
||||||
notification.classList.add(
|
notification.classList.remove('admin-toast--visible');
|
||||||
'p-4', 'rounded-lg', 'shadow-lg', 'text-white',
|
notification.classList.add('admin-toast--leaving');
|
||||||
bgColor,
|
|
||||||
'transition-all', 'duration-300', 'opacity-0', 'transform', 'translate-y-4'
|
window.setTimeout(() => {
|
||||||
);
|
|
||||||
notification.style.cssText = 'pointer-events: auto;';
|
|
||||||
notification.textContent = message;
|
|
||||||
container.appendChild(notification);
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.classList.remove('opacity-0', 'translate-y-4');
|
|
||||||
notification.classList.add('opacity-100', 'translate-y-0');
|
|
||||||
}, 10);
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.classList.remove('opacity-100', 'translate-y-0');
|
|
||||||
notification.classList.add('opacity-0', 'translate-y-4');
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
notification.remove();
|
||||||
// Clean up dialog notification container if empty
|
if (openDialog && container?.classList.contains('dialog-notifications') && container.children.length === 0) {
|
||||||
if (openDialog && container.classList.contains('dialog-notifications') && container.children.length === 0) {
|
|
||||||
container.remove();
|
container.remove();
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 260);
|
||||||
}, 3000);
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const { container, openDialog } = getNotificationContainer();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const meta = getToastMeta(type);
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `admin-toast admin-toast--${meta.tone}`;
|
||||||
|
notification.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||||
|
notification.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="admin-toast-icon" aria-hidden="true">
|
||||||
|
<i class="fas ${meta.icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="admin-toast-body">
|
||||||
|
<span class="admin-toast-label">${meta.label}</span>
|
||||||
|
<p class="admin-toast-message"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="admin-toast-dismiss" aria-label="Dismiss notification">
|
||||||
|
<i class="fas fa-xmark" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div class="admin-toast-progress" aria-hidden="true"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
notification.querySelector('.admin-toast-message').textContent = message;
|
||||||
|
|
||||||
|
const progress = notification.querySelector('.admin-toast-progress');
|
||||||
|
progress.style.animationDuration = `${TOAST_DURATION_MS}ms`;
|
||||||
|
|
||||||
|
const dismiss = () => dismissToast(notification, container, openDialog);
|
||||||
|
notification.querySelector('.admin-toast-dismiss').addEventListener('click', dismiss);
|
||||||
|
|
||||||
|
container.appendChild(notification);
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
notification.classList.add('admin-toast--visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setTimeout(dismiss, TOAST_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// showConfirm is now provided by confirmation-modal.js
|
// showConfirm is now provided by confirmation-modal.js
|
||||||
@@ -93,4 +122,3 @@ function showConfirm(message, callback, options = {}) {
|
|||||||
|
|
||||||
window.showNotification = showNotification;
|
window.showNotification = showNotification;
|
||||||
window.showConfirm = showConfirm;
|
window.showConfirm = showConfirm;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Enhanced Peers UI functions
|
// Peers UI — flat admin layout with overview stats and connection details
|
||||||
|
|
||||||
let peerChart = null;
|
let peerChart = null;
|
||||||
|
|
||||||
// Render peers list - uses generic pagination system
|
|
||||||
async function renderPeers() {
|
async function renderPeers() {
|
||||||
if (window.genericFetch) {
|
if (window.genericFetch) {
|
||||||
await window.genericFetch('peers', true);
|
await window.genericFetch('peers', true);
|
||||||
@@ -10,14 +9,21 @@ async function renderPeers() {
|
|||||||
renderPeerGraph();
|
renderPeerGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter peers - uses generic filter system
|
|
||||||
function filterPeers() {
|
function filterPeers() {
|
||||||
if (window.genericFilter) {
|
if (window.genericFilter) {
|
||||||
window.genericFilter('peers');
|
window.genericFilter('peers');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show peer details modal
|
function setPeerStat(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapePeerHtml(value) {
|
||||||
|
return window.escapeHtml ? window.escapeHtml(value) : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
async function showPeerDetails(peerId) {
|
async function showPeerDetails(peerId) {
|
||||||
try {
|
try {
|
||||||
const [peerRes, historyRes] = await Promise.all([
|
const [peerRes, historyRes] = await Promise.all([
|
||||||
@@ -38,51 +44,66 @@ async function showPeerDetails(peerId) {
|
|||||||
const content = document.getElementById('peer-details-content');
|
const content = document.getElementById('peer-details-content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
const uptime = peer.uptime ? (window.formatUptime ? window.formatUptime(peer.uptime) : `${Math.floor(peer.uptime / 1000)}s`) : 'N/A';
|
const uptime = peer.uptime
|
||||||
|
? (window.formatUptime ? window.formatUptime(peer.uptime) : `${Math.floor(peer.uptime / 1000)}s`)
|
||||||
|
: 'N/A';
|
||||||
const connectTime = peer.connectTime ? new Date(peer.connectTime).toLocaleString() : 'N/A';
|
const connectTime = peer.connectTime ? new Date(peer.connectTime).toLocaleString() : 'N/A';
|
||||||
const lastSeen = peer.metrics?.lastSeen ? new Date(peer.metrics.lastSeen).toLocaleString() : 'N/A';
|
const lastSeen = peer.metrics?.lastSeen ? new Date(peer.metrics.lastSeen).toLocaleString() : 'N/A';
|
||||||
|
const totalDuration = peer.metrics?.totalDuration
|
||||||
|
? (window.formatDuration ? window.formatDuration(peer.metrics.totalDuration) : `${Math.floor(peer.metrics.totalDuration / 1000)}s`)
|
||||||
|
: '0s';
|
||||||
|
const avgDuration = peer.metrics?.avgDuration
|
||||||
|
? (window.formatDuration ? window.formatDuration(peer.metrics.avgDuration) : `${Math.floor(peer.metrics.avgDuration / 1000)}s`)
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
|
const statusBadge = peer.connected
|
||||||
|
? '<span class="peer-badge peer-badge--ok">Connected</span>'
|
||||||
|
: '<span class="peer-badge peer-badge--muted">Disconnected</span>';
|
||||||
|
const blockedBadge = peer.isBlocked
|
||||||
|
? '<span class="peer-badge peer-badge--danger">Blocked</span>'
|
||||||
|
: '<span class="peer-badge peer-badge--muted">Not blocked</span>';
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="space-y-4">
|
<div class="peer-details-grid">
|
||||||
<div>
|
<section class="peer-details-section">
|
||||||
<h4 class="font-semibold mb-2">Peer Information</h4>
|
<h4 class="peer-details-section-title">Peer information</h4>
|
||||||
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
|
<dl class="peer-details-dl">
|
||||||
<p><strong>ID:</strong> <span class="font-mono text-sm break-all">${peer.id}</span></p>
|
<div><dt>ID</dt><dd><code>${escapePeerHtml(peer.id)}</code></dd></div>
|
||||||
<p><strong>Status:</strong> ${peer.connected ? '<span class="text-green-600">Connected</span>' : '<span class="text-gray-600">Disconnected</span>'}</p>
|
<div><dt>Status</dt><dd>${statusBadge}</dd></div>
|
||||||
<p><strong>Uptime:</strong> ${uptime}</p>
|
<div><dt>Blocked</dt><dd>${blockedBadge}</dd></div>
|
||||||
<p><strong>Connected At:</strong> ${connectTime}</p>
|
<div><dt>Uptime</dt><dd>${escapePeerHtml(uptime)}</dd></div>
|
||||||
<p><strong>Blocked:</strong> ${peer.isBlocked ? '<span class="text-red-600">Yes</span>' : '<span class="text-green-600">No</span>'}</p>
|
<div><dt>Connected at</dt><dd>${escapePeerHtml(connectTime)}</dd></div>
|
||||||
</div>
|
</dl>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div>
|
<section class="peer-details-section">
|
||||||
<h4 class="font-semibold mb-2">Metrics</h4>
|
<h4 class="peer-details-section-title">Metrics</h4>
|
||||||
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
|
<dl class="peer-details-dl">
|
||||||
<p><strong>Total Connections:</strong> ${peer.metrics?.connections || 0}</p>
|
<div><dt>Connections</dt><dd>${peer.metrics?.connections || 0}</dd></div>
|
||||||
<p><strong>Total Duration:</strong> ${peer.metrics?.totalDuration ? (window.formatDuration ? window.formatDuration(peer.metrics.totalDuration) : `${Math.floor(peer.metrics.totalDuration / 1000)}s`) : '0s'}</p>
|
<div><dt>Total duration</dt><dd>${escapePeerHtml(totalDuration)}</dd></div>
|
||||||
<p><strong>Average Duration:</strong> ${peer.metrics?.avgDuration ? (window.formatDuration ? window.formatDuration(peer.metrics.avgDuration) : `${Math.floor(peer.metrics.avgDuration / 1000)}s`) : 'N/A'}</p>
|
<div><dt>Average duration</dt><dd>${escapePeerHtml(avgDuration)}</dd></div>
|
||||||
<p><strong>Last Seen:</strong> ${lastSeen}</p>
|
<div><dt>Last seen</dt><dd>${escapePeerHtml(lastSeen)}</dd></div>
|
||||||
</div>
|
</dl>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div>
|
<section class="peer-details-section">
|
||||||
<h4 class="font-semibold mb-2">Connection History (Last 50)</h4>
|
<h4 class="peer-details-section-title">Connection history</h4>
|
||||||
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded max-h-64 overflow-y-auto">
|
<div class="peer-history-list">
|
||||||
${history.length === 0
|
${history.length === 0
|
||||||
? '<p class="text-gray-500">No history available</p>'
|
? '<p class="theme-text-tertiary text-sm">No history available.</p>'
|
||||||
: history.slice(-50).reverse().map(event => `
|
: history.slice(-50).reverse().map((event) => `
|
||||||
<div class="mb-2 pb-2 border-b border-gray-300 dark:border-gray-600">
|
<article class="peer-history-item">
|
||||||
<div class="flex justify-between">
|
<div class="peer-history-head">
|
||||||
<span class="font-semibold ${event.type === 'connect' ? 'text-green-600' : 'text-red-600'}">${event.type === 'connect' ? 'Connected' : 'Disconnected'}</span>
|
<span class="peer-history-type peer-history-type--${event.type === 'connect' ? 'connect' : 'disconnect'}">${event.type === 'connect' ? 'Connected' : 'Disconnected'}</span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">${new Date(event.timestamp).toLocaleString()}</span>
|
<time class="theme-text-tertiary">${escapePeerHtml(new Date(event.timestamp).toLocaleString())}</time>
|
||||||
</div>
|
|
||||||
${event.duration ? `<div class="text-sm text-gray-600 dark:text-gray-400">Duration: ${window.formatDuration ? window.formatDuration(event.duration) : `${Math.floor(event.duration / 1000)}s`}</div>` : ''}
|
|
||||||
${event.error ? `<div class="text-sm text-red-600">Error: ${event.error}</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
|
${event.duration ? `<div class="peer-history-meta">Duration: ${escapePeerHtml(window.formatDuration ? window.formatDuration(event.duration) : `${Math.floor(event.duration / 1000)}s`)}</div>` : ''}
|
||||||
|
${event.error ? `<div class="peer-history-meta text-error">Error: ${escapePeerHtml(event.error)}</div>` : ''}
|
||||||
|
</article>
|
||||||
`).join('')
|
`).join('')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -93,7 +114,6 @@ async function showPeerDetails(peerId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block peer
|
|
||||||
async function blockPeer(peerId) {
|
async function blockPeer(peerId) {
|
||||||
if (window.showConfirm) {
|
if (window.showConfirm) {
|
||||||
window.showConfirm(`Block peer ${peerId.substring(0, 16)}...?`, async () => {
|
window.showConfirm(`Block peer ${peerId.substring(0, 16)}...?`, async () => {
|
||||||
@@ -114,7 +134,6 @@ async function blockPeer(peerId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unblock peer
|
|
||||||
async function unblockPeer(peerId) {
|
async function unblockPeer(peerId) {
|
||||||
if (window.showConfirm) {
|
if (window.showConfirm) {
|
||||||
window.showConfirm(`Unblock peer ${peerId.substring(0, 16)}...?`, async () => {
|
window.showConfirm(`Unblock peer ${peerId.substring(0, 16)}...?`, async () => {
|
||||||
@@ -135,24 +154,38 @@ async function unblockPeer(peerId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render peer connection graph
|
|
||||||
function renderPeerGraph() {
|
function renderPeerGraph() {
|
||||||
const canvas = document.getElementById('peer-graph-chart');
|
const canvas = document.getElementById('peer-graph-chart');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
const peersData = window.peersData || [];
|
||||||
|
const connected = peersData.filter((p) => p.connected).length;
|
||||||
|
const disconnected = peersData.filter((p) => !p.connected).length;
|
||||||
|
const blocked = peersData.filter((p) => p.isBlocked).length;
|
||||||
|
const total = peersData.length;
|
||||||
|
|
||||||
|
setPeerStat('peerStatConnected', connected);
|
||||||
|
setPeerStat('peerStatDisconnected', disconnected);
|
||||||
|
setPeerStat('peerStatBlocked', blocked);
|
||||||
|
|
||||||
|
const overviewEl = document.getElementById('peersOverviewLine');
|
||||||
|
if (overviewEl) {
|
||||||
|
overviewEl.textContent = total === 0
|
||||||
|
? 'No peers on this node'
|
||||||
|
: `${connected} active of ${total} total`;
|
||||||
|
}
|
||||||
|
|
||||||
if (peerChart) {
|
if (peerChart) {
|
||||||
peerChart.destroy();
|
peerChart.destroy();
|
||||||
|
peerChart = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get peers data from global state
|
const colors = window.chartColors || {
|
||||||
const peersData = window.peersData || [];
|
success: 'rgb(34, 197, 94)',
|
||||||
|
gray: 'rgb(107, 114, 128)',
|
||||||
// Group peers by connection status over time (simplified - using current data)
|
danger: 'rgb(239, 68, 68)'
|
||||||
const connected = peersData.filter(p => p.connected).length;
|
};
|
||||||
const disconnected = peersData.filter(p => !p.connected).length;
|
|
||||||
const blocked = peersData.filter(p => p.isBlocked).length;
|
|
||||||
|
|
||||||
peerChart = new Chart(ctx, {
|
peerChart = new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
@@ -160,21 +193,23 @@ function renderPeerGraph() {
|
|||||||
labels: ['Connected', 'Disconnected', 'Blocked'],
|
labels: ['Connected', 'Disconnected', 'Blocked'],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [connected, disconnected, blocked],
|
data: [connected, disconnected, blocked],
|
||||||
backgroundColor: [
|
backgroundColor: [colors.success, colors.gray, colors.danger],
|
||||||
'rgb(34, 197, 94)',
|
borderWidth: 0
|
||||||
'rgb(107, 114, 128)',
|
|
||||||
'rgb(239, 68, 68)'
|
|
||||||
]
|
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
cutout: '62%',
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: {
|
labels: {
|
||||||
color: '#f1f5f9' // White text for better readability on dark background
|
color: '#94a3b8',
|
||||||
|
boxWidth: 10,
|
||||||
|
boxHeight: 10,
|
||||||
|
padding: 14,
|
||||||
|
font: { size: 11 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,10 +217,8 @@ function renderPeerGraph() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make functions globally accessible
|
|
||||||
window.renderPeers = renderPeers;
|
window.renderPeers = renderPeers;
|
||||||
window.showPeerDetails = showPeerDetails;
|
window.showPeerDetails = showPeerDetails;
|
||||||
window.blockPeer = blockPeer;
|
window.blockPeer = blockPeer;
|
||||||
window.unblockPeer = unblockPeer;
|
window.unblockPeer = unblockPeer;
|
||||||
window.filterPeers = filterPeers;
|
window.filterPeers = filterPeers;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -303,15 +303,20 @@
|
|||||||
</label>
|
</label>
|
||||||
<select id="refresh-interval-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
|
<select id="refresh-interval-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
|
||||||
<option value="1000">Realtime (1s)</option>
|
<option value="1000">Realtime (1s)</option>
|
||||||
<option value="2000">Fast (2s)</option>
|
<option value="2000" selected>Fast (2s)</option>
|
||||||
<option value="5000" selected>Normal (5s)</option>
|
<option value="5000">Normal (5s)</option>
|
||||||
<option value="10000">Slow (10s)</option>
|
<option value="10000">Slow (10s)</option>
|
||||||
<option value="30000">Very Slow (30s)</option>
|
<option value="30000">Very Slow (30s)</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="time-range-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
|
<select id="time-range-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
|
||||||
|
<option value="1">Last 1 minute</option>
|
||||||
|
<option value="5" selected>Last 5 minutes</option>
|
||||||
|
<option value="15">Last 15 minutes</option>
|
||||||
|
<option value="30">Last 30 minutes</option>
|
||||||
<option value="60">Last 1 hour</option>
|
<option value="60">Last 1 hour</option>
|
||||||
<option value="360">Last 6 hours</option>
|
<option value="360">Last 6 hours</option>
|
||||||
<option value="1440" selected>Last 24 hours</option>
|
<option value="1440">Last 24 hours</option>
|
||||||
|
<option value="2880">Last 48 hours</option>
|
||||||
</select>
|
</select>
|
||||||
<button onclick="exportStats()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Export Data</button>
|
<button onclick="exportStats()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Export Data</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -787,7 +792,7 @@
|
|||||||
<i class="fas fa-redo"></i>
|
<i class="fas fa-redo"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="notifications" class="fixed bottom-4 right-4 flex flex-col-reverse space-y-2" style="z-index: 99999;"></div>
|
<div id="notifications" class="admin-toast-stack" aria-live="polite" aria-relevant="additions"></div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-fit.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-fit.js"></script>
|
||||||
|
|||||||
@@ -275,15 +275,15 @@ async function handleStatsRoutes(req, res) {
|
|||||||
|
|
||||||
if (method === 'GET' && urlPath === '/api/stats/historical') {
|
if (method === 'GET' && urlPath === '/api/stats/historical') {
|
||||||
try {
|
try {
|
||||||
let minutes = 60;
|
let minutes = 5;
|
||||||
if (req.url.includes('?')) {
|
if (req.url.includes('?')) {
|
||||||
const queryString = req.url.split('?')[1];
|
const queryString = req.url.split('?')[1];
|
||||||
const params = new URLSearchParams(queryString);
|
const params = new URLSearchParams(queryString);
|
||||||
const minutesParam = params.get('minutes');
|
const minutesParam = params.get('minutes');
|
||||||
if (minutesParam) {
|
if (minutesParam) {
|
||||||
minutes = parseInt(minutesParam, 10);
|
minutes = parseInt(minutesParam, 10);
|
||||||
if (isNaN(minutes) || minutes < 1) minutes = 60;
|
if (isNaN(minutes) || minutes < 1) minutes = 5;
|
||||||
if (minutes > 1440) minutes = 1440;
|
if (minutes > 2880) minutes = 2880;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|||||||
@@ -1,57 +1,86 @@
|
|||||||
// Notification and confirmation dialog functions
|
// Notification and confirmation dialog functions
|
||||||
function showNotification(message, type = 'success') {
|
|
||||||
// Check if any dialog is open
|
|
||||||
const openDialog = document.querySelector('dialog[open]');
|
|
||||||
|
|
||||||
// If a dialog is open, append notifications directly to the dialog element
|
const TOAST_DURATION_MS = 4000;
|
||||||
// This ensures they appear above the backdrop since they're children of the dialog
|
|
||||||
// Otherwise, use the regular notifications container
|
const TOAST_META = {
|
||||||
let container;
|
success: { tone: 'success', icon: 'fa-circle-check', label: 'Success' },
|
||||||
|
error: { tone: 'error', icon: 'fa-circle-xmark', label: 'Error' },
|
||||||
|
warning: { tone: 'warning', icon: 'fa-triangle-exclamation', label: 'Warning' },
|
||||||
|
info: { tone: 'info', icon: 'fa-circle-info', label: 'Info' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function getToastMeta(type) {
|
||||||
|
return TOAST_META[type] || TOAST_META.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationContainer() {
|
||||||
|
const openDialog = document.querySelector('dialog[open]');
|
||||||
if (openDialog) {
|
if (openDialog) {
|
||||||
// Check if notification container already exists in the dialog
|
|
||||||
let dialogNotificationContainer = openDialog.querySelector('.dialog-notifications');
|
let dialogNotificationContainer = openDialog.querySelector('.dialog-notifications');
|
||||||
if (!dialogNotificationContainer) {
|
if (!dialogNotificationContainer) {
|
||||||
dialogNotificationContainer = document.createElement('div');
|
dialogNotificationContainer = document.createElement('div');
|
||||||
dialogNotificationContainer.className = 'dialog-notifications';
|
dialogNotificationContainer.className = 'dialog-notifications admin-toast-stack';
|
||||||
// Append directly to dialog element (not inside content)
|
|
||||||
// This ensures it's in the dialog's stacking context above the backdrop
|
|
||||||
openDialog.appendChild(dialogNotificationContainer);
|
openDialog.appendChild(dialogNotificationContainer);
|
||||||
}
|
}
|
||||||
container = dialogNotificationContainer;
|
return { container: dialogNotificationContainer, openDialog };
|
||||||
} else {
|
}
|
||||||
container = document.getElementById('notifications');
|
return { container: document.getElementById('notifications'), openDialog: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!container) return;
|
function dismissToast(notification, container, openDialog) {
|
||||||
const notification = document.createElement('div');
|
if (!notification || notification.dataset.dismissed === 'true') return;
|
||||||
let bgColor = 'bg-green-500';
|
notification.dataset.dismissed = 'true';
|
||||||
if (type === 'error') bgColor = 'bg-red-500';
|
|
||||||
else if (type === 'warning') bgColor = 'bg-yellow-500';
|
|
||||||
else if (type === 'info') bgColor = 'bg-blue-500';
|
|
||||||
|
|
||||||
notification.classList.add(
|
notification.classList.remove('admin-toast--visible');
|
||||||
'p-4', 'rounded-lg', 'shadow-lg', 'text-white',
|
notification.classList.add('admin-toast--leaving');
|
||||||
bgColor,
|
|
||||||
'transition-all', 'duration-300', 'opacity-0', 'transform', 'translate-y-4'
|
window.setTimeout(() => {
|
||||||
);
|
|
||||||
notification.style.cssText = 'pointer-events: auto;';
|
|
||||||
notification.textContent = message;
|
|
||||||
container.appendChild(notification);
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.classList.remove('opacity-0', 'translate-y-4');
|
|
||||||
notification.classList.add('opacity-100', 'translate-y-0');
|
|
||||||
}, 10);
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.classList.remove('opacity-100', 'translate-y-0');
|
|
||||||
notification.classList.add('opacity-0', 'translate-y-4');
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
notification.remove();
|
||||||
// Clean up dialog notification container if empty
|
if (openDialog && container?.classList.contains('dialog-notifications') && container.children.length === 0) {
|
||||||
if (openDialog && container.classList.contains('dialog-notifications') && container.children.length === 0) {
|
|
||||||
container.remove();
|
container.remove();
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 260);
|
||||||
}, 3000);
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const { container, openDialog } = getNotificationContainer();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const meta = getToastMeta(type);
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `admin-toast admin-toast--${meta.tone}`;
|
||||||
|
notification.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||||
|
notification.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="admin-toast-icon" aria-hidden="true">
|
||||||
|
<i class="fas ${meta.icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="admin-toast-body">
|
||||||
|
<span class="admin-toast-label">${meta.label}</span>
|
||||||
|
<p class="admin-toast-message"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="admin-toast-dismiss" aria-label="Dismiss notification">
|
||||||
|
<i class="fas fa-xmark" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div class="admin-toast-progress" aria-hidden="true"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
notification.querySelector('.admin-toast-message').textContent = message;
|
||||||
|
|
||||||
|
const progress = notification.querySelector('.admin-toast-progress');
|
||||||
|
progress.style.animationDuration = `${TOAST_DURATION_MS}ms`;
|
||||||
|
|
||||||
|
const dismiss = () => dismissToast(notification, container, openDialog);
|
||||||
|
notification.querySelector('.admin-toast-dismiss').addEventListener('click', dismiss);
|
||||||
|
|
||||||
|
container.appendChild(notification);
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
notification.classList.add('admin-toast--visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setTimeout(dismiss, TOAST_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showConfirm(message, callback) {
|
function showConfirm(message, callback) {
|
||||||
@@ -83,27 +112,3 @@ function showConfirm(message, callback) {
|
|||||||
|
|
||||||
window.showNotification = showNotification;
|
window.showNotification = showNotification;
|
||||||
window.showConfirm = showConfirm;
|
window.showConfirm = showConfirm;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user