p2ns.admin updates

This commit is contained in:
Raven Scott
2026-05-30 19:45:56 -04:00
parent 6d94b9c16b
commit 841d814f13
19 changed files with 3488 additions and 1344 deletions
@@ -73,7 +73,7 @@ function getAdminHealthPayload() {
};
}
async function buildStatsPageSnapshot(minutes = 1440) {
async function buildStatsPageSnapshot(minutes = 5) {
const [stats, historical] = await Promise.all([
collectAdminStats(),
Promise.resolve(collectHistoricalMinutes(minutes))
+77 -1
View File
@@ -1,8 +1,48 @@
const fs = require('fs');
const path = require('path');
const { trackRequestWithTiming, trackRequest } = require('../../../maintenance/metrics');
const { logError } = require('../../../infrastructure/logger');
const { createErrorResponse } = require('../../../infrastructure/error_handler');
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) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
@@ -27,7 +67,7 @@ async function handleStatsRoutes(req, res) {
if (method === 'GET' && urlPath === '/api/stats/historical') {
try {
let minutes = 60;
let minutes = 5;
if (req.url.includes('?')) {
const queryString = req.url.split('?')[1];
const params = new URLSearchParams(queryString);
@@ -52,6 +92,42 @@ async function handleStatsRoutes(req, res) {
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;
}
@@ -322,8 +322,8 @@ async function collectAdminStats() {
function collectHistoricalMinutes(minutes) {
let m = parseInt(minutes, 10);
if (isNaN(m) || m < 1) m = 60;
if (m > 1440) m = 1440;
if (isNaN(m) || m < 1) m = 5;
if (m > 2880) m = 2880;
return getHistoricalData(m);
}
@@ -13,8 +13,9 @@ let statsBroadcastInterval = null;
let statsBroadcastInFlight = false;
let statsBroadcastIntervalMs = 0;
const DEFAULT_MINUTES = 1440;
const DEFAULT_INTERVAL_MS = 5000;
const DEFAULT_MINUTES = 5;
const MAX_HISTORICAL_MINUTES = 2880;
const DEFAULT_INTERVAL_MS = 2000;
const MIN_INTERVAL_MS = 1000;
function normalizeIntervalMs(value) {
@@ -37,7 +38,7 @@ function getMinSubscriberIntervalMs() {
function parseMinutes(value) {
const m = parseInt(value, 10);
if (isNaN(m) || m < 1) return DEFAULT_MINUTES;
if (m > 1440) return 1440;
if (m > MAX_HISTORICAL_MINUTES) return MAX_HISTORICAL_MINUTES;
return m;
}
+5 -36
View File
@@ -8,7 +8,7 @@ if (location.hash === '#domains') {
}
// Initialize when DOM is ready
function initializeApp() {
async function initializeApp() {
const SCROLLABLE_TABS = ['stats', 'plugins', 'settings'];
function updateAdminContentMode(tabId) {
@@ -264,6 +264,10 @@ function initializeApp() {
if (!scrollableTabs.includes(tabId)) {
document.body.classList.add('no-scroll');
}
if (window.initStatsToolbar) {
await window.initStatsToolbar();
}
showTab(tabId);
@@ -273,41 +277,6 @@ function initializeApp() {
if (window.genericFetch) {
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) {
window.startStatsUpdates();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+122 -36
View File
@@ -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.showCertsListEnd = showCertsListEnd;
window.updatePeersListChrome = updatePeersListChrome;
window.showPeersListEnd = showPeersListEnd;
window.chartColors = {
primary: 'rgb(59, 130, 246)',
@@ -160,16 +198,39 @@ window.tabs = {
containerId: 'entriesTable',
paginationId: 'entriesPagination',
sentinelId: 'entriesScrollSentinel',
countId: 'entries-count',
useLazyScroll: true,
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) => {
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');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
tr.innerHTML = `<td class="p-3 break-all">${item.key}</td>
<td class="p-3 break-all">${item.value}</td>`;
tr.className = 'entry-row';
tr.innerHTML = `
<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;
},
preRender: (visibleCount) => {
if (window.updateEntriesChrome) window.updateEntriesChrome(visibleCount);
},
onAllItemsLoaded: () => {
if (window.showEntriesListEnd) window.showEntriesListEnd();
}
},
peers: {
@@ -179,51 +240,76 @@ window.tabs = {
filteredKey: 'filteredPeers',
containerId: 'peersList',
paginationId: 'peersPagination',
sentinelId: 'peersScrollSentinel',
sort: (a, b) => (a.id || '').localeCompare(b.id || '', undefined, { sensitivity: 'base' }),
filter: (item, query) => {
const queryLower = query.toLowerCase();
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) => {
const li = document.createElement('li');
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow';
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 statusBadge = peer.connected
? '<span class="px-2 py-1 bg-green-500 rounded text-sm" style="color: var(--text-primary);">Connected</span>'
: '<span class="px-2 py-1 bg-gray-500 rounded text-sm" style="color: var(--text-primary);">Disconnected</span>';
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>'
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 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
? '<span class="peer-badge peer-badge--danger">Blocked</span>'
: '';
li.innerHTML = `
<div class="flex justify-between items-center">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="font-mono text-sm break-all cursor-pointer text-blue-500 hover:underline" onclick="showPeerDetails('${peer.id}')">${peer.id}</span>
${statusBadge}
${blockedBadge}
</div>
<div class="text-sm theme-text-secondary">
<div>Uptime: ${uptime}</div>
<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 class="peer-row-main">
<span class="peer-row-icon" aria-hidden="true"><i class="fas fa-server"></i></span>
<div class="peer-row-body">
<div class="peer-row-head">
<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}
</div>
</div>
<dl class="peer-row-meta">
<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 class="flex gap-2 ml-4 flex-shrink-0">
<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>
${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 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>`
}
</div>
</div>
<div class="peer-row-actions">
<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
? `<button type="button" class="admin-btn admin-btn--success admin-btn--sm" onclick="unblockPeer('${peerIdAttr}')" title="Unblock peer">
<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>
`;
return li;
},
preRender: (total) => {
const el = document.getElementById('peers-count');
if (el) el.textContent = `(${total})`;
preRender: () => {
updatePeersListChrome();
},
onAllItemsLoaded: () => {
showPeersListEnd();
}
},
certs: {
-20
View File
@@ -330,26 +330,6 @@ function setupInfiniteScrollObserver(tabId, container) {
// Legacy entries lazy scroll - now uses generic infinite scroll
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');
}
+114
View File
@@ -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;
+16 -26
View File
@@ -53,8 +53,8 @@ function updateHealthStatus(data) {
if (statusEl) {
statusEl.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded';
statusEl.className = data.status === 'healthy'
? 'text-2xl font-bold text-green-600 dark:text-green-400'
: 'text-2xl font-bold text-yellow-600 dark:text-yellow-400';
? 'stats-metric-value stats-health-status stats-health-status--ok'
: 'stats-metric-value stats-health-status stats-health-status--warn';
}
const uptimeEl = document.getElementById('health-uptime');
@@ -117,20 +117,20 @@ function formatDetailEntries(details) {
}
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) => `
<div class="admin-card theme-card flex flex-col health-service-card" data-service="${service.key}">
<div class="flex items-center justify-between mb-3 gap-2">
<div class="flex items-center gap-2.5 min-w-0">
<div class="stats-service-card health-service-card" data-service="${service.key}">
<div class="health-service-top">
<div class="health-service-title">
<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>
<span class="health-service-badge health-service-badge--neutral"></span>
</div>
<p class="health-service-enabled text-xs theme-text-tertiary mb-2"></p>
<dl class="health-service-details text-xs theme-text-tertiary space-y-1 min-h-[3rem] tabular-nums"></dl>
<p class="health-service-enabled theme-text-tertiary"></p>
<dl class="health-service-details"></dl>
</div>
`).join('');
}
@@ -143,22 +143,13 @@ function updateServiceCardElement(card, serviceData) {
const badge = card.querySelector('.health-service-badge');
if (badge) {
const statusText = healthy ? 'Healthy' : 'Unhealthy';
if (badge.textContent !== statusText) {
badge.textContent = statusText;
}
const nextClass = `health-service-badge health-service-badge--${healthy ? 'ok' : 'error'}`;
if (badge.className !== nextClass) {
badge.className = nextClass;
}
badge.textContent = healthy ? 'Healthy' : 'Unhealthy';
badge.className = `health-service-badge health-service-badge--${healthy ? 'ok' : 'error'}`;
}
const enabledEl = card.querySelector('.health-service-enabled');
if (enabledEl) {
const enabledText = enabled ? 'Enabled' : 'Disabled';
if (enabledEl.textContent !== enabledText) {
enabledEl.textContent = enabledText;
}
enabledEl.textContent = enabled ? 'Enabled' : 'Disabled';
}
const detailsEl = card.querySelector('.health-service-details');
@@ -174,15 +165,15 @@ function updateServiceCardElement(card, serviceData) {
let row = existingRows.get(key);
if (!row) {
row = document.createElement('div');
row.className = 'flex justify-between gap-3';
row.className = 'health-service-detail-row';
row.dataset.detailKey = key;
const label = document.createElement('span');
label.className = 'truncate opacity-80';
label.className = 'health-service-detail-key';
label.textContent = key;
const valueEl = document.createElement('span');
valueEl.className = 'health-detail-value shrink-0 text-right';
valueEl.className = 'health-detail-value';
valueEl.textContent = value;
row.appendChild(label);
@@ -197,7 +188,6 @@ function updateServiceCardElement(card, serviceData) {
existingRows.delete(key);
}
// Only remove rows when the payload explicitly includes a new details object
for (const row of existingRows.values()) {
row.remove();
}
@@ -114,7 +114,7 @@ const infoContent = {
},
{
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',
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.'
}
]
},
+11 -3
View File
@@ -128,12 +128,20 @@ function writelnToTerminal(line, channel) {
window.term.writeln(line);
}
function fitLogTerminal() {
if (!window.fitAddon) return;
window.fitAddon.fit();
requestAnimationFrame(() => {
if (window.fitAddon) window.fitAddon.fit();
});
}
function paintLogTerminal(channel) {
if (!window.term) return;
window.term.reset();
const { lines } = getFilteredLogLines(channel);
lines.forEach((line) => window.term.writeln(line));
if (window.fitAddon) window.fitAddon.fit();
fitLogTerminal();
updateLogFilterStats(channel);
updateLogFilterClearButton();
}
@@ -218,8 +226,8 @@ function applyFileLog(data) {
}
window.addEventListener('resize', () => {
if (window.activeTab === 'logs' && window.fitAddon) {
window.fitAddon.fit();
if (window.activeTab === 'logs') {
fitLogTerminal();
}
const holesailLogModal = document.getElementById('holesailLogModal');
if (holesailLogModal && holesailLogModal.open && window.holesailFitAddon) {
@@ -1,57 +1,86 @@
// Notification and confirmation dialog functions
function showNotification(message, type = 'success') {
// Check if any dialog is open
const TOAST_DURATION_MS = 4000;
const TOAST_META = {
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 a dialog is open, append notifications directly to the dialog element
// This ensures they appear above the backdrop since they're children of the dialog
// Otherwise, use the regular notifications container
let container;
if (openDialog) {
// Check if notification container already exists in the dialog
let dialogNotificationContainer = openDialog.querySelector('.dialog-notifications');
if (!dialogNotificationContainer) {
dialogNotificationContainer = document.createElement('div');
dialogNotificationContainer.className = 'dialog-notifications';
// Append directly to dialog element (not inside content)
// This ensures it's in the dialog's stacking context above the backdrop
dialogNotificationContainer.className = 'dialog-notifications admin-toast-stack';
openDialog.appendChild(dialogNotificationContainer);
}
container = dialogNotificationContainer;
} else {
container = document.getElementById('notifications');
return { container: dialogNotificationContainer, openDialog };
}
return { container: document.getElementById('notifications'), openDialog: null };
}
function dismissToast(notification, container, openDialog) {
if (!notification || notification.dataset.dismissed === 'true') return;
notification.dataset.dismissed = 'true';
notification.classList.remove('admin-toast--visible');
notification.classList.add('admin-toast--leaving');
window.setTimeout(() => {
notification.remove();
if (openDialog && container?.classList.contains('dialog-notifications') && container.children.length === 0) {
container.remove();
}
}, 260);
}
function showNotification(message, type = 'success') {
const { container, openDialog } = getNotificationContainer();
if (!container) return;
const meta = getToastMeta(type);
const notification = document.createElement('div');
let bgColor = 'bg-green-500';
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(
'p-4', 'rounded-lg', 'shadow-lg', 'text-white',
bgColor,
'transition-all', 'duration-300', 'opacity-0', 'transform', 'translate-y-4'
);
notification.style.cssText = 'pointer-events: auto;';
notification.textContent = message;
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);
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();
// Clean up dialog notification container if empty
if (openDialog && container.classList.contains('dialog-notifications') && container.children.length === 0) {
container.remove();
}
}, 300);
}, 3000);
window.requestAnimationFrame(() => {
notification.classList.add('admin-toast--visible');
});
window.setTimeout(dismiss, TOAST_DURATION_MS);
}
// showConfirm is now provided by confirmation-modal.js
@@ -70,27 +99,26 @@ function showConfirm(message, callback, options = {}) {
const noBtn = document.getElementById('confirm-no');
const modal = document.getElementById('confirmModal');
if (!messageEl || !yesBtn || !noBtn || !modal) return;
messageEl.textContent = message;
modal.showModal();
const yesHandler = () => {
callback();
modal.close();
yesBtn.removeEventListener('click', yesHandler);
noBtn.removeEventListener('click', noHandler);
};
const noHandler = () => {
modal.close();
yesBtn.removeEventListener('click', yesHandler);
noBtn.removeEventListener('click', noHandler);
};
yesBtn.addEventListener('click', yesHandler);
noBtn.addEventListener('click', noHandler);
}
window.showNotification = showNotification;
window.showConfirm = showConfirm;
+103 -70
View File
@@ -1,8 +1,7 @@
// Enhanced Peers UI functions
// Peers UI — flat admin layout with overview stats and connection details
let peerChart = null;
// Render peers list - uses generic pagination system
async function renderPeers() {
if (window.genericFetch) {
await window.genericFetch('peers', true);
@@ -10,82 +9,104 @@ async function renderPeers() {
renderPeerGraph();
}
// Filter peers - uses generic filter system
function filterPeers() {
if (window.genericFilter) {
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) {
try {
const [peerRes, historyRes] = await Promise.all([
fetch(`/api/peers/${encodeURIComponent(peerId)}`),
fetch(`/api/peers/${encodeURIComponent(peerId)}/history`)
]);
if (!peerRes.ok || !historyRes.ok) {
throw new Error('Failed to fetch peer details');
}
const peer = await peerRes.json();
const history = await historyRes.json();
const modal = document.getElementById('peerDetailsModal');
if (!modal) return;
const content = document.getElementById('peer-details-content');
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 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 = `
<div class="space-y-4">
<div>
<h4 class="font-semibold mb-2">Peer Information</h4>
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
<p><strong>ID:</strong> <span class="font-mono text-sm break-all">${peer.id}</span></p>
<p><strong>Status:</strong> ${peer.connected ? '<span class="text-green-600">Connected</span>' : '<span class="text-gray-600">Disconnected</span>'}</p>
<p><strong>Uptime:</strong> ${uptime}</p>
<p><strong>Connected At:</strong> ${connectTime}</p>
<p><strong>Blocked:</strong> ${peer.isBlocked ? '<span class="text-red-600">Yes</span>' : '<span class="text-green-600">No</span>'}</p>
</div>
</div>
<div>
<h4 class="font-semibold mb-2">Metrics</h4>
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
<p><strong>Total Connections:</strong> ${peer.metrics?.connections || 0}</p>
<p><strong>Total Duration:</strong> ${peer.metrics?.totalDuration ? (window.formatDuration ? window.formatDuration(peer.metrics.totalDuration) : `${Math.floor(peer.metrics.totalDuration / 1000)}s`) : '0s'}</p>
<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>
<p><strong>Last Seen:</strong> ${lastSeen}</p>
</div>
</div>
<div>
<h4 class="font-semibold mb-2">Connection History (Last 50)</h4>
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded max-h-64 overflow-y-auto">
${history.length === 0
? '<p class="text-gray-500">No history available</p>'
: history.slice(-50).reverse().map(event => `
<div class="mb-2 pb-2 border-b border-gray-300 dark:border-gray-600">
<div class="flex justify-between">
<span class="font-semibold ${event.type === 'connect' ? 'text-green-600' : 'text-red-600'}">${event.type === 'connect' ? 'Connected' : 'Disconnected'}</span>
<span class="text-sm text-gray-600 dark:text-gray-400">${new Date(event.timestamp).toLocaleString()}</span>
<div class="peer-details-grid">
<section class="peer-details-section">
<h4 class="peer-details-section-title">Peer information</h4>
<dl class="peer-details-dl">
<div><dt>ID</dt><dd><code>${escapePeerHtml(peer.id)}</code></dd></div>
<div><dt>Status</dt><dd>${statusBadge}</dd></div>
<div><dt>Blocked</dt><dd>${blockedBadge}</dd></div>
<div><dt>Uptime</dt><dd>${escapePeerHtml(uptime)}</dd></div>
<div><dt>Connected at</dt><dd>${escapePeerHtml(connectTime)}</dd></div>
</dl>
</section>
<section class="peer-details-section">
<h4 class="peer-details-section-title">Metrics</h4>
<dl class="peer-details-dl">
<div><dt>Connections</dt><dd>${peer.metrics?.connections || 0}</dd></div>
<div><dt>Total duration</dt><dd>${escapePeerHtml(totalDuration)}</dd></div>
<div><dt>Average duration</dt><dd>${escapePeerHtml(avgDuration)}</dd></div>
<div><dt>Last seen</dt><dd>${escapePeerHtml(lastSeen)}</dd></div>
</dl>
</section>
<section class="peer-details-section">
<h4 class="peer-details-section-title">Connection history</h4>
<div class="peer-history-list">
${history.length === 0
? '<p class="theme-text-tertiary text-sm">No history available.</p>'
: history.slice(-50).reverse().map((event) => `
<article class="peer-history-item">
<div class="peer-history-head">
<span class="peer-history-type peer-history-type--${event.type === 'connect' ? 'connect' : 'disconnect'}">${event.type === 'connect' ? 'Connected' : 'Disconnected'}</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>
${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('')
}
</div>
</div>
</section>
</div>
`;
modal.showModal();
} catch (err) {
console.error('Failed to fetch peer details:', err);
@@ -93,7 +114,6 @@ async function showPeerDetails(peerId) {
}
}
// Block peer
async function blockPeer(peerId) {
if (window.showConfirm) {
window.showConfirm(`Block peer ${peerId.substring(0, 16)}...?`, async () => {
@@ -114,7 +134,6 @@ async function blockPeer(peerId) {
}
}
// Unblock peer
async function unblockPeer(peerId) {
if (window.showConfirm) {
window.showConfirm(`Unblock peer ${peerId.substring(0, 16)}...?`, async () => {
@@ -135,46 +154,62 @@ async function unblockPeer(peerId) {
}
}
// Render peer connection graph
function renderPeerGraph() {
const canvas = document.getElementById('peer-graph-chart');
if (!canvas) return;
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) {
peerChart.destroy();
peerChart = null;
}
// Get peers data from global state
const peersData = window.peersData || [];
// Group peers by connection status over time (simplified - using current data)
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 colors = window.chartColors || {
success: 'rgb(34, 197, 94)',
gray: 'rgb(107, 114, 128)',
danger: 'rgb(239, 68, 68)'
};
peerChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Connected', 'Disconnected', 'Blocked'],
datasets: [{
data: [connected, disconnected, blocked],
backgroundColor: [
'rgb(34, 197, 94)',
'rgb(107, 114, 128)',
'rgb(239, 68, 68)'
]
backgroundColor: [colors.success, colors.gray, colors.danger],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: {
position: 'bottom',
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.showPeerDetails = showPeerDetails;
window.blockPeer = blockPeer;
window.unblockPeer = unblockPeer;
window.filterPeers = filterPeers;
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -303,15 +303,20 @@
</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">
<option value="1000">Realtime (1s)</option>
<option value="2000">Fast (2s)</option>
<option value="5000" selected>Normal (5s)</option>
<option value="2000" selected>Fast (2s)</option>
<option value="5000">Normal (5s)</option>
<option value="10000">Slow (10s)</option>
<option value="30000">Very Slow (30s)</option>
</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">
<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="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>
<button onclick="exportStats()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Export Data</button>
</div>
@@ -787,7 +792,7 @@
<i class="fas fa-redo"></i>
</button>
</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/@xterm/[email protected]/lib/addon-fit.js"></script>
+3 -3
View File
@@ -275,15 +275,15 @@ async function handleStatsRoutes(req, res) {
if (method === 'GET' && urlPath === '/api/stats/historical') {
try {
let minutes = 60;
let minutes = 5;
if (req.url.includes('?')) {
const queryString = req.url.split('?')[1];
const params = new URLSearchParams(queryString);
const minutesParam = params.get('minutes');
if (minutesParam) {
minutes = parseInt(minutesParam, 10);
if (isNaN(minutes) || minutes < 1) minutes = 60;
if (minutes > 1440) minutes = 1440;
if (isNaN(minutes) || minutes < 1) minutes = 5;
if (minutes > 2880) minutes = 2880;
}
}
const startTime = Date.now();
+75 -70
View File
@@ -1,57 +1,86 @@
// Notification and confirmation dialog functions
function showNotification(message, type = 'success') {
// Check if any dialog is open
const TOAST_DURATION_MS = 4000;
const TOAST_META = {
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 a dialog is open, append notifications directly to the dialog element
// This ensures they appear above the backdrop since they're children of the dialog
// Otherwise, use the regular notifications container
let container;
if (openDialog) {
// Check if notification container already exists in the dialog
let dialogNotificationContainer = openDialog.querySelector('.dialog-notifications');
if (!dialogNotificationContainer) {
dialogNotificationContainer = document.createElement('div');
dialogNotificationContainer.className = 'dialog-notifications';
// Append directly to dialog element (not inside content)
// This ensures it's in the dialog's stacking context above the backdrop
dialogNotificationContainer.className = 'dialog-notifications admin-toast-stack';
openDialog.appendChild(dialogNotificationContainer);
}
container = dialogNotificationContainer;
} else {
container = document.getElementById('notifications');
return { container: dialogNotificationContainer, openDialog };
}
return { container: document.getElementById('notifications'), openDialog: null };
}
function dismissToast(notification, container, openDialog) {
if (!notification || notification.dataset.dismissed === 'true') return;
notification.dataset.dismissed = 'true';
notification.classList.remove('admin-toast--visible');
notification.classList.add('admin-toast--leaving');
window.setTimeout(() => {
notification.remove();
if (openDialog && container?.classList.contains('dialog-notifications') && container.children.length === 0) {
container.remove();
}
}, 260);
}
function showNotification(message, type = 'success') {
const { container, openDialog } = getNotificationContainer();
if (!container) return;
const meta = getToastMeta(type);
const notification = document.createElement('div');
let bgColor = 'bg-green-500';
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(
'p-4', 'rounded-lg', 'shadow-lg', 'text-white',
bgColor,
'transition-all', 'duration-300', 'opacity-0', 'transform', 'translate-y-4'
);
notification.style.cssText = 'pointer-events: auto;';
notification.textContent = message;
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);
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();
// Clean up dialog notification container if empty
if (openDialog && container.classList.contains('dialog-notifications') && container.children.length === 0) {
container.remove();
}
}, 300);
}, 3000);
window.requestAnimationFrame(() => {
notification.classList.add('admin-toast--visible');
});
window.setTimeout(dismiss, TOAST_DURATION_MS);
}
function showConfirm(message, callback) {
@@ -60,50 +89,26 @@ function showConfirm(message, callback) {
const noBtn = document.getElementById('confirm-no');
const modal = document.getElementById('confirmModal');
if (!messageEl || !yesBtn || !noBtn || !modal) return;
messageEl.textContent = message;
modal.showModal();
const yesHandler = () => {
callback();
modal.close();
yesBtn.removeEventListener('click', yesHandler);
noBtn.removeEventListener('click', noHandler);
};
const noHandler = () => {
modal.close();
yesBtn.removeEventListener('click', yesHandler);
noBtn.removeEventListener('click', noHandler);
};
yesBtn.addEventListener('click', yesHandler);
noBtn.addEventListener('click', noHandler);
}
window.showNotification = showNotification;
window.showConfirm = showConfirm;