295 lines
14 KiB
HTML
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>
|