Initial commit
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
data/dashboard.db
|
||||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal 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
16
Dockerfile
Normal 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
87
README.md
Normal 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
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>
|
||||||
25
doc/README.md
Normal file
25
doc/README.md
Normal 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
133
doc/api.md
Normal 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
98
doc/architecture.md
Normal 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
85
doc/build-and-deploy.md
Normal 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
54
doc/conventions.md
Normal 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
108
doc/data-models.md
Normal 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
49
doc/environment.md
Normal 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
21
docker-compose.yml
Normal 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
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
requests
|
||||||
|
python-dotenv
|
||||||
|
passlib[bcrypt]
|
||||||
|
paramiko
|
||||||
|
jinja2
|
||||||
|
itsdangerous
|
||||||
Reference in New Issue
Block a user