Initial commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
34
app/auth.py
Normal file
34
app/auth.py
Normal 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
126
app/bitcoin_rpc.py
Normal 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
45
app/config.py
Normal 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
233
app/db.py
Normal 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
413
app/main.py
Normal 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
82
app/ssh_control.py
Normal 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
893
app/static/app.js
Normal 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
466
app/static/styles.css
Normal 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
261
app/templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user