OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy / web / index.html

552 lines · html ref:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>7rnx-proxy</title>
<style>
:root {
  --bg: #1a1a2e;
  --bg-secondary: #16213e;
  --bg-card: #1c2541;
  --text: #e4e4e4;
  --text-muted: #8b8b9e;
  --accent: #e94560;
  --accent-hover: #ff6b81;
  --green: #10b981;
  --red: #ef4444;
  --yellow: #f59e0b;
  --border: #2a2a4a;
  --code-bg: #0d1117;
  --input-bg: #16213e;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.5;
}

.topbar {
  height: 48px;
  background: var(--bg-secondary);
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  padding: 0 24px;
  gap: 16px;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
}

.topbar h1 {
  font-size: 1rem;
  font-weight: 600;
  color: var(--accent);
  letter-spacing: 0.5px;
}

.topbar .sep {
  color: var(--border);
}

.topbar .apache-status {
  font-size: 0.75rem;
  color: var(--text-muted);
  margin-left: auto;
}

.topbar .apache-status .dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin-right: 4px;
  vertical-align: middle;
}

.topbar .apache-status .dot.up { background: var(--green); }
.topbar .apache-status .dot.down { background: var(--red); }
.topbar .apache-status .dot.unknown { background: var(--yellow); }

.container {
  max-width: 1100px;
  margin: 0 auto;
  padding: 72px 24px 48px;
}

.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin-bottom: 24px;
}

.card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 16px;
}

.card h2 {
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--text-muted);
  margin-bottom: 12px;
}

.card.full {
  grid-column: 1 / -1;
}

/* Nodes */
.node-list {
  list-style: none;
}

.node-list li {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
  border-bottom: 1px solid var(--border);
  font-size: 0.9rem;
}

.node-list li:last-child {
  border-bottom: none;
}

.node-name {
  font-weight: 600;
  min-width: 100px;
}

.node-ip {
  font-family: 'SF Mono', 'Fira Code', monospace;
  font-size: 0.8rem;
  color: var(--text-muted);
}

/* Services table */
.svc-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.85rem;
}

.svc-table th {
  text-align: left;
  padding: 8px 12px;
  border-bottom: 2px solid var(--border);
  color: var(--text-muted);
  font-weight: 500;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.svc-table td {
  padding: 10px 12px;
  border-bottom: 1px solid var(--border);
}

.svc-table tr:last-child td {
  border-bottom: none;
}

.svc-table tr:hover td {
  background: rgba(233, 69, 96, 0.05);
}

.status-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 10px;
  border-radius: 12px;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.status-badge.up {
  background: rgba(16, 185, 129, 0.15);
  color: var(--green);
}

.status-badge.down {
  background: rgba(239, 68, 68, 0.15);
  color: var(--red);
}

.status-badge.unknown {
  background: rgba(245, 158, 11, 0.15);
  color: var(--yellow);
}

.port {
  font-family: 'SF Mono', 'Fira Code', monospace;
  font-size: 0.8rem;
  color: var(--accent);
}

.url-path {
  font-family: 'SF Mono', 'Fira Code', monospace;
  font-size: 0.8rem;
}

/* Actions */
.actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.btn {
  padding: 8px 16px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-secondary);
  color: var(--text);
  font-size: 0.85rem;
  cursor: pointer;
  transition: all 0.15s;
}

.btn:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.btn.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: white;
}

