OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / pages / repo-peers.js

155 lines · javascript ref:
/**
 * Per-repo Peers tab — `/repos/:name/peers`.
 *
 * Shows the replication state for a single repo:
 *   - The daemon's own server key (what peers see)
 *   - The repo's swarm topic (the discovery key; peers join on this)
 *   - Whether this daemon is actively serving the repo
 *   - Total peers on the shared swarm (daemon-wide; per-repo split lives in
 *     hyper-sync internals that aren't exposed yet — expand later)
 *
 * Deliberately read-only. Peer add/remove flows live at the daemon-wide
 * `/peers` page (P.4).
 */

import { layout, escapeHtml } from '../helpers.js'
import { renderRepoTabs, REPO_TAB_STYLES } from '../components/repo-tabs.js'
import { isAdmin } from '../../auth/check.js'

export async function renderRepoPeers (svc, ctx, repoName) {
  const prefix = (ctx && ctx.prefix) || ''
  const canAdmin = await isAdmin(ctx, svc.identityRegistry)
  const sync = svc._sync
  if (!sync) return errorPage('Sync service not running', ctx, repoName)

  const info = sync.info(repoName)
  if (!info) return errorPage('Repository "' + repoName + '" not found', ctx, repoName)

  const serverKey = sync.serverKey || null
  const topic = info.topic || null
  const serving = !!info.serving
  const swarmPeers = svc.swarm ? svc.swarm.connections : 0
  const swarmTopic = svc.swarm ? escapeHtml(Buffer.from(svc.swarm.topic).toString('hex')) : null

  // Repo writers = the per-repo issue-tracker autobase writer set (gip push
  // authority). Mirrors the org members page. Best-effort: a peer that
  // hasn't mounted the tracker yet just shows none.
  let writers = []
  try {
    const tracker = await svc.issueRegistry.getForRepo(repoName)
    writers = await tracker.listWriters()
  } catch {}

  const body = `
${REPO_TAB_STYLES}
${renderRepoTabs(repoName, 'peers', {}, prefix)}

<div class="card">
  <h2><a href="${prefix}/repos/${encodeURIComponent(repoName)}">&larr; ${escapeHtml(repoName)}</a> / peers</h2>
</div>

<div class="card">
  <h2>This daemon</h2>
  <div class="row">
    <div class="label">server key</div>
    <div class="mono" style="font-size:0.7rem">${escapeHtml(serverKey || '(unavailable)')}</div>
  </div>
  <div class="row">
    <div class="label">serving</div>
    <div>${serving ? '<span class="badge badge-open">yes</span>' : '<span class="badge badge-closed">no</span>'}</div>
  </div>
  ${topic ? `<div class="row">
    <div class="label">repo topic</div>
    <div class="mono" style="font-size:0.7rem">${escapeHtml(topic)}</div>
  </div>` : ''}
</div>

<div class="card">
  <h2>Swarm</h2>
  <div class="row">
    <div class="label">connections</div>
    <div>${swarmPeers} peer${swarmPeers === 1 ? '' : 's'}</div>
  </div>
  ${swarmTopic ? `<div class="row">
    <div class="label">daemon topic</div>
    <div class="mono" style="font-size:0.7rem">${swarmTopic}</div>
  </div>` : '<div class="row"><div class="label">daemon topic</div><div class="meta">(swarm disabled)</div></div>'}
  <div class="meta" style="margin-top:0.5rem;font-size:0.7rem">
    Per-repo peer breakdown is daemon-wide for now; richer accounting lives in hyper-sync (P.4 / P.10).
  </div>
</div>

<div class="card">
  <h2>Writers (push authority)</h2>
  <div class="meta" style="margin-bottom:0.5rem;font-size:0.8rem">
    The repo's per-repo autobase writer set — who can push (gip authority). Writers are added by accepting a contributor+ invite; remove revokes push access.
  </div>
  ${writers.length === 0 ? '<div class="empty">(no writers tracked yet)</div>' : `
  <div class="member-list">
    ${writers.map(w => `
    <div class="member-row" style="display:flex;align-items:center;gap:0.5rem">
      <div style="flex:1">
        <div class="mono" style="font-size:0.75rem">${escapeHtml(String(w.userId || w.id || '').slice(0, 32))}${String(w.userId || w.id || '').length > 32 ? '…' : ''}</div>
        ${w.role ? `<div class="meta">${escapeHtml(w.role)}</div>` : ''}
      </div>
      ${canAdmin ? `<form action="${prefix}/repos/${encodeURIComponent(repoName)}/writer-revoke" method="POST" style="margin:0" onsubmit="return confirm('Revoke push access for ${escapeHtml(String(w.userId || w.id || '').slice(0, 16))}… on ${escapeHtml(repoName)}?')">
        <input type="hidden" name="userId" value="${escapeHtml(String(w.userId || w.id || ''))}">
        <button class="btn-secondary" type="submit" style="font-size:0.7rem;padding:0.2rem 0.5rem" aria-label="Revoke writer ${escapeHtml(String(w.userId || w.id || '').slice(0, 16))}">revoke</button>
      </form>` : ''}
    </div>`).join('')}
  </div>`}
</div>

<div class="card">
  <h2>How to join</h2>
  <div class="meta" style="font-size:0.8rem">
    To replicate this repo from another daemon, clone with the P2P URL (<code>punch://&lt;server-key&gt;/${escapeHtml(repoName)}</code>)
    or HTTP URL shown on the <a href="${prefix}/repos/${encodeURIComponent(repoName)}">Overview tab</a>.
  </div>
</div>
${canAdmin ? `
<div class="card">
  <h2>Invite collaborator (issue tracker)</h2>
  <div class="meta" style="margin-bottom:0.5rem">Generates a P2P invite code for this repo's issue tracker. Share it — the holder pairs over the swarm and (for contributor+) becomes a writer. No URL, no HTTP.</div>
  <form action="${prefix}/repos/${encodeURIComponent(repoName)}/invite" method="POST">
    <div class="field"><label for="repo-invite-role">Role</label>
      <select id="repo-invite-role" name="role">
        <option value="reader">reader (read-only mirror)</option>
        <option value="contributor">contributor (can write)</option>
        <option value="maintainer">maintainer</option>
        <option value="admin">admin</option>
      </select>
    </div>
    <button type="submit">Generate invite</button>
  </form>
</div>` : ''}
`

  return layout('peers · ' + repoName, '/repos', body, ctx)
}

