Cleanup - Bug Fixes

This commit is contained in:
Raven Scott
2026-05-30 22:27:42 -04:00
parent 229271fbcf
commit 4b9eb916e9
61 changed files with 733 additions and 6944 deletions
+12 -6
View File
@@ -580,21 +580,24 @@ window.tabs = {
sort: (a, b) => (a.opts.name || a.id).localeCompare(b.opts.name || b.id, undefined, { sensitivity: 'base' }),
filter: (item, query) => (item.opts.name || '').toLowerCase().includes(query) || item.id.toLowerCase().includes(query) || item.opts.port.toString().includes(query) || (item.info.url || '').toLowerCase().includes(query),
renderItem: (item) => {
const escAttr = (value) => String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const itemIdAttr = escAttr(item.id);
const itemNameAttr = escAttr(item.opts.name || item.id);
const isPendingRestart = window.pendingServerRestarts && window.pendingServerRestarts.has(item.id);
const isPendingDelete = window.pendingServerDeletions && window.pendingServerDeletions.has(item.id);
const restartBtn = isPendingRestart
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="restartHolesailServer('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
: `<button onclick="restartHolesailServer('${itemIdAttr}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
const deleteBtn = isPendingDelete
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="deleteHolesailServer('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
: `<button onclick="deleteHolesailServer('${itemIdAttr}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
const protocol = item.opts.udp ? 'UDP' : 'TCP';
const url = item.info.url || 'N/A';
const truncatedUrl = window.sdk?.utils?.dom?.truncate ? window.sdk.utils.dom.truncate(url, 40) : (url.length > 40 ? url.substring(0, 37) + '...' : url);
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
tr.innerHTML = `
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.name || item.id}')" title="${item.opts.name || item.id}">${item.opts.name || item.id}</td>
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${itemIdAttr}', '${itemNameAttr}')" title="${item.opts.name || item.id}">${item.opts.name || item.id}</td>
<td class="p-3">${item.opts.port}</td>
<td class="p-3">${item.opts.host || '0.0.0.0'}</td>
<td class="p-3" title="${url}">${truncatedUrl}</td>
@@ -617,21 +620,24 @@ window.tabs = {
sort: (a, b) => a.opts.domain.localeCompare(b.opts.domain, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.opts.domain.toLowerCase().includes(query) || item.opts.key.toLowerCase().includes(query) || item.opts.port.toString().includes(query),
renderItem: (item) => {
const escAttr = (value) => String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const itemIdAttr = escAttr(item.id);
const logLabelAttr = escAttr(`${item.opts.domain}:${item.opts.port}`);
const isPendingRestart = window.pendingClientRestarts && window.pendingClientRestarts.has(item.id);
const isPendingDelete = window.pendingClientDeletions && window.pendingClientDeletions.has(item.id);
const restartBtn = isPendingRestart
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="restartHolesailClient('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
: `<button onclick="restartHolesailClient('${itemIdAttr}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
const deleteBtn = isPendingDelete
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="deleteHolesailClient('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
: `<button onclick="deleteHolesailClient('${itemIdAttr}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
const protocol = (item.opts.protocol || 'tcp').toUpperCase();
const key = item.opts.key || '';
const truncatedKey = window.sdk?.utils?.dom?.truncate ? window.sdk.utils.dom.truncate(key, 30) : (key.length > 30 ? key.substring(0, 27) + '...' : 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 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.domain}:${item.opts.port}')" title="${item.opts.domain}">${item.opts.domain}</td>
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${itemIdAttr}', '${logLabelAttr}')" title="${item.opts.domain}">${item.opts.domain}</td>
<td class="p-3" title="${key}">${truncatedKey}</td>
<td class="p-3">${item.opts.port}</td>
<td class="p-3">${protocol}</td>
+13 -28
View File
@@ -467,7 +467,7 @@ function displayDiagnosticResult(tool, result) {
}
if (result.output) {
content += `<pre class="bg-black text-green-400 p-3 rounded text-sm overflow-x-auto">${escapeHtml(result.output)}</pre>`;
content += `<pre class="bg-black text-green-400 p-3 rounded text-sm overflow-x-auto">${window.escapeHtml(result.output)}</pre>`;
}
if (result.results && Array.isArray(result.results)) {
@@ -530,13 +530,6 @@ function displayBandwidthStats(result) {
`).join('');
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Cancel stream
function cancelStream(resultId) {
console.log('Cancelling stream:', resultId);
@@ -897,14 +890,6 @@ function shortPeerId(peerId) {
return peerId.slice(0, 8) + '…' + peerId.slice(-6);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function ratioClass(ok, total) {
if (total === 0) return 'text-gray-400';
return ok === total ? 'text-green-400' : 'text-yellow-400';
@@ -968,7 +953,7 @@ function displayInviteDiagnostics(diagnostics) {
${statusItems.map(item => `
<div class="bg-gray-700 rounded px-2 py-1">
<div class="text-gray-400 text-xs">${item.label}</div>
<div class="font-semibold ${item.color} truncate" title="${escapeHtml(item.value)}">${escapeHtml(item.value)}</div>
<div class="font-semibold ${item.color} truncate" title="${window.escapeHtml(item.value)}">${window.escapeHtml(item.value)}</div>
</div>
`).join('')}
</div>
@@ -978,10 +963,10 @@ function displayInviteDiagnostics(diagnostics) {
contentDiv.innerHTML += `
<div class="bg-gray-700 rounded p-2 text-xs text-gray-300">
<span class="text-gray-400">Invite wire:</span>
<code class="text-indigo-300">${escapeHtml(protocol.inviteWire || 'invite.deliver')}</code>
<code class="text-indigo-300">${window.escapeHtml(protocol.inviteWire || 'invite.deliver')}</code>
<span class="text-gray-500 mx-1">·</span>
<span class="text-gray-400">Lifecycle:</span>
<code class="text-gray-200">${escapeHtml(protocol.lifecycleChannel || 'p2ns.core-request')}</code>
<code class="text-gray-200">${window.escapeHtml(protocol.lifecycleChannel || 'p2ns.core-request')}</code>
${protocol.legacyInviteChannel === false ? '<span class="ml-2 text-gray-500">(no p2ns.core-invite channel)</span>' : ''}
</div>
`;
@@ -1051,10 +1036,10 @@ function displayInviteDiagnostics(diagnostics) {
if (peerEntries.length > 0) {
const formatRemote = (remote) => {
if (!remote) return '<span class="text-gray-500">—</span>';
if (!remote.ok) return `<span class="text-gray-500">${escapeHtml(remote.error || 'no response')}</span>`;
if (!remote.ok) return `<span class="text-gray-500">${window.escapeHtml(remote.error || 'no response')}</span>`;
const parts = [remote.nodeType, remote.dnsPassInitialized ? 'dnsPass' : 'no dnsPass'];
if (remote.canProvideInvite) parts.push('can invite');
return `<span class="text-indigo-300">${escapeHtml(parts.join(' · '))}</span>`;
return `<span class="text-indigo-300">${window.escapeHtml(parts.join(' · '))}</span>`;
};
const rows = peerEntries.map(([peerId, p]) => {
@@ -1070,7 +1055,7 @@ function displayInviteDiagnostics(diagnostics) {
const rpcMark = rpcOk ? '<span class="text-green-400">✓</span>' : (p.rpc?.attached ? '<span class="text-yellow-400">○</span>' : '<span class="text-red-400">✗</span>');
return `
<tr class="border-t border-gray-600">
<td class="py-1 pr-2 font-mono text-gray-300" title="${escapeHtml(peerId)}">${escapeHtml(shortPeerId(peerId))}</td>
<td class="py-1 pr-2 font-mono text-gray-300" title="${window.escapeHtml(peerId)}">${window.escapeHtml(shortPeerId(peerId))}</td>
<td class="py-1 text-center">${connOk ? '<span class="text-green-400">✓</span>' : '<span class="text-red-400">✗</span>'}</td>
<td class="py-1 text-center">${reqMark}</td>
<td class="py-1 text-center">${rpcMark}</td>
@@ -1106,8 +1091,8 @@ function displayInviteDiagnostics(diagnostics) {
if (diagnostics.pendingInviteAcks?.length > 0) {
const ackRows = diagnostics.pendingInviteAcks.map((a) => `
<li class="font-mono text-gray-300">${escapeHtml(shortPeerId(a.peerId))}
${a.inviteId ? `<span class="text-gray-500">id=${escapeHtml(String(a.inviteId).slice(0, 12))}…</span>` : ''}
<li class="font-mono text-gray-300">${window.escapeHtml(shortPeerId(a.peerId))}
${a.inviteId ? `<span class="text-gray-500">id=${window.escapeHtml(String(a.inviteId).slice(0, 12))}…</span>` : ''}
<span class="text-gray-500">retries=${a.retryCount ?? 0}</span>
</li>
`).join('');
@@ -1121,7 +1106,7 @@ function displayInviteDiagnostics(diagnostics) {
if (diagnostics.pendingMasterInviteQueue?.length > 0) {
const qRows = diagnostics.pendingMasterInviteQueue.map((q) => `
<li class="font-mono text-gray-300">${escapeHtml(shortPeerId(q.peerId))}
<li class="font-mono text-gray-300">${window.escapeHtml(shortPeerId(q.peerId))}
<span class="text-gray-500">age=${Math.round((q.ageMs || 0) / 1000)}s retries=${q.retryCount ?? 0}</span>
</li>
`).join('');
@@ -1152,7 +1137,7 @@ function displayInviteDiagnostics(diagnostics) {
<details class="bg-gray-700 rounded p-2 text-xs">
<summary class="text-gray-400 cursor-pointer">Connection issues (${diagnostics.connectionIssues.length})</summary>
<ul class="mt-1 text-orange-300 space-y-0.5 font-mono">
${diagnostics.connectionIssues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join('')}
${diagnostics.connectionIssues.map((issue) => `<li>${window.escapeHtml(issue)}</li>`).join('')}
</ul>
</details>
`;
@@ -1164,7 +1149,7 @@ function displayInviteDiagnostics(diagnostics) {
<summary class="text-gray-400 cursor-pointer">RPC methods (${protocol.rpcMethods.length})</summary>
<p class="mt-1 text-gray-500">Registered on p2ns.core-request-rpc</p>
<ul class="mt-1 text-indigo-300 font-mono columns-2 gap-x-4">
${protocol.rpcMethods.map((m) => `<li>${escapeHtml(m)}</li>`).join('')}
${protocol.rpcMethods.map((m) => `<li>${window.escapeHtml(m)}</li>`).join('')}
</ul>
</details>
`;
@@ -1175,7 +1160,7 @@ function displayInviteDiagnostics(diagnostics) {
<div class="bg-blue-900 border border-blue-600 rounded p-2">
<div class="text-xs text-blue-400 mb-1">Recommendations</div>
<ul class="text-xs text-blue-300 space-y-0.5">
${diagnostics.recommendations.map((rec) => `<li>• ${escapeHtml(rec)}</li>`).join('')}
${diagnostics.recommendations.map((rec) => `<li>• ${window.escapeHtml(rec)}</li>`).join('')}
</ul>
</div>
`;
+26 -35
View File
@@ -2,15 +2,6 @@
let pluginsData = [];
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Fetch plugins from API
async function fetchPlugins() {
try {
@@ -194,7 +185,7 @@ function renderPluginCard(plugin) {
class="admin-btn admin-btn--primary admin-btn--sm"
title="${action.description || action.label}"
>
${action.icon ? `<i class="fas fa-${escapeHtml(action.icon)}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || action.name}
${action.icon ? `<i class="fas fa-${window.escapeHtml(action.icon)}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || action.name}
</button>
`).join('')}
</div>
@@ -241,18 +232,18 @@ function renderPluginCard(plugin) {
<div class="flex-1">
<div class="flex items-center gap-4 mb-2">
<h3 class="text-xl font-bold theme-text-primary flex items-center gap-2">
${plugin.icon ? `<i class="fa-solid fa-${escapeHtml(plugin.icon)}"></i>` : ''}
${escapeHtml(plugin.name)}
${plugin.icon ? `<i class="fa-solid fa-${window.escapeHtml(plugin.icon)}"></i>` : ''}
${window.escapeHtml(plugin.name)}
${isLoading ? '<span class="ml-2 text-sm"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></span>' : ''}
</h3>
<div class="ml-2">
${statusBadge}
</div>
</div>
<p class="text-sm theme-text-secondary">${escapeHtml(plugin.description || 'No description')}</p>
<p class="text-sm theme-text-secondary">${window.escapeHtml(plugin.description || 'No description')}</p>
<div class="flex items-center gap-4 mt-2 text-xs theme-text-tertiary">
<span>v${escapeHtml(plugin.version)}</span>
${plugin.author ? `<span>by ${escapeHtml(plugin.author)}</span>` : ''}
<span>v${window.escapeHtml(plugin.version)}</span>
${plugin.author ? `<span>by ${window.escapeHtml(plugin.author)}</span>` : ''}
</div>
${featuresHtml ? `<div class="flex gap-2 mt-2">${featuresHtml}</div>` : ''}
</div>
@@ -334,7 +325,7 @@ function renderPluginCard(plugin) {
</div>
<div class="mt-4 text-xs theme-text-tertiary">
<span>Domain: <code class="theme-glass px-1 rounded">${escapeHtml(plugin.domain)}</code></span>
<span>Domain: <code class="theme-glass px-1 rounded">${window.escapeHtml(plugin.domain)}</code></span>
</div>
</div>
`;
@@ -359,17 +350,17 @@ function renderSettingInput(domain, key, setting) {
class="w-4 h-4 text-primary theme-glass rounded focus:ring-primary"
/>
<label for="${inputId}" class="text-sm theme-text-primary">
${escapeHtml(setting.label || key)}
${window.escapeHtml(setting.label || key)}
</label>
</div>
${setting.description ? `<p class="text-xs theme-text-tertiary ml-6">${escapeHtml(setting.description)}</p>` : ''}
${setting.description ? `<p class="text-xs theme-text-tertiary ml-6">${window.escapeHtml(setting.description)}</p>` : ''}
`;
case 'number':
return `
<div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)}
${window.escapeHtml(setting.label || key)}
</label>
<input
type="number"
@@ -379,7 +370,7 @@ function renderSettingInput(domain, key, setting) {
value="${currentValue}"
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
/>
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div>
`;
@@ -387,13 +378,13 @@ function renderSettingInput(domain, key, setting) {
const optionsHtml = (setting.options || []).map(opt => {
const value = typeof opt === 'object' ? opt.value : opt;
const label = typeof opt === 'object' ? opt.label : opt;
return `<option value="${escapeHtml(value)}" ${value === currentValue ? 'selected' : ''}>${escapeHtml(label)}</option>`;
return `<option value="${window.escapeHtml(value)}" ${value === currentValue ? 'selected' : ''}>${window.escapeHtml(label)}</option>`;
}).join('');
return `
<div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)}
${window.escapeHtml(setting.label || key)}
</label>
<select
id="${inputId}"
@@ -403,7 +394,7 @@ function renderSettingInput(domain, key, setting) {
>
${optionsHtml}
</select>
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div>
`;
@@ -411,7 +402,7 @@ function renderSettingInput(domain, key, setting) {
return `
<div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)}
${window.escapeHtml(setting.label || key)}
</label>
<textarea
id="${inputId}"
@@ -419,8 +410,8 @@ function renderSettingInput(domain, key, setting) {
data-setting-key="${key}"
rows="3"
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
>${escapeHtml(currentValue)}</textarea>
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
>${window.escapeHtml(currentValue)}</textarea>
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div>
`;
@@ -428,17 +419,17 @@ function renderSettingInput(domain, key, setting) {
return `
<div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)}
${window.escapeHtml(setting.label || key)}
</label>
<input
type="text"
id="${inputId}"
data-plugin-domain="${domain}"
data-setting-key="${key}"
value="${escapeHtml(currentValue)}"
value="${window.escapeHtml(currentValue)}"
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
/>
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div>
`;
}
@@ -467,16 +458,16 @@ async function collectActionParameters(action) {
const inputId = `plugin-action-param-${index}`;
const type = param.type || 'string';
const required = param.required ? 'required' : '';
const placeholder = param.placeholder ? `placeholder="${escapeHtml(param.placeholder)}"` : '';
const placeholder = param.placeholder ? `placeholder="${window.escapeHtml(param.placeholder)}"` : '';
const defaultValue = param.default !== undefined ? String(param.default) : '';
const description = param.description
? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(param.description)}</p>`
? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(param.description)}</p>`
: '';
if (type === 'boolean') {
return `
<label class="block text-sm theme-text-primary mb-3">
<span class="block mb-1">${escapeHtml(param.label || param.name)}</span>
<span class="block mb-1">${window.escapeHtml(param.label || param.name)}</span>
<select id="${inputId}" class="w-full p-2 theme-input rounded">
<option value="false" ${defaultValue === 'false' ? 'selected' : ''}>False</option>
<option value="true" ${defaultValue === 'true' ? 'selected' : ''}>True</option>
@@ -488,10 +479,10 @@ async function collectActionParameters(action) {
return `
<label class="block text-sm theme-text-primary mb-3">
<span class="block mb-1">${escapeHtml(param.label || param.name)}${param.required ? ' *' : ''}</span>
<span class="block mb-1">${window.escapeHtml(param.label || param.name)}${param.required ? ' *' : ''}</span>
<input id="${inputId}" type="${type === 'number' ? 'number' : 'text'}"
class="w-full p-2 theme-input rounded"
value="${escapeHtml(defaultValue)}"
value="${window.escapeHtml(defaultValue)}"
${placeholder}
${required} />
${description}
@@ -501,7 +492,7 @@ async function collectActionParameters(action) {
const confirmed = await window.ConfirmationModal.show({
title: action.label || action.name || 'Run Action',
message: `<div><p class="theme-text-secondary mb-3">${escapeHtml(action.description || 'Provide action parameters.')}</p><div>${formFields}</div></div>`,
message: `<div><p class="theme-text-secondary mb-3">${window.escapeHtml(action.description || 'Provide action parameters.')}</p><div>${formFields}</div></div>`,
type: 'info',
confirmText: 'Run Action',
cancelText: 'Cancel',
-296
View File
@@ -1,296 +0,0 @@
/**
* P2NS Admin Panel - Utilities
* Consolidated utilities matching the P2NS Plugin SDK
*/
// Initialize sdk global if not present
window.sdk = window.sdk || {};
window.sdk.utils = window.sdk.utils || {};
/**
* Formatting Utilities
*/
window.sdk.utils.format = {
/**
* Format a timestamp to human-readable relative time
*/
formatTimestamp(timestamp) {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) {
return `${Math.floor(diff / 1000)}s ago`;
} else if (diff < 3600000) {
return `${Math.floor(diff / 60000)}m ago`;
} else if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}h ago`;
} else {
return date.toLocaleString();
}
},
/**
* Format a peer ID to shortened version
*/
formatPeerId(peerId, short = true) {
if (!peerId) return 'N/A';
if (!short) return peerId;
if (peerId.length <= 16) return peerId;
return `${peerId.slice(0, 8)}...${peerId.slice(-8)}`;
},
/**
* Format a hash to shortened version
*/
formatHash(hash) {
if (!hash) return 'N/A';
if (hash.length <= 20) return hash;
return `${hash.slice(0, 10)}...${hash.slice(-10)}`;
},
/**
* Format duration (milliseconds to readable string)
*/
formatDuration(ms) {
if (!ms || ms === 0) return '-';
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(2)}m`;
return `${(ms / 3600000).toFixed(2)}h`;
},
/**
* Format uptime (milliseconds to readable string)
*/
formatUptime(ms) {
if (!ms || ms === 0) return '0s';
const days = Math.floor(ms / 86400000);
const hours = Math.floor((ms % 86400000) / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
},
/**
* Format bytes to human readable string
*/
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
if (!bytes) return 'N/A';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
/**
* Format number with commas
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
},
/**
* Format CPU usage
*/
formatCPUUsage(cpuUsage, uptime) {
if (!cpuUsage) return 'N/A';
if (typeof cpuUsage.percentage === 'number') {
return `${cpuUsage.percentage.toFixed(2)}%`;
}
if (typeof cpuUsage.user === 'number' && typeof cpuUsage.system === 'number') {
if (uptime && uptime > 0) {
const uptimeMicroseconds = uptime * 1000;
const totalCpuMicroseconds = cpuUsage.user + cpuUsage.system;
const cpuPercent = (totalCpuMicroseconds / uptimeMicroseconds) * 100;
return `${cpuPercent.toFixed(2)}%`;
}
return `${(cpuUsage.user / 1000).toFixed(2)}ms user, ${(cpuUsage.system / 1000).toFixed(2)}ms system`;
}
return 'N/A';
},
/**
* Format memory usage
*/
formatMemoryUsage(memoryUsage) {
if (!memoryUsage) return 'N/A';
const rssMB = (memoryUsage.rss / 1024 / 1024).toFixed(2);
const heapUsedMB = (memoryUsage.heapUsed / 1024 / 1024).toFixed(2);
const heapTotalMB = (memoryUsage.heapTotal / 1024 / 1024).toFixed(2);
return `${rssMB} MB RSS (${heapUsedMB}/${heapTotalMB} MB heap)`;
},
/**
* Format Holesail hash for display
*/
formatHolesailHash(hash) {
if (!hash) return 'none';
if (hash.startsWith('hs://')) return hash;
return `hs://${hash}`;
}
};
/**
* DOM and UI Utilities
*/
window.sdk.utils.dom = {
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
async copyToClipboard(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
throw new Error('Clipboard API not available');
} catch (err) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
}
},
truncate(text, maxLength = 40) {
if (!text || text === 'N/A') return 'N/A';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
},
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
};
/**
* Status and Badge Utilities
*/
window.sdk.utils.status = {
renderStatusBadge(state) {
const badges = {
'running': { class: 'bg-green-500', icon: '✓', text: 'Running' },
'stopped': { class: 'bg-gray-500', icon: '○', text: 'Stopped' },
'starting': { class: 'bg-yellow-500', icon: '⟳', text: 'Starting' },
'error': { class: 'bg-red-500', icon: '✗', text: 'Error' }
};
const badge = badges[state] || badges['stopped'];
return `<span class="px-2 py-1 ${badge.class} text-xs font-semibold rounded-full flex items-center gap-1 w-fit" style="color: var(--text-primary);" title="${badge.text}">
<span>${badge.icon}</span>
<span>${badge.text}</span>
</span>`;
},
getConsensusBadgeClass(status) {
switch (status) {
case 'resolved': return 'status-badge resolved';
case 'conflict': return 'status-badge conflict';
case 'insufficient_quorum': return 'status-badge insufficient_quorum';
case 'tie': return 'status-badge tie';
case 'no_claims': return 'status-badge no_claims';
case 'error': return 'status-badge error';
default: return 'status-badge no_claims';
}
},
/**
* Get display text for a consensus status
*/
getConsensusText(status) {
switch (status) {
case 'resolved': return 'Resolved';
case 'conflict': return 'Conflict';
case 'insufficient_quorum': return 'Insufficient Quorum';
case 'tie': return 'Tie';
case 'no_claims': return 'No Claims';
case 'error': return 'Error';
default: return 'Unknown';
}
},
/**
* Get hex color for a consensus status
*/
getConsensusColor(status) {
switch (status) {
case 'resolved': return '#10b981';
case 'conflict': return '#ef4444';
case 'insufficient_quorum': return '#eab308';
case 'tie': return '#f97316';
case 'no_claims': return '#6b7280';
case 'error': return '#ef4444';
default: return '#6b7280';
}
}
};
/**
* Legacy compatibility layers
*/
window.renderStatusBadge = window.sdk.utils.status.renderStatusBadge;
window.truncateUrl = (url, maxLength) => window.sdk.utils.dom.truncate(url, maxLength);
window.escapeHtml = window.sdk.utils.dom.escapeHtml;
window.formatUptime = window.sdk.utils.format.formatUptime;
window.formatDuration = window.sdk.utils.format.formatDuration;
window.formatCPUUsage = window.sdk.utils.format.formatCPUUsage;
window.formatMemoryUsage = window.sdk.utils.format.formatMemoryUsage;
// Default chart colors if not defined
window.chartColors = window.chartColors || {
primary: 'rgb(59, 130, 246)',
success: 'rgb(34, 197, 94)',
warning: 'rgb(234, 179, 8)',
danger: 'rgb(239, 68, 68)',
info: 'rgb(59, 130, 246)',
gray: 'rgb(107, 114, 128)',
dark: 'rgb(17, 24, 39)'
};
window.darkModeColors = window.darkModeColors || {
primary: 'rgb(96, 165, 250)',
success: 'rgb(74, 222, 128)',
warning: 'rgb(250, 204, 21)',
danger: 'rgb(248, 113, 113)',
info: 'rgb(96, 165, 250)',
gray: 'rgb(156, 163, 175)',
dark: 'rgb(243, 244, 246)'
};
window.getChartColors = () => {
return document.documentElement.classList.contains('dark') ? window.darkModeColors : window.chartColors;
};
@@ -171,6 +171,8 @@ function connectWebSocket() {
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
window.genericRenderPaginated('host-clients');
}
}).catch((err) => {
if (window.showNotification) window.showNotification(`Failed to refresh holesail clients: ${err.message}`, 'error');
});
}
return;
@@ -198,6 +200,8 @@ function connectWebSocket() {
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
window.genericRenderPaginated('host-servers');
}
}).catch((err) => {
if (window.showNotification) window.showNotification(`Failed to refresh holesail servers: ${err.message}`, 'error');
});
}
return;