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

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
APP_USERNAME=admin
APP_PASSWORD=change-me-now
# APP_PASSWORD_HASH=$2b$12$replace_with_bcrypt_hash_if_you_prefer
SESSION_SECRET=replace-with-a-long-random-secret
SESSION_COOKIE_SECURE=false
RPC_TIMEOUT_SECONDS=15
METRICS_SAMPLER_INTERVAL_SECONDS=60
DATA_DIR=./data
DB_PATH=./data/dashboard.db

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
data/dashboard.db

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
## [Unreleased]
### Added
- Added a structured `/doc` documentation set with indexed, cross-referenced guides for architecture, API contracts, data models, build and deployment rules, environment requirements, and coding conventions.
### Changed
- Refactored `README.md` into a repository landing page focused on project overview, quick start, and links to detailed documentation.

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY data ./data
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# Bitcoin Core Admin Dashboard
Web dashboard for operating and inspecting a Bitcoin Core node over RPC and SSH.
## Features
- Session-based dashboard login.
- Persistent node settings in SQLite.
- Live node summary cards and history charts.
- RPC explorer with command catalog from node `help`.
- Node control actions:
- stop via RPC
- start via SSH
- restart via RPC stop plus SSH start
## Tech Stack
- FastAPI + Jinja templates
- Vanilla JavaScript + Chart.js
- SQLite persistence
- `requests` for JSON-RPC
- `paramiko` for SSH control
## Quick Start (Docker)
1. Create environment file:
```bash
cp .env.example .env
```
2. Update at minimum:
- `APP_USERNAME`
- `APP_PASSWORD` or `APP_PASSWORD_HASH`
- `SESSION_SECRET`
3. Start:
```bash
docker compose up --build
```
4. Open:
- `http://localhost:8080`
## Quick Start (Local Python)
```bash
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload --port 8080
```
## Runtime Notes
- Settings and metrics history persist in `data/dashboard.db`.
- Background metric sampling runs every `METRICS_SAMPLER_INTERVAL_SECONDS` (minimum 15 seconds).
- Start and restart actions require working SSH configuration.
## Docker RPC Connectivity Note
If the dashboard runs in Docker and Bitcoin Core runs on the Docker host, use:
- `http://{HOST_IP}:8332`
Using `http://127.0.0.1:8332` inside the container points to the container itself.
Make sure your node allows connections on that port from the Docker IP/Subnet
## Documentation
See the full documentation set in `doc/README.md`:
- Architecture: `doc/architecture.md`
- API reference: `doc/api.md`
- Data models: `doc/data-models.md`
- Build and deploy: `doc/build-and-deploy.md`
- Environment config: `doc/environment.md`
- Conventions: `doc/conventions.md`
## Security
- Use strong credentials and a random session secret.
- Prefer password hash (`APP_PASSWORD_HASH`) over plaintext password.
- Restrict dashboard network access to trusted operators.

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>

25
doc/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Documentation Index
This directory contains project documentation for the Bitcoin Core Admin Dashboard.
## Document Map
- `doc/architecture.md` - System architecture, module responsibilities, and runtime flow.
- `doc/api.md` - HTTP API reference, authentication model, and endpoint contracts.
- `doc/data-models.md` - SQLite schema, data invariants, and retention behavior.
- `doc/build-and-deploy.md` - Build, runtime, and deployment rules for local and Docker usage.
- `doc/environment.md` - Environment variables, defaults, and production guidance.
- `doc/conventions.md` - Coding patterns and implementation conventions for this repository.
## Reading Order
1. Start with `doc/architecture.md`.
2. Use `doc/api.md` and `doc/data-models.md` for implementation details.
3. Use `doc/build-and-deploy.md` and `doc/environment.md` for operations.
4. Use `doc/conventions.md` when adding or modifying code.
## Cross-References
- API endpoints depend on persisted settings described in `doc/data-models.md`.
- Runtime behavior and deployment constraints are described in `doc/architecture.md` and `doc/build-and-deploy.md`.
- Configuration contracts in `doc/environment.md` are consumed by modules listed in `doc/architecture.md`.

133
doc/api.md Normal file
View File

