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

0
app/__init__.py Normal file
View File

34
app/auth.py Normal file
View File

@@ -0,0 +1,34 @@
import secrets
from passlib.context import CryptContext
from starlette.requests import Request
from fastapi import HTTPException, status
from app.config import get_config
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_credentials(username: str, password: str) -> bool:
cfg = get_config()
username_ok = secrets.compare_digest(username, cfg.app_username)
if not username_ok:
return False
if cfg.app_password_hash:
return pwd_context.verify(password, cfg.app_password_hash)
return secrets.compare_digest(password, cfg.app_password)
def require_auth(request: Request) -> str:
username = request.session.get("username")
cfg = get_config()
if not username or username != cfg.app_username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
return username

126
app/bitcoin_rpc.py Normal file
View File

@@ -0,0 +1,126 @@
import json
import re
from typing import Any
from urllib.parse import quote
import requests
from app.config import get_config
class BitcoinRPCError(RuntimeError):
pass
class BitcoinRPCClient:
def __init__(
self,
rpc_url: str,
username: str,
password: str,
wallet: str = "",
timeout: int | None = None,
) -> None:
cfg = get_config()
self.rpc_url = rpc_url.rstrip("/")
self.username = username
self.password = password
self.wallet = wallet.strip()
self.timeout = timeout or cfg.request_timeout_seconds
def _endpoint(self, wallet_override: str | None = None) -> str:
wallet = self.wallet if wallet_override is None else wallet_override.strip()
if wallet:
return f"{self.rpc_url}/wallet/{quote(wallet, safe='')}"
return self.rpc_url
def call(
self,
method: str,
params: list[Any] | None = None,
wallet_override: str | None = None,
) -> Any:
payload = {
"jsonrpc": "1.0",
"id": "tellscoin-dashboard",
"method": method,
"params": params or [],
}
try:
response = requests.post(
self._endpoint(wallet_override),
json=payload,
auth=(self.username, self.password),
timeout=self.timeout,
)
response.raise_for_status()
body = response.json()
except requests.RequestException as exc:
raise BitcoinRPCError(f"RPC request failed: {exc}") from exc
except json.JSONDecodeError as exc:
raise BitcoinRPCError("RPC response was not valid JSON") from exc
if body.get("error"):
raise BitcoinRPCError(str(body["error"]))
return body.get("result")
def batch_call(self, calls: list[tuple[str, list[Any]]]) -> dict[str, Any]:
payload = []
for index, (method, params) in enumerate(calls):
payload.append(
{
"jsonrpc": "1.0",
"id": str(index),
"method": method,
"params": params,
}
)
try:
response = requests.post(
self._endpoint(),
json=payload,
auth=(self.username, self.password),
timeout=self.timeout,
)
response.raise_for_status()
body = response.json()
except requests.RequestException as exc:
raise BitcoinRPCError(f"Batch RPC request failed: {exc}") from exc
by_id = {entry.get("id"): entry for entry in body}
results = {}
for index, (method, _) in enumerate(calls):
key = str(index)
entry = by_id.get(key)
if not entry:
raise BitcoinRPCError(f"Missing batch result for method {method}")
if entry.get("error"):
raise BitcoinRPCError(f"{method}: {entry['error']}")
results[method] = entry.get("result")
return results
COMMAND_LINE_RE = re.compile(r"^([a-z][a-z0-9_]*)\b")
def parse_help_output(help_text: str) -> list[dict[str, str]]:
commands: list[dict[str, str]] = []
seen = set()
for raw in help_text.splitlines():
line = raw.strip()
if not line or line.startswith("=="):
continue
match = COMMAND_LINE_RE.match(line)
if not match:
continue
method = match.group(1)
if method in seen:
continue
seen.add(method)
commands.append({"method": method, "synopsis": line})
commands.sort(key=lambda item: item["method"])
return commands

45
app/config.py Normal file
View File

