forked from snxraven/p2ns
p2ns.admin updates
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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.'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user