Skip to content

Instantly share code, notes, and snippets.

@drego85
Last active April 16, 2026 08:50
Show Gist options
  • Select an option

  • Save drego85/243c32d7e939e6bc2e401c65d02306af to your computer and use it in GitHub Desktop.

Select an option

Save drego85/243c32d7e939e6bc2e401c65d02306af to your computer and use it in GitHub Desktop.
CKBoard is a lightweight PHP dashboard for monitoring CKPool solo Bitcoin mining statistics by address and region. It fetches pool data server-side and displays hashrate, workers, shares, best share, and per- worker mining performance in a clean Bootstrap interface.
<?php
declare(strict_types=1);
/*
* CKBoard is a small PHP dashboard for CKPool solo Bitcoin mining stats.
*
* The page accepts a Bitcoin address and a CKPool region through GET parameters,
* fetches the JSON data server-side to avoid browser CORS restrictions, validates
* user input against a strict allowlist, and renders aggregate plus per-worker
* mining metrics with Bootstrap.
*
* Made with ♥ by Andrea Draghetti
*
* This file may be licensed under the terms of of the
* GNU General Public License Version 3 (the ``GPL'').
*
*/
const DEFAULT_POOL = 'eu';
const DEFAULT_USER = '';
const CKPOOL_POOLS = [
'eu' => [
'label' => 'Europe and Africa',
'host' => 'eusolo.ckpool.org',
],
'us' => [
'label' => 'America',
'host' => 'solo.ckpool.org',
],
'au' => [
'label' => 'Oceania',
'host' => 'ausolo.ckpool.org',
],
];
$rawUser = isset($_GET['user']) ? trim((string) $_GET['user']) : DEFAULT_USER;
$rawPool = isset($_GET['pool']) ? trim((string) $_GET['pool']) : DEFAULT_POOL;
$validationError = null;
$user = '';
$poolKey = array_key_exists($rawPool, CKPOOL_POOLS) ? $rawPool : DEFAULT_POOL;
if ($rawPool !== '' && !array_key_exists($rawPool, CKPOOL_POOLS)) {
$validationError = 'Invalid pool parameter: using Europe and Africa.';
}
if ($rawUser !== '' && preg_match('/^[A-Za-z0-9]{26,120}$/', $rawUser) === 1) {
$user = $rawUser;
} elseif ($rawUser !== '') {
$validationError = 'Invalid user parameter: enter a valid Bitcoin address.';
}
$pool = CKPOOL_POOLS[$poolKey];
$ckpoolBaseUrl = 'https://' . $pool['host'] . '/users/';
$ckpoolUrl = $user !== '' ? $ckpoolBaseUrl . rawurlencode($user) : '';
$data = null;
$loadError = null;
if ($user !== '') {
try {
$json = fetchUrl($ckpoolUrl);
$data = decodeJson($json);
} catch (Throwable $error) {
$loadError = 'Unable to load data from CKPool.';
}
}
$workers = is_array($data['worker'] ?? null) ? $data['worker'] : [];
usort($workers, static fn (array $a, array $b): int => parseHashrate((string) ($b['hashrate1m'] ?? '0')) <=> parseHashrate((string) ($a['hashrate1m'] ?? '0')));
$hashratePeriods = [
'1 minute' => $data['hashrate1m'] ?? null,
'5 minutes' => $data['hashrate5m'] ?? null,
'1 hour' => $data['hashrate1hr'] ?? null,
'1 day' => $data['hashrate1d'] ?? null,
'7 days' => $data['hashrate7d'] ?? null,
];
$hashrateValues = array_map(static fn ($value): float => parseHashrate((string) $value), $hashratePeriods);
$maxHashrate = max(array_merge($hashrateValues, [1]));
function fetchUrl(string $url): string
{
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'CKBoard/1.0',
]);
$body = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($body === false || $status < 200 || $status >= 300) {
throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status);
}
return (string) $body;
}
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "Accept: application/json\r\nUser-Agent: CKBoard/1.0\r\n",
'timeout' => 10,
],
]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
throw new RuntimeException('Richiesta HTTP non riuscita');
}
return (string) $body;
}
function decodeJson(string $json): array
{
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new RuntimeException('Risposta JSON non valida');
}
return $decoded;
}
function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function valueOrDash($value): string
{
if ($value === null || $value === '') {
return '-';
}
return e((string) $value);
}
function formatNumber($value): string
{
if (!is_numeric($value)) {
return '-';
}
$numericValue = (float) $value;
$decimals = floor($numericValue) === $numericValue ? 0 : 2;
return number_format($numericValue, $decimals, ',', '.');
}
function formatLargeShare($value): string
{
if (!is_numeric($value)) {
return '-';
}
$numericValue = (float) $value;
$absoluteValue = abs($numericValue);
$units = [
['threshold' => 1000000000000000, 'divisor' => 1000000000000000, 'suffix' => 'Qn'],
['threshold' => 1000000000000, 'divisor' => 1000000000000, 'suffix' => 'Tn'],
['threshold' => 1000000000, 'divisor' => 1000000000, 'suffix' => 'Bn'],
['threshold' => 1000000, 'divisor' => 1000000, 'suffix' => 'Mn'],
];
foreach ($units as $unit) {
if ($absoluteValue >= $unit['threshold']) {
$shortValue = floor(($numericValue / $unit['divisor']) * 1000) / 1000;
$formatted = number_format($shortValue, 3, '.', '');
$formatted = rtrim(rtrim($formatted, '0'), '.');
return $formatted . ' ' . $unit['suffix'];
}
}
return formatNumber($value);
}
function formatUnixTime($value): string
{
if (!is_numeric($value) || (int) $value <= 0) {
return '-';
}
return date('d/m/Y H:i:s', (int) $value);
}
function parseHashrate(string $value): float
{
if ($value === '' || $value === '0') {
return 0.0;
}
if (preg_match('/^([\d.]+)\s*([kKmMgGtTpPeE]?)/', trim($value), $matches) !== 1) {
return 0.0;
}
$number = (float) $matches[1];
$unit = strtoupper($matches[2] ?? '');
$multipliers = [
'K' => 1e3,
'M' => 1e6,
'G' => 1e9,
'T' => 1e12,
'P' => 1e15,
'E' => 1e18,
];
return $number * ($multipliers[$unit] ?? 1);
}
function shortenWorkerName(?string $workerName, string $user): string
{
if ($workerName === null || $workerName === '') {
return '-';
}
$prefix = $user . '.';
if (substr($workerName, 0, strlen($prefix)) === $prefix) {
$shortName = substr($workerName, strlen($prefix));
return $shortName !== '' ? $shortName : $workerName;
}
$dotPosition = strpos($workerName, '.');
return $dotPosition === false ? $workerName : substr($workerName, $dotPosition + 1);
}
function isShareStale($lastShare): bool
{
if (!is_numeric($lastShare)) {
return true;
}
return time() - (int) $lastShare > 1800;
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CKPool Solo Mining Dashboard</title>
<!-- SEO Meta Tags -->
<meta name="description" content="CKBoard is a PHP dashboard for viewing CKPool solo Bitcoin mining statistics by address, region, hashrate, workers, shares, and best share data.">
<meta name="keywords" content="CKBoard, CKPool, solo mining, Bitcoin mining dashboard, Bitcoin hashrate, mining workers, best share, solo.ckpool.org, eusolo.ckpool.org, ausolo.ckpool.org">
<meta name="author" content="Andrea Draghetti">
<meta name="robots" content="index, follow">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="CKBoard - CKPool Solo Mining Dashboard">
<meta property="og:description" content="View CKPool solo Bitcoin mining statistics by address and region, including hashrate, active workers, shares, and best share data.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="CKBoard">
<link rel="icon" type="image/svg+xml" href="https://eusolo.ckpool.org/ckbtc.svg">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css" integrity="sha512-2bBQCjcnw658Lho4nlXJcc6WkV/UxpE/sAokbXPxQNGqmNdQrWqtw26Ns9kFF/yG792pKR1Sx8/Y1Lf1XN4GKA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
:root {
--ck-bg: #f6f8fb;
--ck-ink: #17202a;
--ck-muted: #657485;
--ck-line: #dbe3ec;
--ck-primary: #116466;
--ck-accent: #d79922;
--ck-good: #1f8a5b;
}
body {
background: var(--ck-bg);
color: var(--ck-ink);
}
.top-band {
background: #ffffff;
border-bottom: 1px solid var(--ck-line);
}
.brand-mark {
width: 42px;
height: 42px;
object-fit: contain;
}
.dashboard-form {
width: 100%;
}
.dashboard-form .form-select,
.dashboard-form .form-control,
.dashboard-form .btn {
min-height: 44px;
}
.dashboard-form .form-control {
min-width: 0;
}
.metric-card {
border: 1px solid var(--ck-line);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(23, 32, 42, 0.06);
}
.metric-label {
color: var(--ck-muted);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.metric-value {
font-size: clamp(1.85rem, 4vw, 3rem);
font-weight: 800;
line-height: 1;
overflow-wrap: anywhere;
}
.primary-metric {
border-top: 5px solid var(--ck-primary);
}
.accent-metric {
border-top: 5px solid var(--ck-accent);
}
.good-metric {
border-top: 5px solid var(--ck-good);
}
.hash-bar {
height: 12px;
border-radius: 8px;
background: #e8eef5;
overflow: hidden;
}
.hash-bar > span {
display: block;
height: 100%;
min-width: 2px;
background: linear-gradient(90deg, var(--ck-primary), #2a9d8f);
}
.table {
--bs-table-bg: #ffffff;
--bs-table-striped-bg: #f7fafc;
border: 1px solid var(--ck-line);
}
.table th,
.table td {
white-space: nowrap;
}
.worker-name {
font-weight: 800;
overflow-wrap: anywhere;
white-space: normal;
}
.small-muted {
color: var(--ck-muted);
font-size: 0.92rem;
}
.status-dot {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 999px;
background: var(--ck-good);
margin-right: 0.4rem;
}
.status-dot.stale {
background: #b45309;
}
.section-title {
font-weight: 800;
letter-spacing: 0;
}
.alert code {
overflow-wrap: anywhere;
white-space: normal;
}
@media (min-width: 992px) {
.dashboard-form {
max-width: 760px;
}
.dashboard-form .pool-select {
max-width: 280px;
}
}
@media (max-width: 767.98px) {
.top-band .container {
padding-bottom: 1rem;
padding-top: 1rem;
}
.brand-mark {
width: 36px;
height: 36px;
}
.dashboard-form .btn,
.dashboard-action {
width: 100%;
}
.metric-card .card-body {
padding: 1rem;
}
.metric-value {
font-size: 2rem;
}
}
@media (max-width: 575.98px) {
.mobile-secondary {
display: none;
}
.table th,
.table td {
padding-left: 0.65rem;
padding-right: 0.65rem;
}
}
</style>
</head>
<body>
<header class="top-band">
<div class="container py-4">
<div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
<div class="d-flex gap-3 align-items-center">
<img class="brand-mark" src="https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Bitcoin.svg/120px-Bitcoin.svg.png" alt="Bitcoin">
<div>
<h1 class="h3 mb-1 fw-bold">CKPool Solo Mining Dashboard</h1>
<div class="small-muted">
<?php if ($user !== ''): ?>
Bitcoin mining statistics for <span class="fw-semibold"><?= e($user) ?></span>
<?php else: ?>
Select a CKPool portal and enter a Bitcoin address to load mining statistics.
<?php endif; ?>
</div>
</div>
</div>
<form class="dashboard-form d-flex flex-column flex-md-row gap-2" method="get" autocomplete="off">
<select class="form-select pool-select" name="pool" aria-label="CKPool portal">
<?php foreach (CKPOOL_POOLS as $key => $poolOption): ?>
<option value="<?= e($key) ?>" <?= $key === $poolKey ? 'selected' : '' ?>>
<?= e($poolOption['label'] . ' - ' . $poolOption['host']) ?>
</option>
<?php endforeach; ?>
</select>
<input class="form-control" name="user" type="text" aria-label="Bitcoin address" placeholder="Bitcoin address" value="<?= e($user) ?>" inputmode="text" autocapitalize="none" autocomplete="off" autocorrect="off" spellcheck="false">
<button class="btn btn-dark" type="submit">Load</button>
</form>
</div>
</div>
</header>
<main class="container py-4 py-lg-3">
<?php if ($validationError !== null): ?>
<div class="alert alert-warning" role="alert"><?= e($validationError) ?></div>
<?php endif; ?>
<?php if ($loadError !== null): ?>
<div class="alert alert-danger" role="alert"><?= e($loadError) ?></div>
<?php endif; ?>
<?php if ($user === ''): ?>
<div class="alert alert-info" role="alert">
<div class="fw-semibold mb-1">Enter a Bitcoin address and choose a CKPool portal to load the dashboard.</div>
<div class="small">
You can also use GET parameters:
<code>?pool=eu&amp;user=YOUR_BITCOIN_ADDRESS</code>,
<code>?pool=us&amp;user=YOUR_BITCOIN_ADDRESS</code>, or
<code>?pool=au&amp;user=YOUR_BITCOIN_ADDRESS</code>.
</div>
</div>
<?php endif; ?>
<div class="d-flex flex-column flex-md-row gap-2 align-items-md-center justify-content-between mb-4">
<div class="small-muted">
<?php if ($data !== null): ?>
Updated <?= e(date('H:i:s')) ?>
<?php endif; ?>
</div>
<?php if ($ckpoolUrl !== ''): ?>
<a class="btn btn-outline-dark btn-sm dashboard-action" href="<?= e($ckpoolUrl) ?>" target="_blank" rel="noopener noreferrer">Open original CKPool data</a>
<?php endif; ?>
</div>
<section class="row g-3 mb-4" aria-label="Main metrics">
<div class="col-12 col-md-4">
<div class="card metric-card primary-metric h-100">
<div class="card-body">
<div class="metric-label">1-minute hashrate</div>
<div class="metric-value mt-2"><?= valueOrDash($data['hashrate1m'] ?? null) ?></div>
<div class="small-muted mt-2">Recent total mining power</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card metric-card accent-metric h-100">
<div class="card-body">
<div class="metric-label">Active workers</div>
<div class="metric-value mt-2"><?= formatNumber($data['workers'] ?? null) ?></div>
<div class="small-muted mt-2">Devices reported by the pool</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card metric-card good-metric h-100">
<div class="card-body">
<div class="metric-label">Best ever</div>
<div class="metric-value mt-2" title="<?= e(formatNumber($data['bestever'] ?? null)) ?>"><?= formatLargeShare($data['bestever'] ?? null) ?></div>
<div class="small-muted mt-2">Highest share recorded</div>
</div>
</div>
</div>
</section>
<section class="row g-4 mb-4">
<div class="col-12 col-lg-7">
<div class="card metric-card h-100">
<div class="card-body">
<h2 class="h5 section-title mb-3">Hashrate trend</h2>
<div class="vstack gap-3">
<?php foreach ($hashratePeriods as $label => $value): ?>
<?php
$parsed = parseHashrate((string) $value);
$width = $parsed > 0 ? max(($parsed / $maxHashrate) * 100, 3) : 0;
?>
<div>
<div class="d-flex justify-content-between gap-3 mb-1">
<span class="fw-semibold"><?= e($label) ?></span>
<span class="small-muted"><?= valueOrDash($value) ?></span>
</div>
<div class="hash-bar" aria-label="Hashrate <?= e($label) ?>">
<span style="width: <?= e((string) $width) ?>%"></span>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card metric-card h-100">
<div class="card-body">
<h2 class="h5 section-title mb-3">Pool summary</h2>
<dl class="row mb-0">
<dt class="col-5 small-muted">Last share</dt>
<dd class="col-7 fw-semibold text-end"><?= formatUnixTime($data['lastshare'] ?? null) ?></dd>
<dt class="col-5 small-muted">Total shares</dt>
<dd class="col-7 fw-semibold text-end"><?= formatNumber($data['shares'] ?? null) ?></dd>
<dt class="col-5 small-muted">Best share</dt>
<dd class="col-7 fw-semibold text-end"><?= formatNumber($data['bestshare'] ?? null) ?></dd>
<dt class="col-5 small-muted">Authorised since</dt>
<dd class="col-7 fw-semibold text-end"><?= formatUnixTime($data['authorised'] ?? null) ?></dd>
</dl>
</div>
</div>
</div>
</section>
<section>
<div class="d-flex flex-column flex-md-row align-items-md-end justify-content-between gap-2 mb-3">
<div>
<h2 class="h4 section-title mb-1">Worker</h2>
<div class="small-muted">Short name, recent hashrate, and highest share by device.</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">Worker</th>
<th scope="col">Hashrate 1m</th>
<th class="mobile-secondary" scope="col">Hashrate 1h</th>
<th class="mobile-secondary" scope="col">Hashrate 1d</th>
<th scope="col">Best ever</th>
<th class="mobile-secondary" scope="col">Last share</th>
</tr>
</thead>
<tbody>
<?php if (count($workers) === 0): ?>
<tr>
<td colspan="6" class="text-center py-4 small-muted">
<?= $user === '' ? 'Enter a Bitcoin address to load workers.' : 'No workers available.' ?>
</td>
</tr>
<?php else: ?>
<?php foreach ($workers as $worker): ?>
<?php
$shortName = shortenWorkerName(isset($worker['workername']) ? (string) $worker['workername'] : null, $user);
$stale = isShareStale($worker['lastshare'] ?? null);
?>
<tr>
<td>
<span class="status-dot <?= $stale ? 'stale' : '' ?>" title="<?= $stale ? 'Stale share' : 'Recent share' ?>"></span>
<span class="worker-name"><?= e($shortName) ?></span>
</td>
<td class="fw-bold"><?= valueOrDash($worker['hashrate1m'] ?? null) ?></td>
<td class="mobile-secondary"><?= valueOrDash($worker['hashrate1hr'] ?? null) ?></td>
<td class="mobile-secondary"><?= valueOrDash($worker['hashrate1d'] ?? null) ?></td>
<td class="fw-semibold" title="<?= e(formatNumber($worker['bestever'] ?? null)) ?>"><?= formatLargeShare($worker['bestever'] ?? null) ?></td>
<td class="mobile-secondary"><?= formatUnixTime($worker['lastshare'] ?? null) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</main>
<footer class="container pb-4">
<div class="small-muted text-center">
CKBoard made with &hearts; by <a href="https://www.andreadraghetti.it/" target="_blank" rel="noopener noreferrer">Andrea Draghetti</a>.
</div>
</footer>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment