OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / peer-ui.js

274 lines · javascript ref:
/**
 * 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'
}