/**
* Peer UI — peer management dashboard
*/
import { renderPage } from './page-layout.js'
import { escapeHtml } from './response.js'
const PAGE_CSS = `
.section { margin-bottom: 40px; }
.section h2 { margin-bottom: 15px; font-size: 20px; }
.self-info {
background: #161b22; border: 1px solid #30363d;
border-radius: 6px; padding: 16px 20px; margin-bottom: 30px;
}
.self-info h3 { color: #58a6ff; margin-bottom: 10px; }
.self-info code {
font-size: 12px; color: #c9d1d9; background: #0d1117;
padding: 4px 8px; border-radius: 4px; word-break: break-all;
}
.peer-card {
background: #161b22; border: 1px solid #30363d;
border-radius: 6px; padding: 20px; margin-bottom: 15px;
}
.peer-card-header {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 12px;
}
.peer-card-header h3 { font-size: 18px; }
.peer-meta {
display: flex; gap: 20px; font-size: 13px;
color: #8b949e; margin-bottom: 10px; flex-wrap: wrap;
}
.peer-meta span { display: flex; align-items: center; gap: 5px; }
.badge.mirror { background: #238636; color: white; }
.badge.peer { background: #1f6feb; color: white; }
.badge.active { background: #238636; color: white; }
.badge.revoked { background: #da3633; color: white; }
.actions-row { display: flex; gap: 10px; margin-bottom: 30px; flex-wrap: wrap; }
.invite-display {
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
padding: 15px; margin: 15px 0; font-family: monospace;
font-size: 12px; word-break: break-all; color: #58a6ff;
}
.repair-result { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 15px; margin: 15px 0; }
.repair-item { padding: 8px 0; border-bottom: 1px solid #21262d; display: flex; gap: 10px; }
.repair-item:last-child { border-bottom: none; }
.repair-status { font-weight: 600; min-width: 120px; }
.repair-status.shared { color: #3fb950; }
.repair-status.missing { color: #f85149; }
.repair-status.independent { color: #d29922; }
.panel { display: none; }
.panel.visible { display: block; }
`
export function renderPeerDashboard ({ peers = [], selfKey = null, hostname = 'unknown' } = {}, pathPrefix = '') {
const safeHostname = escapeHtml(hostname)
const safeSelfKey = escapeHtml(selfKey || 'Not initialized')
const activePeers = peers.filter(p => p.status === 'active')
const mirrorPeers = activePeers.filter(p => p.mode === 'mirror')
const selectivePeers = activePeers.filter(p => p.mode === 'peer' || !p.mode)
const content = `
<div id="status-area"></div>
<div class="self-info">
<h3>This Server: ${safeHostname}</h3>
<div style="margin-top: 8px;">
<span style="color: #8b949e;">Server Key:</span>
<code>${safeSelfKey}</code>
</div>
</div>
<div class="actions-row">
<button onclick="togglePanel('invite-panel')">Create Invite</button>
<button class="secondary" onclick="togglePanel('join-panel')">Join Peer</button>
<button class="secondary" onclick="runRepair()">Repair</button>
<button class="secondary" onclick="forceSync()">Force Sync</button>
</div>
<div id="invite-panel" class="panel">
<div class="peer-card">
<h3 style="margin-bottom: 15px;">Create Peering Invite</h3>
<div class="form-group">
<label>Mode:</label>
<select id="invite-mode">
<option value="mirror">Mirror — Full replication (all repos, all registries)</option>
<option value="peer">Peer — Selective (browse remote, sync chosen repos)</option>
</select>
<div class="help-text">Mirror: both servers become interchangeable. Peer: selective cooperation.</div>
</div>
<button onclick="createInvite()">Generate Invite</button>
<div id="invite-result"></div>
</div>
</div>
<div id="join-panel" class="panel">
<div class="peer-card">
<h3 style="margin-bottom: 15px;">Join with Invite</h3>
<div class="form-group">
<label>Invite Hex:</label>
<textarea id="join-invite" rows="3" placeholder="Paste the invite hex here..."></textarea>
</div>
<button onclick="joinPeer()">Join</button>
</div>
</div>
<div id="repair-panel" class="panel">
<div class="peer-card">
<h3 style="margin-bottom: 15px;">Repair Results</h3>
<div id="repair-results"></div>
</div>
</div>
<div class="section">
<h2>Mirror Peers (${mirrorPeers.length})</h2>
${mirrorPeers.length === 0
? '<div class="empty-state">No mirror peers. Create an invite to link servers.</div>'
: mirrorPeers.map(p => renderPeerCard(p)).join('')}
</div>
<div class="section">
<h2>Selective Peers (${selectivePeers.length})</h2>
${selectivePeers.length === 0
? '<div class="empty-state">No selective peers.</div>'
: selectivePeers.map(p => renderPeerCard(p)).join('')}
</div>
`
const scripts = `
function getBasePath() { return '${pathPrefix}'; }
function showStatus(msg, type) {
var area = document.getElementById('status-area');
area.innerHTML = '<div class="status-msg ' + type + '">' + msg + '</div>';
if (type !== 'info') setTimeout(function() { area.innerHTML = ''; }, 8000);
}
function togglePanel(id) {
var panel = document.getElementById(id);
var wasVisible = panel.classList.contains('visible');
document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('visible'); });
if (!wasVisible) panel.classList.add('visible');
}
async function createInvite() {
var mode = document.getElementById('invite-mode').value;
showStatus('Creating ' + mode + ' invite...', 'info');
try {
var resp = await window.fetch(getBasePath() + '/api/peer/invite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: mode }) });
var data = await resp.json();
if (data.error) { showStatus('Error: ' + data.error, 'error'); return; }
document.getElementById('invite-result').innerHTML = '<div class="invite-display">' + data.invite + '</div><button class="secondary small" onclick="copyInvite()">Copy to clipboard</button><div class="help-text" style="margin-top:8px;">Share this invite with the other server.</div>';
showStatus('Invite created (' + mode + ' mode). Waiting for peer to join...', 'success');
} catch (err) { showStatus('Error: ' + err.message, 'error'); }
}
function copyInvite() {
var el = document.querySelector('.invite-display');
if (el && navigator.clipboard) { navigator.clipboard.writeText(el.textContent.trim()); showStatus('Invite copied', 'success'); }
}
async function joinPeer() {
var invite = document.getElementById('join-invite').value.trim();
if (!invite) { showStatus('Paste an invite hex first', 'error'); return; }
showStatus('Joining peer...', 'info');
try {
var resp = await window.fetch(getBasePath() + '/api/peer/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ invite: invite }) });
var data = await resp.json();
if (data.error) { showStatus('Join failed: ' + data.error, 'error'); return; }
showStatus('Joined ' + (data.hostname || data.serverKey.substring(0, 16) + '...') + ' in ' + data.mode + ' mode!', 'success');
setTimeout(function() { location.reload(); }, 2000);
} catch (err) { showStatus('Error: ' + err.message, 'error'); }
}
async function runRepair() {
showStatus('Running repair audit...', 'info');
document.getElementById('repair-panel').classList.add('visible');
document.getElementById('repair-results').innerHTML = '<span class="spinner"></span> Auditing...';
try {
var resp = await window.fetch(getBasePath() + '/api/peer/repair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apply: true }) });
var data = await resp.json();
if (data.error) { showStatus('Repair error: ' + data.error, 'error'); return; }
var html = '<div class="repair-result">';
if (data.repairs.length === 0) { html += '<div style="color:#3fb950;padding:10px;">All registries properly shared.</div>'; }
else { for (var i = 0; i < data.repairs.length; i++) { var r = data.repairs[i]; html += '<div class="repair-item"><span class="repair-status ' + (r.status === 'missing' ? 'missing' : r.status === 'independent' ? 'independent' : 'shared') + '">' + r.type + '</span><span>' + r.description + '</span>' + (r.applied ? '<span style="color:#3fb950;font-weight:600;"> FIXED</span>' : '') + '</div>'; } }
html += '</div>';
if (data.needsRestart) html += '<div class="status-msg info" style="margin-top:10px;">Restart both servers to complete repair.</div>';
document.getElementById('repair-results').innerHTML = html;
showStatus(data.message, data.needsRestart ? 'info' : 'success');
} catch (err) { showStatus('Repair error: ' + err.message, 'error'); }
}
async function forceSync() {
showStatus('Triggering sync...', 'info');
try {
var resp = await window.fetch(getBasePath() + '/api/peer/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
var data = await resp.json();
showStatus(data.message || 'Sync triggered', data.success ? 'success' : 'error');
} catch (err) { showStatus('Sync error: ' + err.message, 'error'); }
}
async function removePeer(serverKey) {
if (!confirm('Remove this peer?')) return;
try {
var resp = await window.fetch(getBasePath() + '/api/peer/' + serverKey, { method: 'DELETE' });
var data = await resp.json();
if (data.success) { showStatus('Peer removed', 'success'); setTimeout(function() { location.reload(); }, 1000); }
else showStatus('Error: ' + (data.error || 'unknown'), 'error');
} catch (err) { showStatus('Error: ' + err.message, 'error'); }
}
async function upgradePeer(serverKey) {
if (!confirm('Upgrade this peer to mirror mode?')) return;
try {
var resp = await window.fetch(getBasePath() + '/api/peer/' + serverKey + '/upgrade', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
var data = await resp.json();
if (data.success) { showStatus(data.message, 'success'); setTimeout(function() { location.reload(); }, 1000); }
else showStatus('Error: ' + (data.error || 'unknown'), 'error');
} catch (err) { showStatus('Error: ' + err.message, 'error'); }
}
`
return renderPage('Peers', content, { pathPrefix, activePage: 'peer', css: PAGE_CSS, scripts })
}
function renderPeerCard (peer) {
const modeClass = peer.mode === 'mirror' ? 'mirror' : 'peer'
const statusClass = peer.status === 'active' ? 'active' : 'revoked'
const linkedDate = peer.linkedAt ? new Date(peer.linkedAt).toLocaleDateString() : 'unknown'
const lastSeen = peer.lastSeenAt ? timeSince(peer.lastSeenAt) + ' ago' : 'never'
const displayName = escapeHtml(peer.displayName || peer.hostname || peer.serverKey.slice(0, 16) + '...')
const safeKey = escapeHtml(peer.serverKey)
const safeUrl = peer.httpUrl ? escapeHtml(peer.httpUrl) : null
const safeMode = escapeHtml(peer.mode || 'peer')
const safeStatus = escapeHtml(peer.status)
return `
<div class="peer-card">
<div class="peer-card-header">
<h3>${displayName}</h3>
<div style="display:flex;gap:8px;align-items:center;">
<span class="badge ${modeClass}">${safeMode}</span>
<span class="badge ${statusClass}">${safeStatus}</span>
</div>
</div>
<div class="peer-meta">
<span>Key: <code style="font-size:11px;">${safeKey.slice(0, 24)}...</code></span>
<span>Linked: ${linkedDate}</span>
<span>Last seen: ${lastSeen}</span>
${safeUrl ? '<span>URL: ' + safeUrl + '</span>' : '<span style="color:#f85149;">No HTTP URL</span>'}
</div>
${peer.grantedRepos && peer.grantedRepos.length > 0
? '<div class="peer-meta"><span>Repos: ' + escapeHtml(peer.grantedRepos.join(', ')) + '</span></div>'
: ''}
<div style="display:flex;gap:8px;margin-top:10px;">
${peer.mode !== 'mirror' && peer.status === 'active'
? '<button class="secondary small" onclick="upgradePeer(\'' + safeKey + '\')">Upgrade to Mirror</button>'
: ''}
${peer.status === 'active'
? '<button class="danger small" onclick="removePeer(\'' + safeKey + '\')">Remove</button>'
: ''}
</div>
</div>
`
}
function timeSince (ts) {
const seconds = Math.floor((Date.now() - ts) / 1000)
if (seconds < 60) return seconds + 's'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm'
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h'
return Math.floor(seconds / 86400) + 'd'
}