3145 lines
96 KiB
Bash
Executable File
3145 lines
96 KiB
Bash
Executable File
#!/bin/bash
|
||
#
|
||
# Bitcoin Node for Solo Miners Appliance Installer
|
||
# An Open Source Linux Appliance from Robbie Ferguson
|
||
# (c) 2025 Robbie Ferguson
|
||
|
||
# Version 1.0.3
|
||
|
||
set -e
|
||
|
||
BTC_VER="29.2"
|
||
|
||
APP_ROOT="/opt/btc-solo"
|
||
WWW_ROOT="/var/www/btc-solo"
|
||
BITCOIN_CONF="/etc/bitcoin/bitcoin.conf"
|
||
CERT_DIR="/etc/ssl/localcerts"
|
||
CKPOOL_USER="ckpool"
|
||
CKPOOL_PORT="3333"
|
||
BITCOIN_DATA="/var/lib/bitcoind"
|
||
BTC_RPC_USER="user"
|
||
BTC_RPC_PASS="pass"
|
||
PRUNE_SIZE="550"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PURGE
|
||
# ---------------------------------------------------------------------------
|
||
if [[ "$1" == "--purge" ]]; then
|
||
systemctl stop ckpool 2>/dev/null || true
|
||
systemctl stop bitcoind 2>/dev/null || true
|
||
rm -f /var/lib/btc-solo/.setup-complete
|
||
rm -f /etc/systemd/system/ckpool.service
|
||
rm -f /etc/systemd/system/bitcoind.service
|
||
rm -f /etc/nginx/sites-enabled/btc-solo
|
||
rm -f /etc/nginx/sites-available/btc-solo
|
||
rm -f /etc/sudoers.d/btc-solo
|
||
rm -rf "$APP_ROOT" "$WWW_ROOT" "$CERT_DIR" "$BITCOIN_DATA"
|
||
rm -f /usr/local/sbin/btc-apply-admin.sh /usr/local/sbin/btc-set-prune.sh /usr/local/sbin/btc-make-cert.sh
|
||
# Remove bitcoin binaries we installed directly
|
||
rm -f /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli /usr/local/bin/bitcoin-tx /usr/local/bin/bitcoin-util 2>/dev/null || true
|
||
systemctl daemon-reload
|
||
systemctl restart nginx 2>/dev/null || true
|
||
echo "Bitcoin Solo Miner Appliance purged."
|
||
exit 0
|
||
fi
|
||
|
||
if [[ $EUID -ne 0 ]]; then
|
||
echo "Run as root."
|
||
exit 1
|
||
fi
|
||
|
||
echo "[***] Updating apt…"
|
||
apt-get update
|
||
|
||
echo "[***] Installing base packages…"
|
||
apt-get install -y \
|
||
curl ca-certificates gnupg \
|
||
nginx php-fpm php-cli \
|
||
git build-essential autoconf automake libtool pkg-config \
|
||
libjansson-dev libevent-dev libcurl4-openssl-dev libssl-dev zlib1g-dev \
|
||
openssl jq bc sqlite3 php-sqlite3
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Install Bitcoin Core from official binaries
|
||
# ---------------------------------------------------------------------------
|
||
echo "[***] Installing official Bitcoin Core binaries…"
|
||
|
||
# ===== Detect CPU arch and install correct Bitcoin Core binary =====
|
||
|
||
detect_arch() {
|
||
# Prefer dpkg (Debian/Ubuntu), fall back to uname
|
||
local deb_arch
|
||
deb_arch=$(dpkg --print-architecture 2>/dev/null || true)
|
||
case "$deb_arch" in
|
||
amd64) echo "x86_64-linux-gnu"; return ;;
|
||
arm64) echo "aarch64-linux-gnu"; return ;;
|
||
armhf) echo "arm-linux-gnueabihf"; return ;;
|
||
esac
|
||
|
||
# Fallback by kernel arch
|
||
local u
|
||
u=$(uname -m)
|
||
case "$u" in
|
||
x86_64) echo "x86_64-linux-gnu" ;;
|
||
aarch64) echo "aarch64-linux-gnu" ;;
|
||
armv7l|armv6l)
|
||
echo "arm-linux-gnueabihf" ;;
|
||
*)
|
||
echo "unsupported"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
BTC_PLAT="$(detect_arch)"
|
||
if [ "$BTC_PLAT" = "unsupported" ]; then
|
||
echo "ERROR: Unsupported CPU architecture ($(uname -m))."
|
||
echo "Tip: For exotic arches, build from source using depends/ then install bitcoind/bitcoin-cli."
|
||
exit 1
|
||
fi
|
||
|
||
BASE_URL="https://bitcoincore.org/bin/bitcoin-core-$BTC_VER"
|
||
TARBALL="bitcoin-$BTC_VER-$BTC_PLAT.tar.gz"
|
||
TMPD="$(mktemp -d)"
|
||
|
||
echo "[Bitcoin Core] Detected platform: $BTC_PLAT"
|
||
echo "[Bitcoin Core] Downloading $TARBALL …"
|
||
curl -fsSL "$BASE_URL/$TARBALL" -o "$TMPD/$TARBALL"
|
||
|
||
# Get SHA256SUMS (and try to verify signature if gpg is present)
|
||
curl -fsSL "$BASE_URL/SHA256SUMS" -o "$TMPD/SHA256SUMS"
|
||
if command -v gpg >/dev/null 2>&1; then
|
||
curl -fsSL "$BASE_URL/SHA256SUMS.asc" -o "$TMPD/SHA256SUMS.asc" || true
|
||
# If you've already imported the Bitcoin Core release keys, this will verify.
|
||
# If not, we still proceed after SHA256 check below.
|
||
gpg --verify "$TMPD/SHA256SUMS.asc" "$TMPD/SHA256SUMS" || echo "[WARN] Could not verify SHA256SUMS signature (no keys?). Continuing with SHA256 check."
|
||
fi
|
||
|
||
echo "[Bitcoin Core] Verifying tarball checksum …"
|
||
( cd "$TMPD" && grep " $TARBALL" SHA256SUMS | sha256sum -c - )
|
||
|
||
echo "[Bitcoin Core] Installing to /usr/local/bin …"
|
||
tar -xzf "$TMPD/$TARBALL" -C "$TMPD"
|
||
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoind" /usr/local/bin/
|
||
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoin-cli" /usr/local/bin/
|
||
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoin-tx" /usr/local/bin/
|
||
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoin-wallet" /usr/local/bin/
|
||
|
||
rm -rf "$TMPD"
|
||
echo "[Bitcoin Core] Installed bitcoind/bitcoin-cli for $BTC_PLAT"
|
||
|
||
# systemd service for bitcoind
|
||
cat > /etc/systemd/system/bitcoind.service <<EOF
|
||
[Unit]
|
||
Description=Bitcoin daemon
|
||
After=network.target
|
||
|
||
[Service]
|
||
ExecStart=/usr/local/bin/bitcoind -conf=/etc/bitcoin/bitcoin.conf -datadir=${BITCOIN_DATA}
|
||
ExecStop=/usr/local/bin/bitcoin-cli -conf=/etc/bitcoin/bitcoin.conf stop
|
||
User=root
|
||
Group=root
|
||
Type=simple
|
||
Restart=on-failure
|
||
RuntimeDirectory=bitcoind
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
|
||
mkdir -p /etc/bitcoin
|
||
mkdir -p "${BITCOIN_DATA}"
|
||
|
||
# Bitcoin Core Configuration
|
||
cat > "$BITCOIN_CONF" <<EOF
|
||
server=1
|
||
txindex=0
|
||
rpcallowip=127.0.0.1
|
||
rpcbind=127.0.0.1
|
||
listen=1
|
||
daemon=0
|
||
prune=${PRUNE_SIZE}
|
||
rpcuser=${BTC_RPC_USER}
|
||
rpcpassword=${BTC_RPC_PASS}
|
||
rpcbind=127.0.0.1
|
||
maxconnections=250 # Allow up to 250 connections to speed up sync
|
||
maxuploadtarget=0 # Unlimited upload speed
|
||
dbcache=2048 # 2 GB RAM for sync
|
||
maxmempool=300 # Use less RAM post-sync
|
||
EOF
|
||
|
||
systemctl daemon-reload
|
||
systemctl enable bitcoind
|
||
systemctl restart bitcoind
|
||
echo "[***] bitcoind started (pruned=550 MB)."
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fetch/build ckpool (solo) - official Bitbucket source
|
||
# ---------------------------------------------------------------------------
|
||
echo "[***] Fetching ckpool from Bitbucket…"
|
||
mkdir -p "$APP_ROOT"
|
||
cd "$APP_ROOT"
|
||
|
||
if [[ -e ckpool ]]; then
|
||
rm -rf ckpool
|
||
fi
|
||
|
||
echo "[***] Building ckpool..."
|
||
# Obtain the current source code
|
||
git clone https://bitbucket.org/ckolivas/ckpool.git ckpool
|
||
chown -R $service_user:$service_user ckpool
|
||
cd ckpool
|
||
|
||
# This simply sets the donation address to allow you to donate to Robbie Ferguson if you win
|
||
# This optional donation (Default 2% of block reward) can be adjusted or turned off in the Settings screen
|
||
btc_address="1MoGAsK8bRFKjHrpnpFJyqxdqamSPH19dP"
|
||
find . -type f \( -name '*.c' -o -name '*.h' -o -name '*.cpp' \) -exec \
|
||
sed -i -E \
|
||
"s#(ckp\.donaddress\s*=\s*\")[^\"]*(\";)#\1$btc_address\2#g;
|
||
s#(ckp\.tndonaddress\s*=\s*\")[^\"]*(\";)#\1$btc_address\2#g;
|
||
s#(ckp\.rtdonaddress\s*=\s*\")[^\"]*(\";)#\1$btc_address\2#g" {} +
|
||
|
||
# Build and install CKPool
|
||
./autogen.sh
|
||
./configure
|
||
make
|
||
make install
|
||
|
||
# ensure the binary exists and is executable
|
||
if [[ ! -x "/opt/btc-solo/ckpool/src/ckpool" ]]; then
|
||
echo "ERROR: ckpool did not build correctly; /opt/btc-solo/ckpool/ckpool not found or not executable."
|
||
exit 1
|
||
fi
|
||
|
||
id -u "$CKPOOL_USER" >/dev/null 2>&1 || useradd -r -s /bin/false "$CKPOOL_USER"
|
||
usermod -aG $CKPOOL_USER www-data
|
||
# try to restart the running PHP-FPM service so www-data picks up new group
|
||
php_fpm_service=""
|
||
# 1) ask systemd what php-fpm services exist
|
||
php_fpm_service=$(systemctl list-units --type=service --all \
|
||
| awk '/php.*fpm\.service/ {print $1; exit}')
|
||
|
||
if [ -n "$php_fpm_service" ]; then
|
||
systemctl restart "$php_fpm_service"
|
||
fi
|
||
|
||
mkdir -p "$APP_ROOT/conf" "$APP_ROOT/logs"
|
||
cat > "$APP_ROOT/conf/ckpool.conf" <<EOF
|
||
{
|
||
"logdir" : "${APP_ROOT}/logs",
|
||
"stratum" : {
|
||
"port" : ${CKPOOL_PORT},
|
||
"bind" : "0.0.0.0"
|
||
},
|
||
"solomining" : true,
|
||
"donrate" : 2.0,
|
||
"btcsig" : "/Mined by a Baldnerd Solo Miner/",
|
||
"note": "Below settings allow low-hashrate miners. Disable if Public Pool.",
|
||
"mindiff": 1,
|
||
"startdiff": 1,
|
||
"idle_timeout": 300
|
||
}
|
||
EOF
|
||
|
||
chown -R "$CKPOOL_USER":"$CKPOOL_USER" "$APP_ROOT"
|
||
|
||
# make ckpmsg available to PHP
|
||
ln -sf ${APP_ROOT}/${CKPOOL_USER}/src/ckpmsg /usr/local/bin/ckpmsg
|
||
|
||
cat > /etc/systemd/system/ckpool.service <<EOF
|
||
[Unit]
|
||
Description=ckpool solo mining server
|
||
After=network.target bitcoind.service
|
||
Wants=bitcoind.service
|
||
|
||
[Service]
|
||
User=${CKPOOL_USER}
|
||
Group=${CKPOOL_USER}
|
||
RuntimeDirectory=${CKPOOL_USER}
|
||
RuntimeDirectoryMode=0770
|
||
WorkingDirectory=${APP_ROOT}/${CKPOOL_USER}
|
||
ExecStart=${APP_ROOT}/${CKPOOL_USER}/src/ckpool -c ${APP_ROOT}/conf/ckpool.conf -B -s /run/${CKPOOL_USER}
|
||
Restart=on-failure
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
|
||
systemctl daemon-reload
|
||
systemctl enable ckpool
|
||
systemctl restart ckpool || true
|
||
echo "[***] ckpool service installed."
|
||
|
||
# ---------------------------------------------------------------------------
|
||
echo "[***] Helper scripts (sudo targets)"
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# apply admin creds
|
||
cat > /usr/local/sbin/btc-apply-admin.sh <<'EOF'
|
||
#!/bin/bash
|
||
CONF="/etc/bitcoin/bitcoin.conf"
|
||
CKPOOL_CONF="/opt/btc-solo/conf/ckpool.conf"
|
||
|
||
ADMINUSER="$1"
|
||
ADMINPASS="$2"
|
||
|
||
if [[ -z "$ADMINUSER" || -z "$ADMINPASS" ]]; then
|
||
echo "Usage: $0 <adminuser> <adminpass>"
|
||
exit 1
|
||
fi
|
||
|
||
# Update bitcoin.conf
|
||
sed -i '/^rpcuser=/d' "$CONF"
|
||
sed -i '/^rpcpassword=/d' "$CONF"
|
||
echo "rpcuser=${ADMINUSER}" >> "$CONF"
|
||
echo "rpcpassword=${ADMINPASS}" >> "$CONF"
|
||
|
||
# Update ckpool config so it can still talk to bitcoind
|
||
if [[ -f "$CKPOOL_CONF" ]]; then
|
||
sed -i "s/\"user\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"user\" : \"${ADMINUSER}\"/" "$CKPOOL_CONF"
|
||
sed -i "s/\"pass\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"pass\" : \"${ADMINPASS}\"/" "$CKPOOL_CONF"
|
||
fi
|
||
|
||
systemctl restart bitcoind
|
||
systemctl restart ckpool
|
||
EOF
|
||
chmod 700 /usr/local/sbin/btc-apply-admin.sh
|
||
|
||
# set prune size
|
||
cat > /usr/local/sbin/btc-set-prune.sh <<'EOF'
|
||
#!/bin/bash
|
||
CONF="/etc/bitcoin/bitcoin.conf"
|
||
SIZE="$1"
|
||
|
||
if [[ -z "$SIZE" ]]; then
|
||
echo "Usage: $0 <size-in-mb|full>"
|
||
exit 1
|
||
fi
|
||
|
||
sed -i '/^prune=/d' "$CONF"
|
||
|
||
if [[ "$SIZE" != "full" ]]; then
|
||
echo "prune=${SIZE}" >> "$CONF"
|
||
fi
|
||
|
||
systemctl restart bitcoind
|
||
EOF
|
||
chmod 700 /usr/local/sbin/btc-set-prune.sh
|
||
|
||
# setup donation system
|
||
cat > /usr/local/sbin/btc-set-donation.sh <<'EOF'
|
||
#!/bin/bash
|
||
CONF="/opt/btc-solo/conf/ckpool.conf"
|
||
ADDR="$1"
|
||
RATE="$2"
|
||
|
||
if [[ -z "$ADDR" || -z "$RATE" ]]; then
|
||
echo "Usage: $0 <donation-address> <donation-rate-percent>"
|
||
exit 1
|
||
fi
|
||
|
||
# ensure conf exists
|
||
if [[ ! -f "$CONF" ]]; then
|
||
echo "ckpool.conf not found at $CONF"
|
||
exit 1
|
||
fi
|
||
|
||
# remove old lines
|
||
sed -i '/"donaddress"/d' "$CONF"
|
||
sed -i '/"donrate"/d' "$CONF"
|
||
|
||
# add new ones just before the final }
|
||
# (very simple appender; good enough for our generated file)
|
||
sed -i '$i \ ,"donaddress" : "'"$ADDR"'",\n "donrate" : '"$RATE"'' "$CONF"
|
||
|
||
systemctl restart ckpool
|
||
EOF
|
||
chmod 700 /usr/local/sbin/btc-set-donation.sh
|
||
|
||
# (re)create self-signed cert
|
||
cat > /usr/local/sbin/btc-make-cert.sh <<'EOF'
|
||
#!/bin/bash
|
||
CERTDIR="/etc/ssl/localcerts"
|
||
mkdir -p "$CERTDIR"
|
||
|
||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||
-keyout ${CERTDIR}/btc-solo.key \
|
||
-out ${CERTDIR}/btc-solo.crt \
|
||
-subj "/C=CA/ST=Ontario/L=Barrie/O=BTC-Solo/OU=IT/CN=$(hostname -f)"
|
||
|
||
chmod 600 ${CERTDIR}/btc-solo.key
|
||
systemctl restart nginx
|
||
EOF
|
||
chmod 700 /usr/local/sbin/btc-make-cert.sh
|
||
|
||
# ensure sudoers.d exists
|
||
mkdir -p /etc/sudoers.d
|
||
chmod 750 /etc/sudoers.d
|
||
|
||
# sudoers
|
||
cat > /etc/sudoers.d/btc-solo <<'EOF'
|
||
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-apply-admin.sh
|
||
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-set-prune.sh
|
||
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-make-cert.sh
|
||
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-set-donation.sh
|
||
EOF
|
||
chmod 440 /etc/sudoers.d/btc-solo
|
||
|
||
# ---------------------------------------------------------------------------
|
||
echo "[***] Web UI (dashboard + activate)"
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# web-owned state dir
|
||
mkdir -p /var/lib/btc-solo
|
||
chown www-data:www-data /var/lib/btc-solo
|
||
|
||
# web dashboard
|
||
mkdir -p "$WWW_ROOT"
|
||
mkdir -p "$WWW_ROOT/activate"
|
||
|
||
# Main dashboard
|
||
cat > "$WWW_ROOT/index.php" <<'EOF'
|
||
<?php
|
||
$flag = '/var/lib/btc-solo/.setup-complete';
|
||
if (!file_exists($flag)) {
|
||
header('Location: /activate/');
|
||
exit;
|
||
}
|
||
|
||
// Host / stratum endpoint
|
||
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_ADDR'] ?? gethostname();
|
||
$host = preg_replace('/:\d+$/', '', $host);
|
||
$stratum = 'stratum+tcp://' . $host . ':3333';
|
||
|
||
// Optional wallet address in query for worker view
|
||
$addr = '';
|
||
if (isset($_GET['addr'])) {
|
||
$candidate = trim($_GET['addr']);
|
||
// light sanity check; ckpool will do the real validation
|
||
if (preg_match('/^[13bc][a-km-zA-HJ-NP-Z1-9]{25,}$/', $candidate)) {
|
||
$addr = $candidate;
|
||
}
|
||
}
|
||
?>
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Bitcoin Node for Solo Miners</title>
|
||
<link id="app-favicon" rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23222'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='32' fill='%23ffb347'%3E%E2%9A%A0%3C/text%3E%3C/svg%3E">
|
||
<style>
|
||
:root {
|
||
--bg: #0b0b0e;
|
||
--bg-card: #16161d;
|
||
--fg: #eee;
|
||
--accent: #4caf50;
|
||
--accent-warn: #ffb347;
|
||
--accent-err: #ff6b6b;
|
||
--muted: #999;
|
||
--border-soft: #262636;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
height: 100%;
|
||
}
|
||
body {
|
||
min-height: 100vh;
|
||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
background: radial-gradient(circle at top left, #181830, #050509 55%);
|
||
color: var(--fg);
|
||
margin: 0;
|
||
}
|
||
header {
|
||
background: rgba(10,10,18,0.96);
|
||
backdrop-filter: blur(12px);
|
||
padding: 1rem 2rem;
|
||
display:flex;
|
||
justify-content:space-between;
|
||
align-items:center;
|
||
border-bottom: 1px solid var(--border-soft);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
h1 { margin: 0; font-size: 1.4rem; }
|
||
header p { margin:0; color: var(--muted); font-size: .9rem; }
|
||
a { color: #7ad; text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
|
||
main {
|
||
display:grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 1rem;
|
||
padding: 1rem 2rem 2rem;
|
||
}
|
||
@media (max-width: 960px) {
|
||
main { grid-template-columns: minmax(0,1fr); }
|
||
}
|
||
|
||
.card {
|
||
background: linear-gradient(145deg, #14141f, #101018);
|
||
border-radius: 14px;
|
||
padding: 1rem 1.5rem 1.25rem;
|
||
box-shadow: 0 18px 35px rgba(0,0,0,0.65);
|
||
border: 1px solid var(--border-soft);
|
||
}
|
||
.card h2 {
|
||
margin-top: 0;
|
||
font-size: 1.1rem;
|
||
display:flex;
|
||
align-items:center;
|
||
gap:.4rem;
|
||
}
|
||
.card h2 span.label {
|
||
font-size: .8rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: .05em;
|
||
color: var(--muted);
|
||
margin-left: .3rem;
|
||
}
|
||
|
||
code {
|
||
background: #22253a;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 6px;
|
||
font-size: .9rem;
|
||
}
|
||
|
||
.status-ok { color: var(--accent); }
|
||
.status-warn { color: var(--accent-warn); }
|
||
.status-err { color: var(--accent-err); }
|
||
|
||
.progress-wrap {
|
||
background:#06070b;
|
||
border-radius:6px;
|
||
height:12px;
|
||
overflow:hidden;
|
||
border:1px solid #26283e;
|
||
margin-top:.35rem;
|
||
}
|
||
.progress-bar {
|
||
background: linear-gradient(90deg, #4caf50, #9be15d);
|
||
height:100%;
|
||
width:0%;
|
||
transition:width .6s ease;
|
||
}
|
||
|
||
ul.status-list { list-style:none; padding-left:0; margin:.5rem 0 0; font-size:.9rem; }
|
||
ul.status-list li { margin-bottom:.15rem; }
|
||
|
||
.meta-grid {
|
||
display:grid;
|
||
grid-template-columns: repeat(auto-fit,minmax(170px,1fr));
|
||
gap:.6rem;
|
||
margin-top:.75rem;
|
||
font-size: .85rem;
|
||
}
|
||
.meta-pill {
|
||
background:#10121f;
|
||
border-radius:8px;
|
||
padding:.5rem .65rem .6rem;
|
||
border:1px solid #24263a;
|
||
}
|
||
.meta-label { color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.06em; }
|
||
.meta-value { margin-top:.15rem; }
|
||
|
||
.mini-bar-wrap {
|
||
margin-top:.25rem;
|
||
background:#05060b;
|
||
border-radius:999px;
|
||
height:6px;
|
||
overflow:hidden;
|
||
}
|
||
.mini-bar {
|
||
height:100%;
|
||
width:0;
|
||
background:linear-gradient(90deg,#4cafef,#b388ff);
|
||
transition:width .4s ease;
|
||
}
|
||
|
||
.workers-header {
|
||
display:flex;
|
||
justify-content:space-between;
|
||
align-items:flex-end;
|
||
gap: .75rem;
|
||
flex-wrap:wrap;
|
||
}
|
||
.workers-form label { font-size:.84rem; color:var(--muted); display:block; margin-bottom:.15rem; }
|
||
.workers-form input[type=text] {
|
||
background:#11131e;
|
||
border-radius:8px;
|
||
border:1px solid #30324d;
|
||
padding:.4rem .6rem;
|
||
color:var(--fg);
|
||
min-width:260px;
|
||
}
|
||
.workers-form button {
|
||
margin-left:.4rem;
|
||
padding:.4rem .8rem;
|
||
border-radius:8px;
|
||
border:0;
|
||
background:#2563eb;
|
||
color:#fff;
|
||
font-size:.85rem;
|
||
cursor:pointer;
|
||
}
|
||
.workers-form button:hover { background:#1d4ed8; }
|
||
|
||
.workers-summary {
|
||
margin-top:.6rem;
|
||
font-size:.86rem;
|
||
}
|
||
.workers-summary strong { font-weight:600; }
|
||
|
||
.workers-table {
|
||
width:100%;
|
||
border-collapse:collapse;
|
||
margin-top:.75rem;
|
||
font-size:.82rem;
|
||
}
|
||
.workers-table th,
|
||
.workers-table td {
|
||
padding:.35rem .35rem;
|
||
border-bottom:1px solid #25273a;
|
||
text-align:left;
|
||
white-space:nowrap;
|
||
}
|
||
.workers-table th { color:var(--muted); font-weight:500; font-size:.78rem; }
|
||
.workers-table tr:hover { background:#15172a; cursor:pointer; }
|
||
|
||
.worker-detail {
|
||
margin-top:.7rem;
|
||
padding-top:.5rem;
|
||
border-top:1px dashed #30324d;
|
||
font-size:.84rem;
|
||
}
|
||
.worker-detail h3 { margin:.1rem 0 .25rem; font-size:.92rem; }
|
||
|
||
.small-note {
|
||
font-size:.8rem;
|
||
color:var(--muted);
|
||
margin-top:.5rem;
|
||
}
|
||
|
||
#stratum-card {
|
||
/* margin-top:.6rem; */
|
||
font-size:.88rem;
|
||
max-width: 640px;
|
||
justify-self: end;
|
||
}
|
||
#stratum-card .stratum-grid {
|
||
row-gap: .4rem;
|
||
}
|
||
.worker-idle td {
|
||
color: var(--accent-warn);
|
||
}
|
||
.worker-inactive td {
|
||
color: var(--accent-err);
|
||
opacity: 0.8;
|
||
}
|
||
.workers-controls {
|
||
font-size:.8rem;
|
||
color:var(--muted);
|
||
margin-top:.25rem;
|
||
display:none;
|
||
}
|
||
.workers-controls label {
|
||
cursor:pointer;
|
||
user-select:none;
|
||
}
|
||
.workers-controls input {
|
||
vertical-align:middle;
|
||
margin-right:.25rem;
|
||
}
|
||
.worker-detail-row td {
|
||
background:#11131e;
|
||
border-top:0;
|
||
padding-top:.4rem;
|
||
padding-bottom:.7rem;
|
||
}
|
||
.worker-detail {
|
||
font-size:.84rem;
|
||
color:var(--fg);
|
||
border-top:1px dashed #30324d;
|
||
margin-top:.2rem;
|
||
padding-top:.3rem;
|
||
}
|
||
@keyframes fadeDown {
|
||
from { opacity:0; transform: translateY(-4px); }
|
||
to { opacity:1; transform: translateY(0); }
|
||
}
|
||
tr[data-worker].is-open {
|
||
background:#181a30;
|
||
}
|
||
.pool-hashrate {
|
||
margin-top:.5rem;
|
||
}
|
||
.pool-hash-grid {
|
||
display:flex;
|
||
flex-wrap:wrap;
|
||
gap:.4rem .6rem;
|
||
margin-top:.25rem;
|
||
}
|
||
.pool-hash-item {
|
||
display:inline-flex;
|
||
flex-direction:column;
|
||
padding:.25rem .8rem .35rem;
|
||
border-radius:999px;
|
||
background:radial-gradient(circle at top left,#232745,#111321);
|
||
border:1px solid #2f3457;
|
||
min-width:6.3rem;
|
||
}
|
||
.pool-hash-item span {
|
||
color:var(--muted);
|
||
font-size:.7rem;
|
||
text-transform:uppercase;
|
||
letter-spacing:.08em;
|
||
}
|
||
.pool-hash-item strong {
|
||
font-family:var(--font-mono,"JetBrains Mono",Consolas,monospace);
|
||
font-size:.92rem;
|
||
font-weight:500;
|
||
}
|
||
footer {
|
||
padding:.6rem 2rem 1rem;
|
||
font-size:.75rem;
|
||
color:var(--muted);
|
||
text-align:right;
|
||
opacity:0.75;
|
||
}
|
||
footer a {
|
||
color:var(--muted);
|
||
text-decoration:none;
|
||
}
|
||
footer a:hover {
|
||
text-decoration:underline;
|
||
}
|
||
.graph-section {
|
||
margin-top:.8rem;
|
||
padding:.5rem .4rem .3rem;
|
||
background:#05060b;
|
||
border-radius:10px;
|
||
border:1px solid #24263a;
|
||
overflow: hidden;
|
||
}
|
||
.graph-section canvas {
|
||
width:100%;
|
||
height:140px;
|
||
display:block;
|
||
}
|
||
.graph-inner {
|
||
display: flex;
|
||
gap: 1.2rem;
|
||
align-items: stretch;
|
||
}
|
||
.graph-legend {
|
||
min-width: 160px;
|
||
font-size: .78rem;
|
||
color: var(--muted);
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
}
|
||
.graph-legend-heading {
|
||
font-size: .75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: .12em;
|
||
color: #7579a8;
|
||
margin-bottom: .4rem;
|
||
}
|
||
.graph-legend-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: .45rem;
|
||
margin-bottom: .35rem;
|
||
}
|
||
.graph-swatch {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(90deg,#4cafef,#b388ff);
|
||
flex-shrink: 0;
|
||
}
|
||
.graph-legend-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: .1rem;
|
||
}
|
||
.graph-legend-label {
|
||
font-size: .75rem;
|
||
color: #8a8fbc;
|
||
}
|
||
.graph-legend-value {
|
||
font-family: var(--font-mono,"JetBrains Mono",Consolas,monospace);
|
||
font-size: .88rem;
|
||
color: #e5e8ff;
|
||
}
|
||
.graph-legend-sub {
|
||
font-size: .72rem;
|
||
color: #a9aedc;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-top: .15rem;
|
||
}
|
||
.graph-canvas-wrap {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.graph-caption {
|
||
font-size:.7rem;
|
||
color:var(--muted);
|
||
margin-top:.25rem;
|
||
}
|
||
/* Worker detail animation: only when explicitly requested */
|
||
.worker-detail {
|
||
margin-top:.7rem;
|
||
padding-top:.5rem;
|
||
border-top:1px dashed #30324d;
|
||
font-size:.84rem;
|
||
}
|
||
.worker-detail h3 { margin:.1rem 0 .25rem; font-size:.92rem; }
|
||
.worker-detail-animate {
|
||
animation: slideDown .18s ease-out;
|
||
}
|
||
@keyframes slideDown {
|
||
from { opacity:0; transform:translateY(-6px); }
|
||
to { opacity:1; transform:translateY(0); }
|
||
}
|
||
.pool-header {
|
||
display:flex;
|
||
justify-content:space-between;
|
||
align-items:flex-start;
|
||
margin-bottom:.55rem;
|
||
}
|
||
.pool-title-block h2 {
|
||
margin:0;
|
||
font-size:1.25rem;
|
||
}
|
||
.pool-subtitle {
|
||
font-size:.75rem;
|
||
text-transform:uppercase;
|
||
letter-spacing:.12em;
|
||
color:#6b6f9b;
|
||
}
|
||
.pool-stat-row {
|
||
display:flex;
|
||
gap:.35rem;
|
||
flex-wrap:wrap;
|
||
justify-content:flex-end;
|
||
font-size:.75rem;
|
||
}
|
||
.stat-chip {
|
||
display:inline-flex;
|
||
flex-direction:column;
|
||
align-items:flex-start;
|
||
padding:.2rem .5rem;
|
||
border-radius:999px;
|
||
background:radial-gradient(circle at top left,#232745,#111321);
|
||
border:1px solid #2e3255;
|
||
min-width:5.2rem;
|
||
}
|
||
.stat-chip-label {
|
||
font-size:.65rem;
|
||
text-transform:uppercase;
|
||
letter-spacing:.11em;
|
||
color:#7b7faa;
|
||
}
|
||
.stat-chip-value {
|
||
font-family:var(--font-mono, "JetBrains Mono", Consolas, monospace);
|
||
font-size:.8rem;
|
||
color:#e5e8ff;
|
||
}
|
||
.pool-metrics-row {
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:space-between;
|
||
margin:.2rem 0 .25rem;
|
||
}
|
||
.pool-metrics-label {
|
||
font-size:.7rem;
|
||
text-transform:uppercase;
|
||
letter-spacing:.15em;
|
||
color:#7579a8;
|
||
}
|
||
.pool-shares-line {
|
||
font-size:.78rem;
|
||
color:#c7caec;
|
||
margin-top:.1rem;
|
||
}
|
||
.pool-shares-line strong {
|
||
font-family:var(--font-mono, "JetBrains Mono", Consolas, monospace);
|
||
}
|
||
.pool-shares-line {
|
||
display:flex;
|
||
flex-wrap:wrap;
|
||
align-items:center;
|
||
gap:.4rem;
|
||
margin-top:.4rem;
|
||
font-size:.78rem;
|
||
}
|
||
.shares-label {
|
||
font-size:.7rem;
|
||
text-transform:uppercase;
|
||
letter-spacing:.14em;
|
||
color:#7579a8;
|
||
margin-right:.1rem;
|
||
}
|
||
.shares-pill {
|
||
display:inline-flex;
|
||
flex-direction:column;
|
||
padding:.2rem .55rem;
|
||
border-radius:999px;
|
||
background:radial-gradient(circle at top left,#232745,#111321);
|
||
border:1px solid #2f3457;
|
||
}
|
||
.shares-pill span {
|
||
font-size:.65rem;
|
||
text-transform:uppercase;
|
||
letter-spacing:.08em;
|
||
color:#8a8fbc;
|
||
}
|
||
.shares-pill strong {
|
||
font-family:var(--font-mono,"JetBrains Mono",Consolas,monospace);
|
||
font-size:.8rem;
|
||
color:#e5e8ff;
|
||
}
|
||
.shares-pill-pct {
|
||
font-size:.72rem;
|
||
color:#a9aedc;
|
||
}
|
||
.shares-pill-rejected { border-color:#ff6b81; }
|
||
.shares-pill-best { border-color:#4cafef; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div>
|
||
<h1>Bitcoin Node for Solo Miners</h1>
|
||
<p>A Linux Appliance by Robbie Ferguson</p>
|
||
</div>
|
||
<div>
|
||
<a href="/settings.php">Settings</a>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<!-- Node + system status (top-left) -->
|
||
<section class="card" id="node-status">
|
||
<h2>Node Status <span class="label">blockchain & system</span></h2>
|
||
<p>Loading status…</p>
|
||
</section>
|
||
|
||
<!-- Stratum info card (top-right: connect your miners) -->
|
||
<section class="card" id="stratum-card">
|
||
<h2>Connect Your Miners <span class="label">stratum endpoint</span></h2>
|
||
<div class="stratum-grid">
|
||
<div>
|
||
<div class="meta-label">Endpoint</div>
|
||
<p><code><?php echo htmlspecialchars($stratum); ?></code></p>
|
||
</div>
|
||
<div>
|
||
<div class="meta-label">Username</div>
|
||
<p><code>Your BTC payout address</code></p>
|
||
</div>
|
||
<div>
|
||
<div class="meta-label">Password</div>
|
||
<p><code>anything</code></p>
|
||
</div>
|
||
</div>
|
||
<p class="small-note" id="mining-note">
|
||
Your miners will start submitting shares once Bitcoin Core is fully synced and connected.
|
||
</p>
|
||
</section>
|
||
|
||
<!-- Workers / pool dashboard (full-width bottom) -->
|
||
<section class="card" id="workers-card" style="grid-column:1 / -1;">
|
||
<div class="workers-header">
|
||
<h2>Workers & Pool <span class="label">mining overview</span></h2>
|
||
<form class="workers-form" id="addr-form">
|
||
<div>
|
||
<label for="addr-input">Your payout address (for detailed worker view)</label>
|
||
<div>
|
||
<input type="text" id="addr-input" name="addr" placeholder="bc1… or 1/3 address"
|
||
value="<?php echo htmlspecialchars($addr); ?>">
|
||
<button type="submit">Load</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div id="workers-summary">
|
||
<p class="small-note">Pulling pool stats from CKPool logs...</p>
|
||
</div>
|
||
<div class="graph-section" id="pool-hashrate-graph"></div>
|
||
<div class="workers-controls" id="workers-controls">
|
||
<label>
|
||
<input type="checkbox" id="toggle-inactive">
|
||
Show inactive workers
|
||
</label>
|
||
</div>
|
||
<div id="workers-table-wrap"></div>
|
||
<div id="worker-detail"></div>
|
||
<p class="small-note">
|
||
Best of luck to you!
|
||
</p>
|
||
</section>
|
||
</main>
|
||
|
||
<footer>
|
||
© Copyright 2025-<?php echo date('Y'); ?> Robbie Ferguson //
|
||
<a href="https://category5.tv/">Category5 TV</a>
|
||
</footer>
|
||
|
||
<script>
|
||
// ---------------- Shared app state ----------------
|
||
const appState = {
|
||
bitcoin: {
|
||
ibd: true,
|
||
progress: 0,
|
||
serviceActive: false
|
||
},
|
||
pool: {
|
||
workers: 0
|
||
},
|
||
error: false,
|
||
lastStatusOk: Date.now()
|
||
};
|
||
|
||
let currentAddr = (document.getElementById('addr-input')?.value || '').trim() || null;
|
||
|
||
// Will want to use the JSON 1m average rather than the most recent database entry, so need to setup the variable to share this data
|
||
let currentPoolH1m = null;
|
||
|
||
// Sliding window tracker for sync ETA
|
||
const syncTracker = { samples: [] };
|
||
const WINDOW_SECONDS = 900; // 15 minutes
|
||
|
||
const STALE_MS = 30000;
|
||
|
||
// Inline SVG favicons
|
||
const FAV_SYNC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23222'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='32' fill='%23ffb347'%3E%E2%9A%A0%3C/text%3E%3C/svg%3E";
|
||
const FAV_READY = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23161717'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='34' fill='%238f8'%3E%E2%9C%94%3C/text%3E%3C/svg%3E";
|
||
const FAV_ERR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23222222'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='34' fill='%23ff6b6b'%3E!%3C/text%3E%3C/svg%3E";
|
||
|
||
function formatEta(seconds) {
|
||
if (seconds == null || !isFinite(seconds)) return 'estimating…';
|
||
if (seconds < 60) return seconds.toFixed(0) + 's';
|
||
const mins = seconds / 60;
|
||
if (mins < 60) return mins.toFixed(1) + 'm';
|
||
const hours = mins / 60;
|
||
return hours.toFixed(1) + 'h';
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes == null || !isFinite(bytes)) return 'n/a';
|
||
const units = ['B','KiB','MiB','GiB','TiB'];
|
||
let i = 0;
|
||
let v = bytes;
|
||
while (v >= 1024 && i < units.length - 1) {
|
||
v /= 1024;
|
||
i++;
|
||
}
|
||
return v.toFixed(2) + ' ' + units[i];
|
||
}
|
||
|
||
function humanUptime(seconds) {
|
||
if (!seconds || seconds < 0) return 'n/a';
|
||
const days = Math.floor(seconds / 86400);
|
||
const hours = Math.floor((seconds % 86400) / 3600);
|
||
const mins = Math.floor((seconds % 3600) / 60);
|
||
const parts = [];
|
||
if (days) parts.push(days + ' day' + (days !== 1 ? 's' : ''));
|
||
if (hours) parts.push(hours + ' hour' + (hours !== 1 ? 's' : ''));
|
||
parts.push(mins + ' minute' + (mins !== 1 ? 's' : ''));
|
||
return parts.join(', ');
|
||
}
|
||
|
||
function updateSamples(progress) {
|
||
const now = Date.now() / 1000;
|
||
syncTracker.samples.push({ t: now, p: progress });
|
||
const cutoff = now - WINDOW_SECONDS;
|
||
syncTracker.samples = syncTracker.samples.filter(s => s.t >= cutoff);
|
||
}
|
||
|
||
function estimateEta(progress) {
|
||
const samples = syncTracker.samples;
|
||
if (samples.length < 2) return null;
|
||
const first = samples[0];
|
||
const last = samples[samples.length - 1];
|
||
const dP = last.p - first.p;
|
||
const dT = last.t - first.t;
|
||
if (dP <= 0 || dT <= 5) return null;
|
||
const rate = dP / dT;
|
||
if (rate <= 0) return null;
|
||
const remaining = Math.max(0, 100 - progress);
|
||
return remaining / rate;
|
||
}
|
||
|
||
const graphCache = {};
|
||
function drawLineGraph(containerId, points, opts = {}) {
|
||
const container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
|
||
container.innerHTML = '';
|
||
|
||
if (!points || !points.length) {
|
||
container.innerHTML =
|
||
'<p class="small-note graph-caption">Not enough history yet.</p>';
|
||
return;
|
||
}
|
||
|
||
const seriesKey = opts.series || 'h1m';
|
||
const height = opts.height || 140;
|
||
const label = opts.label || 'Pool hashrate';
|
||
|
||
// Sanitize numeric values (ignore zeros, NaN, and insane outliers)
|
||
const xs = points.map(p => p.ts);
|
||
const rawVals = points
|
||
.map(p => p[seriesKey])
|
||
.filter(v => typeof v === 'number' && isFinite(v) && v > 0 && v <= 1e18);
|
||
|
||
if (!rawVals.length) {
|
||
container.innerHTML =
|
||
'<p class="small-note graph-caption">No hashrate yet.</p>';
|
||
return;
|
||
}
|
||
|
||
let maxRaw = Math.max(...rawVals);
|
||
|
||
// Choose unit
|
||
let unit = 'H/s', div = 1;
|
||
if (maxRaw >= 1e12) { unit = 'TH/s'; div = 1e12; }
|
||
else if (maxRaw >= 1e9) { unit = 'GH/s'; div = 1e9; }
|
||
else if (maxRaw >= 1e6) { unit = 'MH/s'; div = 1e6; }
|
||
else if (maxRaw >= 1e3) { unit = 'kH/s'; div = 1e3; }
|
||
|
||
// Values in chosen unit
|
||
const ys = points.map(p => {
|
||
const v = p[seriesKey];
|
||
if (typeof v !== 'number' || !isFinite(v) || v <= 0 || v > 1e18) return 0;
|
||
return v / div;
|
||
});
|
||
|
||
const positiveYs = ys.filter(v => v > 0);
|
||
if (!positiveYs.length) {
|
||
container.innerHTML =
|
||
'<p class="small-note graph-caption">No hashrate yet.</p>';
|
||
return;
|
||
}
|
||
|
||
const minX = Math.min(...xs);
|
||
const maxX = Math.max(...xs);
|
||
const minY = 0;
|
||
const maxY = Math.max(...positiveYs);
|
||
const latestGood = positiveYs[positiveYs.length - 1];
|
||
|
||
// Live 1m value (raw H/s) from opts.currentValue
|
||
const explicitCurrent =
|
||
(opts.currentValue != null && isFinite(opts.currentValue) && opts.currentValue > 0)
|
||
? (opts.currentValue / div)
|
||
: null;
|
||
|
||
const displayLast = explicitCurrent != null ? explicitCurrent : latestGood;
|
||
const minVal = Math.min(...positiveYs);
|
||
const maxVal = maxY;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
|
||
// ------- Build DOM first so flex can lay out -------
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'graph-inner';
|
||
|
||
const legend = document.createElement('div');
|
||
legend.className = 'graph-legend';
|
||
legend.innerHTML = `
|
||
<div>
|
||
<div class="graph-legend-heading">${label}</div>
|
||
<div class="graph-legend-row">
|
||
<span class="graph-swatch"></span>
|
||
<div class="graph-legend-text">
|
||
<div class="graph-legend-label">1m average</div>
|
||
<div class="graph-legend-value">${
|
||
isFinite(displayLast) ? displayLast.toFixed(2) + ' ' + unit : 'no data yet'
|
||
}</div>
|
||
</div>
|
||
</div>
|
||
<div class="graph-legend-row graph-legend-sub">
|
||
<span class="graph-legend-label">Range</span>
|
||
<span class="graph-legend-value">${
|
||
isFinite(minVal) && isFinite(maxVal)
|
||
? minVal.toFixed(2) + '-' + maxVal.toFixed(2) + ' ' + unit
|
||
: 'n/a'
|
||
}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const canvasWrap = document.createElement('div');
|
||
canvasWrap.className = 'graph-canvas-wrap';
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvasWrap.appendChild(canvas);
|
||
|
||
wrapper.appendChild(legend);
|
||
wrapper.appendChild(canvasWrap);
|
||
container.appendChild(wrapper);
|
||
|
||
// ------- Now we know the real width available to the canvas -------
|
||
|
||
const widthCSS = canvasWrap.clientWidth || container.clientWidth || 300;
|
||
|
||
canvas.width = widthCSS * dpr;
|
||
canvas.height = height * dpr;
|
||
canvas.style.width = widthCSS + 'px';
|
||
canvas.style.height = height + 'px';
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(dpr, dpr);
|
||
|
||
const padL = 32, padR = 16, padT = 6, padB = 16;
|
||
const w = widthCSS - padL - padR;
|
||
const h = height - padT - padB;
|
||
|
||
function nx(x) {
|
||
return padL + (w * (x - minX) / (maxX - minX || 1));
|
||
}
|
||
function ny(y) {
|
||
return padT + h - (h * (y - minY) / (maxY - minY || 1));
|
||
}
|
||
|
||
// Grid
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = '#1a1c2b';
|
||
ctx.beginPath();
|
||
for (let i = 0; i <= 4; i++) {
|
||
const yy = padT + (h * i / 4);
|
||
ctx.moveTo(padL, yy);
|
||
ctx.lineTo(widthCSS - padR, yy);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Y labels
|
||
ctx.fillStyle = '#666b8f';
|
||
ctx.font = '10px system-ui, sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.textBaseline = 'middle';
|
||
for (let i = 0; i <= 4; i++) {
|
||
const value = maxY * (1 - i / 4);
|
||
const yy = padT + (h * i / 4);
|
||
ctx.fillText(value.toFixed(2), padL - 4, yy);
|
||
}
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(unit, padL, padT);
|
||
|
||
// Line
|
||
ctx.lineWidth = 1.7;
|
||
const grad = ctx.createLinearGradient(padL, padT, widthCSS - padR, padT);
|
||
grad.addColorStop(0, '#4cafef');
|
||
grad.addColorStop(1, '#b388ff');
|
||
ctx.strokeStyle = grad;
|
||
|
||
ctx.beginPath();
|
||
points.forEach((p, idx) => {
|
||
const raw = p[seriesKey];
|
||
const v = (typeof raw === 'number' && isFinite(raw) && raw > 0 && raw <= 1e18)
|
||
? raw / div
|
||
: 0;
|
||
const x = nx(p.ts);
|
||
const y = ny(v);
|
||
if (idx === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
ctx.stroke();
|
||
|
||
if (opts.caption) {
|
||
const cap = document.createElement('div');
|
||
cap.className = 'graph-caption';
|
||
const capText = isFinite(displayLast)
|
||
? `${opts.caption} (current ${displayLast.toFixed(2)} ${unit})`
|
||
: opts.caption;
|
||
cap.textContent = capText;
|
||
canvasWrap.appendChild(cap);
|
||
}
|
||
}
|
||
|
||
function loadWorkerHistory(addr, workerName, containerId, currentH1m) {
|
||
if (!addr || !workerName || !containerId) return;
|
||
|
||
const container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
|
||
const key = addr + '::' + workerName;
|
||
const now = Date.now();
|
||
const last = workerHistoryLastFetch[key] || 0;
|
||
|
||
// If the container is empty, we MUST draw something, even if we're inside
|
||
// the 60s rate limit window. This avoids the "graph disappears" issue when
|
||
// the table/detail HTML gets rebuilt.
|
||
const needsImmediateRedraw = !container.hasChildNodes();
|
||
|
||
// Only skip if we've drawn recently *and* the existing graph is still there
|
||
if (!needsImmediateRedraw && now - last < 60000) {
|
||
return;
|
||
}
|
||
|
||
workerHistoryLastFetch[key] = now;
|
||
|
||
// Optional: tiny "loading" hint while the history fetch happens
|
||
container.innerHTML =
|
||
'<p class="small-note graph-caption">Loading worker history…</p>';
|
||
|
||
fetch(
|
||
'/history.php?mode=worker&addr=' +
|
||
encodeURIComponent(addr) +
|
||
'&worker=' + encodeURIComponent(workerName) +
|
||
'&limit=120',
|
||
{ cache: 'no-store' }
|
||
)
|
||
.then(res => {
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
return res.json();
|
||
})
|
||
.then(data => {
|
||
if (!data || !Array.isArray(data.points)) return;
|
||
|
||
drawLineGraph(containerId, data.points, {
|
||
series: 'h1m',
|
||
caption: 'Worker hashrate (1m average over time)',
|
||
label: 'Worker hashrate',
|
||
// explicit current value so legend matches the JSON
|
||
currentValue: currentH1m
|
||
});
|
||
})
|
||
.catch(() => {
|
||
// leave whatever was last drawn, or the small "loading" text if first time
|
||
});
|
||
}
|
||
|
||
function renderServices(svcs) {
|
||
if (!svcs) return '';
|
||
const map = {
|
||
bitcoind: 'Bitcoin Core',
|
||
ckpool: 'CKPool'
|
||
};
|
||
let out = '<h3 style="margin-top:.75rem;font-size:.9rem;">Service Health</h3><ul class="status-list">';
|
||
for (const key of Object.keys(map)) {
|
||
if (!(key in svcs)) continue;
|
||
const state = svcs[key];
|
||
const ok = state === 'active';
|
||
out += '<li>' + (ok ? '✅' : '❌') + ' ' + map[key] + ' — ' + state + '</li>';
|
||
}
|
||
out += '</ul>';
|
||
return out;
|
||
}
|
||
|
||
function refreshTitleAndFavicon() {
|
||
const link = document.getElementById('app-favicon');
|
||
if (!link) return;
|
||
|
||
if (appState.error || !appState.bitcoin.serviceActive) {
|
||
link.href = FAV_ERR;
|
||
document.title = "Disconnected • Bitcoin Solo Miner";
|
||
return;
|
||
}
|
||
|
||
if (appState.bitcoin.ibd) {
|
||
link.href = FAV_SYNC;
|
||
document.title = "Syncing… " + appState.bitcoin.progress.toFixed(1) + "% | Bitcoin Solo Miner";
|
||
return;
|
||
}
|
||
|
||
// In sync
|
||
if (appState.pool.workers > 0) {
|
||
link.href = FAV_READY;
|
||
document.title = "Mining • Bitcoin Solo Miner";
|
||
} else {
|
||
link.href = FAV_READY;
|
||
document.title = "Waiting for Workers • Bitcoin Solo Miner";
|
||
}
|
||
}
|
||
|
||
async function loadPoolHistory() {
|
||
try {
|
||
const res = await fetch('/history.php?mode=pool&limit=720', { cache: 'no-store' });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (!data || !Array.isArray(data.points)) return;
|
||
|
||
lastPoolHistory = data.points;
|
||
renderPoolGraph(); // <-- new
|
||
} catch (e) {
|
||
// silent failure is fine
|
||
}
|
||
}
|
||
|
||
function renderPoolGraph() {
|
||
const container = document.getElementById('pool-hashrate-graph');
|
||
|
||
if (!lastPoolHistory || !Array.isArray(lastPoolHistory) || !lastPoolHistory.length) {
|
||
if (container) {
|
||
container.innerHTML =
|
||
'<p class="small-note graph-caption">Not enough history yet.</p>';
|
||
}
|
||
bestPoolH1m = null;
|
||
return;
|
||
}
|
||
|
||
// Compute the best 1m pool hashrate seen in the loaded history window
|
||
let maxH1m = 0;
|
||
for (const p of lastPoolHistory) {
|
||
const v = (typeof p.h1m === 'number' && isFinite(p.h1m) && p.h1m > 0 && p.h1m <= 1e18)
|
||
? p.h1m
|
||
: 0;
|
||
if (v > maxH1m) maxH1m = v;
|
||
}
|
||
bestPoolH1m = maxH1m > 0 ? maxH1m : null;
|
||
|
||
drawLineGraph('pool-hashrate-graph', lastPoolHistory, {
|
||
series: 'h1m',
|
||
caption: 'Current hashrate',
|
||
label: 'Pool hashrate',
|
||
currentValue: currentPoolH1m // live 1m from JSON
|
||
});
|
||
}
|
||
|
||
// ---------------- Load node/system status ----------------
|
||
async function loadStatus() {
|
||
try {
|
||
const res = await fetch('/status.php', {cache: 'no-store'});
|
||
const data = await res.json();
|
||
const box = document.getElementById('node-status');
|
||
|
||
if (data.error) {
|
||
appState.error = true;
|
||
appState.bitcoin.serviceActive = false;
|
||
box.innerHTML = '<h2>Node Status <span class="label">blockchain & system</span></h2>' +
|
||
'<p class="status-err">' + data.error + '</p>';
|
||
refreshTitleAndFavicon();
|
||
return;
|
||
}
|
||
|
||
appState.error = false;
|
||
appState.lastStatusOk = Date.now();
|
||
|
||
const rawIbd = data.initialblockdownload;
|
||
const hasRealIbd = (typeof rawIbd === 'boolean');
|
||
const ibd = hasRealIbd ? rawIbd : true;
|
||
const blocks = data.blocks ?? '-';
|
||
const pruned = data.pruned ? '(pruned)' : '';
|
||
let progress = 0;
|
||
if (typeof data.verificationprogress === 'number') {
|
||
progress = data.verificationprogress * 100;
|
||
} else if (data.blocks && data.headers && data.headers > 0) {
|
||
progress = (data.blocks / data.headers) * 100;
|
||
}
|
||
appState.bitcoin.ibd = ibd;
|
||
appState.bitcoin.progress = progress;
|
||
|
||
const bitcoindActive = data.services && data.services.bitcoind === 'active';
|
||
appState.bitcoin.serviceActive = !!bitcoindActive;
|
||
|
||
updateSamples(progress);
|
||
const etaSeconds = ibd ? estimateEta(progress) : null;
|
||
|
||
let html = '<h2>Node Status <span class="label">blockchain & system</span></h2>';
|
||
|
||
if (!bitcoindActive) {
|
||
html += '<p class="status-err">❌ Bitcoin service is not running.</p>';
|
||
} else if (!hasRealIbd) {
|
||
html += '<p class="status-err">❌ Cannot read Bitcoin Core status. If this persists, try refreshing your browser.</p>';
|
||
} else if (ibd && progress < 0.1) {
|
||
html += '<p class="status-warn">⚠ Bitcoin Core is starting up and loading blockchain data…</p>';
|
||
} else if (ibd) {
|
||
html += '<p class="status-warn">⚠ Bitcoin Core is syncing. Your miners will not be able to submit shares or receive work until the node is fully synced.</p>';
|
||
} else {
|
||
html += '<p class="status-ok">✔ Bitcoin Core is in sync.</p>';
|
||
}
|
||
|
||
html += '<p style="margin-bottom:.3rem;">Blocks: ' + blocks + ' ' + pruned + '</p>';
|
||
|
||
// Only show sync bar while still syncing
|
||
if (ibd) {
|
||
html += '<div class="progress-wrap"><div class="progress-bar" id="syncbar"></div></div>';
|
||
html += '<p style="margin-top:.25rem;"><small>Sync progress: ' + progress.toFixed(1) + '%';
|
||
if (etaSeconds && isFinite(etaSeconds) && etaSeconds > 0) {
|
||
const etaDate = new Date(Date.now() + etaSeconds * 1000);
|
||
const etaString = etaDate.toLocaleString(undefined, {
|
||
month: 'long', day: 'numeric', year: 'numeric',
|
||
hour: 'numeric', minute: '2-digit'
|
||
});
|
||
html += ' • Estimated time left: ' + formatEta(etaSeconds) + ' (' + etaString + ')';
|
||
}
|
||
html += '</small></p>';
|
||
}
|
||
|
||
// System metrics
|
||
const sys = data.system || {};
|
||
const load = sys.loadavg || [];
|
||
const memTotal = sys.mem_total_bytes;
|
||
const memAvail = sys.mem_available_bytes;
|
||
let memPct = null;
|
||
if (memTotal && memAvail != null) {
|
||
memPct = (1 - (memAvail / memTotal)) * 100;
|
||
}
|
||
const disk = sys.disk_bitcoind || {};
|
||
let diskPct = null;
|
||
if (disk.total_bytes && disk.used_bytes != null) {
|
||
diskPct = (disk.used_bytes / disk.total_bytes) * 100;
|
||
}
|
||
|
||
html += '<div class="meta-grid">';
|
||
|
||
// Uptime
|
||
if (sys.uptime_seconds != null) {
|
||
html += '<div class="meta-pill"><div class="meta-label">Node Uptime</div>' +
|
||
'<div class="meta-value">' + humanUptime(sys.uptime_seconds) + '</div></div>';
|
||
}
|
||
|
||
// CPU load (1m / 5m / 15m)
|
||
if (load && load.length >= 3) {
|
||
const l1 = (typeof load[0] === 'number') ? load[0].toFixed(2) : 'n/a';
|
||
const l5 = (typeof load[1] === 'number') ? load[1].toFixed(2) : 'n/a';
|
||
const l15 = (typeof load[2] === 'number') ? load[2].toFixed(2) : 'n/a';
|
||
|
||
html += '<div class="meta-pill">' +
|
||
'<div class="meta-label">CPU Load (1m / 5m / 15m)</div>' +
|
||
'<div class="meta-value">' + l1 + ' / ' + l5 + ' / ' + l15 + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// Memory
|
||
if (memTotal) {
|
||
const memText = formatBytes(memTotal - (memAvail || 0)) +
|
||
' / ' +
|
||
formatBytes(memTotal);
|
||
const pct = memPct != null ? Math.max(0, Math.min(100, memPct)) : 0;
|
||
html += '<div class="meta-pill">' +
|
||
'<div class="meta-label">Memory</div>' +
|
||
'<div class="meta-value">' + memText + '</div>' +
|
||
'<div class="mini-bar-wrap"><div class="mini-bar" id="mem-bar" style="width:' + pct.toFixed(1) + '%"></div></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// Disk
|
||
if (disk.total_bytes) {
|
||
const used = disk.used_bytes ?? (disk.total_bytes - (disk.free_bytes || 0));
|
||
const usedText = formatBytes(used) +
|
||
' / ' +
|
||
formatBytes(disk.total_bytes);
|
||
const dpct = diskPct != null ? Math.max(0, Math.min(100, diskPct)) : 0;
|
||
html += '<div class="meta-pill">' +
|
||
'<div class="meta-label">Bitcoin Data Disk</div>' +
|
||
'<div class="meta-value">' + usedText + '</div>' +
|
||
'<div class="mini-bar-wrap"><div class="mini-bar" id="disk-bar" style="width:' + dpct.toFixed(1) + '%"></div></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// CPU temp if present
|
||
if (sys.cpu_temp_c != null) {
|
||
html += '<div class="meta-pill">' +
|
||
'<div class="meta-label">CPU Temp</div>' +
|
||
'<div class="meta-value">' + sys.cpu_temp_c.toFixed(1) + ' °C</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
html += '</div>'; // meta-grid
|
||
|
||
// Services
|
||
html += renderServices(data.services);
|
||
|
||
box.innerHTML = html;
|
||
|
||
const bar = document.getElementById('syncbar');
|
||
if (bar && ibd) {
|
||
bar.style.width = Math.min(progress, 100).toFixed(2) + '%';
|
||
}
|
||
|
||
// Hide "your miners will start..." note once fully in sync
|
||
const miningNote = document.getElementById('mining-note');
|
||
if (miningNote) {
|
||
miningNote.style.display = ibd ? 'block' : 'none';
|
||
}
|
||
|
||
refreshTitleAndFavicon();
|
||
} catch (e) {
|
||
const box = document.getElementById('node-status');
|
||
box.innerHTML =
|
||
'<h2>Node Status <span class="label">blockchain & system</span></h2>' +
|
||
'<p class="status-err">Failed to fetch status.</p>';
|
||
appState.error = true;
|
||
refreshTitleAndFavicon();
|
||
}
|
||
}
|
||
|
||
// ---------------- CKPool / workers dashboard ----------------
|
||
function parseHashrate(str) {
|
||
if (!str || typeof str !== 'string') return null;
|
||
const m = str.trim().match(/^([\d.]+)\s*([kKmMgGtTpP]?)/);
|
||
if (!m) return null;
|
||
let v = parseFloat(m[1]);
|
||
const unit = m[2].toUpperCase();
|
||
const mult = {
|
||
'': 1,
|
||
'K': 1e3,
|
||
'M': 1e6,
|
||
'G': 1e9,
|
||
'T': 1e12,
|
||
'P': 1e15
|
||
}[unit] || 1;
|
||
return v * mult;
|
||
}
|
||
|
||
function formatHashrateHps(value) {
|
||
if (value == null || !isFinite(value) || value <= 0) return '-';
|
||
|
||
let v = value;
|
||
let unit = 'H/s';
|
||
|
||
if (v >= 1e12) { unit = 'TH/s'; v /= 1e12; }
|
||
else if (v >= 1e9) { unit = 'GH/s'; v /= 1e9; }
|
||
else if (v >= 1e6) { unit = 'MH/s'; v /= 1e6; }
|
||
else if (v >= 1e3) { unit = 'kH/s'; v /= 1e3; }
|
||
|
||
return v.toFixed(2) + ' ' + unit;
|
||
}
|
||
|
||
function getDisplayName(rawName) {
|
||
let displayName = rawName || '';
|
||
if (currentAddr && displayName) {
|
||
if (displayName === currentAddr) {
|
||
displayName = 'Unnamed Worker';
|
||
} else if (displayName.startsWith(currentAddr + '.')) {
|
||
displayName = displayName.substring(currentAddr.length + 1) || 'Unnamed Worker';
|
||
}
|
||
}
|
||
if (!displayName) displayName = 'Unnamed Worker';
|
||
return displayName;
|
||
}
|
||
|
||
let selectedWorkerKey = null;
|
||
let workerDetailShouldAnimate = false;
|
||
const workerHistoryLastFetch = {};
|
||
|
||
let showInactiveWorkers = false;
|
||
const IDLE_SEC = 30 * 60; // 30 minutes -> mark as idle
|
||
const HIDE_SEC = 3 * 24 * 3600; // 3 days -> "inactive" list
|
||
const PURGE_SEC = 30 * 24 * 3600; // 30 days -> purge from UI
|
||
|
||
function formatShare(value) {
|
||
if (value == null) return '-';
|
||
const num = typeof value === 'number' ? value : parseFloat(value);
|
||
if (!isFinite(num)) return '-';
|
||
const units = ['', 'k', 'M', 'G', 'T', 'P'];
|
||
let v = num;
|
||
let i = 0;
|
||
while (v >= 1000 && i < units.length - 1) {
|
||
v /= 1000;
|
||
i++;
|
||
}
|
||
return v.toFixed(1) + ' ' + units[i];
|
||
}
|
||
|
||
function slugifyWorkerName(name) {
|
||
return (name || '')
|
||
.toString()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '') || 'worker';
|
||
}
|
||
|
||
function buildWorkerDetailHtml(worker, animate, graphId) {
|
||
const displayName = getDisplayName(worker.workername);
|
||
|
||
const now = Date.now() / 1000;
|
||
let lastRel = 'never';
|
||
let lastAbs = 'never';
|
||
|
||
if (worker.lastshare) {
|
||
const age = now - worker.lastshare;
|
||
if (age < 60) lastRel = Math.floor(age) + 's ago';
|
||
else if (age < 3600) lastRel = Math.floor(age / 60) + 'm ago';
|
||
else if (age < 86400) lastRel = Math.floor(age / 3600) + 'h ago';
|
||
else lastRel = Math.floor(age / 86400) + 'd ago';
|
||
|
||
const d = new Date(worker.lastshare * 1000);
|
||
lastAbs = d.toLocaleString();
|
||
}
|
||
|
||
const classes = ['worker-detail'];
|
||
if (animate) classes.push('worker-detail-animate');
|
||
|
||
const detail = [];
|
||
detail.push('<div class="' + classes.join(' ') + '">');
|
||
detail.push('<h3>Worker: ' + displayName + '</h3>');
|
||
detail.push('<div>Last share: ' + lastRel + ' (' + lastAbs + ')</div>');
|
||
detail.push('<div style="margin-top:.25rem;">Hashrate (averages):</div>');
|
||
detail.push('<ul style="margin:.2rem 0 .3rem .9rem;font-size:.84rem;">' +
|
||
'<li>1 minute: ' + (worker.hashrate1m || '-') + '</li>' +
|
||
'<li>5 minutes: ' + (worker.hashrate5m || '-') + '</li>' +
|
||
'<li>1 hour: ' + (worker.hashrate1hr || '-') + '</li>' +
|
||
'<li>1 day: ' + (worker.hashrate1d || '-') + '</li>' +
|
||
'<li>7 days: ' + (worker.hashrate7d || '-') + '</li>' +
|
||
'</ul>');
|
||
detail.push('<div>Shares: ' + (worker.shares ?? '-') +
|
||
' • Best share: ' + formatShare(worker.bestshare) +
|
||
' • Best ever: ' + formatShare(worker.bestever) + '</div>');
|
||
|
||
if (graphId) {
|
||
detail.push(
|
||
'<div class="graph-section" style="margin-top:.6rem;">' +
|
||
'<div id="' + graphId + '"></div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
detail.push('</div>');
|
||
|
||
return detail.join('');
|
||
}
|
||
|
||
async function loadPool() {
|
||
try {
|
||
const url = currentAddr
|
||
? '/ckpool_status.php?addr=' + encodeURIComponent(currentAddr)
|
||
: '/ckpool_status.php';
|
||
|
||
const res = await fetch(url, {cache:'no-store'});
|
||
const data = await res.json();
|
||
|
||
const summaryEl = document.getElementById('workers-summary');
|
||
const tableWrap = document.getElementById('workers-table-wrap');
|
||
|
||
let summaryHtml = '';
|
||
let tableHtml = '';
|
||
|
||
if (!data || data.error && !data.pool) {
|
||
summaryHtml = '<p class="status-err">Unable to read CKPool stats: ' +
|
||
(data && data.error ? data.error : 'Unknown error') +
|
||
'</p>';
|
||
summaryEl.innerHTML = summaryHtml;
|
||
appState.pool.workers = 0;
|
||
refreshTitleAndFavicon();
|
||
return;
|
||
}
|
||
|
||
// Pool-level stats
|
||
const counts = data.pool && data.pool.counts ? data.pool.counts : null;
|
||
const hashr = data.pool && data.pool.hashrate ? data.pool.hashrate : null;
|
||
const shares = data.pool && data.pool.shares ? data.pool.shares : null;
|
||
|
||
// Track the current pool 1m hashrate (from live JSON) for the graph legend
|
||
if (hashr && hashr.hashrate1m) {
|
||
currentPoolH1m = parseHashrate(hashr.hashrate1m) || null;
|
||
} else {
|
||
currentPoolH1m = null;
|
||
}
|
||
|
||
// Keep the graph legend in sync with the pills using the latest 1m value
|
||
renderPoolGraph();
|
||
|
||
const workers = counts && typeof counts.Workers === 'number'
|
||
? counts.Workers
|
||
: 0;
|
||
appState.pool.workers = workers;
|
||
|
||
summaryHtml += '<div class="workers-summary">';
|
||
|
||
// POOL STATUS: runtime + worker counts as pills
|
||
if (counts) {
|
||
let runtimeStr = 'n/a';
|
||
if (typeof counts.runtime === 'number') {
|
||
const rt = counts.runtime;
|
||
const h = Math.floor(rt / 3600);
|
||
const m = Math.floor((rt % 3600) / 60);
|
||
runtimeStr = (h ? h + 'h ' : '') + m + 'm';
|
||
}
|
||
|
||
const idleStr = typeof counts.Idle === 'number' ? String(counts.Idle) : '0';
|
||
const discStr = typeof counts.Disconnected === 'number' ? String(counts.Disconnected) : '0';
|
||
|
||
summaryHtml += '<div class="pool-metrics-label" style="margin-top:.4rem;">POOL STATUS</div>';
|
||
summaryHtml += '<div class="pool-meta-pills pool-hash-grid">';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>Runtime</span>' +
|
||
' <strong>' + runtimeStr + '</strong>' +
|
||
' </div>';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>Workers</span>' +
|
||
' <strong>' + workers + '</strong>' +
|
||
' </div>';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>Idle</span>' +
|
||
' <strong>' + idleStr + '</strong>' +
|
||
' </div>';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>Disconnected</span>' +
|
||
' <strong>' + discStr + '</strong>' +
|
||
' </div>';
|
||
summaryHtml += '</div>';
|
||
} else {
|
||
summaryHtml += '<div class="pool-metrics-label" style="margin-top:.4rem;">POOL STATUS</div>';
|
||
summaryHtml += '<div class="pool-shares-line">No pool summary yet — waiting for shares.</div>';
|
||
}
|
||
|
||
// HASHRATE AVERAGES: big pills for 1m, 5m, etc.
|
||
if (hashr) {
|
||
const buckets = [
|
||
['1 Minute', hashr.hashrate1m],
|
||
['5 Minutes', hashr.hashrate5m],
|
||
['15 Minutes', hashr.hashrate15m],
|
||
['1 Hour', hashr.hashrate1hr],
|
||
['6 Hours', hashr.hashrate6hr],
|
||
['1 Day', hashr.hashrate1d],
|
||
['7 Days', hashr.hashrate7d],
|
||
];
|
||
|
||
summaryHtml += '<div class="pool-metrics-label" style="margin-top:1rem;">HASHRATE AVERAGES</div>';
|
||
summaryHtml += '<div class="pool-hashrate"><div class="pool-hash-grid">';
|
||
|
||
for (const [label, value] of buckets) {
|
||
if (!value) continue;
|
||
summaryHtml += '<div class="shares-pill">' +
|
||
'<span>' + label + '</span>' +
|
||
'<strong>' + value + '</strong>' +
|
||
'</div>';
|
||
}
|
||
|
||
summaryHtml += '</div></div>';
|
||
}
|
||
|
||
// SHARES: pills for accepted / rejected / best share / best hashrate
|
||
if (shares) {
|
||
const acceptedNum = Number(shares.accepted ?? 0);
|
||
const rejectedNum = Number(shares.rejected ?? 0);
|
||
const totalShares = acceptedNum + rejectedNum;
|
||
|
||
const acceptedText = shares.accepted != null
|
||
? acceptedNum.toLocaleString()
|
||
: '-';
|
||
const rejectedText = shares.rejected != null
|
||
? rejectedNum.toLocaleString()
|
||
: '-';
|
||
|
||
let rejectPct = null;
|
||
if (totalShares > 0 && rejectedNum >= 0) {
|
||
rejectPct = ((rejectedNum / totalShares) * 100).toFixed(2);
|
||
}
|
||
|
||
const bestHashText = bestPoolH1m ? formatHashrateHps(bestPoolH1m) : '-';
|
||
|
||
summaryHtml += '<div class="pool-metrics-label" style="margin-top:1rem;">SHARES</div>';
|
||
summaryHtml += '<div class="pool-shares-line">';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>accepted</span>' +
|
||
' <strong>' + acceptedText + '</strong>' +
|
||
' </div>';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>rejected</span>' +
|
||
' <strong>' + rejectedText +
|
||
(rejectPct !== null
|
||
? ' <span class="shares-pill-pct">(' + rejectPct + '%)</span>' +
|
||
''
|
||
: '') +
|
||
' </strong>' +
|
||
' </div>';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>best share</span>' +
|
||
' <strong>' + formatShare(shares.bestshare) + '</strong>' +
|
||
' </div>';
|
||
summaryHtml += ' <div class="shares-pill">' +
|
||
' <span>best hashrate</span>' +
|
||
' <strong>' + bestHashText + '</strong>' +
|
||
' </div>';
|
||
summaryHtml += '</div>';
|
||
}
|
||
|
||
summaryHtml += '</div>'; // .workers-summary
|
||
summaryEl.innerHTML = summaryHtml;
|
||
|
||
|
||
// User + workers table
|
||
if (data.user && Array.isArray(data.workers) && data.workers.length > 0) {
|
||
const workersArr = data.workers;
|
||
|
||
// Sort workers alphabetically by display name, with "Unnamed Worker" last
|
||
const sortedWorkers = workersArr.slice().sort((a, b) => {
|
||
const nameA = getDisplayName(a.workername);
|
||
const nameB = getDisplayName(b.workername);
|
||
|
||
const unnamedA = (nameA === 'Unnamed Worker');
|
||
const unnamedB = (nameB === 'Unnamed Worker');
|
||
|
||
if (unnamedA && !unnamedB) return 1;
|
||
if (!unnamedA && unnamedB) return -1;
|
||
|
||
return nameA.localeCompare(nameB, undefined, { sensitivity: 'base' });
|
||
});
|
||
|
||
tableHtml += '<table class="workers-table"><thead><tr>' +
|
||
'<th>Worker</th>' +
|
||
'<th>1m</th>' +
|
||
'<th>5m</th>' +
|
||
'<th>1h</th>' +
|
||
'<th>1d</th>' +
|
||
'<th>7d</th>' +
|
||
'<th>Shares</th>' +
|
||
'<th>Best Share</th>' +
|
||
'<th>Last Share</th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
const now = Date.now() / 1000;
|
||
let hasInactive = false;
|
||
|
||
const pendingWorkerGraphs = [];
|
||
|
||
for (const w of sortedWorkers) {
|
||
const displayName = getDisplayName(w.workername);
|
||
|
||
// Time since last share
|
||
const lastAge = w.lastshare ? (now - w.lastshare) : null;
|
||
let lastStr = 'never';
|
||
if (lastAge != null) {
|
||
if (lastAge < 60) lastStr = Math.floor(lastAge) + 's ago';
|
||
else if (lastAge < 3600) lastStr = Math.floor(lastAge / 60) + 'm ago';
|
||
else if (lastAge < 86400) lastStr = Math.floor(lastAge / 3600) + 'h ago';
|
||
else lastStr = Math.floor(lastAge / 86400) + 'd ago';
|
||
}
|
||
|
||
let rowClass = '';
|
||
let stateTag = '';
|
||
|
||
if (lastAge == null) {
|
||
rowClass = 'worker-inactive';
|
||
stateTag = ' (inactive)';
|
||
hasInactive = true;
|
||
} else if (lastAge > PURGE_SEC) {
|
||
continue;
|
||
} else if (lastAge > HIDE_SEC) {
|
||
if (!showInactiveWorkers) continue;
|
||
rowClass = 'worker-inactive';
|
||
stateTag = ' (inactive)';
|
||
hasInactive = true;
|
||
} else if (lastAge > IDLE_SEC) {
|
||
rowClass = 'worker-idle';
|
||
stateTag = ' (idle)';
|
||
}
|
||
|
||
const lastCol = lastStr + stateTag;
|
||
const isOpen = (selectedWorkerKey === w.workername);
|
||
const openClass = isOpen ? ' is-open' : '';
|
||
|
||
tableHtml += '<tr class="' + rowClass + openClass + '" data-worker="' +
|
||
encodeURIComponent(w.workername) + '">' +
|
||
'<td>' + displayName + '</td>' +
|
||
'<td>' + (w.hashrate1m || '-') + '</td>' +
|
||
'<td>' + (w.hashrate5m || '-') + '</td>' +
|
||
'<td>' + (w.hashrate1hr || '-') + '</td>' +
|
||
'<td>' + (w.hashrate1d || '-') + '</td>' +
|
||
'<td>' + (w.hashrate7d || '-') + '</td>' +
|
||
'<td>' + (w.shares ?? '-') + '</td>' +
|
||
'<td>' + formatShare(w.bestshare) + '</td>' +
|
||
'<td>' + lastCol + '</td>' +
|
||
'</tr>';
|
||
|
||
if (isOpen) {
|
||
const graphId = 'worker-graph-' + slugifyWorkerName(w.workername || '');
|
||
tableHtml += '<tr class="worker-detail-row">' +
|
||
'<td colspan="9">' + buildWorkerDetailHtml(w, workerDetailShouldAnimate, graphId) + '</td>' +
|
||
'</tr>';
|
||
pendingWorkerGraphs.push({
|
||
workername: w.workername,
|
||
graphId,
|
||
currentH1m: parseHashrate(w.hashrate1m || null)
|
||
});
|
||
}
|
||
}
|
||
|
||
tableHtml += '</tbody></table>';
|
||
tableWrap.innerHTML = tableHtml;
|
||
|
||
// Once we've rendered the detail, don't re-animate on auto-refreshes
|
||
workerDetailShouldAnimate = false;
|
||
|
||
// Kick off worker history graphs (rate-limited per worker)
|
||
if (currentAddr && pendingWorkerGraphs.length) {
|
||
pendingWorkerGraphs.forEach(info => {
|
||
loadWorkerHistory(
|
||
currentAddr,
|
||
info.workername,
|
||
info.graphId,
|
||
info.currentH1m
|
||
);
|
||
});
|
||
}
|
||
|
||
const controlsEl = document.getElementById('workers-controls');
|
||
const toggleEl = document.getElementById('toggle-inactive');
|
||
|
||
if (controlsEl) {
|
||
if (currentAddr && hasInactive) {
|
||
controlsEl.style.display = 'block';
|
||
} else {
|
||
controlsEl.style.display = 'none';
|
||
showInactiveWorkers = false;
|
||
if (toggleEl) toggleEl.checked = false;
|
||
}
|
||
}
|
||
|
||
// Click handler for detail
|
||
tableWrap.querySelectorAll('tr[data-worker]').forEach(row => {
|
||
row.addEventListener('click', () => {
|
||
const name = decodeURIComponent(row.getAttribute('data-worker'));
|
||
|
||
if (selectedWorkerKey === name) {
|
||
selectedWorkerKey = null;
|
||
workerDetailShouldAnimate = false;
|
||
} else {
|
||
selectedWorkerKey = name;
|
||
workerDetailShouldAnimate = true;
|
||
}
|
||
|
||
loadPool();
|
||
});
|
||
});
|
||
|
||
} else if (currentAddr) {
|
||
tableWrap.innerHTML =
|
||
'<p class="small-note">No worker stats yet for this address. ' +
|
||
'If you just pointed your miners here, give it a moment.</p>';
|
||
} else {
|
||
tableWrap.innerHTML =
|
||
'<p class="small-note">Enter your BTC payout address above for a ' +
|
||
'per-worker breakdown. Without an address, you\'ll just see pool-wide stats.</p>';
|
||
}
|
||
|
||
refreshTitleAndFavicon();
|
||
|
||
} catch (e) {
|
||
const summaryEl = document.getElementById('workers-summary');
|
||
summaryEl.innerHTML =
|
||
'<p class="status-err">Failed to fetch CKPool stats.</p>';
|
||
appState.pool.workers = 0;
|
||
refreshTitleAndFavicon();
|
||
}
|
||
}
|
||
|
||
// Form wiring for address -> URL
|
||
const addrForm = document.getElementById('addr-form');
|
||
if (addrForm) {
|
||
addrForm.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const input = document.getElementById('addr-input');
|
||
const val = (input.value || '').trim();
|
||
currentAddr = val || null;
|
||
|
||
const url = new URL(window.location.href);
|
||
if (currentAddr) url.searchParams.set('addr', currentAddr);
|
||
else url.searchParams.delete('addr');
|
||
window.history.replaceState(null, '', url.toString());
|
||
|
||
loadPool();
|
||
});
|
||
}
|
||
|
||
// Toggle display of inactive workers
|
||
const toggleInactive = document.getElementById('toggle-inactive');
|
||
if (toggleInactive) {
|
||
toggleInactive.addEventListener('change', () => {
|
||
showInactiveWorkers = toggleInactive.checked;
|
||
loadPool();
|
||
});
|
||
}
|
||
|
||
let lastPoolHistory = null;
|
||
let bestPoolH1m = null; // highest 1m pool hashrate seen in the loaded history window
|
||
|
||
// Initial load & timers
|
||
loadStatus();
|
||
loadPool();
|
||
loadPoolHistory();
|
||
setInterval(loadStatus, 5000);
|
||
setInterval(loadPool, 7000);
|
||
setInterval(loadPoolHistory, 60000); // ~every 60s for history, which matches the cron task that updates the database
|
||
|
||
// Stale detection for status.php
|
||
setInterval(() => {
|
||
if (Date.now() - appState.lastStatusOk > STALE_MS) {
|
||
appState.error = true;
|
||
refreshTitleAndFavicon();
|
||
const box = document.getElementById('node-status');
|
||
if (box && !box.innerHTML.includes('Status information unavailable')) {
|
||
box.innerHTML =
|
||
'<h2>Node Status <span class="label">blockchain & system</span></h2>' +
|
||
'<p class="status-err">Status information unavailable (stale data > 30s). ' +
|
||
'If this persists, try refreshing your browser.</p>';
|
||
}
|
||
}
|
||
}, 3000);
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|
||
EOF
|
||
|
||
# Settings page
|
||
cat > "$WWW_ROOT/settings.php" <<'EOF'
|
||
<?php
|
||
$flag = '/var/lib/btc-solo/.setup-complete';
|
||
if (!file_exists($flag)) {
|
||
header('Location: /activate/');
|
||
exit;
|
||
}
|
||
|
||
$opts = [
|
||
'550' => '550 MB (default, small)',
|
||
(2*1024) => '2 GB',
|
||
(4*1024) => '4 GB',
|
||
(8*1024) => '8 GB',
|
||
(16*1024) => '16 GB',
|
||
(32*1024) => '32 GB',
|
||
(64*1024) => '64 GB',
|
||
(100*1024) => '100 GB',
|
||
(200*1024) => '200 GB',
|
||
(300*1024) => '300 GB',
|
||
(400*1024) => '400 GB',
|
||
'full' => 'Full (no pruning)',
|
||
];
|
||
|
||
$current = '550';
|
||
$conf = @file_get_contents('/etc/bitcoin/bitcoin.conf');
|
||
if ($conf && preg_match('/^prune=(\d+)/m', $conf, $m)) {
|
||
$current = $m[1];
|
||
} elseif ($conf && !preg_match('/^prune=/m', $conf)) {
|
||
$current = 'full';
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['prune'])) {
|
||
$sel = $_POST['prune'];
|
||
if (isset($opts[$sel])) {
|
||
shell_exec('sudo /usr/local/sbin/btc-set-prune.sh ' . escapeshellarg($sel));
|
||
$current = $sel;
|
||
sleep(1);
|
||
}
|
||
}
|
||
|
||
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_ADDR'] ?? gethostname();
|
||
$host = preg_replace('/:\d+$/', '', $host); // strip port if present
|
||
$stratum = 'stratum+tcp://' . $host . ':3333';
|
||
?>
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Bitcoin Node for Solo Miners [Settings]</title>
|
||
<style>
|
||
body { font-family: sans-serif; background: #111; color: #eee; margin: 0; }
|
||
header { background: #222; padding: 1rem 2rem; }
|
||
h1 { margin: 0; }
|
||
.card { background: #1b1b1b; margin: 1rem 2rem; padding: 1rem 1.5rem; border-radius: 12px; }
|
||
code { background: #333; padding: 0.25rem 0.5rem; border-radius: 6px; }
|
||
label { display:block; margin-bottom: .5rem; }
|
||
select, button { padding: .4rem .6rem; border-radius: 6px; border:0; }
|
||
button { background:#4caf50; color:#fff; cursor:pointer; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Bitcoin Node for Solo Miners</h1>
|
||
<p style="margin:0;">A Linux Appliance by Robbie Ferguson</p>
|
||
</header>
|
||
<div class="card">
|
||
<h2>Stratum Endpoint</h2>
|
||
<p><code><?php echo htmlspecialchars($stratum); ?></code></p>
|
||
<p>Username: BTC address for payout</p>
|
||
<p>Password: anything</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Blockchain Prune Size</h2>
|
||
<form method="post">
|
||
<label for="prune">Current setting:</label>
|
||
<select name="prune" id="prune">
|
||
<?php foreach ($opts as $val => $label): ?>
|
||
<option value="<?php echo $val; ?>" <?php echo ($val == $current ? 'selected' : ''); ?>>
|
||
<?php echo $label; ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<button type="submit">Apply</button>
|
||
<p><small><b>Note:</b> Prune size only limits stored block data. You should also expect the node to use up to 500 MB for chainstate and metadata. Actual disk usage will be higher than the value selected here.</small></p>
|
||
<p><small>Changing this will restart bitcoind.</small></p>
|
||
</form>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Donate to Bald Nerd</h2>
|
||
<p><small>If you happen to win a BTC block, you can donate a small percentage to Robbie Ferguson (developer of this appliance, host of Category5 Technology TV) with big thanks. You can set to 0 if you prefer to keep the full block to yourself.</small></p>
|
||
<?php
|
||
$conf = @file_get_contents('/opt/btc-solo/conf/ckpool.conf');
|
||
$currAddr = '1MoGAsK8bRFKjHrpnpFJyqxdqamSPH19dP';
|
||
$currRate = 2;
|
||
if ($conf) {
|
||
if (preg_match('/"donaddress"\s*:\s*"([^"]+)"/', $conf, $m)) $currAddr = $m[1];
|
||
if (preg_match('/"donrate"\s*:\s*([0-9.]+)/', $conf, $m)) $currRate = $m[1];
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['donation_addr'])) {
|
||
$newAddr = trim($_POST['donation_addr']);
|
||
$newRate = floatval($_POST['donation_rate']);
|
||
if ($newAddr !== '') {
|
||
shell_exec('sudo /usr/local/sbin/btc-set-donation.sh '
|
||
. escapeshellarg($newAddr) . ' ' . escapeshellarg($newRate));
|
||
$currAddr = $newAddr;
|
||
$currRate = $newRate;
|
||
}
|
||
}
|
||
?>
|
||
<form method="post">
|
||
<label>Donation address (payout for your 2%)</label>
|
||
<input type="text" name="donation_addr" value="<?php echo htmlspecialchars($currAddr); ?>" style="width:100%;max-width:420px;">
|
||
<label>Donation %</label>
|
||
<select name="donation_rate">
|
||
<?php foreach ([0,0.5,1,2,3,4,5] as $r): ?>
|
||
<option value="<?php echo $r; ?>" <?php echo ($r == $currRate ? 'selected' : ''); ?>>
|
||
<?php echo $r; ?>%
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<button type="submit">Save</button>
|
||
</form>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
EOF
|
||
|
||
# AJAX status info
|
||
cat > "$WWW_ROOT/status.php" <<'EOF'
|
||
<?php
|
||
// lightweight JSON status for dashboard / monitoring
|
||
|
||
$flag = '/var/lib/btc-solo/.setup-complete';
|
||
if (!file_exists($flag)) {
|
||
http_response_code(403);
|
||
header('Content-Type: application/json');
|
||
echo json_encode(['error' => 'not activated']);
|
||
exit;
|
||
}
|
||
|
||
header('Content-Type: application/json');
|
||
|
||
// ---- 1) bitcoin core info ----
|
||
$btcInfo = null;
|
||
$out = shell_exec('/usr/local/bin/bitcoin-cli -conf=/etc/bitcoin/bitcoin.conf getblockchaininfo 2>/dev/null');
|
||
if ($out) {
|
||
$btcInfo = json_decode($out, true);
|
||
}
|
||
|
||
// ---- 2) service health ----
|
||
function svc($name) {
|
||
$res = trim(shell_exec('systemctl is-active ' . escapeshellarg($name) . ' 2>/dev/null'));
|
||
return $res === 'active' ? 'active' : $res;
|
||
}
|
||
$services = [
|
||
'bitcoind' => svc('bitcoind'),
|
||
'ckpool' => svc('ckpool'),
|
||
];
|
||
|
||
// ---- 3) system info (non-sensitive) ----
|
||
|
||
// uptime
|
||
$uptime_seconds = null;
|
||
if (is_readable('/proc/uptime')) {
|
||
$up = trim(file_get_contents('/proc/uptime'));
|
||
$parts = explode(' ', $up);
|
||
$uptime_seconds = (int)floatval($parts[0]);
|
||
}
|
||
|
||
// load average
|
||
$load = function_exists('sys_getloadavg') ? sys_getloadavg() : null;
|
||
|
||
// memory
|
||
$mem_total = null;
|
||
$mem_avail = null;
|
||
if (is_readable('/proc/meminfo')) {
|
||
$meminfo = file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
foreach ($meminfo as $line) {
|
||
if (strpos($line, 'MemTotal:') === 0) {
|
||
$mem_total = (int)filter_var($line, FILTER_SANITIZE_NUMBER_INT) * 1024;
|
||
} elseif (strpos($line, 'MemAvailable:') === 0) {
|
||
$mem_avail = (int)filter_var($line, FILTER_SANITIZE_NUMBER_INT) * 1024;
|
||
}
|
||
}
|
||
}
|
||
|
||
// disk (root)
|
||
$disk_root_total = @disk_total_space('/');
|
||
$disk_root_free = @disk_free_space('/');
|
||
|
||
// disk (bitcoind datadir)
|
||
$btcDir = '/var/lib/bitcoind';
|
||
$disk_btc_total = @disk_total_space($btcDir);
|
||
$disk_btc_free = @disk_free_space($btcDir);
|
||
|
||
// bitcoind dir size (used)
|
||
$btc_dir_size = null;
|
||
$du = shell_exec('du -sb ' . escapeshellarg($btcDir) . ' 2>/dev/null');
|
||
if ($du) {
|
||
$parts = preg_split('/\s+/', trim($du));
|
||
if (isset($parts[0]) && ctype_digit($parts[0])) {
|
||
$btc_dir_size = (int)$parts[0];
|
||
}
|
||
}
|
||
|
||
// CPU temperature (if available, eg. Raspberry Pi)
|
||
$cpu_temp_c = null;
|
||
foreach (glob('/sys/class/thermal/thermal_zone*/temp') as $zone) {
|
||
$raw = @file_get_contents($zone);
|
||
if ($raw === false) continue;
|
||
$raw = trim($raw);
|
||
if ($raw === '' || !is_numeric($raw)) continue;
|
||
$val = (float)$raw;
|
||
// Many systems report millidegC
|
||
if ($val > 200) $val = $val / 1000.0;
|
||
if ($val > 0 && $val < 120) {
|
||
$cpu_temp_c = $val;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ---- 4) output ----
|
||
echo json_encode([
|
||
'initialblockdownload' => $btcInfo['initialblockdownload'] ?? null,
|
||
'blocks' => $btcInfo['blocks'] ?? null,
|
||
'headers' => $btcInfo['headers'] ?? null,
|
||
'verificationprogress' => $btcInfo['verificationprogress'] ?? null,
|
||
'pruned' => $btcInfo['pruned'] ?? null,
|
||
'services' => $services,
|
||
'system' => [
|
||
'uptime_seconds' => $uptime_seconds,
|
||
'loadavg' => $load,
|
||
'mem_total_bytes' => $mem_total,
|
||
'mem_available_bytes' => $mem_avail,
|
||
'disk_root' => [
|
||
'total_bytes' => $disk_root_total,
|
||
'free_bytes' => $disk_root_free,
|
||
],
|
||
'disk_bitcoind' => [
|
||
'total_bytes' => $disk_btc_total,
|
||
'free_bytes' => $disk_btc_free,
|
||
// Prefer filesystem view (matches `df`); fall back to du if needed
|
||
'used_bytes' => ($disk_btc_total !== false && $disk_btc_free !== false)
|
||
? ($disk_btc_total - $disk_btc_free)
|
||
: $btc_dir_size,
|
||
],
|
||
'cpu_temp_c' => $cpu_temp_c,
|
||
],
|
||
], JSON_UNESCAPED_SLASHES);
|
||
EOF
|
||
|
||
# CKPool Status
|
||
# (reads CKPool JSON logs; address supplied per-request) ---
|
||
cat > /var/www/btc-solo/ckpool_status.php <<'PHP'
|
||
<?php
|
||
header('Content-Type: application/json');
|
||
|
||
$POOL_STATUS = '/opt/btc-solo/logs/pool/pool.status';
|
||
$USERS_DIR = '/opt/btc-solo/logs/users';
|
||
|
||
// BTC address is provided by the client *per request* (no server-side storage)
|
||
$addr = '';
|
||
if (isset($_GET['addr'])) {
|
||
$addr = trim($_GET['addr']);
|
||
}
|
||
|
||
// light sanity check (not strict - CKPool does the real validation)
|
||
if ($addr !== '' && !preg_match('/^[13bc][a-km-zA-HJ-NP-Z1-9]{25,}$/', $addr)) {
|
||
$addr = '';
|
||
}
|
||
|
||
function read_json_lines($path) {
|
||
if (!is_readable($path)) return null;
|
||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
$objs = [];
|
||
foreach ($lines as $ln) {
|
||
$j = json_decode(trim($ln), true);
|
||
if (is_array($j)) $objs[] = $j;
|
||
}
|
||
return $objs ?: null;
|
||
}
|
||
|
||
function read_json_file($path) {
|
||
if (!is_readable($path)) return null;
|
||
$txt = file_get_contents($path);
|
||
$j = json_decode($txt, true);
|
||
return is_array($j) ? $j : null;
|
||
}
|
||
|
||
$resp = [
|
||
'source' => 'logs',
|
||
'pool' => null,
|
||
'user_addr' => $addr ?: null,
|
||
'user' => null,
|
||
'workers' => [],
|
||
'worker_count' => 0,
|
||
'error' => null,
|
||
];
|
||
|
||
// Pool summary (3 JSON lines)
|
||
$pool_objs = read_json_lines($POOL_STATUS);
|
||
if ($pool_objs) {
|
||
$resp['pool'] = [
|
||
'counts' => $pool_objs[0] ?? null, // runtime/users/workers/idle/disconnected
|
||
'hashrate' => $pool_objs[1] ?? null, // hashrate1m/5m/15m/1hr/...
|
||
'shares' => $pool_objs[2] ?? null, // accepted/rejected/bestshare/SPS...
|
||
];
|
||
} else {
|
||
$resp['error'] = 'Cannot read pool.status';
|
||
}
|
||
|
||
// User + workers (only if client supplied an address)
|
||
if ($addr !== '') {
|
||
$user_file = $USERS_DIR . '/' . $addr;
|
||
$user_obj = read_json_file($user_file);
|
||
if ($user_obj) {
|
||
$resp['user'] = $user_obj;
|
||
if (!empty($user_obj['worker']) && is_array($user_obj['worker'])) {
|
||
foreach ($user_obj['worker'] as $w) {
|
||
$resp['workers'][] = [
|
||
'workername' => $w['workername'] ?? 'unknown',
|
||
'hashrate1m' => $w['hashrate1m'] ?? null,
|
||
'hashrate5m' => $w['hashrate5m'] ?? null,
|
||
'hashrate1hr' => $w['hashrate1hr'] ?? null,
|
||
'hashrate1d' => $w['hashrate1d'] ?? null,
|
||
'hashrate7d' => $w['hashrate7d'] ?? null,
|
||
'lastshare' => $w['lastshare'] ?? null,
|
||
'shares' => $w['shares'] ?? null,
|
||
'bestshare' => $w['bestshare'] ?? null,
|
||
'bestever' => $w['bestever'] ?? null,
|
||
];
|
||
}
|
||
}
|
||
$resp['worker_count'] = count($resp['workers']);
|
||
} else {
|
||
// Not a fatal error - just "no data yet" for that address
|
||
if ($resp['error'] === null) {
|
||
$resp['error'] = 'No worker stats yet for this address';
|
||
}
|
||
}
|
||
}
|
||
|
||
echo json_encode($resp);
|
||
PHP
|
||
chmod 0644 /var/www/btc-solo/ckpool_status.php
|
||
|
||
|
||
# activation page
|
||
cat > "$WWW_ROOT/activate/index.php" <<'EOF'
|
||
<?php
|
||
$flag = '/var/lib/btc-solo/.setup-complete';
|
||
if (file_exists($flag)) {
|
||
http_response_code(403);
|
||
echo "Setup already completed.";
|
||
exit;
|
||
}
|
||
|
||
$msg = '';
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$adminuser = trim($_POST['adminuser'] ?? '');
|
||
$adminpass = trim($_POST['adminpass'] ?? '');
|
||
$regen = isset($_POST['regen_cert']);
|
||
|
||
if ($adminuser !== '' && $adminpass !== '') {
|
||
// apply admin creds (writes rpcuser/rpcpassword, restarts services)
|
||
shell_exec('sudo /usr/local/sbin/btc-apply-admin.sh ' . escapeshellarg($adminuser) . ' ' . escapeshellarg($adminpass));
|
||
// optionally regenerate HTTPS cert
|
||
if ($regen) {
|
||
shell_exec('sudo /usr/local/sbin/btc-make-cert.sh');
|
||
}
|
||
// create setup-complete flag
|
||
file_put_contents($flag, date('c'));
|
||
// redirect to main dashboard
|
||
header('Location: /');
|
||
exit;
|
||
} else {
|
||
$msg = 'Both fields are required.';
|
||
}
|
||
}
|
||
?>
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Create New Admin User</title>
|
||
<style>
|
||
body { font-family: sans-serif; background: #111; color: #eee; display:flex; justify-content:center; align-items:center; height:100vh; }
|
||
form { background: #1b1b1b; padding: 2rem; border-radius: 12px; width: 340px; }
|
||
label { display:block; margin-bottom: .5rem; }
|
||
input[type=text], input[type=password] { width:100%; padding:.5rem; margin-bottom:1rem; border:0; border-radius:6px; }
|
||
button { padding:.5rem 1rem; border:0; border-radius:6px; background:#4caf50; color:#fff; cursor:pointer; }
|
||
.msg { color: #f66; margin-bottom:1rem; }
|
||
h2 { margin-top:0; }
|
||
small { color:#aaa; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<form method="post">
|
||
<h2>Create New Admin User</h2>
|
||
<p><small>
|
||
This sets the Bitcoin node's administrative (RPC) credentials used to control the server itself.<br /><br />
|
||
Keep these private — they grant full access to your node.<br /><br />
|
||
These credentials are <b>not</b> used by miners to connect; miners only need your Bitcoin address and can use any password.
|
||
</small></p>
|
||
<?php if ($msg): ?>
|
||
<div class="msg"><?php echo htmlspecialchars($msg); ?></div>
|
||
<?php endif; ?>
|
||
<label>Admin Username</label>
|
||
<input type="text" name="adminuser" value="">
|
||
<label>Admin Password</label>
|
||
<input type="password" name="adminpass" value="">
|
||
<label style="display:flex;gap:.5rem;align-items:center;">
|
||
<input type="checkbox" name="regen_cert" checked>
|
||
Generate HTTPS certificate
|
||
</label>
|
||
<button type="submit">Continue</button>
|
||
</form>
|
||
</body>
|
||
</html>
|
||
EOF
|
||
|
||
# AJAX History Endpoint
|
||
cat > "$WWW_ROOT/history.php" <<'PHP'
|
||
<?php
|
||
$flag = '/var/lib/btc-solo/.setup-complete';
|
||
if (!file_exists($flag)) {
|
||
http_response_code(403);
|
||
header('Content-Type: application/json');
|
||
echo json_encode(['error' => 'not activated']);
|
||
exit;
|
||
}
|
||
|
||
header('Content-Type: application/json');
|
||
|
||
$dbPath = '/opt/btc-solo/db/hashrate_history.sqlite';
|
||
if (!file_exists($dbPath)) {
|
||
echo json_encode(['error' => 'no history database']);
|
||
exit;
|
||
}
|
||
|
||
try {
|
||
$db = new SQLite3($dbPath, SQLITE3_OPEN_READONLY);
|
||
} catch (Exception $e) {
|
||
echo json_encode(['error' => 'cannot open history database']);
|
||
exit;
|
||
}
|
||
|
||
$mode = $_GET['mode'] ?? 'pool';
|
||
$limit = (int)($_GET['limit'] ?? 120);
|
||
if ($limit < 10) $limit = 10;
|
||
if ($limit > 720) $limit = 720; // up to 12 hours at 1/min
|
||
|
||
function parse_rate(?string $s): ?float {
|
||
if ($s === null || $s === '') return null;
|
||
if (!preg_match('/^([0-9.]+)\s*([kMGTPE]?)/i', $s, $m)) return null;
|
||
$v = (float)$m[1];
|
||
$unit = strtoupper($m[2] ?? '');
|
||
$mult = [
|
||
'' => 1,
|
||
'K' => 1e3,
|
||
'M' => 1e6,
|
||
'G' => 1e9,
|
||
'T' => 1e12,
|
||
'P' => 1e15,
|
||
'E' => 1e18,
|
||
][$unit] ?? 1;
|
||
return $v * $mult;
|
||
}
|
||
|
||
function downsample(array $points, int $limit): array {
|
||
$n = count($points);
|
||
if ($n <= $limit) return $points;
|
||
$step = (int)ceil($n / $limit);
|
||
$out = [];
|
||
for ($i = 0; $i < $n; $i += $step) {
|
||
$out[] = $points[$i];
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
if ($mode === 'worker') {
|
||
$addr = $_GET['addr'] ?? '';
|
||
$worker = $_GET['worker'] ?? '';
|
||
|
||
if ($addr === '' || $worker === '') {
|
||
echo json_encode(['error' => 'missing addr or worker']);
|
||
exit;
|
||
}
|
||
|
||
$stmt = $db->prepare(
|
||
'SELECT ts,payload FROM history WHERE address = :addr ORDER BY ts DESC LIMIT :lim'
|
||
);
|
||
$rawLimit = $limit * 4;
|
||
$stmt->bindValue(':addr', $addr, SQLITE3_TEXT);
|
||
$stmt->bindValue(':lim', $rawLimit, SQLITE3_INTEGER);
|
||
$res = $stmt->execute();
|
||
|
||
$points = [];
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$ts = (int)$row['ts'];
|
||
$payload = base64_decode($row['payload'], true);
|
||
if ($payload === false) continue;
|
||
$obj = json_decode($payload, true);
|
||
if (!is_array($obj) || empty($obj['workers']) || !is_array($obj['workers'])) continue;
|
||
|
||
$match = null;
|
||
foreach ($obj['workers'] as $w) {
|
||
if (!isset($w['workername'])) continue;
|
||
if ($w['workername'] === $worker) {
|
||
$match = $w;
|
||
break;
|
||
}
|
||
}
|
||
if (!$match) continue;
|
||
|
||
$points[] = [
|
||
'ts' => $ts,
|
||
'h1m' => parse_rate($match['hashrate1m'] ?? null),
|
||
'h5m' => parse_rate($match['hashrate5m'] ?? null),
|
||
'h1h' => parse_rate($match['hashrate1hr'] ?? null),
|
||
'h1d' => parse_rate($match['hashrate1d'] ?? null),
|
||
'h7d' => parse_rate($match['hashrate7d'] ?? null),
|
||
];
|
||
}
|
||
|
||
usort($points, fn($a,$b) => $a['ts'] <=> $b['ts']);
|
||
$points = downsample($points, $limit);
|
||
|
||
echo json_encode(['mode' => 'worker', 'addr' => $addr, 'worker' => $worker, 'points' => $points]);
|
||
exit;
|
||
}
|
||
|
||
// Default: pool history
|
||
$stmt = $db->prepare(
|
||
'SELECT ts,payload FROM history WHERE address = :addr ORDER BY ts DESC LIMIT :lim'
|
||
);
|
||
$rawLimit = $limit * 4;
|
||
$stmt->bindValue(':addr', '__POOL__', SQLITE3_TEXT);
|
||
$stmt->bindValue(':lim', $rawLimit, SQLITE3_INTEGER);
|
||
$res = $stmt->execute();
|
||
|
||
$seen = [];
|
||
$points = [];
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$ts = (int)$row['ts'];
|
||
if (isset($seen[$ts])) continue;
|
||
$seen[$ts] = true;
|
||
|
||
$payload = base64_decode($row['payload'], true);
|
||
if ($payload === false) continue;
|
||
$obj = json_decode($payload, true);
|
||
if (!is_array($obj) || empty($obj['pool']['hashrate'])) continue;
|
||
$h = $obj['pool']['hashrate'];
|
||
|
||
$points[] = [
|
||
'ts' => $ts,
|
||
'h1m' => parse_rate($h['hashrate1m'] ?? null),
|
||
'h5m' => parse_rate($h['hashrate5m'] ?? null),
|
||
'h15m'=> parse_rate($h['hashrate15m'] ?? null),
|
||
'h1h' => parse_rate($h['hashrate1hr'] ?? null),
|
||
'h1d' => parse_rate($h['hashrate1d'] ?? null),
|
||
'h7d' => parse_rate($h['hashrate7d'] ?? null),
|
||
];
|
||
}
|
||
|
||
usort($points, fn($a,$b) => $a['ts'] <=> $b['ts']);
|
||
$points = downsample($points, $limit);
|
||
|
||
echo json_encode(['mode' => 'pool', 'points' => $points]);
|
||
PHP
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
echo "Console Dashboard"
|
||
# ---------------------------------------------------------------------------
|
||
cat > /usr/local/sbin/btc-console-dashboard <<'EOF'
|
||
#!/bin/bash
|
||
# Console dashboard for Bitcoin Solo Miner
|
||
|
||
BITCOIN_CLI="/usr/local/bin/bitcoin-cli"
|
||
BITCOIN_CONF="/etc/bitcoin/bitcoin.conf"
|
||
BTC_DIR="/var/lib/bitcoind"
|
||
|
||
# colors
|
||
RED="\033[31m"
|
||
GRN="\033[32m"
|
||
YEL="\033[33m"
|
||
BLU="\033[34m"
|
||
CYN="\033[36m"
|
||
BOLD="\033[1m"
|
||
RST="\033[0m"
|
||
|
||
CONSEC_FAILS=0
|
||
MAX_FAILS=3
|
||
LAST_INFO=""
|
||
|
||
get_ip() {
|
||
ip -4 addr show scope global 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -n1
|
||
}
|
||
|
||
make_bar() {
|
||
local pct="$1"
|
||
local width="$2"
|
||
[ -z "$pct" ] && pct=0
|
||
pct=$(printf "%.1f" "$pct")
|
||
# clamp
|
||
if (( $(echo "$pct < 0" | bc -l 2>/dev/null || echo 0) )); then pct=0; fi
|
||
if (( $(echo "$pct > 100" | bc -l 2>/dev/null || echo 0) )); then pct=100; fi
|
||
local filled=$(printf "%.0f" "$(echo "$pct / 100 * $width" | bc -l)")
|
||
local empty=$((width - filled))
|
||
printf "["
|
||
for ((i=0; i<filled; i++)); do printf "█"; done
|
||
for ((i=0; i<empty; i++)); do printf "-"; done
|
||
printf "] %s%%" "$pct"
|
||
}
|
||
|
||
draw_header() {
|
||
tput clear
|
||
tput civis 2>/dev/null || true
|
||
tput cup 0 0; echo -e "${BOLD}Bitcoin Node for Solo Miners${RST}"
|
||
tput cup 1 0; echo -e "A Linux Appliance by Robbie Ferguson"
|
||
tput cup 2 0; printf '%*s' "$(tput cols)" '' | tr ' ' '-'
|
||
}
|
||
|
||
draw_loading() {
|
||
tput cup 3 0; echo -e "${YEL}Loading node status…${RST}\033[K"
|
||
}
|
||
|
||
draw_dashboard() {
|
||
# args:
|
||
# 1 now 2 ip 3 web 4 status 5 bar 6 progress 7 services 8 cpu 9 mem 10 disk 11 workers
|
||
local now="$1"
|
||
local ip="$2"
|
||
local web="$3"
|
||
local status="$4"
|
||
local bar="$5"
|
||
local progress="$6"
|
||
local services="$7"
|
||
local cpu="$8"
|
||
local mem="$9"
|
||
local disk="${10}"
|
||
local workers="${11}"
|
||
|
||
tput cup 3 0; echo -e "Time: ${CYN}${now}${RST}\033[K"
|
||
tput cup 4 0; echo -e "IP: ${CYN}${ip}${RST}\033[K"
|
||
tput cup 5 0; echo -e "Web: ${CYN}${web}${RST}\033[K"
|
||
tput cup 6 0; printf '%*s' "$(tput cols)" '' | tr ' ' '-'
|
||
|
||
tput cup 7 0; echo -e "${status}\033[K"
|
||
tput cup 8 0; echo -e "${bar}\033[K"
|
||
tput cup 9 0; echo -e "${progress}\033[K"
|
||
tput cup 10 0; echo -e "${services}\033[K"
|
||
|
||
tput cup 11 0; printf '%*s' "$(tput cols)" '' | tr ' ' '-'
|
||
tput cup 12 0; echo -e "CPU load: ${cpu}\033[K"
|
||
tput cup 13 0; echo -e "Memory: ${mem}\033[K"
|
||
tput cup 14 0; echo -e "Disk (${BTC_DIR}): ${disk}\033[K"
|
||
|
||
tput cup 16 0; echo -e "Mining:\033[K"
|
||
tput cup 17 2; echo -e "${workers}\033[K"
|
||
|
||
# tput cup 19 0; echo -e "(Appliance console view — use the web UI for full details.)\033[K"
|
||
}
|
||
|
||
draw_header
|
||
draw_loading
|
||
|
||
FIRST_DONE=0
|
||
|
||
while true; do
|
||
# -------- collect data (non-blocking) --------
|
||
NOW=$(date '+%Y-%m-%d %H:%M')
|
||
|
||
IPADDR=$(get_ip)
|
||
[ -z "$IPADDR" ] && IPADDR="No network"
|
||
WEB="https://${IPADDR}/"
|
||
|
||
BTC_STATE=$(systemctl is-active bitcoind 2>/dev/null || true)
|
||
CKP_STATE=$(systemctl is-active ckpool 2>/dev/null || true)
|
||
|
||
STATUS_LINE=""
|
||
PROGRESS_LINE=""
|
||
BAR_LINE="[--------------] 0.0%"
|
||
SERVICES_LINE="Bitcoin: ${BTC_STATE} CKPool: ${CKP_STATE}"
|
||
|
||
# --- CKPool worker summary (console-safe, no per-worker listing) ---
|
||
POOL_STATUS="/opt/btc-solo/logs/pool/pool.status"
|
||
|
||
# Humanize hashrate (expects H/s as a number; falls back to raw if unknown)
|
||
human_hashrate() {
|
||
local v="$1"
|
||
if [[ -z "$v" || "$v" == "null" ]]; then echo "-"; return; fi
|
||
if ! [[ "$v" =~ ^[0-9]+([.][0-9]+)?$ ]]; then echo "$v"; return; fi
|
||
|
||
awk -v x="$v" 'BEGIN{
|
||
u[0]="H/s"; u[1]="kH/s"; u[2]="MH/s"; u[3]="GH/s"; u[4]="TH/s"; u[5]="PH/s"; u[6]="EH/s";
|
||
i=0;
|
||
while (x>=1000 && i<6) { x/=1000; i++ }
|
||
if (x<10) printf("%.2f %s", x, u[i]);
|
||
else if (x<100)printf("%.1f %s", x, u[i]);
|
||
else printf("%.0f %s", x, u[i]);
|
||
}'
|
||
}
|
||
|
||
# Add commas to large integers (e.g. 1000000 -> 1,000,000)
|
||
fmt_int() {
|
||
[[ "$1" =~ ^[0-9]+$ ]] && printf "%'d" "$1" || printf "%s" "$1"
|
||
}
|
||
|
||
if command -v jq >/dev/null 2>&1; then
|
||
WORKERS_LINE="No workers detected (point your ASICs at this node)"
|
||
else
|
||
WORKERS_LINE="Install jq for worker stats."
|
||
fi
|
||
|
||
if [[ -r "$POOL_STATUS" ]]; then
|
||
# pool.status is 3 JSON objects, one per line: counts, hashrate, shares
|
||
# Slurp into array so we can index [0],[1],[2]
|
||
POOL_JSON="$(jq -s '.' "$POOL_STATUS" 2>/dev/null || true)"
|
||
|
||
if [[ -n "$POOL_JSON" && "$POOL_JSON" != "null" ]]; then
|
||
workers="$(jq -r '.[0].Workers // empty' <<<"$POOL_JSON")"
|
||
idle="$(jq -r '.[0].idle // empty' <<<"$POOL_JSON")"
|
||
disc="$(jq -r '.[0].disconnected // empty' <<<"$POOL_JSON")"
|
||
users="$(jq -r '.[0].users // empty' <<<"$POOL_JSON")"
|
||
|
||
hr1m="$(jq -r '.[1].hashrate1m // empty' <<<"$POOL_JSON")"
|
||
hr5m="$(jq -r '.[1].hashrate5m // empty' <<<"$POOL_JSON")"
|
||
hr15m="$(jq -r '.[1].hashrate15m // empty' <<<"$POOL_JSON")"
|
||
|
||
acc="$(jq -r '.[2].accepted // empty' <<<"$POOL_JSON")"
|
||
rej="$(jq -r '.[2].rejected // empty' <<<"$POOL_JSON")"
|
||
stale="$(jq -r '.[2].stale // empty' <<<"$POOL_JSON")"
|
||
dup="$(jq -r '.[2].dup // empty' <<<"$POOL_JSON")"
|
||
sps="$(jq -r '.[2].SPS1m // empty' <<<"$POOL_JSON")"
|
||
bestshare="$(jq -r '.[2].bestshare // empty' <<<"$POOL_JSON")"
|
||
|
||
if [[ -n "$workers" && "$workers" != "0" ]]; then
|
||
# Compact worker line (single line, console-safe)
|
||
# Example: Workers: 3 (idle 1, disc 0) | HR: 1m 1.2 TH/s / 5m 1.1 TH/s / 15m 1.0 TH/s | Shares: acc 120 rej 0 stale 1 dup 0 | SPS 0.03
|
||
WORKERS_LINE="Workers: ${workers}"
|
||
[[ -n "$idle" || -n "$disc" ]] && WORKERS_LINE+=" (idle ${idle:-0}, disc ${disc:-0})"
|
||
WORKERS_LINE+=" " # Extend a little beyond the end of the current text to blank if new output is shorter
|
||
[[ -n "$users" ]] && WORKERS_LINE+="\n Users: ${users}"
|
||
WORKERS_LINE+=" " # Extend a little beyond the end of the current text to blank if new output is shorter
|
||
|
||
WORKERS_LINE+="\n Hashrate: 1m $(human_hashrate "$hr1m") / 5m $(human_hashrate "$hr5m") / 15m $(human_hashrate "$hr15m")"
|
||
WORKERS_LINE+=" " # Extend a little beyond the end of the current text to blank if new output is shorter
|
||
|
||
# Shares (show only fields that exist; keep it tight)
|
||
WORKERS_LINE+="\n Shares:"
|
||
WORKERS_LINE+="\n Accepted: $(fmt_int "${acc:-0}") "
|
||
[[ -n "$rej" ]] && WORKERS_LINE+="\n Rejected: $(fmt_int "$rej") "
|
||
[[ -n "$stale" ]] && WORKERS_LINE+="\n Stale: $(fmt_int "$stale") "
|
||
[[ -n "$dup" ]] && WORKERS_LINE+="\n Duplicate:$(fmt_int "$dup") "
|
||
[[ -n "$sps" ]] && WORKERS_LINE+="\n Shares Per Second: $sps "
|
||
[[ -n "$bestshare" ]] && WORKERS_LINE+="\n Best Share: $(fmt_int "$bestshare") "
|
||
|
||
fi
|
||
fi
|
||
fi
|
||
# --- /CKPool worker summary ---
|
||
|
||
# cpu load
|
||
if [ -r /proc/loadavg ]; then
|
||
read -r L1 L5 L15 _ < /proc/loadavg
|
||
CPU_LINE="${L1} ${L5} ${L15}"
|
||
else
|
||
CPU_LINE="n/a"
|
||
fi
|
||
|
||
# memory
|
||
if [ -r /proc/meminfo ]; then
|
||
MEM_TOTAL=$(awk '/MemTotal:/{print $2}' /proc/meminfo)
|
||
MEM_AVAIL=$(awk '/MemAvailable:/{print $2}' /proc/meminfo)
|
||
if [ -n "$MEM_TOTAL" ] && [ -n "$MEM_AVAIL" ]; then
|
||
MEM_USED=$((MEM_TOTAL - MEM_AVAIL))
|
||
MEM_PCT=$((MEM_USED * 100 / MEM_TOTAL))
|
||
MEM_LINE="$(printf "%dMiB / %dMiB (%d%% used)" "$((MEM_USED/1024))" "$((MEM_TOTAL/1024))" "$MEM_PCT")"
|
||
else
|
||
MEM_LINE="n/a"
|
||
fi
|
||
else
|
||
MEM_LINE="n/a"
|
||
fi
|
||
|
||
# disk
|
||
if df -h "$BTC_DIR" >/dev/null 2>&1; then
|
||
read -r _ SIZE USED AVAIL USEP MNT <<< "$(df -h "$BTC_DIR" | awk 'NR==2{print $1,$2,$3,$4,$5,$6}')"
|
||
DISK_LINE="${AVAIL} free of ${SIZE} (${USEP} used)"
|
||
else
|
||
DISK_LINE="n/a"
|
||
fi
|
||
|
||
# bitcoin-cli, but don't let it hang
|
||
INFO=$(timeout 15 "$BITCOIN_CLI" -conf="$BITCOIN_CONF" getblockchaininfo 2>/dev/null)
|
||
|
||
if [ -z "$INFO" ]; then
|
||
# failed this round
|
||
CONSEC_FAILS=$((CONSEC_FAILS + 1))
|
||
# if we have an older good value, reuse it
|
||
if [ -n "$LAST_INFO" ]; then
|
||
INFO="$LAST_INFO"
|
||
fi
|
||
else
|
||
# success - store and reset fail counter
|
||
LAST_INFO="$INFO"
|
||
CONSEC_FAILS=0
|
||
fi
|
||
|
||
if [ "$BTC_STATE" != "active" ]; then
|
||
STATUS_LINE="${RED}❌ Bitcoin service is not running.${RST}"
|
||
elif [ -z "$INFO" ]; then
|
||
if [ $CONSEC_FAILS -ge $MAX_FAILS ]; then
|
||
STATUS_LINE="${RED}❌ bitcoin-cli has not responded for several checks.${RST}"
|
||
BAR_LINE="[--------------] 0.0%"
|
||
PROGRESS_LINE=""
|
||
else
|
||
# transient hiccup — keep showing last good data, or at least don't scream
|
||
STATUS_LINE="${YEL}⚠ Waiting for Bitcoin Core…${RST}"
|
||
fi
|
||
else
|
||
if command -v jq >/dev/null 2>&1; then
|
||
IBD=$(echo "$INFO" | jq -r '.initialblockdownload')
|
||
BLOCKS=$(echo "$INFO" | jq -r '.blocks')
|
||
HEADERS=$(echo "$INFO" | jq -r '.headers')
|
||
PROG=$(echo "$INFO" | jq -r '.verificationprogress')
|
||
PRUNED=$(echo "$INFO" | jq -r '.pruned')
|
||
|
||
if [ "$PROG" != "null" ]; then
|
||
PROG_PCT=$(printf "%.1f" "$(echo "$PROG * 100" | bc -l 2>/dev/null || echo 0)")
|
||
else
|
||
PROG_PCT="0.0"
|
||
fi
|
||
|
||
if [ "$IBD" = "true" ]; then
|
||
if [ "$(echo "$PROG_PCT < 0.1" | bc -l 2>/dev/null || echo 0)" = "1" ]; then
|
||
STATUS_LINE="${YEL}⚠ Bitcoin Core is starting up and loading blockchain data...${RST}"
|
||
else
|
||
STATUS_LINE="${YEL}⚠ Syncing blockchain... ${PROG_PCT}%${RST}"
|
||
fi
|
||
else
|
||
STATUS_LINE="${GRN}✔ Bitcoin Core is in sync.${RST}"
|
||
fi
|
||
|
||
PROGRESS_LINE="Blocks: ${BLOCKS} / ${HEADERS} $( [ "$PRUNED" = "true" ] && echo '(pruned)' )"
|
||
BAR_LINE=$(make_bar "$PROG_PCT" 30)
|
||
else
|
||
STATUS_LINE="${CYN}ℹ Bitcoin Core is active (install jq for more detail).${RST}"
|
||
fi
|
||
fi
|
||
|
||
# -------- draw --------
|
||
draw_dashboard "$NOW" "$IPADDR" "$WEB" "$STATUS_LINE" "$BAR_LINE" "$PROGRESS_LINE" "$SERVICES_LINE" "$CPU_LINE" "$MEM_LINE" "$DISK_LINE" "$WORKERS_LINE"
|
||
|
||
FIRST_DONE=1
|
||
sleep 5
|
||
done
|
||
EOF
|
||
chmod +x /usr/local/sbin/btc-console-dashboard
|
||
|
||
cat > /etc/systemd/system/btc-console-dashboard.service <<'EOF'
|
||
[Unit]
|
||
Description=Bitcoin Solo Miner Console Dashboard (dialog)
|
||
After=network-online.target
|
||
WantedBy=multi-user.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
ExecStart=/usr/local/sbin/btc-console-dashboard
|
||
StandardInput=tty
|
||
StandardOutput=tty
|
||
TTYPath=/dev/tty1
|
||
TTYReset=yes
|
||
TTYVHangup=yes
|
||
Restart=always
|
||
|
||
[Install]
|
||
WantedBy=getty.target
|
||
EOF
|
||
|
||
systemctl daemon-reload
|
||
systemctl disable getty@tty1.service --now 2>/dev/null || true
|
||
systemctl enable --now btc-console-dashboard.service
|
||
|
||
# ---------------------------------------------------------------------------
|
||
echo "NGINX with HTTPS + redirect"
|
||
# ---------------------------------------------------------------------------
|
||
mkdir -p "$CERT_DIR"
|
||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||
-keyout ${CERT_DIR}/btc-solo.key \
|
||
-out ${CERT_DIR}/btc-solo.crt \
|
||
-subj "/C=CA/ST=Ontario/L=Barrie/O=BTC-Solo/OU=IT/CN=$(hostname -f)" >/dev/null 2>&1
|
||
chmod 600 ${CERT_DIR}/btc-solo.key
|
||
|
||
cat > /etc/nginx/sites-available/btc-solo <<EOF
|
||
server {
|
||
listen 80 default_server;
|
||
return 301 https://\$host\$request_uri;
|
||
}
|
||
|
||
server {
|
||
listen 443 ssl;
|
||
ssl_certificate ${CERT_DIR}/btc-solo.crt;
|
||
ssl_certificate_key ${CERT_DIR}/btc-solo.key;
|
||
|
||
root ${WWW_ROOT};
|
||
index index.php;
|
||
|
||
# main app: try file, dir, then index.php
|
||
location / {
|
||
try_files \$uri \$uri/ /index.php;
|
||
}
|
||
|
||
# activation: let PHP regex still handle .php
|
||
location /activate/ {
|
||
try_files \$uri /activate/index.php;
|
||
}
|
||
|
||
# PHP handler (use the generic symlink, which you have)
|
||
location ~ \.php\$ {
|
||
include snippets/fastcgi-php.conf;
|
||
fastcgi_pass unix:/run/php/php-fpm.sock;
|
||
}
|
||
}
|
||
EOF
|
||
|
||
ln -sf /etc/nginx/sites-available/btc-solo /etc/nginx/sites-enabled/btc-solo
|
||
rm -f /etc/nginx/sites-enabled/default || true
|
||
systemctl restart nginx
|
||
|
||
echo "[***] Setting up SQLite Databases"
|
||
|
||
# Create SQLite DB for hashrate history
|
||
if [ ! -e "$APP_ROOT/db" ]; then
|
||
mkdir -p "$APP_ROOT/db"
|
||
fi
|
||
HISTDB="$APP_ROOT/db/hashrate_history.sqlite"
|
||
|
||
if [ ! -f "$HISTDB" ]; then
|
||
sqlite3 "$HISTDB" <<'EOSQL'
|
||
CREATE TABLE IF NOT EXISTS history (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts INTEGER NOT NULL,
|
||
address TEXT NOT NULL,
|
||
payload TEXT NOT NULL -- base64-encoded JSON from status.php
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_history_addr_ts ON history(address, ts);
|
||
EOSQL
|
||
|
||
fi
|
||
chown www-data:www-data "$HISTDB" || true
|
||
|
||
# Save history script
|
||
cat > /usr/local/sbin/btc-save-history.sh <<'EOF'
|
||
#!/bin/bash
|
||
APP_ROOT="/opt/btc-solo"
|
||
HISTDB="$APP_ROOT/db/hashrate_history.sqlite"
|
||
USERS_DIR="$APP_ROOT/logs/users"
|
||
CKPOOL_PHP="/var/www/btc-solo/ckpool_status.php"
|
||
|
||
# bail quietly if prerequisites are missing
|
||
for bin in sqlite3 jq php; do
|
||
command -v "$bin" >/dev/null 2>&1 || exit 0
|
||
done
|
||
|
||
[ -f "$HISTDB" ] || exit 0
|
||
[ -d "$USERS_DIR" ] || exit 0
|
||
[ -f "$CKPOOL_PHP" ] || exit 0
|
||
|
||
TS=$(date +%s)
|
||
|
||
insert_row() {
|
||
local ts="$1" addr="$2" json="$3" b64
|
||
if base64 --help 2>&1 | grep -q '\-w'; then
|
||
b64=$(printf '%s' "$json" | base64 -w0)
|
||
else
|
||
b64=$(printf '%s' "$json" | base64)
|
||
fi
|
||
sqlite3 "$HISTDB" \
|
||
"INSERT INTO history (ts,address,payload) VALUES ($ts,'$addr','$b64');" \
|
||
2>/dev/null || true
|
||
}
|
||
|
||
# 1) Pool snapshot (no addr = global pool)
|
||
POOL_JSON=$(php "$CKPOOL_PHP" 2>/dev/null)
|
||
if [ -n "$POOL_JSON" ]; then
|
||
ERR=$(printf '%s' "$POOL_JSON" | jq -r '.error // empty' 2>/dev/null)
|
||
if [ -z "$ERR" ]; then
|
||
insert_row "$TS" "__POOL__" "$POOL_JSON"
|
||
fi
|
||
fi
|
||
|
||
# 2) Per-address snapshots for worker history
|
||
for f in "$USERS_DIR"/*; do
|
||
[ -f "$f" ] || continue
|
||
ADDR=$(basename "$f")
|
||
[ -n "$ADDR" ] || continue
|
||
|
||
JSON=$(ADDR="$ADDR" php -r '$_GET["addr"] = getenv("ADDR"); include "/var/www/btc-solo/ckpool_status.php";' 2>/dev/null)
|
||
[ -n "$JSON" ] || continue
|
||
|
||
ERR=$(printf '%s' "$JSON" | jq -r '.error // empty' 2>/dev/null)
|
||
[ -n "$ERR" ] && continue
|
||
|
||
insert_row "$TS" "$ADDR" "$JSON"
|
||
done
|
||
|
||
exit 0
|
||
EOF
|
||
chmod 0755 /usr/local/sbin/btc-save-history.sh
|
||
|
||
echo "[***] Setting up Cron Jobs..."
|
||
# Cron job for hashrate history logging
|
||
cat > /etc/cron.d/btc-save-history <<'EOF'
|
||
* * * * * www-data /usr/local/sbin/btc-save-history.sh >/dev/null 2>&1
|
||
EOF
|
||
chmod 0644 /etc/cron.d/btc-save-history
|
||
|
||
|
||
echo "Configuring Log Rotation"
|
||
cat > /etc/logrotate.d/btc-solo <<'EOF'
|
||
/opt/btc-solo/logs/*.log {
|
||
weekly
|
||
rotate 8
|
||
compress
|
||
missingok
|
||
notifempty
|
||
copytruncate
|
||
}
|
||
EOF
|
||
|
||
cat > /etc/logrotate.d/nginx <<'EOF'
|
||
/var/log/nginx/*.log {
|
||
weekly
|
||
rotate 8
|
||
compress
|
||
missingok
|
||
notifempty
|
||
copytruncate
|
||
}
|
||
EOF
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
echo "[***] Done"
|
||
# ---------------------------------------------------------------------------
|
||
echo
|
||
echo "================================================================"
|
||
echo " Bitcoin Solo Miner Appliance installed."
|
||
echo
|
||
echo " 1) Open: https://$(hostname -I | awk '{print $1}')/activate/"
|
||
echo " 2) Set Admin Username / Admin Password"
|
||
echo " 3) (Optional) Regenerate the cert"
|
||
echo " 4) You will be redirected to the dashboard."
|
||
echo
|
||
echo " Stratum endpoint for ASICs: stratum+tcp://$(hostname -I | awk '{print $1}'):${CKPOOL_PORT}"
|
||
echo "================================================================"
|