@@ -0,0 +1,133 @@
# API Reference
## Base Behavior
- Base URL: same origin as UI host.
- UI route:
- `GET /` returns the dashboard HTML shell.
- API route namespace:
- All API endpoints are under `/api/...`.
- Cache behavior:
- Responses under `/api/` and `/static/` include no-store cache headers.
## Authentication Model
- Login endpoint sets a session value on success.
- Protected endpoints require `Depends(require_auth)`.
- Session cookie behavior:
- `same_site=lax`
- max age: 8 hours
- secure flag controlled by `SESSION_COOKIE_SECURE`
Related references:
- Configuration: `doc/environment.md`
- Auth implementation: `app/auth.py`
## Error Contract
- `401` for authentication failures.
- `400` for missing required node configuration values.
- `422` for request validation and unsupported chart window values.
- `502` for RPC or SSH operation failures.
- Error shape for handled exceptions:
- `{ "detail": "<message>" }`
## Endpoint Contracts
### Health and Session
| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/api/health` | No | Returns service liveness: `{ "ok": true }`. |
| `GET` | `/api/auth/me` | No | Returns current authentication state and username if authenticated. |
| `POST` | `/api/auth/login` | No | Validates credentials and creates session. |
| `POST` | `/api/auth/logout` | Yes | Clears session. |
`POST /api/auth/login` request body:
```json
{
"username": "admin",
"password": "secret"
}
```
### Node Settings
| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/api/settings` | Yes | Returns persisted node connection and control settings. |
| `PUT` | `/api/settings` | Yes | Validates and persists node settings. |
`PUT /api/settings` request body fields:
- `rpc_url` string
- `rpc_username` string
- `rpc_password` string
- `rpc_wallet` string
- `config_path` string
- `ssh_host` string
- `ssh_port` integer `1..65535`
- `ssh_username` string
- `ssh_password` string
- `ssh_key_path` string
- `bitcoin_binary` string
All string fields are trimmed before persistence.
### Dashboard
| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/api/dashboard/summary` | Yes | Returns chain, network, mempool, mining, uptime, optional wallet data, and `updated_at`. |
| `GET` | `/api/dashboard/history` | Yes | Returns metric history for query-param `window` and `limit`. |
| `GET` | `/api/dashboard/history/{window}` | Yes | Same as above with `window` as a path parameter. |
Supported `window` values:
- `5m`
- `30m`
- `2h`
- `6h`
- `all`
`limit` is clamped server-side to `[1, 20000]`.
### RPC Explorer
| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/api/rpc/call` | Yes | Executes a JSON-RPC method with params and optional wallet override. |
| `GET` | `/api/rpc/commands` | Yes | Calls node `help` and returns parsed method catalog. |
| `GET` | `/api/rpc/help/{method_name}` | Yes | Returns detailed `help` output for one method. |
`POST /api/rpc/call` request body:
```json
{
"method": "getblockchaininfo",
"params": [],
"wallet": null
}
```
### Node Actions
| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/api/actions/stop` | Yes | Calls RPC `stop`. |
| `POST` | `/api/actions/start` | Yes | Runs SSH start command built from persisted settings. |
| `POST` | `/api/actions/restart` | Yes | Attempts RPC stop then runs SSH start command. |
Action responses include action metadata and remote execution payload when SSH is involved.
## Operational Notes
- Startup and restart require:
- valid SSH connectivity
- `config_path`
- SSH user credentials or key access
- When running in Docker, `rpc_url` should usually target `host.docker.internal` if the node is on the Docker host.
See `doc/build-and-deploy.md` for Docker network details.

98
doc/architecture.md Normal file
View File

@@ -0,0 +1,98 @@
# Architecture
## Overview
The project is a single-service web application that provides operational control and observability for a remote Bitcoin Core node.
Core runtime components:
- FastAPI server for UI and API delivery.
- Session-based authentication middleware.
- SQLite persistence for node settings and sampled metrics.
- Bitcoin Core JSON-RPC client for node data and RPC execution.
- SSH command runner for daemon start and restart operations.
- Vanilla JavaScript frontend with Chart.js visualizations.
Related references:
- API contracts: `doc/api.md`
- Persistence schema: `doc/data-models.md`
- Deployment rules: `doc/build-and-deploy.md`
## Component Boundaries
### Backend Modules
- `app/main.py`
- Composes FastAPI app, middleware, startup/shutdown hooks, and all route handlers.
- Validates payloads via Pydantic models.
- Aggregates dashboard summary data and persists metric samples.
- Starts a background sampler thread that periodically stores metrics.
- `app/auth.py`
- Verifies login credentials.
- Enforces authenticated API access through `require_auth`.
- `app/config.py`
- Loads and validates environment-driven runtime configuration.
- Creates the data directory when needed.
- `app/db.py`
- Initializes schema.
- Persists and reads node settings.
- Persists and reads metric history with retention trimming.
- `app/bitcoin_rpc.py`
- Encapsulates JSON-RPC request and batch request behavior.
- Normalizes RPC errors through `BitcoinRPCError`.
- Parses `help` output into command catalog entries.
- `app/ssh_control.py`
- Resolves SSH host and connection parameters.
- Executes remote commands and returns execution result payloads.
- Builds safe daemon start command strings.
### Frontend Assets
- `app/templates/index.html`
- Declares login, dashboard, settings modal, control actions, and RPC explorer regions.
- `app/static/app.js`
- Owns client state, auth transitions, API calls, chart hydration, and polling.
- Caches metric history in browser local storage.
- `app/static/styles.css`
- Provides responsive layout and UI styling.
## Runtime Flow
1. Application startup calls `init_db()` and starts the metrics sampler thread.
2. Browser loads `/`, then frontend checks `/api/auth/me`.
3. After login, frontend loads settings, RPC command catalog, history, and summary.
4. Frontend refreshes summary every 15 seconds while page is open.
5. Backend stores sampled metrics:
- on each summary API call.
- in the background sampler at `METRICS_SAMPLER_INTERVAL_SECONDS` cadence.
## Data and Control Paths
- Read-only node visibility:
- `/api/dashboard/summary` uses batch RPC to fetch chain, network, mempool, mining, and uptime data.
- `/api/dashboard/history` and `/api/dashboard/history/{window}` read persisted metric points.
- Node management:
- Stop uses Bitcoin RPC `stop`.
- Start uses SSH execution of `bitcoind -daemon -conf=<config_path>`.
- Restart attempts RPC stop, waits briefly, then uses SSH start.
- RPC explorer:
- Command catalog derives from `help` output.
- Arbitrary RPC call endpoint accepts method and JSON-array params.
## Error and Resilience Model
- `BitcoinRPCError` and `SSHControlError` are mapped to HTTP `502`.
- Missing required node settings are surfaced as HTTP `400`.
- Validation issues return HTTP `422`.
- Background sampler is best-effort and suppresses internal exceptions to avoid service interruption.
## Security Model
- Authentication is session-cookie based using `SessionMiddleware`.
- Access to operational endpoints requires `Depends(require_auth)`.
- Credential verification supports:
- plaintext password via `APP_PASSWORD`
- bcrypt hash via `APP_PASSWORD_HASH`
See `doc/environment.md` for security-relevant configuration fields.

85
doc/build-and-deploy.md Normal file
View File

@@ -0,0 +1,85 @@
# Build and Deployment
## Runtime Targets
- Local Python process using Uvicorn.
- Docker container via `Dockerfile`.
- Docker Compose service via `docker-compose.yml`.
Related references:
- Configuration fields: `doc/environment.md`
- Architecture: `doc/architecture.md`
## Local Run
Typical local launch command:
```bash
uvicorn app.main:app --reload --port 8080
```
Service endpoint:
- `http://localhost:8080`
Notes:
- The app reads environment variables at startup.
- Database parent directory is created automatically.
## Docker Image Build Rules
Build file: `Dockerfile`
Current build behavior:
1. Base image: `python:3.12-slim`
2. Install Python dependencies from `requirements.txt`
3. Copy `app/` and `data/` into image
4. Expose container port `8080`
5. Start with Uvicorn on `0.0.0.0:8080`
## Docker Compose Rules
Compose file: `docker-compose.yml`
Dashboard service settings:
- Publishes `8080:8080`
- Loads environment from `.env`
- Sets:
- `DATA_DIR=/app/data`
- `DB_PATH=/app/data/dashboard.db`
- Mounts volume:
- `./data:/app/data`
- Adds host gateway alias:
- `host.docker.internal:host-gateway`
- Restart policy:
- `unless-stopped`
## Persistence Rules
- Persist `./data` on the host to retain:
- node settings
- chart metric history
- If `data/dashboard.db` is not persisted, settings and history reset when the container is recreated.
## Network Rules
If the Bitcoin node runs on the Docker host:
- Prefer RPC URL `http://host.docker.internal:8332`.
If using remote node hosts:
- Set RPC URL directly to the node endpoint.
- Ensure SSH host/credentials are valid for start/restart actions.
## Deployment Checklist
1. Set strong `APP_PASSWORD` or provide `APP_PASSWORD_HASH`.
2. Set a long random `SESSION_SECRET`.
3. Set `SESSION_COOKIE_SECURE=true` behind HTTPS.
4. Persist the database volume.
5. Restrict dashboard network exposure to trusted operators.