@@ -0,0 +1,45 @@
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class AppConfig:
app_username: str
app_password: str
app_password_hash: str | None
session_secret: str
database_path: Path
request_timeout_seconds: int
session_cookie_secure: bool
metrics_sampler_interval_seconds: int
def _bool_env(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@lru_cache(maxsize=1)
def get_config() -> AppConfig:
data_dir = Path(os.getenv("DATA_DIR", "./data"))
data_dir.mkdir(parents=True, exist_ok=True)
return AppConfig(
app_username=os.getenv("APP_USERNAME", "admin"),
app_password=os.getenv("APP_PASSWORD", "changeme"),
app_password_hash=os.getenv("APP_PASSWORD_HASH"),
session_secret=os.getenv("SESSION_SECRET", "change-this-secret"),
database_path=Path(os.getenv("DB_PATH", str(data_dir / "dashboard.db"))),
request_timeout_seconds=int(os.getenv("RPC_TIMEOUT_SECONDS", "15")),
session_cookie_secure=_bool_env("SESSION_COOKIE_SECURE", False),
metrics_sampler_interval_seconds=max(15, int(os.getenv("METRICS_SAMPLER_INTERVAL_SECONDS", "60"))),
)

233
app/db.py Normal file
View File

@@ -0,0 +1,233 @@
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from app.config import get_config
SETTINGS_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS node_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
rpc_url TEXT NOT NULL DEFAULT '',
rpc_username TEXT NOT NULL DEFAULT '',
rpc_password TEXT NOT NULL DEFAULT '',
rpc_wallet TEXT NOT NULL DEFAULT '',
config_path TEXT NOT NULL DEFAULT '',
ssh_host TEXT NOT NULL DEFAULT '',
ssh_port INTEGER NOT NULL DEFAULT 22,
ssh_username TEXT NOT NULL DEFAULT '',
ssh_password TEXT NOT NULL DEFAULT '',
ssh_key_path TEXT NOT NULL DEFAULT '',
bitcoin_binary TEXT NOT NULL DEFAULT 'bitcoind',
updated_at TEXT NOT NULL
);
"""
METRICS_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS metrics_history (
ts INTEGER PRIMARY KEY,
blocks INTEGER NOT NULL,
headers INTEGER NOT NULL,
mempool_bytes INTEGER NOT NULL,
peers INTEGER NOT NULL
);
"""
METRICS_INDEX_SQL = """
CREATE INDEX IF NOT EXISTS idx_metrics_history_ts ON metrics_history (ts);
"""
DEFAULT_SETTINGS = {
"rpc_url": "http://127.0.0.1:8332",
"rpc_username": "",
"rpc_password": "",
"rpc_wallet": "",
"config_path": "",
"ssh_host": "",
"ssh_port": 22,
"ssh_username": "",
"ssh_password": "",
"ssh_key_path": "",
"bitcoin_binary": "bitcoind",
"updated_at": "",
}
METRICS_RETENTION_SECONDS = 60 * 60 * 24 * 30
METRICS_MAX_ROWS = 20000
def _ensure_db_parent(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
@contextmanager
def _connect() -> sqlite3.Connection:
cfg = get_config()
db_path = cfg.database_path
_ensure_db_parent(db_path)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def init_db() -> None:
with _connect() as conn:
conn.execute(SETTINGS_TABLE_SQL)
conn.execute(METRICS_TABLE_SQL)
conn.execute(METRICS_INDEX_SQL)
conn.commit()
def get_settings() -> dict:
with _connect() as conn:
row = conn.execute("SELECT * FROM node_settings WHERE id = 1").fetchone()
if not row:
return DEFAULT_SETTINGS.copy()
data = dict(row)
for key, value in DEFAULT_SETTINGS.items():
data.setdefault(key, value)
return data
def save_settings(values: dict) -> dict:
current = get_settings()
merged = {**current, **values}
merged["updated_at"] = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO node_settings (
id,
rpc_url,
rpc_username,
rpc_password,
rpc_wallet,
config_path,
ssh_host,
ssh_port,
ssh_username,
ssh_password,
ssh_key_path,
bitcoin_binary,
updated_at
)
VALUES (
1,
:rpc_url,
:rpc_username,
:rpc_password,
:rpc_wallet,
:config_path,
:ssh_host,
:ssh_port,
:ssh_username,
:ssh_password,
:ssh_key_path,
:bitcoin_binary,
:updated_at
)
ON CONFLICT(id) DO UPDATE SET
rpc_url = excluded.rpc_url,
rpc_username = excluded.rpc_username,
rpc_password = excluded.rpc_password,
rpc_wallet = excluded.rpc_wallet,
config_path = excluded.config_path,
ssh_host = excluded.ssh_host,
ssh_port = excluded.ssh_port,
ssh_username = excluded.ssh_username,
ssh_password = excluded.ssh_password,
ssh_key_path = excluded.ssh_key_path,
bitcoin_binary = excluded.bitcoin_binary,
updated_at = excluded.updated_at
""",
merged,
)
conn.commit()
return merged
def save_metric_point(
*,
timestamp: int,
blocks: int,
headers: int,
mempool_bytes: int,
peers: int,
) -> None:
with _connect() as conn:
conn.execute(
"""
INSERT INTO metrics_history (ts, blocks, headers, mempool_bytes, peers)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(ts) DO UPDATE SET
blocks = excluded.blocks,
headers = excluded.headers,
mempool_bytes = excluded.mempool_bytes,
peers = excluded.peers
""",
(timestamp, blocks, headers, mempool_bytes, peers),
)
cutoff = timestamp - METRICS_RETENTION_SECONDS
conn.execute("DELETE FROM metrics_history WHERE ts < ?", (cutoff,))
conn.execute(
"""
DELETE FROM metrics_history
WHERE ts NOT IN (
SELECT ts FROM metrics_history ORDER BY ts DESC LIMIT ?
)
""",
(METRICS_MAX_ROWS,),
)
conn.commit()
def get_metric_history(*, since_ts: int | None = None, limit: int = 5000) -> list[dict]:
if limit < 1:
limit = 1
if limit > METRICS_MAX_ROWS:
limit = METRICS_MAX_ROWS
with _connect() as conn:
if since_ts is None:
rows = conn.execute(
"""
SELECT ts, blocks, headers, mempool_bytes, peers
FROM metrics_history
ORDER BY ts DESC
LIMIT ?
""",
(limit,),
).fetchall()
rows = list(reversed(rows))
else:
rows = conn.execute(
"""
SELECT ts, blocks, headers, mempool_bytes, peers
FROM metrics_history
WHERE ts >= ?
ORDER BY ts ASC
LIMIT ?
""",
(since_ts, limit),
).fetchall()
return [dict(row) for row in rows]

413
app/main.py Normal file
View File

@@ -0,0 +1,413 @@
import time
import threading
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from app.auth import require_auth, verify_credentials
from app.bitcoin_rpc import BitcoinRPCClient, BitcoinRPCError, parse_help_output
from app.config import get_config
from app.db import get_metric_history, get_settings, init_db, save_metric_point, save_settings
from app.ssh_control import SSHControlError, build_start_command, run_remote_command
cfg = get_config()
CHART_WINDOW_SECONDS = {
"5m": 5 * 60,
"30m": 30 * 60,
"2h": 2 * 60 * 60,
"6h": 6 * 60 * 60,
"all": None,
}
sampler_stop_event = threading.Event()
sampler_thread: threading.Thread | None = None
app = FastAPI(title="Bitcoin Core Admin Dashboard")
app.add_middleware(
SessionMiddleware,
secret_key=cfg.session_secret,
same_site="lax",
https_only=cfg.session_cookie_secure,
max_age=60 * 60 * 8,
)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
@app.on_event("startup")
def on_startup() -> None:
global sampler_thread
init_db()
if sampler_thread and sampler_thread.is_alive():
return
sampler_stop_event.clear()
sampler_thread = threading.Thread(
target=_metrics_sampler_loop,
name="metrics-sampler",
daemon=True,
)
sampler_thread.start()
@app.on_event("shutdown")
def on_shutdown() -> None:
global sampler_thread
sampler_stop_event.set()
if sampler_thread and sampler_thread.is_alive():
sampler_thread.join(timeout=2)
sampler_thread = None
@app.middleware("http")
async def no_cache_for_api(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/") or request.url.path.startswith("/static/"):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@app.exception_handler(BitcoinRPCError)
async def handle_rpc_error(_: Request, exc: BitcoinRPCError) -> JSONResponse:
return JSONResponse(status_code=502, content={"detail": str(exc)})
@app.exception_handler(SSHControlError)
async def handle_ssh_error(_: Request, exc: SSHControlError) -> JSONResponse:
return JSONResponse(status_code=502, content={"detail": str(exc)})
class LoginPayload(BaseModel):
username: str
password: str
class NodeSettingsPayload(BaseModel):
rpc_url: str = Field(default="http://127.0.0.1:8332")
rpc_username: str = ""
rpc_password: str = ""
rpc_wallet: str = ""
config_path: str = ""
ssh_host: str = ""
ssh_port: int = 22
ssh_username: str = ""
ssh_password: str = ""
ssh_key_path: str = ""
bitcoin_binary: str = "bitcoind"
class RPCCallPayload(BaseModel):
method: str
params: list[Any] = Field(default_factory=list)
wallet: str | None = None
def _coerce_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _window_to_since(window: str) -> int | None:
if window not in CHART_WINDOW_SECONDS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported window '{window}'",
)
seconds = CHART_WINDOW_SECONDS[window]
if seconds is None:
return None
return int(time.time()) - seconds
def _collect_core_results(client: BitcoinRPCClient) -> dict[str, Any]:
return client.batch_call(
[
("getblockchaininfo", []),
("getnetworkinfo", []),
("getmempoolinfo", []),
("getmininginfo", []),
("uptime", []),
]
)
def _store_metrics_from_core(core_results: dict[str, Any], updated_at: int) -> None:
chain_data = core_results.get("getblockchaininfo") or {}
network_data = core_results.get("getnetworkinfo") or {}
mempool_data = core_results.get("getmempoolinfo") or {}
save_metric_point(
timestamp=updated_at,
blocks=_coerce_int(chain_data.get("blocks"), 0),
headers=_coerce_int(chain_data.get("headers"), 0),
mempool_bytes=_coerce_int(mempool_data.get("bytes"), 0),
peers=_coerce_int(network_data.get("connections"), 0),
)
def _metrics_sampler_loop() -> None:
interval = cfg.metrics_sampler_interval_seconds
while not sampler_stop_event.is_set():
try:
settings = get_settings()
client = _rpc_client_from_settings(settings)
core_results = _collect_core_results(client)
_store_metrics_from_core(core_results, int(time.time()))
except Exception:
# Background sampler is best-effort. API requests surface actionable errors.
pass
sampler_stop_event.wait(interval)
def _rpc_client_from_settings(settings: dict) -> BitcoinRPCClient:
rpc_url = (settings.get("rpc_url") or "").strip()
rpc_username = settings.get("rpc_username") or ""
rpc_password = settings.get("rpc_password") or ""
rpc_wallet = settings.get("rpc_wallet") or ""
if not rpc_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Node RPC URL is not configured",
)
if not rpc_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Node RPC username is not configured",
)
if not rpc_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Node RPC password is not configured",
)
return BitcoinRPCClient(
rpc_url=rpc_url,
username=rpc_username,
password=rpc_password,
wallet=rpc_wallet,
)
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/api/health")
def health() -> dict:
return {"ok": True}
@app.get("/api/auth/me")
def auth_me(request: Request) -> dict:
username = request.session.get("username")
return {
"authenticated": bool(username and username == cfg.app_username),
"username": username if username == cfg.app_username else None,
}
@app.post("/api/auth/login")
def auth_login(payload: LoginPayload, request: Request) -> dict:
if not verify_credentials(payload.username, payload.password):
raise HTTPException(status_code=401, detail="Invalid username or password")
request.session["username"] = payload.username
return {"ok": True, "username": payload.username}
@app.post("/api/auth/logout")
def auth_logout(request: Request, _: str = Depends(require_auth)) -> dict:
request.session.clear()
return {"ok": True}
@app.get("/api/settings")
def read_settings(_: str = Depends(require_auth)) -> dict:
return get_settings()
@app.put("/api/settings")
def update_settings(payload: NodeSettingsPayload, _: str = Depends(require_auth)) -> dict:
values = payload.model_dump()
values["rpc_url"] = values["rpc_url"].strip()
for key in [
"rpc_username",
"rpc_password",
"rpc_wallet",
"config_path",
"ssh_host",
"ssh_username",
"ssh_password",
"ssh_key_path",
"bitcoin_binary",
]:
values[key] = str(values.get(key, "")).strip()
if values["ssh_port"] < 1 or values["ssh_port"] > 65535:
raise HTTPException(status_code=422, detail="SSH port must be between 1 and 65535")
return save_settings(values)
@app.post("/api/rpc/call")
def rpc_call(payload: RPCCallPayload, _: str = Depends(require_auth)) -> dict:
settings = get_settings()
client = _rpc_client_from_settings(settings)
method = payload.method.strip()
if not method:
raise HTTPException(status_code=422, detail="RPC method is required")
result = client.call(method=method, params=payload.params, wallet_override=payload.wallet)
return {"method": method, "result": result}
@app.get("/api/rpc/commands")
def rpc_commands(_: str = Depends(require_auth)) -> dict:
settings = get_settings()
client = _rpc_client_from_settings(settings)
help_text = client.call("help", [])
if not isinstance(help_text, str):
raise HTTPException(status_code=500, detail="Unexpected help response from node")
commands = parse_help_output(help_text)
return {"count": len(commands), "commands": commands}
@app.get("/api/rpc/help/{method_name}")
def rpc_method_help(method_name: str, _: str = Depends(require_auth)) -> dict:
settings = get_settings()
client = _rpc_client_from_settings(settings)
details = client.call("help", [method_name])
return {"method": method_name, "help": details}
@app.get("/api/dashboard/summary")
def dashboard_summary(_: str = Depends(require_auth)) -> dict:
settings = get_settings()
client = _rpc_client_from_settings(settings)
core_results = _collect_core_results(client)
chain_data = core_results.get("getblockchaininfo") or {}
network_data = core_results.get("getnetworkinfo") or {}
mempool_data = core_results.get("getmempoolinfo") or {}
mining_data = core_results.get("getmininginfo") or {}
uptime_data = core_results.get("uptime")
updated_at = int(time.time())
_store_metrics_from_core(core_results, updated_at)
wallet_data = None
wallet_name = settings.get("rpc_wallet") or ""
if wallet_name:
try:
wallet_data = client.call("getwalletinfo", [], wallet_override=wallet_name)
except BitcoinRPCError:
wallet_data = None
return {
"blockchain": chain_data,
"network": network_data,
"mempool": mempool_data,
"mining": mining_data,
"uptime": uptime_data,
"wallet": wallet_data,
"updated_at": updated_at,
}
@app.get("/api/dashboard/history")
def dashboard_history(
window: str = "30m",
limit: int = 5000,
_: str = Depends(require_auth),
) -> dict:
since_ts = _window_to_since(window)
points = get_metric_history(since_ts=since_ts, limit=limit)
return {
"window": window,
"count": len(points),
"points": points,
}
@app.get("/api/dashboard/history/{window}")
def dashboard_history_by_window(
window: str,
limit: int = 5000,
_: str = Depends(require_auth),
) -> dict:
since_ts = _window_to_since(window)
points = get_metric_history(since_ts=since_ts, limit=limit)
return {
"window": window,
"count": len(points),
"points": points,
}
@app.post("/api/actions/stop")
def stop_node(_: str = Depends(require_auth)) -> dict:
settings = get_settings()
client = _rpc_client_from_settings(settings)
result = client.call("stop", [])
return {"action": "stop", "result": result}
@app.post("/api/actions/start")
def start_node(_: str = Depends(require_auth)) -> dict:
settings = get_settings()
start_command = build_start_command(settings)
remote = run_remote_command(settings, start_command)
if remote["exit_code"] != 0:
raise HTTPException(
status_code=502,
detail=f"Remote start command failed (exit {remote['exit_code']}): {remote['stderr'] or remote['stdout']}",
)
return {
"action": "start",
"command": start_command,
"remote": remote,
}
@app.post("/api/actions/restart")
def restart_node(_: str = Depends(require_auth)) -> dict:
settings = get_settings()
stop_response = None
stop_error = None
try:
client = _rpc_client_from_settings(settings)
stop_response = client.call("stop", [])
time.sleep(2)
except (BitcoinRPCError, HTTPException) as exc:
stop_error = str(exc)
start_command = build_start_command(settings)
remote = run_remote_command(settings, start_command)
if remote["exit_code"] != 0:
raise HTTPException(
status_code=502,
detail=f"Remote restart command failed (exit {remote['exit_code']}): {remote['stderr'] or remote['stdout']}",
)
return {
"action": "restart",
"stop_result": stop_response,
"stop_error": stop_error,
"start_command": start_command,
"remote": remote,
}

82
app/ssh_control.py Normal file
View File

@@ -0,0 +1,82 @@
import shlex
from urllib.parse import urlparse
import paramiko
class SSHControlError(RuntimeError):
pass
def _derive_host_from_url(rpc_url: str) -> str:
try:
parsed = urlparse(rpc_url)
return parsed.hostname or ""
except ValueError:
return ""
def run_remote_command(settings: dict, command: str) -> dict:
ssh_host = (settings.get("ssh_host") or "").strip() or _derive_host_from_url(settings.get("rpc_url", ""))
ssh_username = (settings.get("ssh_username") or "").strip()
ssh_password = settings.get("ssh_password") or ""
ssh_key_path = (settings.get("ssh_key_path") or "").strip()
ssh_port = int(settings.get("ssh_port") or 22)
if not ssh_host:
raise SSHControlError("SSH host is required to run start/restart commands")
if not ssh_username:
raise SSHControlError("SSH username is required to run start/restart commands")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_args = {
"hostname": ssh_host,
"port": ssh_port,
"username": ssh_username,
"timeout": 10,
"look_for_keys": False,
"allow_agent": False,
}
if ssh_key_path:
connect_args["key_filename"] = ssh_key_path
if ssh_password:
connect_args["passphrase"] = ssh_password
elif ssh_password:
connect_args["password"] = ssh_password
else:
connect_args["look_for_keys"] = True
connect_args["allow_agent"] = True
try:
client.connect(**connect_args)
_, stdout, stderr = client.exec_command(command, timeout=20)
exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
except Exception as exc: # noqa: BLE001
raise SSHControlError(f"SSH command failed: {exc}") from exc
finally:
client.close()
return {
"exit_code": exit_code,
"stdout": out,
"stderr": err,
"host": ssh_host,
}
def build_start_command(settings: dict) -> str:
config_path = (settings.get("config_path") or "").strip()
bitcoin_binary = (settings.get("bitcoin_binary") or "bitcoind").strip() or "bitcoind"
if not config_path:
raise SSHControlError("Config file path is required to start bitcoind")
return f"{shlex.quote(bitcoin_binary)} -daemon -conf={shlex.quote(config_path)}"

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

466
app/static/styles.css Normal file
View File

@@ -0,0 +1,466 @@
:root {
--bg: #f3efe8;
--ink: #17120f;
--surface: rgba(255, 255, 255, 0.78);
--surface-solid: #fffaf2;
--stroke: rgba(104, 74, 41, 0.24);
--accent: #005c53;
--accent-2: #cf6f1b;
--danger: #9f2525;
--muted: #665a50;
--mono: "IBM Plex Mono", monospace;
--sans: "Space Grotesk", sans-serif;
--shadow: 0 16px 35px rgba(59, 41, 23, 0.14);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
}
body {
font-family: var(--sans);
color: var(--ink);
background: radial-gradient(circle at 8% 14%, #ffe0af 0%, transparent 46%),
radial-gradient(circle at 85% 20%, #c6f4e3 0%, transparent 42%),
linear-gradient(160deg, #f8f4ed 0%, #f0e8dc 52%, #ece3d6 100%);
}
.background-layer {
position: fixed;
inset: 0;
background: repeating-linear-gradient(
-32deg,
rgba(145, 107, 77, 0.04),
rgba(145, 107, 77, 0.04) 2px,
transparent 2px,
transparent 18px
);
pointer-events: none;
}
.container {
width: min(1200px, 100% - 2rem);
margin: 2rem auto 4rem;
position: relative;
z-index: 2;
}
.panel {
background: var(--surface);
backdrop-filter: blur(10px);
border: 1px solid var(--stroke);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 1rem 1.1rem;
}
.hidden {
display: none !important;
}
.login-panel {
max-width: 420px;
margin: 8vh auto;
}
h1,
h2,
h3 {
margin: 0;
letter-spacing: 0.01em;
}
h1 {
font-size: clamp(1.45rem, 1.2rem + 1.15vw, 2.1rem);
}
h2 {
font-size: 1rem;
margin-bottom: 0.6rem;
}
.eyebrow {
text-transform: uppercase;
font-family: var(--mono);
letter-spacing: 0.12em;
font-size: 0.74rem;
margin: 0 0 0.35rem;
color: var(--accent);
}
.subtle {
color: var(--muted);
font-size: 0.9rem;
}
.error {
color: var(--danger);
min-height: 1.2rem;
margin: 0.7rem 0 0;
white-space: pre-wrap;
}
form {
display: grid;
gap: 0.8rem;
}
label {
display: grid;
gap: 0.4rem;
font-size: 0.88rem;
color: #2f2822;
}
input,
textarea,
button {
font: inherit;
}
input,
textarea {
border: 1px solid rgba(74, 56, 34, 0.26);
background: var(--surface-solid);
border-radius: 12px;
padding: 0.62rem 0.75rem;
width: 100%;
}
textarea {
resize: vertical;
min-height: 90px;
font-family: var(--mono);
font-size: 0.86rem;
}
button {
border: 1px solid transparent;
border-radius: 12px;
padding: 0.55rem 0.88rem;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
transition: transform 120ms ease, opacity 120ms ease;
}
button:hover {
transform: translateY(-1px);
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
button.secondary {
background: transparent;
color: #18312f;
border-color: rgba(7, 70, 65, 0.37);
}
button.danger {
background: var(--danger);
}
.app-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.header-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.sync-panel {
margin-top: 1rem;
}
.sync-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.7rem;
}
.sync-percent {
font-family: var(--mono);
font-weight: 600;
color: #1f3f3b;
}
.sync-track {
margin-top: 0.6rem;
width: 100%;
height: 12px;
border-radius: 999px;
overflow: hidden;
border: 1px solid rgba(74, 56, 34, 0.22);
background: rgba(255, 250, 242, 0.8);
}
.sync-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #005c53 0%, #cf6f1b 100%);
transition: width 220ms ease;
}
.sync-summary {
margin: 0.55rem 0 0;
font-family: var(--mono);
font-size: 0.86rem;
}
.cards-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.8rem;
}
.stat-card {
min-height: 112px;
}
.stat-value {
font-family: var(--mono);
font-size: clamp(1.1rem, 1rem + 0.8vw, 1.65rem);
margin: 0.7rem 0 0;
word-break: break-word;
}
.charts-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.chart-toolbar {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.chart-toolbar label {
max-width: 260px;
}
.chart-history-info {
margin-top: 0.25rem;
}
.chart-toolbar select {
border: 1px solid rgba(74, 56, 34, 0.26);
background: var(--surface-solid);
border-radius: 12px;
padding: 0.62rem 0.75rem;
width: 100%;
}
.chart-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chart-frame {
position: relative;
height: 240px;
width: 100%;
}
.chart-frame canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.actions-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 0.8rem;
}
.button-row {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.button-row.wrap button {
font-size: 0.86rem;
}
.explorer-panel {
margin-top: 1rem;
}
.result-panel {
margin-top: 1rem;
}
.explorer-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.explorer-grid {
margin-top: 0.8rem;
display: grid;
grid-template-columns: minmax(220px, 340px) minmax(0, 1fr);
gap: 0.9rem;
}
.command-list-wrap {
display: grid;
gap: 0.6rem;
}
.command-list {
max-height: 360px;
overflow: auto;
border: 1px solid rgba(74, 56, 34, 0.2);
background: var(--surface-solid);
border-radius: 12px;
}
.command-item {
border: none;
border-bottom: 1px solid rgba(74, 56, 34, 0.12);
width: 100%;
text-align: left;
background: transparent;
color: #1a1713;
border-radius: 0;
padding: 0.52rem 0.6rem;
display: grid;
gap: 0.15rem;
}
.command-item:last-child {
border-bottom: none;
}
.command-item:hover,
.command-item.active {
background: rgba(0, 92, 83, 0.12);
}
.command-method {
font-family: var(--mono);
font-size: 0.8rem;
}
.command-synopsis {
font-size: 0.74rem;
color: #544941;
}
.rpc-form {
display: grid;
gap: 0.72rem;
}
#rpc-output {
margin: 0.65rem 0 0;
background: #110f0d;
color: #d7f5dd;
border-radius: 12px;
padding: 0.88rem;
max-height: 360px;
overflow: auto;
font-family: var(--mono);
font-size: 0.78rem;
}
.modal {
position: fixed;
inset: 0;
background: rgba(21, 17, 13, 0.45);
display: grid;
place-items: center;
z-index: 15;
padding: 1rem;
}
.modal-panel {
width: min(900px, 100%);
max-height: calc(100vh - 2rem);
overflow: auto;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin-top: 0.8rem;
}
.modal-actions {
grid-column: 1 / -1;
}
@media (max-width: 980px) {
.cards-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.charts-grid {
grid-template-columns: 1fr;
}
.chart-frame {
height: 220px;
}
.actions-grid {
grid-template-columns: 1fr;
}
.chart-toolbar {
flex-direction: column;
align-items: flex-start;
}
.explorer-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
.container {
width: min(1200px, 100% - 1rem);
margin-top: 1rem;
}
.app-header {
flex-direction: column;
}
.cards-grid {
grid-template-columns: 1fr;
}
.settings-grid {
grid-template-columns: 1fr;
}
}

261
app/templates/index.html Normal file
View File

@@ -0,0 +1,261 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bitcoin Core Admin Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<div class="background-layer"></div>
<main class="container">
<section id="login-view" class="panel login-panel">
<h1>Node Admin Login</h1>
<p class="subtle">Credentials come from the app environment file.</p>
<form id="login-form">
<label>
Username
<input type="text" id="login-username" autocomplete="username" required>
</label>
<label>
Password
<input type="password" id="login-password" autocomplete="current-password" required>
</label>
<button type="submit">Sign in</button>
</form>
<p id="login-error" class="error"></p>
</section>
<section id="app-view" class="hidden">
<header class="app-header panel">
<div>
<p class="eyebrow">Bitcoin Core</p>
<h1>Admin Dashboard</h1>
<p id="live-status" class="subtle">Not connected</p>
</div>
<div class="header-actions">
<button id="refresh-btn" class="secondary">Refresh</button>
<button id="settings-btn" class="secondary">Node Settings</button>
<button id="logout-btn" class="danger">Log out</button>
</div>
</header>
<section class="panel sync-panel">
<div class="sync-head">
<h2>Chain Sync</h2>
<span id="sync-percent" class="sync-percent">-</span>
</div>
<div id="sync-track" class="sync-track" role="progressbar" aria-label="Chain sync progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div id="sync-fill" class="sync-fill"></div>
</div>
<p id="sync-summary" class="sync-summary">Waiting for chain data.</p>
<p id="sync-details" class="subtle">-</p>
</section>
<section class="cards-grid">
<article class="panel stat-card">
<h2>Chain</h2>
<p id="stat-chain" class="stat-value">-</p>
</article>
<article class="panel stat-card">
<h2>Blocks</h2>
<p id="stat-blocks" class="stat-value">-</p>
</article>
<article class="panel stat-card">
<h2>Headers</h2>
<p id="stat-headers" class="stat-value">-</p>
</article>
<article class="panel stat-card">
<h2>Peers</h2>
<p id="stat-peers" class="stat-value">-</p>
</article>
<article class="panel stat-card">
<h2>Mempool TX</h2>
<p id="stat-mempool-tx" class="stat-value">-</p>
</article>
<article class="panel stat-card">
<h2>Mempool Size</h2>
<p id="stat-mempool-size" class="stat-value">-</p>
</article>
<article class="panel stat-card">
<h2>Difficulty</h2>
<p id="stat-difficulty" class="stat-value">-</p>
</article>
<article class="panel stat-card">
<h2>Uptime</h2>
<p id="stat-uptime" class="stat-value">-</p>
</article>
</section>
<section class="panel chart-toolbar">
<label>
Chart Window
<select id="chart-window">
<option value="5m">Last 5 minutes</option>
<option value="30m" selected>Last 30 minutes</option>
<option value="2h">Last 2 hours</option>
<option value="6h">Last 6 hours</option>
<option value="all">All collected</option>
</select>
</label>
<div>
<p class="subtle">History is sampled server-side (default every 60s). While this page is open, live refresh runs every 15s.</p>
<p id="chart-history-info" class="subtle chart-history-info">History status: -</p>
</div>
</section>
<section class="charts-grid">
<article class="panel chart-panel">
<h2>Blocks vs Headers</h2>
<div class="chart-frame">
<canvas id="chart-blocks"></canvas>
</div>
</article>
<article class="panel chart-panel">
<h2>Mempool Bytes</h2>
<div class="chart-frame">
<canvas id="chart-mempool"></canvas>
</div>
</article>
<article class="panel chart-panel">
<h2>Peers</h2>
<div class="chart-frame">
<canvas id="chart-peers"></canvas>
</div>
</article>
</section>
<section class="actions-grid">
<article class="panel">
<h2>Node Controls</h2>
<p class="subtle">`start`/`restart` run over SSH using your saved config path.</p>
<div class="button-row">
<button id="action-stop" class="danger">Stop Node</button>
<button id="action-start">Start Node</button>
<button id="action-restart">Restart Node</button>
</div>
</article>
<article class="panel">
<h2>Quick RPC Calls</h2>
<div class="button-row wrap">
<button class="secondary quick-rpc" data-method="getblockchaininfo">Blockchain</button>
<button class="secondary quick-rpc" data-method="getnetworkinfo">Network</button>
<button class="secondary quick-rpc" data-method="getmempoolinfo">Mempool</button>
<button class="secondary quick-rpc" data-method="getwalletinfo">Wallet</button>
<button class="secondary quick-rpc" data-method="getpeerinfo">Peers</button>
<button class="secondary quick-rpc" data-method="getrawmempool">Raw Mempool</button>
</div>
</article>
</section>
<section class="panel explorer-panel">
<div class="explorer-head">
<h2>RPC Explorer</h2>
<span id="command-count" class="subtle"></span>
</div>
<div class="explorer-grid">
<div class="command-list-wrap">
<input type="text" id="command-search" placeholder="Filter methods (e.g. getblock)" autocomplete="off">
<div id="command-list" class="command-list"></div>
</div>
<div>
<form id="rpc-form" class="rpc-form">
<label>
Method
<input type="text" id="rpc-method" placeholder="getblockchaininfo" required>
</label>
<label>
Params (JSON array)
<textarea id="rpc-params" rows="4" placeholder="[]"></textarea>
</label>
<label>
Wallet override (optional)
<input type="text" id="rpc-wallet" placeholder="walletname">
</label>
<div class="button-row">
<button type="submit">Execute RPC</button>
<button type="button" id="rpc-help-btn" class="secondary">Method Help</button>
</div>
</form>
<p id="rpc-error" class="error"></p>
</div>
</div>
</section>
<section class="panel result-panel">
<h2>Result</h2>
<pre id="rpc-output">Run a command to inspect response data.</pre>
</section>
</section>
</main>
<div id="settings-modal" class="modal hidden" role="dialog" aria-modal="true">
<div class="panel modal-panel">
<div class="explorer-head">
<h2>Node Settings</h2>
<button id="settings-close" class="secondary">Close</button>
</div>
<form id="settings-form" class="settings-grid">
<label>
RPC URL
<input type="url" name="rpc_url" placeholder="http://127.0.0.1:8332" required>
</label>
<label>
RPC Username
<input type="text" name="rpc_username" required>
</label>
<label>
RPC Password
<input type="password" name="rpc_password" required>
</label>
<label>
Wallet (optional)
<input type="text" name="rpc_wallet" placeholder="walletname">
</label>
<label>
Node Config Path
<input type="text" name="config_path" placeholder="/etc/bitcoin/bitcoin.conf">
</label>
<label>
Bitcoin Binary
<input type="text" name="bitcoin_binary" placeholder="bitcoind">
</label>
<label>
SSH Host (optional if derivable from RPC URL)
<input type="text" name="ssh_host" placeholder="node.example.com">
</label>
<label>
SSH Port
<input type="number" name="ssh_port" min="1" max="65535" value="22">
</label>
<label>
SSH Username
<input type="text" name="ssh_username" placeholder="bitcoinadmin">
</label>
<label>
SSH Password / Key Passphrase
<input type="password" name="ssh_password">
</label>
<label>
SSH Private Key Path (optional)
<input type="text" name="ssh_key_path" placeholder="~/.ssh/id_rsa">
</label>
<div class="button-row modal-actions">
<button type="submit">Save Settings</button>
</div>
</form>
<p class="subtle">Settings persist in the local SQLite database (`data/dashboard.db`).</p>
<p id="settings-error" class="error"></p>
</div>
</div>
</body>
</html>