.btn.primary:hover {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Config viewer */
.config-viewer {
  background: var(--code-bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 16px;
  font-family: 'SF Mono', 'Fira Code', monospace;
  font-size: 0.8rem;
  line-height: 1.6;
  overflow-x: auto;
  white-space: pre;
  max-height: 400px;
  overflow-y: auto;
  color: var(--text-muted);
  display: none;
}

.config-viewer.visible {
  display: block;
  margin-top: 12px;
}

/* Toast */
.toast {
  position: fixed;
  bottom: 24px;
  right: 24px;
  padding: 12px 20px;
  border-radius: 6px;
  font-size: 0.85rem;
  z-index: 100;
  animation: slidein 0.2s ease;
  max-width: 400px;
}

.toast.success {
  background: var(--green);
  color: white;
}

.toast.error {
  background: var(--red);
  color: white;
}

@keyframes slidein {
  from { transform: translateY(20px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

/* Summary bar */
.summary {
  display: flex;
  gap: 24px;
  margin-bottom: 16px;
}

.summary .stat {
  font-size: 0.85rem;
  color: var(--text-muted);
}

.summary .stat strong {
  color: var(--text);
  font-size: 1.1rem;
}

/* Loading */
.loading {
  color: var(--text-muted);
  font-size: 0.85rem;
  padding: 16px 0;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.loading { animation: pulse 1.5s infinite; }

/* Responsive */
@media (max-width: 768px) {
  .grid { grid-template-columns: 1fr; }
  .topbar { padding: 0 12px; }
  .container { padding: 60px 12px 24px; }
  .svc-table { font-size: 0.8rem; }
  .svc-table th, .svc-table td { padding: 8px 6px; }
}
</style>
</head>
<body>

<div class="topbar">
  <h1>7rnx-proxy</h1>
  <span class="sep">|</span>
  <span style="font-size:0.8rem;color:var(--text-muted)">reverse proxy dashboard</span>
  <span class="apache-status">
    <span class="dot unknown" id="apache-dot"></span>
    <span id="apache-label">checking...</span>
  </span>
</div>

<div class="container">
  <div class="summary" id="summary">
    <div class="stat"><strong id="node-count">-</strong> nodes</div>
    <div class="stat"><strong id="svc-count">-</strong> services</div>
    <div class="stat"><strong id="up-count">-</strong> up</div>
    <div class="stat"><strong id="down-count">-</strong> down</div>
  </div>

  <div class="grid">
    <div class="card">
      <h2>Nodes</h2>
      <ul class="node-list" id="node-list">
        <li class="loading">loading...</li>
      </ul>
    </div>

    <div class="card">
      <h2>Actions</h2>
      <div class="actions" style="flex-direction:column;gap:12px;">
        <div class="actions">
          <button class="btn" onclick="refreshStatus()">Refresh</button>
          <button class="btn" onclick="showDryRun()">Dry Run</button>
          <button class="btn" onclick="showRemoteConfig()">Live Config</button>
        </div>
        <div class="actions">
          <button class="btn primary" onclick="deploy()" id="deploy-btn">Deploy to 7rnx.net</button>
        </div>
      </div>
    </div>

    <div class="card full">
      <h2>Services</h2>
      <table class="svc-table">
        <thead>
          <tr>
            <th>Status</th>
            <th>Node</th>
            <th>Service</th>
            <th>Port</th>
            <th>URL</th>
          </tr>
        </thead>
        <tbody id="svc-body">
          <tr><td colspan="5" class="loading">loading...</td></tr>
        </tbody>
      </table>
    </div>

    <div class="card full" id="config-card" style="display:none;">
      <h2 id="config-title">Config</h2>
      <div class="config-viewer visible" id="config-viewer"></div>
    </div>
  </div>
</div>

<script>
async function api (endpoint, opts) {
  const res = await fetch('/api/' + endpoint, opts)
  return res.json()
}

function toast (msg, type) {
  const el = document.createElement('div')
  el.className = 'toast ' + type
  el.textContent = msg
  document.body.appendChild(el)
  setTimeout(() => el.remove(), 4000)
}

function badge (status) {
  const cls = status === 'UP' ? 'up' : status === 'DOWN' ? 'down' : 'unknown'
  return `<span class="status-badge ${cls}">${status}</span>`
}

async function refreshStatus () {
  try {
    const data = await api('status')

    // Nodes
    const nodeList = document.getElementById('node-list')
    if (data.nodes.length === 0) {
      nodeList.innerHTML = '<li style="color:var(--text-muted)">No nodes registered</li>'
    } else {
      nodeList.innerHTML = data.nodes.map(n => {
        const ident = n.keetIdent ? `<span style="color:var(--text-muted);font-size:0.75rem;font-family:monospace">${n.keetIdent.slice(0, 12)}...</span>` : ''
        return `<li><span class="node-name">${n.petname}</span><span class="node-ip">${n.ip}</span>${ident}</li>`
      }).join('')
    }

    // Services
    const svcBody = document.getElementById('svc-body')
    if (data.services.length === 0) {
      svcBody.innerHTML = '<tr><td colspan="5" style="color:var(--text-muted)">No services registered</td></tr>'
    } else {
      svcBody.innerHTML = data.services.map(s => {
        const urlPath = s.rootPath || `/${s.node}/${s.name}/`
        const extUrl = `https://7rnx.net${urlPath}`
        return `<tr>
          <td>${badge(s.status)}</td>
          <td>${s.node}</td>
          <td>${s.name}</td>
          <td><span class="port">:${s.port}</span></td>
          <td><a href="${extUrl}" target="_blank" class="url-path" style="color:var(--text);text-decoration:none;">${urlPath}</a></td>
        </tr>`
      }).join('')
    }

    // Summary
    document.getElementById('node-count').textContent = data.nodes.length
    document.getElementById('svc-count').textContent = data.services.length
    const up = data.services.filter(s => s.status === 'UP').length
    const down = data.services.filter(s => s.status === 'DOWN').length
    document.getElementById('up-count').textContent = up
    document.getElementById('up-count').style.color = up > 0 ? 'var(--green)' : 'var(--text)'
    document.getElementById('down-count').textContent = down
    document.getElementById('down-count').style.color = down > 0 ? 'var(--red)' : 'var(--text)'

  } catch (err) {
    toast('Failed to refresh: ' + err.message, 'error')
  }
}

async function checkApache () {
  try {
    const data = await api('apache-status')
    const dot = document.getElementById('apache-dot')
    const label = document.getElementById('apache-label')
    if (data.active === 'active') {
      dot.className = 'dot up'
      label.textContent = 'apache2 active'
    } else {
      dot.className = 'dot down'
      label.textContent = 'apache2 ' + data.active
    }
  } catch {
    document.getElementById('apache-dot').className = 'dot unknown'
    document.getElementById('apache-label').textContent = 'unreachable'
  }
}

async function showDryRun () {
  const card = document.getElementById('config-card')
  const viewer = document.getElementById('config-viewer')
  const title = document.getElementById('config-title')
  title.textContent = 'Generated Config (Dry Run)'
  card.style.display = 'block'
  viewer.textContent = 'loading...'
  try {
    const data = await api('dry-run')
    viewer.textContent = data.config
  } catch (err) {
    viewer.textContent = 'Error: ' + err.message
  }
}

async function showRemoteConfig () {
  const card = document.getElementById('config-card')
  const viewer = document.getElementById('config-viewer')
  const title = document.getElementById('config-title')
  title.textContent = 'Live Config (7rnx.net)'
  card.style.display = 'block'
  viewer.textContent = 'loading...'
  try {
    const data = await api('remote-config')
    viewer.textContent = data.config || 'No config found'
  } catch (err) {
    viewer.textContent = 'Error: ' + err.message
  }
}

async function deploy () {
  const btn = document.getElementById('deploy-btn')
  if (!confirm('Deploy config to 7rnx.net and reload Apache?')) return
  btn.disabled = true
  btn.textContent = 'Deploying...'
  try {
    const data = await api('deploy', { method: 'POST' })
    if (data.ok) {
      toast('Deploy complete', 'success')
      refreshStatus()
      checkApache()
    } else {
      toast('Deploy failed: ' + data.error, 'error')
    }
  } catch (err) {
    toast('Deploy failed: ' + err.message, 'error')
  } finally {
    btn.disabled = false
    btn.textContent = 'Deploy to 7rnx.net'
  }
}

// Init
refreshStatus()
checkApache()

// Auto-refresh every 30s
setInterval(refreshStatus, 30000)
</script>

</body>
</html>