54
doc/conventions.md Normal file
View File

@@ -0,0 +1,54 @@
# Conventions and Coding Patterns
This document defines repository conventions to preserve existing architecture and behavior.
## Module Boundaries
- Keep backend concerns separated by module:
- auth in `app/auth.py`
- configuration in `app/config.py`
- persistence in `app/db.py`
- RPC transport in `app/bitcoin_rpc.py`
- SSH control in `app/ssh_control.py`
- route composition in `app/main.py`
- Do not collapse these responsibilities into one file.
## API and Error Patterns
- Protected routes must use `Depends(require_auth)`.
- Domain errors should raise module-specific exceptions:
- `BitcoinRPCError`
- `SSHControlError`
- Domain exceptions are converted to HTTP `502` in centralized exception handlers.
- Input validation should use Pydantic models and explicit HTTP errors for invalid state.
## Persistence Patterns
- Access SQLite through `app/db.py` helpers.
- Keep node settings as a single upserted row with `id = 1`.
- Preserve metrics retention behavior:
- 30-day time window
- 20000 row cap
## Frontend Patterns
- Keep frontend implementation in vanilla JavaScript plus Chart.js.
- Maintain centralized client `state` object in `app/static/app.js`.
- Use existing API helpers (`api`, error handling, auth transitions).
- Keep responsive behavior aligned with current CSS breakpoints.
## Security and Operational Patterns
- Require authentication for all state-changing and node-control APIs.
- Keep SSH command composition shell-safe using quoting.
- Treat background sampling as best-effort to avoid impacting request flow.
## Documentation and Change Tracking
When behavior, contracts, or operations change:
1. Update affected docs in `/doc`.
2. Update `README.md` if repository landing information changes.
3. Record meaningful changes in `CHANGELOG.md` under `[Unreleased]`.
Reference index: `doc/README.md`.

