<!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>