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(); } });