Files

295 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Qortal Node - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
background: radial-gradient(ellipse at top, #0b2a4a 0%, #000 60%) fixed;
color: white;
overflow-x: hidden;
}
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
.glass-card {
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 1rem;
padding: 1.5rem;
}
.glass-button {
background-color: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
border-radius: 0.5rem;
padding: 0.45rem 0.9rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.glass-button:hover { color: white; background-color: rgba(255, 255, 255, 0.08); }
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.app-header-id { display: flex; align-items: center; gap: 1rem; min-width: 0; }
.app-header-id img { width: 3.25rem; height: 3.25rem; }
.app-header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
.pill {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.35rem 0.8rem; border-radius: 999px;
font-size: 0.78rem; font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.pill.syncing .dot { background: #38bdf8; box-shadow: 0 0 8px #38bdf8; }
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.banner { margin-bottom: 1rem; border-left: 3px solid #38bdf8; }
.banner.hidden { display: none; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.card-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
margin-bottom: 1rem;
}
.stat { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.stat:last-child { border-bottom: none; }
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
.stat .value { font-size: 0.9rem; font-weight: 600; text-align: right; overflow-wrap: anywhere; }
.mono { font-family: ui-monospace, Menlo, monospace; font-size: 0.8rem; }
.bar { height: 6px; background: rgba(255, 255, 255, 0.1); border-radius: 3px; overflow: hidden; margin-top: 0.75rem; }
.bar > div { height: 100%; width: 0; background: linear-gradient(90deg, #0ea5e9, #38bdf8); transition: width 0.6s ease; }
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th { text-align: left; color: rgba(255, 255, 255, 0.5); font-weight: 600; padding: 0.4rem 0.5rem; border-bottom: 1px solid rgba(255, 255, 255, 0.12); }
td { padding: 0.4rem 0.5rem; border-bottom: 1px solid rgba(255, 255, 255, 0.06); }
.table-wrap { overflow-x: auto; }
.field { display: flex; gap: 0.5rem; align-items: center; margin: 0.6rem 0; flex-wrap: wrap; }
.field code { flex: 1 1 auto; min-width: 0; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 0.5rem; padding: 0.45rem 0.7rem; overflow-wrap: anywhere; }
input[type=text] { flex: 1 1 12rem; min-width: 0; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 0.5rem; padding: 0.45rem 0.7rem; color: white; font-size: 0.85rem; }
p.note { color: rgba(255, 255, 255, 0.6); font-size: 0.82rem; line-height: 1.5; margin-top: 0.5rem; }
ol { margin: 0.5rem 0 0 1.2rem; color: rgba(255, 255, 255, 0.75); font-size: 0.85rem; line-height: 1.7; }
@media (max-width: 700px) {
.app-header { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="app-header">
<div class="app-header-id">
<img src="qortal.png" alt="">
<div>
<h1>Qortal Node</h1>
<div class="sub" id="version">Qortal Core</div>
</div>
</div>
<span class="pill offline" id="status-pill"><span class="dot"></span><span id="status-text">Connecting…</span></span>
</header>
<div class="glass-card banner hidden" id="starting-banner">
<div class="card-title">Starting up</div>
<p class="note" style="margin-top:0">
Core is not answering yet. On a fresh install it first downloads and imports the
official bootstrap before its API starts. Duration depends on network and storage;
a running container does not mean the node is synchronized. This page refreshes on its own.
</p>
</div>
<div class="grid">
<section class="glass-card">
<div class="card-title">Blockchain</div>
<div class="stat"><span class="label">Block height</span><span class="value" id="height">—</span></div>
<div class="stat"><span class="label">Network height (best peer)</span><span class="value" id="net-height">—</span></div>
<div class="stat"><span class="label">Chain peers</span><span class="value" id="chain-peers">—</span></div>
<div class="stat"><span class="label">Data (QDN) peers</span><span class="value" id="data-peers">—</span></div>
<div class="bar"><div id="sync-bar"></div></div>
</section>
<section class="glass-card">
<div class="card-title">Node</div>
<div class="stat"><span class="label">Version</span><span class="value" id="build">—</span></div>
<div class="stat"><span class="label">Type</span><span class="value" id="type">—</span></div>
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
<div class="stat"><span class="label">Node ID</span><span class="value mono" id="node-id">—</span></div>
<div class="stat"><span class="label">Minting</span><span class="value" id="minting">Coming in a later release</span></div>
</section>
</div>
<div class="grid">
<section class="glass-card">
<div class="card-title">Connect Qortal Hub</div>
<p class="note" style="margin-top:0">
Point Qortal Hub (desktop or mobile) at this node instead of a public one. Your keys
stay in Hub; the node only receives signed transactions.
</p>
<div class="field"><code id="api-url">—</code><button class="glass-button" data-copy="api-url">Copy</button></div>
<ol>
<li>In Qortal Hub, add a custom node.</li>
<li>Enter the URL above. It is the node's API for Hub, not a web page: opened in a
browser it only shows "API documentation pages are currently disabled".</li>
<li>Ask the node administrator for the managed API key when Hub asks for it.
Keep it private: it authorizes node administration. Only enter it into Hub
installations you control.</li>
</ol>
</section>
<section class="glass-card">
<div class="card-title">Qortal Gateway</div>
<p class="note" style="margin-top:0">
Open any Qortal website or Q-App by its registered name. The first visit to a
name fetches it from the data network, so it may show a loading page briefly.
</p>
<form class="field" id="gateway-form">
<input type="text" id="gateway-name" placeholder="Qortal name, e.g. Q-Tube" autocomplete="off">
<button class="glass-button" type="submit">Open</button>
</form>
<div class="field">
<a class="glass-button gw-link" data-name="Q-Tube" target="_blank" rel="noopener">Q-Tube</a>
<a class="glass-button gw-link" data-name="Qortal" target="_blank" rel="noopener">Qortal</a>
</div>
</section>
</div>
<section class="glass-card">
<div class="card-title">Connected peers</div>
<div class="table-wrap">
<table>
<thead><tr><th>Address</th><th>Direction</th><th>Height</th><th>Version</th></tr></thead>
<tbody id="peers"><tr><td colspan="4" class="sub">—</td></tr></tbody>
</table>
</div>
</section>
</div>
<script>
// The gate serves this page on the same scheme and host the dashboard
// used, and fronts the API (12391) and gateway (12380) the same way, so
// build those URLs from the current location rather than any fixed IP.
const API_PORT = 12391;
const GATEWAY_PORT = 12380;
const origin = (port) => `${location.protocol}//${location.hostname}:${port}`;
const $ = (id) => document.getElementById(id);
async function core(path) {
const res = await fetch(`core/${path}`, { cache: 'no-store' });
if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
return res.json();
}
function fmtUptime(ms) {
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`;
}
function setStatus(kind, text) {
$('status-pill').className = `pill ${kind}`;
$('status-text').textContent = text;
}
function renderPeers(peers) {
const body = $('peers');
body.replaceChildren();
if (!peers.length) {
const tr = body.insertRow();
const td = tr.insertCell();
td.colSpan = 4;
td.className = 'sub';
td.textContent = 'No peers connected yet';
return;
}
peers
.slice()
.sort((a, b) => (b.lastHeight || 0) - (a.lastHeight || 0))
.slice(0, 15)
.forEach((p) => {
const tr = body.insertRow();
[p.address, String(p.direction ?? '—').toLowerCase(), p.lastHeight ?? '—', p.version ?? '—'].forEach((v) => {
tr.insertCell().textContent = v;
});
});
}
async function refresh() {
let status, info;
try {
[status, info] = await Promise.all([core('admin/status'), core('admin/info')]);
} catch (e) {
$('starting-banner').classList.remove('hidden');
setStatus('offline', 'Starting');
return;
}
$('starting-banner').classList.add('hidden');
let peers = [];
try { peers = await core('peers'); } catch (e) { /* keep rendering status */ }
const netHeight = peers.reduce((max, p) => Math.max(max, p.lastHeight || 0), 0);
$('height').textContent = status.height.toLocaleString();
$('net-height').textContent = netHeight ? netHeight.toLocaleString() : '—';
$('chain-peers').textContent = status.numberOfConnections;
$('data-peers').textContent = status.numberOfDataConnections ?? '—';
const pct = status.syncPercent ?? (netHeight ? Math.min(100, Math.floor(status.height / netHeight * 100)) : null);
$('sync-bar').style.width = `${pct ?? 0}%`;
if (status.numberOfConnections === 0) setStatus('offline', 'No peers');
else if (status.isSynchronizing || (pct !== null && pct < 100)) setStatus('syncing', `Syncing ${pct ?? ''}%`.trim());
else setStatus('online', 'Synced');
$('version').textContent = info.buildVersion;
$('build').textContent = info.buildVersion.replace(/^qortal-/, '');
$('type').textContent = `${info.type}${info.isTestNet ? ' (testnet)' : ''}`;
$('uptime').textContent = fmtUptime(info.uptime);
$('node-id').textContent = info.nodeId;
renderPeers(peers);
}
$('api-url').textContent = origin(API_PORT);
document.querySelectorAll('.gw-link').forEach((a) => {
a.href = `${origin(GATEWAY_PORT)}/${encodeURIComponent(a.dataset.name)}`;
});
$('gateway-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('gateway-name').value.trim();
if (name) window.open(`${origin(GATEWAY_PORT)}/${encodeURIComponent(name)}`, '_blank', 'noopener');
});
document.querySelectorAll('[data-copy]').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText($(btn.dataset.copy).textContent);
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
} catch (e) { /* clipboard needs a secure context; the text stays selectable */ }
});
});
refresh();
setInterval(refresh, 10000);
</script>
</body>
</html>