Initial commit

This commit is contained in:
2026-02-15 16:28:38 +00:00
commit 0e793197bf
24 changed files with 3268 additions and 0 deletions

893
app/static/app.js Normal file
View File

@@ -0,0 +1,893 @@
const CHART_WINDOWS = {
"5m": 5 * 60 * 1000,
"30m": 30 * 60 * 1000,
"2h": 2 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
all: null,
};
const chartWindowSetting = window.localStorage.getItem("chartWindow");
const state = {
settings: null,
commands: [],
charts: {},
chartWindow: chartWindowSetting in CHART_WINDOWS ? chartWindowSetting : "30m",
history: {
timestamps: [],
blocks: [],
headers: [],
mempool: [],
peers: [],
},
pollTimer: null,
};
const numberFmt = new Intl.NumberFormat();
const MAX_HISTORY_POINTS = 20000;
const HISTORY_CACHE_KEY = "tellscoin_history_cache_v1";
const GAP_BREAK_MS = 10 * 60 * 1000;
const el = {
loginView: document.getElementById("login-view"),
appView: document.getElementById("app-view"),
loginForm: document.getElementById("login-form"),
loginUser: document.getElementById("login-username"),
loginPass: document.getElementById("login-password"),
loginError: document.getElementById("login-error"),
logoutBtn: document.getElementById("logout-btn"),
refreshBtn: document.getElementById("refresh-btn"),
settingsBtn: document.getElementById("settings-btn"),
settingsModal: document.getElementById("settings-modal"),
settingsClose: document.getElementById("settings-close"),
settingsForm: document.getElementById("settings-form"),
settingsError: document.getElementById("settings-error"),
liveStatus: document.getElementById("live-status"),
syncTrack: document.getElementById("sync-track"),
syncFill: document.getElementById("sync-fill"),
syncPercent: document.getElementById("sync-percent"),
syncSummary: document.getElementById("sync-summary"),
syncDetails: document.getElementById("sync-details"),
commandList: document.getElementById("command-list"),
commandSearch: document.getElementById("command-search"),
commandCount: document.getElementById("command-count"),
rpcForm: document.getElementById("rpc-form"),
rpcMethod: document.getElementById("rpc-method"),
rpcParams: document.getElementById("rpc-params"),
rpcWallet: document.getElementById("rpc-wallet"),
rpcHelpBtn: document.getElementById("rpc-help-btn"),
rpcError: document.getElementById("rpc-error"),
rpcOutput: document.getElementById("rpc-output"),
actionStop: document.getElementById("action-stop"),
actionStart: document.getElementById("action-start"),
actionRestart: document.getElementById("action-restart"),
chartWindow: document.getElementById("chart-window"),
chartHistoryInfo: document.getElementById("chart-history-info"),
};
function showToast(message, isError = false) {
const node = document.createElement("div");
node.textContent = message;
node.style.position = "fixed";
node.style.right = "1rem";
node.style.bottom = "1rem";
node.style.padding = "0.7rem 0.9rem";
node.style.maxWidth = "340px";
node.style.borderRadius = "12px";
node.style.fontSize = "0.86rem";
node.style.fontWeight = "600";
node.style.background = isError ? "#9f2525" : "#005c53";
node.style.color = "#fff";
node.style.zIndex = "30";
node.style.boxShadow = "0 14px 30px rgba(0,0,0,0.2)";
document.body.appendChild(node);
window.setTimeout(() => node.remove(), 3200);
}
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "-";
}
return numberFmt.format(Number(value));
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes)) {
return "-";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
}
function formatUptime(seconds) {
const sec = Number(seconds);
if (!Number.isFinite(sec)) {
return "-";
}
const days = Math.floor(sec / 86400);
const hours = Math.floor((sec % 86400) / 3600);
const mins = Math.floor((sec % 3600) / 60);
return `${days}d ${hours}h ${mins}m`;
}
function formatChartTick(valueMs) {
const ts = Number(valueMs);
if (!Number.isFinite(ts)) {
return "-";
}
const date = new Date(ts);
if (state.chartWindow === "all" || state.chartWindow === "6h") {
return date.toLocaleString(undefined, {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function getWindowMs(windowKey) {
if (Object.prototype.hasOwnProperty.call(CHART_WINDOWS, windowKey)) {
return CHART_WINDOWS[windowKey];
}
return CHART_WINDOWS["30m"];
}
async function api(path, options = {}) {
const opts = {
method: options.method || "GET",
cache: "no-store",
credentials: "same-origin",
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
},
...(options.body ? { body: JSON.stringify(options.body) } : {}),
};
const response = await fetch(path, opts);
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
if (response.status === 401) {
moveToLogin();
throw new Error("Session expired, please sign in again.");
}
const detail = payload && payload.detail ? payload.detail : `HTTP ${response.status}`;
throw new Error(detail);
}
return payload;
}
function moveToLogin() {
el.appView.classList.add("hidden");
el.loginView.classList.remove("hidden");
if (state.pollTimer) {
window.clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
function moveToApp() {
el.loginView.classList.add("hidden");
el.appView.classList.remove("hidden");
}
function setStat(id, text) {
const node = document.getElementById(id);
if (node) {
node.textContent = text;
}
}
function updateSyncPanel(chain) {
if (!el.syncFill || !el.syncPercent || !el.syncSummary || !el.syncDetails) {
return;
}
const rawProgress = Number(chain.verificationprogress);
const hasProgress = Number.isFinite(rawProgress);
const clampedPercent = hasProgress ? Math.max(0, Math.min(rawProgress * 100, 100)) : 0;
const blocks = Number(chain.blocks);
const headers = Number(chain.headers);
const blockGap = Number.isFinite(blocks) && Number.isFinite(headers) ? Math.max(headers - blocks, 0) : 0;
const inIbd = Boolean(chain.initialblockdownload);
el.syncFill.style.width = `${clampedPercent.toFixed(3)}%`;
el.syncPercent.textContent = hasProgress ? `${clampedPercent.toFixed(2)}%` : "-";
if (el.syncTrack) {
el.syncTrack.setAttribute("aria-valuenow", clampedPercent.toFixed(3));
}
if (!hasProgress) {
el.syncSummary.textContent = "Sync progress unavailable.";
} else if (inIbd || clampedPercent < 99.999) {
el.syncSummary.textContent = `Syncing: ${formatNumber(blocks)} / ${formatNumber(headers)} blocks`;
} else {
el.syncSummary.textContent = "Node reports fully synced.";
}
const details = [];
details.push(`${formatNumber(blockGap)} headers behind`);
details.push(`IBD: ${inIbd ? "yes" : "no"}`);
const tipTime = Number(chain.mediantime);
if (Number.isFinite(tipTime)) {
details.push(`Tip time: ${new Date(tipTime * 1000).toLocaleString()}`);
}
el.syncDetails.textContent = details.join(" | ");
}
function resetHistory() {
state.history.timestamps = [];
state.history.blocks = [];
state.history.headers = [];
state.history.mempool = [];
state.history.peers = [];
}
function getHistoryPoints() {
const points = [];
const length = state.history.timestamps.length;
for (let index = 0; index < length; index += 1) {
points.push({
ts: Math.floor(state.history.timestamps[index] / 1000),
blocks: state.history.blocks[index],
headers: state.history.headers[index],
mempool_bytes: state.history.mempool[index],
peers: state.history.peers[index],
});
}
return points;
}
function saveHistoryCache() {
try {
const points = getHistoryPoints();
const tail = points.slice(Math.max(0, points.length - 12000));
window.localStorage.setItem(HISTORY_CACHE_KEY, JSON.stringify(tail));
} catch {
// best-effort cache only
}
}
function loadHistoryCache() {
try {
const raw = window.localStorage.getItem(HISTORY_CACHE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function setHistoryInfo(message) {
if (el.chartHistoryInfo) {
el.chartHistoryInfo.textContent = message;
}
}
function describeHistory(points, source) {
if (!points.length) {
return `History status: no points (${source}).`;
}
const firstTs = Number(points[0].ts);
const lastTs = Number(points[points.length - 1].ts);
const firstLabel = Number.isFinite(firstTs) ? new Date(firstTs * 1000).toLocaleString() : "-";
const lastLabel = Number.isFinite(lastTs) ? new Date(lastTs * 1000).toLocaleString() : "-";
const parts = [`History status: ${points.length} points (${source}) from ${firstLabel} to ${lastLabel} (browser local time).`];
const windowMs = getWindowMs(state.chartWindow);
if (windowMs !== null && Number.isFinite(firstTs)) {
const expectedStartMs = Date.now() - windowMs;
if (firstTs * 1000 > expectedStartMs + 60_000) {
parts.push("No samples exist for earlier part of selected window.");
}
}
return parts.join(" ");
}
function filterPointsForWindow(points, windowKey) {
const windowMs = getWindowMs(windowKey);
if (windowMs === null) {
return points;
}
const threshold = Date.now() - windowMs;
return points.filter((point) => Number(point.ts) * 1000 >= threshold);
}
function setHistoryFromPoints(points) {
resetHistory();
const sortedPoints = [...points].sort((a, b) => Number(a.ts) - Number(b.ts));
sortedPoints.forEach((point) => {
state.history.timestamps.push(Number(point.ts) * 1000);
state.history.blocks.push(Number(point.blocks || 0));
state.history.headers.push(Number(point.headers || 0));
state.history.mempool.push(Number(point.mempool_bytes || 0));
state.history.peers.push(Number(point.peers || 0));
});
saveHistoryCache();
}
async function loadHistory() {
const params = new URLSearchParams();
params.set("limit", state.chartWindow === "all" ? "20000" : "8000");
params.set("_", String(Date.now()));
const payload = await api(`/api/dashboard/history/${encodeURIComponent(state.chartWindow)}?${params.toString()}`);
const points = Array.isArray(payload.points) ? payload.points : [];
if (points.length > 0) {
setHistoryFromPoints(points);
setHistoryInfo(describeHistory(points, "server"));
refreshCharts();
return;
}
const cachedPoints = filterPointsForWindow(loadHistoryCache(), state.chartWindow);
setHistoryFromPoints(cachedPoints);
setHistoryInfo(describeHistory(cachedPoints, "browser cache"));
refreshCharts();
}
function updateHistory(summary) {
const timestamp = Number(summary.updated_at || Math.floor(Date.now() / 1000)) * 1000;
const chain = summary.blockchain || {};
const net = summary.network || {};
const mem = summary.mempool || {};
const blocks = Number(chain.blocks || 0);
const headers = Number(chain.headers || 0);
const mempoolBytes = Number(mem.bytes || 0);
const peers = Number(net.connections || 0);
const lastIndex = state.history.timestamps.length - 1;
if (lastIndex >= 0 && state.history.timestamps[lastIndex] === timestamp) {
state.history.blocks[lastIndex] = blocks;
state.history.headers[lastIndex] = headers;
state.history.mempool[lastIndex] = mempoolBytes;
state.history.peers[lastIndex] = peers;
return;
}
state.history.timestamps.push(timestamp);
state.history.blocks.push(blocks);
state.history.headers.push(headers);
state.history.mempool.push(mempoolBytes);
state.history.peers.push(peers);
if (state.history.timestamps.length > MAX_HISTORY_POINTS) {
Object.keys(state.history).forEach((key) => {
state.history[key].shift();
});
}
saveHistoryCache();
}
function getChartSlice() {
const timestamps = state.history.timestamps;
if (!timestamps.length) {
return {
timestamps: [],
blocks: [],
headers: [],
mempool: [],
peers: [],
};
}
const windowMs = getWindowMs(state.chartWindow);
let startIndex = 0;
if (windowMs !== null) {
const threshold = Date.now() - windowMs;
while (startIndex < timestamps.length && timestamps[startIndex] < threshold) {
startIndex += 1;
}
if (startIndex >= timestamps.length) {
return {
timestamps: [],
blocks: [],
headers: [],
mempool: [],
peers: [],
};
}
}
return {
timestamps: timestamps.slice(startIndex),
blocks: state.history.blocks.slice(startIndex),
headers: state.history.headers.slice(startIndex),
mempool: state.history.mempool.slice(startIndex),
peers: state.history.peers.slice(startIndex),
};
}
function createChart(nodeId, datasets, yScale = {}) {
const context = document.getElementById(nodeId);
return new Chart(context, {
type: "line",
data: {
labels: [],
datasets,
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
parsing: false,
normalized: true,
spanGaps: false,
scales: {
x: {
type: "linear",
ticks: {
maxTicksLimit: 6,
callback: (value) => formatChartTick(value),
},
},
y: yScale,
},
plugins: {
legend: {
labels: {
boxWidth: 12,
},
},
},
},
});
}
function ensureCharts() {
if (state.charts.blocks) {
return;
}
state.charts.blocks = createChart("chart-blocks", [
{
label: "Blocks",
data: [],
borderColor: "#005c53",
backgroundColor: "rgba(0,92,83,0.16)",
borderWidth: 2,
tension: 0,
pointRadius: 1.2,
pointHoverRadius: 3,
},
{
label: "Headers",
data: [],
borderColor: "#cf6f1b",
backgroundColor: "rgba(207,111,27,0.14)",
borderWidth: 2,
tension: 0,
pointRadius: 1.2,
pointHoverRadius: 3,
},
]);
state.charts.mempool = createChart("chart-mempool", [
{
label: "Bytes",
data: [],
borderColor: "#234b9b",
backgroundColor: "rgba(35,75,155,0.14)",
borderWidth: 2,
tension: 0,
pointRadius: 1.2,
pointHoverRadius: 3,
},
], {
min: 0,
beginAtZero: true,
ticks: {
callback: (value) => formatBytes(value),
},
});
state.charts.peers = createChart("chart-peers", [
{
label: "Connections",
data: [],
borderColor: "#8f3c23",
backgroundColor: "rgba(143,60,35,0.14)",
borderWidth: 2,
tension: 0,
pointRadius: 1.2,
pointHoverRadius: 3,
},
], {
min: 0,
beginAtZero: true,
ticks: {
precision: 0,
stepSize: 1,
callback: (value) => Math.round(Number(value)).toString(),
},
});
}
function refreshCharts() {
ensureCharts();
const slice = getChartSlice();
const toSeries = (values) => {
const points = [];
for (let index = 0; index < slice.timestamps.length; index += 1) {
const ts = slice.timestamps[index];
const y = values[index];
if (index > 0) {
const prevTs = slice.timestamps[index - 1];
if (ts - prevTs > GAP_BREAK_MS) {
points.push({ x: prevTs + 1, y: null });
points.push({ x: ts - 1, y: null });
}
}
points.push({ x: ts, y });
}
return points;
};
const windowMs = getWindowMs(state.chartWindow);
const now = Date.now();
let xMin = null;
let xMax = null;
if (windowMs === null) {
if (slice.timestamps.length > 0) {
xMin = slice.timestamps[0];
xMax = slice.timestamps[slice.timestamps.length - 1];
}
} else {
xMin = now - windowMs;
xMax = now;
}
state.charts.blocks.data.labels = [];
state.charts.blocks.data.datasets[0].data = toSeries(slice.blocks);
state.charts.blocks.data.datasets[1].data = toSeries(slice.headers);
state.charts.mempool.data.labels = [];
state.charts.mempool.data.datasets[0].data = toSeries(slice.mempool);
state.charts.peers.data.labels = [];
state.charts.peers.data.datasets[0].data = toSeries(slice.peers);
Object.values(state.charts).forEach((chart) => {
chart.options.scales.x.min = xMin;
chart.options.scales.x.max = xMax;
});
Object.values(state.charts).forEach((chart) => chart.update("none"));
}
async function refreshSummary(showSuccess = false) {
try {
const summary = await api("/api/dashboard/summary");
const chain = summary.blockchain || {};
const net = summary.network || {};
const mem = summary.mempool || {};
setStat("stat-chain", chain.chain || "-");
setStat("stat-blocks", formatNumber(chain.blocks));
setStat("stat-headers", formatNumber(chain.headers));
setStat("stat-peers", formatNumber(net.connections));
setStat("stat-mempool-tx", formatNumber(mem.size));
setStat("stat-mempool-size", formatBytes(mem.bytes));
setStat("stat-difficulty", formatNumber(chain.difficulty));
setStat("stat-uptime", formatUptime(summary.uptime));
updateSyncPanel(chain);
updateHistory(summary);
refreshCharts();
setHistoryInfo(describeHistory(getHistoryPoints(), "live"));
const now = new Date().toLocaleTimeString();
el.liveStatus.textContent = `Connected - updated ${now}`;
if (showSuccess) {
showToast("Dashboard refreshed");
}
} catch (error) {
el.liveStatus.textContent = `Connection issue: ${error.message}`;
if (showSuccess) {
showToast(error.message, true);
}
}
}
function populateSettingsForm(settings) {
Object.entries(settings).forEach(([key, value]) => {
const input = el.settingsForm.elements.namedItem(key);
if (input) {
input.value = value ?? "";
}
});
}
async function loadSettings() {
state.settings = await api("/api/settings");
populateSettingsForm(state.settings);
}
function openSettings() {
el.settingsError.textContent = "";
el.settingsModal.classList.remove("hidden");
}
function closeSettings() {
el.settingsModal.classList.add("hidden");
}
function renderCommandList() {
const filter = (el.commandSearch.value || "").trim().toLowerCase();
const filtered = state.commands.filter((item) => item.method.includes(filter));
el.commandList.innerHTML = "";
filtered.forEach((item) => {
const button = document.createElement("button");
button.className = "command-item";
button.type = "button";
const method = document.createElement("span");
method.className = "command-method";
method.textContent = item.method;
const synopsis = document.createElement("span");
synopsis.className = "command-synopsis";
synopsis.textContent = item.synopsis;
button.append(method, synopsis);
button.addEventListener("click", () => {
el.rpcMethod.value = item.method;
el.rpcError.textContent = "";
Array.from(el.commandList.children).forEach((child) => child.classList.remove("active"));
button.classList.add("active");
});
el.commandList.appendChild(button);
});
el.commandCount.textContent = `${filtered.length} shown / ${state.commands.length} total`;
}
async function loadCommandCatalog() {
const payload = await api("/api/rpc/commands");
state.commands = payload.commands || [];
renderCommandList();
}
function parseParams(text) {
const trimmed = (text || "").trim();
if (!trimmed) {
return [];
}
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error("Params must be valid JSON.");
}
if (!Array.isArray(parsed)) {
throw new Error("Params must be a JSON array, e.g. [] or [\"value\", 1].");
}
return parsed;
}
function writeOutput(label, data) {
const timestamp = new Date().toLocaleString();
el.rpcOutput.textContent = `${label} @ ${timestamp}\n\n${JSON.stringify(data, null, 2)}`;
}
async function executeRpc(method, params, wallet) {
const result = await api("/api/rpc/call", {
method: "POST",
body: {
method,
params,
wallet: wallet || null,
},
});
writeOutput(`RPC ${method}`, result.result);
}
async function handleNodeAction(action, button) {
const confirmations = {
stop: "Stop bitcoind via RPC?",
restart: "Restart bitcoind now?",
};
if (confirmations[action] && !window.confirm(confirmations[action])) {
return;
}
const originalText = button.textContent;
button.disabled = true;
button.textContent = "Working...";
try {
const result = await api(`/api/actions/${action}`, { method: "POST" });
writeOutput(`Action ${action}`, result);
showToast(`Action ${action} completed`);
window.setTimeout(() => refreshSummary(false), 1500);
} catch (error) {
showToast(error.message, true);
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
async function bootDashboard() {
moveToApp();
if (el.chartWindow) {
el.chartWindow.value = state.chartWindow;
}
await loadSettings();
await loadCommandCatalog();
await loadHistory();
await refreshSummary(false);
if (state.pollTimer) {
window.clearInterval(state.pollTimer);
}
state.pollTimer = window.setInterval(() => {
refreshSummary(false);
}, 15000);
if (!state.settings.rpc_username || !state.settings.rpc_password) {
openSettings();
showToast("Configure node settings to connect.", true);
}
}
el.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
el.loginError.textContent = "";
try {
await api("/api/auth/login", {
method: "POST",
body: {
username: el.loginUser.value,
password: el.loginPass.value,
},
});
await bootDashboard();
} catch (error) {
el.loginError.textContent = error.message;
}
});
el.logoutBtn.addEventListener("click", async () => {
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
// ignore; local state still transitions to login
}
moveToLogin();
});
el.refreshBtn.addEventListener("click", async () => {
await refreshSummary(true);
});
el.settingsBtn.addEventListener("click", openSettings);
el.settingsClose.addEventListener("click", closeSettings);
el.settingsModal.addEventListener("click", (event) => {
if (event.target === el.settingsModal) {
closeSettings();
}
});
el.settingsForm.addEventListener("submit", async (event) => {
event.preventDefault();
el.settingsError.textContent = "";
const body = Object.fromEntries(new FormData(el.settingsForm).entries());
body.ssh_port = Number(body.ssh_port || 22);
try {
state.settings = await api("/api/settings", {
method: "PUT",
body,
});
populateSettingsForm(state.settings);
closeSettings();
showToast("Settings saved");
await refreshSummary(false);
await loadCommandCatalog();
} catch (error) {
el.settingsError.textContent = error.message;
}
});
el.commandSearch.addEventListener("input", renderCommandList);
el.rpcForm.addEventListener("submit", async (event) => {
event.preventDefault();
el.rpcError.textContent = "";
try {
const params = parseParams(el.rpcParams.value);
await executeRpc(el.rpcMethod.value.trim(), params, el.rpcWallet.value.trim());
} catch (error) {
el.rpcError.textContent = error.message;
}
});
el.rpcHelpBtn.addEventListener("click", async () => {
el.rpcError.textContent = "";
const method = el.rpcMethod.value.trim();
if (!method) {
el.rpcError.textContent = "Enter a method first.";
return;
}
try {
const result = await api(`/api/rpc/help/${encodeURIComponent(method)}`);
writeOutput(`Help ${method}`, result.help);
} catch (error) {
el.rpcError.textContent = error.message;
}
});
document.querySelectorAll(".quick-rpc").forEach((button) => {
button.addEventListener("click", async () => {
const method = button.dataset.method;
el.rpcMethod.value = method;
el.rpcParams.value = "[]";
el.rpcError.textContent = "";
try {
await executeRpc(method, [], el.rpcWallet.value.trim());
} catch (error) {
el.rpcError.textContent = error.message;
}
});
});
el.actionStop.addEventListener("click", () => handleNodeAction("stop", el.actionStop));
el.actionStart.addEventListener("click", () => handleNodeAction("start", el.actionStart));
el.actionRestart.addEventListener("click", () => handleNodeAction("restart", el.actionRestart));
if (el.chartWindow) {
el.chartWindow.addEventListener("change", async () => {
state.chartWindow = el.chartWindow.value in CHART_WINDOWS ? el.chartWindow.value : "30m";
window.localStorage.setItem("chartWindow", state.chartWindow);
try {
await loadHistory();
} catch (error) {
showToast(error.message, true);
}
});
}
window.addEventListener("load", async () => {
try {
const auth = await api("/api/auth/me");
if (auth.authenticated) {
await bootDashboard();
} else {
moveToLogin();
}
} catch {
moveToLogin();
}
});