/**
 * Render the result of minting a repo invite — shows the P2P invite code.
 * The recipient runs `pgh repo join <code>` or pastes it into the Join box;
 * they pair over the swarm (no URL, no HTTP).
 */
export function renderRepoInviteResult (repoName, invitation, role, ctx) {
  const prefix = (ctx && ctx.prefix) || ''
  const body = `
<h2><a href="${prefix}/repos/${encodeURIComponent(repoName)}">&larr; ${escapeHtml(repoName)}</a> / invite</h2>
<div class="card">
  <h2 style="margin-top:0">Invite created — role: ${escapeHtml(role)}</h2>
  <div class="meta" style="margin-bottom:0.5rem">Share this code. The recipient runs <code>pgh repo join &lt;code&gt;</code> or pastes it into the Join box on the repos page — they pair over the swarm and (for contributor+) become a writer.</div>
  <textarea readonly rows="4" style="width:100%;font-family:monospace;font-size:0.8rem" onclick="this.select()">${escapeHtml(invitation)}</textarea>
</div>`
  return layout('invite · ' + repoName, '/repos', body, ctx)
}

function errorPage (msg, ctx, repoName) {
  const body = `
${REPO_TAB_STYLES}
${repoName ? renderRepoTabs(repoName, 'peers', {}, prefix) : ''}
<div class="card"><h2>Error</h2><div class="empty">${escapeHtml(msg)}</div></div>`
  return layout('Error', '/repos', body, ctx)
}