108
doc/data-models.md Normal file
View File

@@ -0,0 +1,108 @@
# Data Models and Schema
## Storage Backend
- Database engine: SQLite.
- Default file path: `./data/dashboard.db`.
- Effective path is controlled by `DB_PATH`.
Related references:
- Configuration values: `doc/environment.md`
- Persistence implementation: `app/db.py`
## Tables
### `node_settings`
Single-row settings table used as the source of truth for RPC and SSH connectivity.
Schema fields:
- `id INTEGER PRIMARY KEY CHECK (id = 1)`
- `rpc_url TEXT NOT NULL`
- `rpc_username TEXT NOT NULL`
- `rpc_password TEXT NOT NULL`
- `rpc_wallet TEXT NOT NULL`
- `config_path TEXT NOT NULL`
- `ssh_host TEXT NOT NULL`
- `ssh_port INTEGER NOT NULL`
- `ssh_username TEXT NOT NULL`
- `ssh_password TEXT NOT NULL`
- `ssh_key_path TEXT NOT NULL`
- `bitcoin_binary TEXT NOT NULL`
- `updated_at TEXT NOT NULL` (UTC ISO 8601 string)
Invariants:
- Exactly one logical settings record exists with `id = 1`.
- `save_settings` performs an upsert.
- Missing settings row is represented by in-code defaults.
### `metrics_history`
Time-series table for chart data and historical dashboard views.
Schema fields:
- `ts INTEGER PRIMARY KEY` (Unix timestamp, seconds)
- `blocks INTEGER NOT NULL`
- `headers INTEGER NOT NULL`
- `mempool_bytes INTEGER NOT NULL`
- `peers INTEGER NOT NULL`
Index:
- `idx_metrics_history_ts` on `ts`
Invariants:
- A timestamp has at most one row.
- Inserts on duplicate timestamp overwrite existing point.
- Returned history is chronological for UI consumption.
## Retention and Limits
Retention behavior is enforced during each metric insert:
- Time retention: rows older than 30 days are deleted.
- Count retention: rows beyond the newest 20000 samples are deleted.
- Query limit input is clamped to `[1, 20000]`.
## Metric Sampling Sources
A metric point is composed from RPC responses:
- `blocks` and `headers` from `getblockchaininfo`
- `mempool_bytes` from `getmempoolinfo.bytes`
- `peers` from `getnetworkinfo.connections`
Points are written:
- On each `/api/dashboard/summary` request.
- In a background sampler loop at configured interval.
## Settings Defaults
Default values used when no persisted row exists:
- `rpc_url`: `http://127.0.0.1:8332`
- `rpc_username`: empty string
- `rpc_password`: empty string
- `rpc_wallet`: empty string
- `config_path`: empty string
- `ssh_host`: empty string
- `ssh_port`: `22`
- `ssh_username`: empty string
- `ssh_password`: empty string
- `ssh_key_path`: empty string
- `bitcoin_binary`: `bitcoind`
- `updated_at`: empty string
## Data Consumers
- `/api/settings` and `/api/settings` (PUT) read and write `node_settings`.
- `/api/dashboard/history*` reads `metrics_history`.
- `/api/dashboard/summary` and sampler thread write `metrics_history`.
See `doc/api.md` for endpoint-level contracts.

