Add Pool Status action, rename from Stratum Smoke Test
Adds a comprehensive status action showing pool health, connected miners, block history with chain tags, and debug memory-vs-DB comparison. Removes the clear-network-orphans action (now handled automatically by the reconcile loop). Updates manifest description to accurately reflect capabilities.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { types as T } from "../deps.ts";
|
||||
|
||||
const POOL_HOST = "kamado-pool.embassy";
|
||||
const STRATUM_PORT = 3333; // shown in output; direct TCP not testable from sandbox
|
||||
const API_BASE = `http://${POOL_HOST}:8080`;
|
||||
|
||||
// ── formatting helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function ts(): string { return new Date().toISOString().slice(11, 22); }
|
||||
|
||||
function fmtHR(hs: number): string {
|
||||
if (hs <= 0) return "0 H/s";
|
||||
if (hs >= 1e18) return `${(hs / 1e18).toFixed(2)} EH/s`;
|
||||
if (hs >= 1e15) return `${(hs / 1e15).toFixed(2)} PH/s`;
|
||||
if (hs >= 1e12) return `${(hs / 1e12).toFixed(2)} TH/s`;
|
||||
if (hs >= 1e9) return `${(hs / 1e9 ).toFixed(2)} GH/s`;
|
||||
if (hs >= 1e6) return `${(hs / 1e6 ).toFixed(2)} MH/s`;
|
||||
if (hs >= 1e3) return `${(hs / 1e3 ).toFixed(2)} kH/s`;
|
||||
return `${hs.toFixed(0)} H/s`;
|
||||
}
|
||||
|
||||
function fmtDiff(d: number): string {
|
||||
if (d <= 0) return "0";
|
||||
if (d >= 1e12) return `${(d / 1e12).toFixed(2)}T`;
|
||||
if (d >= 1e9) return `${(d / 1e9 ).toFixed(2)}G`;
|
||||
if (d >= 1e6) return `${(d / 1e6 ).toFixed(2)}M`;
|
||||
if (d >= 1e3) return `${(d / 1e3 ).toFixed(2)}K`;
|
||||
return d.toFixed(2);
|
||||
}
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
if (s <= 0) return "0m";
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function fmtBTC(btc: number): string { return `${btc.toFixed(8)} BTC`; }
|
||||
function pad(s: string, w: number): string { return s.padEnd(w); }
|
||||
|
||||
const chainNames: Record<string, string> = { main: "mainnet" };
|
||||
function displayChain(c: string): string { return chainNames[c] ?? c; }
|
||||
|
||||
// ── snapshot shape ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PoolStats {
|
||||
workers: number; users: number; accepted: number; rejected: number;
|
||||
shares: number; dsps1: number; dsps5: number; dsps60: number; dsps1440: number;
|
||||
}
|
||||
interface Chain {
|
||||
chain: string; blocks: number; headers: number; difficulty: number;
|
||||
initialblockdownload: boolean; verificationprogress: number; bestblockhash: string;
|
||||
}
|
||||
interface Worker { worker: string; dsps1: number; bestdiff: number; bestever: number; idle: boolean; }
|
||||
interface Client { id: number; workername: string; diff: number; dsps1: number; useragent: string; }
|
||||
interface BlockRecord {
|
||||
height: number; hash?: string; reward_btc?: number; found_at: string;
|
||||
orphaned_at?: string; chain?: string;
|
||||
}
|
||||
interface Snapshot {
|
||||
pool: PoolStats | null;
|
||||
uptime_seconds: number;
|
||||
hashrate_hs_1m: number; hashrate_hs_5m: number;
|
||||
hashrate_hs_1h: number; hashrate_hs_24h: number;
|
||||
best_diff: number; cumulative_shares: number;
|
||||
next_block_reward_btc: number; next_difficulty_percent: number;
|
||||
chain: Chain | null; network_hashrate_hs: number;
|
||||
recent_blocks: BlockRecord[];
|
||||
ckpool_ok: boolean; bitcoin_ok: boolean; last_error?: string;
|
||||
block_submit_attempts: number; block_submits_confirmed: number;
|
||||
zmq_enabled: boolean; zmq_stale: boolean;
|
||||
has_last_zmq_event: boolean; last_zmq_event_age: number;
|
||||
workers: Worker[]; clients: Client[];
|
||||
}
|
||||
|
||||
// ── actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const action: T.ExpectedExports.action = {
|
||||
|
||||
// ── Pool status ─────────────────────────────────────────────────────────────
|
||||
"stratum-smoke-test": async (effects) => {
|
||||
const lines: string[] = [];
|
||||
const log = (s: string) => lines.push(`[${ts()}] ${s}`);
|
||||
const sep = () => log("──────────────────────────────────────────");
|
||||
let allPass = true;
|
||||
|
||||
log("Kamado Pool — Status");
|
||||
sep();
|
||||
|
||||
// ── 1. Full snapshot ────────────────────────────────────────────────────
|
||||
log("● Pool & Bitcoin Core status");
|
||||
let snap: Snapshot | null = null;
|
||||
try {
|
||||
const res = await effects.fetch(`${API_BASE}/api/snapshot`);
|
||||
snap = await res.json() as Snapshot;
|
||||
} catch (e) {
|
||||
log(` API unreachable: ${(e as Error).message}`);
|
||||
allPass = false;
|
||||
}
|
||||
|
||||
if (snap) {
|
||||
const chain = snap.chain;
|
||||
const pool = snap.pool;
|
||||
|
||||
log(` Bitcoin Core: ${snap.bitcoin_ok ? "OK" : "FAIL"}`);
|
||||
if (chain) {
|
||||
const ibd = chain.initialblockdownload;
|
||||
const syncPct = (chain.verificationprogress * 100).toFixed(2);
|
||||
log(` Network: ${chain.chain} height=${chain.blocks} headers=${chain.headers}`);
|
||||
log(` Sync: ${ibd ? `IBD — ${syncPct}% (pool will not mine until synced)` : `${syncPct}% — fully synced`}`);
|
||||
log(` Block hash: ${chain.bestblockhash}`);
|
||||
log(` Difficulty: ${fmtDiff(chain.difficulty)}`);
|
||||
log(` Network hashrate: ${fmtHR(snap.network_hashrate_hs)}`);
|
||||
if (ibd) allPass = false;
|
||||
}
|
||||
if (!snap.bitcoin_ok) allPass = false;
|
||||
|
||||
if (snap.next_block_reward_btc > 0)
|
||||
log(` Next block reward: ${fmtBTC(snap.next_block_reward_btc)}`);
|
||||
if (snap.next_difficulty_percent !== 0) {
|
||||
const sign = snap.next_difficulty_percent > 0 ? "+" : "";
|
||||
log(` Next diff adjust: ${sign}${snap.next_difficulty_percent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
if (snap.zmq_enabled) {
|
||||
const age = snap.has_last_zmq_event
|
||||
? `last event ${snap.last_zmq_event_age.toFixed(0)}s ago`
|
||||
: "no event yet since startup";
|
||||
log(` ZMQ: ${snap.zmq_stale ? "STALE — " : "OK — "}${age}`);
|
||||
if (snap.zmq_stale) allPass = false;
|
||||
} else {
|
||||
log(` ZMQ: disabled`);
|
||||
}
|
||||
|
||||
log("");
|
||||
log(` ckpool process: ${snap.ckpool_ok ? "OK" : "FAIL"}`);
|
||||
if (!snap.ckpool_ok) allPass = false;
|
||||
if (snap.last_error) log(` last_error: ${snap.last_error}`);
|
||||
if (pool) {
|
||||
log(` Uptime: ${fmtUptime(snap.uptime_seconds)}`);
|
||||
log(` Workers online: ${pool.workers} (${pool.users} user${pool.users !== 1 ? "s" : ""})`);
|
||||
log(` Shares accepted: ${pool.accepted} rejected: ${pool.rejected}`);
|
||||
log(` Hashrate 1m: ${fmtHR(snap.hashrate_hs_1m)}`);
|
||||
log(` Hashrate 5m: ${fmtHR(snap.hashrate_hs_5m)}`);
|
||||
log(` Hashrate 1h: ${fmtHR(snap.hashrate_hs_1h)}`);
|
||||
log(` Hashrate 24h: ${fmtHR(snap.hashrate_hs_24h)}`);
|
||||
log(` Best share ever: diff ${fmtDiff(snap.best_diff)}`);
|
||||
log(` Cumul. work: ${fmtDiff(snap.cumulative_shares)} diff-1 shares`);
|
||||
}
|
||||
|
||||
const gap = snap.block_submit_attempts - snap.block_submits_confirmed;
|
||||
log(` Submit attempts: ${snap.block_submit_attempts} confirmed: ${snap.block_submits_confirmed}`);
|
||||
log(` Submit gap: ${gap > 0 ? `WARN — ${gap} unconfirmed` : "OK (all confirmed)"}`);
|
||||
if (gap > 0) allPass = false;
|
||||
|
||||
if (snap.clients && snap.clients.length > 0) {
|
||||
log("");
|
||||
log(`● Connected miners (${snap.clients.length})`);
|
||||
for (const c of snap.clients) {
|
||||
const tag = c.useragent ? ` [${c.useragent}]` : "";
|
||||
log(` ${pad(c.workername || `client#${c.id}`, 30)} diff=${fmtDiff(c.diff)} ${fmtHR(c.dsps1 * 4294967296)}${tag}`);
|
||||
}
|
||||
} else {
|
||||
log(""); log("● Connected miners: none");
|
||||
}
|
||||
|
||||
if (snap.workers && snap.workers.length > 0) {
|
||||
log(""); log("● Workers — best shares");
|
||||
for (const w of snap.workers) {
|
||||
const ever = w.bestever > 0 ? w.bestever : w.bestdiff;
|
||||
log(` ${pad(w.worker, 30)} best=${fmtDiff(ever)}${w.idle ? " (idle)" : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
log("");
|
||||
if (snap.recent_blocks && snap.recent_blocks.length > 0) {
|
||||
const currentNetwork = chain?.chain ?? "";
|
||||
log(`● Blocks found (${snap.recent_blocks.length})`);
|
||||
for (const b of snap.recent_blocks) {
|
||||
let status = "";
|
||||
if (b.orphaned_at) {
|
||||
status = " ORPHANED";
|
||||
} else if (b.chain && currentNetwork && b.chain !== currentNetwork) {
|
||||
status = ` [${displayChain(b.chain)}]`;
|
||||
}
|
||||
const reward = b.reward_btc ? ` ${fmtBTC(b.reward_btc)}` : "";
|
||||
const hash = b.hash ? ` ${b.hash.slice(0, 16)}…` : "";
|
||||
log(` height=${b.height} ${b.found_at.slice(0, 19)}${reward}${hash}${status}`);
|
||||
log(` chain="${b.chain ?? ""}" orphaned_at="${b.orphaned_at ?? ""}" hash="${b.hash ?? ""}"`);
|
||||
}
|
||||
} else {
|
||||
log("● Blocks found: none yet");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1b. Debug: memory vs DB comparison ─────────────────────────────────
|
||||
try {
|
||||
const dbgRes = await effects.fetch(`${API_BASE}/api/admin/debug-blocks`);
|
||||
const dbg = await dbgRes.json() as {
|
||||
memory: { height: number; hash: string; chain: string; orphaned_at: string; found_at: string }[];
|
||||
db: { height: number; hash: string; chain: string; orphaned_at: string; found_at: string }[];
|
||||
};
|
||||
log(""); log("● Debug: memory vs DB");
|
||||
log(` Memory blocks: ${dbg.memory?.length ?? 0} DB blocks: ${dbg.db?.length ?? 0}`);
|
||||
if (dbg.memory && dbg.memory.length > 0) {
|
||||
for (const m of dbg.memory) {
|
||||
const dbRow = dbg.db?.find(d => d.height === m.height);
|
||||
const memOrph = m.orphaned_at || "none";
|
||||
const dbOrph = dbRow?.orphaned_at || "none";
|
||||
const match = memOrph === dbOrph ? "✓" : "MISMATCH";
|
||||
log(` h=${m.height} mem_orphan="${memOrph}" db_orphan="${dbOrph}" ${match}`);
|
||||
if (memOrph !== dbOrph) {
|
||||
log(` mem: chain="${m.chain}" hash="${(m.hash || "").slice(0, 20)}…"`);
|
||||
log(` db: chain="${dbRow?.chain ?? "?"}" hash="${(dbRow?.hash || "").slice(0, 20)}…"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(` (debug endpoint unavailable: ${(e as Error).message})`);
|
||||
}
|
||||
|
||||
// ── 2. Stratum ───────────────────────────────────────────────────────────
|
||||
log(""); sep();
|
||||
log(`● Stratum (port ${STRATUM_PORT})`);
|
||||
if (snap?.ckpool_ok) {
|
||||
log(" ckpool process: healthy — stratum port is served by the same process");
|
||||
} else {
|
||||
log(" FAIL — ckpool is not running, stratum port will not accept connections");
|
||||
}
|
||||
|
||||
// ── 3. Overall ──────────────────────────────────────────────────────────
|
||||
log(""); sep();
|
||||
const summary = allPass && (snap?.ckpool_ok ?? false)
|
||||
? "PASS — pool is healthy"
|
||||
: "FAIL — see details above";
|
||||
log(`Overall: ${summary}`);
|
||||
|
||||
return {
|
||||
result: {
|
||||
version: "0",
|
||||
message: summary,
|
||||
value: lines.join("\n"),
|
||||
copyable: true,
|
||||
qr: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// ── Regenerate TLS certificate ──────────────────────────────────────────────
|
||||
"regen-tls-cert": async (effects) => {
|
||||
const VOLUME = "main";
|
||||
const TLS_PATH = "tls";
|
||||
const certFiles = ["stratum.crt", "stratum.key", "stratum.pem", "cert_version", "fingerprint.txt"];
|
||||
|
||||
const removed: string[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const f of certFiles) {
|
||||
try {
|
||||
await effects.removeFile({ volumeId: VOLUME, path: `${TLS_PATH}/${f}` });
|
||||
removed.push(f);
|
||||
} catch {
|
||||
missing.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
const hadCert = removed.length > 0;
|
||||
const message = hadCert
|
||||
? "TLS certificate cleared — restart the service to generate a new one"
|
||||
: "No TLS certificate files found (TLS may not have been enabled)";
|
||||
|
||||
const detail = [
|
||||
hadCert ? `Removed: ${removed.join(", ")}` : "No certificate files were present.",
|
||||
missing.length > 0 ? `Already absent: ${missing.join(", ")}` : "",
|
||||
"",
|
||||
"Next steps:",
|
||||
" 1. Restart Kamado Pool.",
|
||||
" 2. The new certificate fingerprint will appear in the service logs",
|
||||
' (search for "stratum TLS SHA256 fingerprint").',
|
||||
" 3. Provide the new fingerprint to miners that pin the certificate.",
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
return {
|
||||
result: { version: "0", message, value: detail, copyable: false, qr: false },
|
||||
};
|
||||
},
|
||||
|
||||
};
|
||||
Reference in New Issue
Block a user