49
doc/environment.md Normal file
View File

@@ -0,0 +1,49 @@
# Environment Requirements
## Environment Loading
- Environment values are loaded from process environment.
- `.env` is supported through `python-dotenv` at app startup.
- Configuration is cached in memory after first load.
Source implementation: `app/config.py`
## Variables
| Variable | Default | Purpose |
| --- | --- | --- |
| `APP_USERNAME` | `admin` | Login username accepted by the dashboard. |
| `APP_PASSWORD` | `changeme` | Plaintext password used when hash is not provided. |
| `APP_PASSWORD_HASH` | unset | Bcrypt hash used instead of plaintext password when present. |
| `SESSION_SECRET` | `change-this-secret` | Session signing secret for cookie middleware. |
| `SESSION_COOKIE_SECURE` | `false` | When true, session cookie is sent only over HTTPS. |
| `RPC_TIMEOUT_SECONDS` | `15` | Timeout for Bitcoin RPC HTTP requests. |
| `METRICS_SAMPLER_INTERVAL_SECONDS` | `60` | Background metrics sampling interval in seconds. |
| `DATA_DIR` | `./data` | Data directory root. Created if missing. |
| `DB_PATH` | `./data/dashboard.db` | SQLite file path. Overrides default path under `DATA_DIR`. |
## Validation and Normalization Rules
- `METRICS_SAMPLER_INTERVAL_SECONDS` minimum is clamped to `15`.
- `SESSION_COOKIE_SECURE` accepts truthy values:
- `1`
- `true`
- `yes`
- `on`
- `DB_PATH` parent directory is created automatically when needed.
## Security Guidance
Production baseline:
1. Do not use default `APP_USERNAME`.
2. Use `APP_PASSWORD_HASH` instead of plaintext password where possible.
3. Use a long random `SESSION_SECRET`.
4. Set `SESSION_COOKIE_SECURE=true` when served over HTTPS.
5. Scope dashboard network access to trusted hosts only.
## Example `.env`
Reference example is provided in `.env.example`.
See `doc/build-and-deploy.md` for environment injection in Docker Compose.

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
dashboard:
build: .
container_name: tellscoin-dashboard
#ports:
# - "8080:8080"
env_file:
- /srv/docker/secrets/tellscoin/.env
environment:
DATA_DIR: /app/data
DB_PATH: /app/data/dashboard.db
volumes:
- ./data:/app/data
restart: unless-stopped
networks:
npm_proxy:
ipv4_address: 192.168.100.13
networks:
npm_proxy:
external: true

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
requests
python-dotenv
passlib[bcrypt]
paramiko
jinja2
itsdangerous