Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,35 @@ Pithead ships as **one product, one version** — the version lives in the top-l
[`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut
per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).

## [Unreleased]
## [1.9.3] - 2026-07-19

### Fixed

- **Keyboard users can sort the Workers table (#671).** Each sort header's click target is now a
real `<button>` (the #657 pattern), so it is focusable and activates on Enter/Space. The
worker-history table also stops advertising a pointer cursor on headers that do nothing.
- **The configuration reference says how to make an edit take effect (#675).** The callout above
the key table now states that editing `config.json` changes nothing until `./pithead apply`,
for readers who deep-link into the table and never see the intro.
- **Config editor saves work again (#679).** Every save through the dashboard's Configuration
editor — Form and JSON mode alike — was rejected with the "sets both workers.list[] and
dashboard.workers[]" error: the editor merges `config.reference.json` (which ships both worker
keys as empty-array defaults) under the operator's config and round-trips the merged document,
and validation refused on key presence. The refusal now keys on populated lists; an empty array
beside the populated key is the schema default and passes. The editor's own
`_core_keys`/`_editable_keys` metadata is likewise stripped from the intent before it reaches
the host gate.

### Changed

- **The disk readout switches to TB at 1 TB (#677).** The system card and the Telegram `/system`
reply format disk used/total through one shared helper: GB with one decimal below 1024 GB
(unchanged), both values scaled to TB together at or above it — `Disk: 0.4 / 3.6 TB` instead
of `Disk: 408.6 / 3666.4 GB`. RAM, stored telemetry, and the metrics endpoint stay GB.
- **`apply` migrates the deprecated `dashboard.workers[]` to `workers.list[]` (#679).** A
validated legacy list is moved in place on the next apply: entries land under `workers.list`,
the old key is deleted, and the pre-migration file is kept beside the config as
`config.json.bak-workers`. Dry runs (including every dashboard preview) never write.

## [1.9.2] - 2026-07-19

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.9.2
1.9.3
13 changes: 8 additions & 5 deletions build/dashboard/mining_dashboard/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,9 @@
# parse only has to stay safe if the mount is stale or hand-edited.
#
# workers.list[] is the current sub-key (#506); dashboard.workers[] (#172) is read as a deprecated
# fallback when workers.list is unset, logged once, and removed in v1.9 — pithead's apply-time
# validation refuses a config that sets both, so this loader only needs to pick whichever is present.
# fallback when workers.list is unset or empty, logged once, and removed in v1.9 — pithead's
# apply-time validation refuses a config that populates both, so this loader only needs to pick
# whichever is populated (an empty array is a schema default, never an operator choice, #679).
# name — 1-128 printable non-space ASCII chars (matched against the stratum name, '+' stripped)
# host — hostname or IPv4 literal: letters/digits/dot/dash/underscore only, so a config value
# can never smuggle a port, path, or userinfo into the probe URL (no ':/@?#'; IPv6
Expand All @@ -231,8 +232,10 @@ def _valid_watts(v):
def load_worker_endpoints(path=None):
"""The validated workers.list[] entries (#506); invalid entries dropped, first name wins.

dashboard.workers[] (#172) is read as a deprecated fallback when workers.list is unset
(removed in v1.9 — pithead's apply-time validation refuses a config that sets both).
dashboard.workers[] (#172) is read as a deprecated fallback when workers.list is unset or an
empty array (removed in v1.9 — pithead's apply-time validation refuses a config that
populates both, but an empty workers.list may legitimately sit alongside a populated legacy
key, #679: empty arrays are schema defaults, not operator choices).
"""
try:
with open(path or HOST_CONFIG_PATH) as f:
Expand All @@ -241,7 +244,7 @@ def load_worker_endpoints(path=None):
return []
workers_block = doc.get("workers") if isinstance(doc, dict) else None
raw = workers_block.get("list") if isinstance(workers_block, dict) else None
if raw is None:
if raw is None or raw == []:
dashboard_block = doc.get("dashboard") if isinstance(doc, dict) else None
if isinstance(dashboard_block, dict) and "workers" in dashboard_block:
raw = dashboard_block["workers"]
Expand Down
20 changes: 20 additions & 0 deletions build/dashboard/mining_dashboard/helper/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ def format_hashrate(hashrate):
return "0 H/s"


def format_disk_size(used_gb, total_gb):
"""Format a disk used/total pair, switching from GB to TB once the volume reaches
1 TB (1024 GB — the same 1024-based scale as the collector's ``BYTES_IN_GB``).

Both values switch together so the pair stays comparable; a mixed
``408.6 GB / 3.6 TB`` line would not. Shared by the dashboard system card and the
Telegram ``/system`` reply so disk reads the same on every surface (#677).

Returns:
tuple: (used_str, total_str, unit) with one decimal place.
"""
try:
used, total = float(used_gb or 0), float(total_gb or 0)
except (ValueError, TypeError):
used, total = 0.0, 0.0
if total >= 1024:
return f"{used / 1024:.1f}", f"{total / 1024:.1f}", "TB"
return f"{used:.1f}", f"{total:.1f}", "GB"


def format_xmr(amount):
"""Format an XMR amount with magnitude-adaptive precision — 4 decimal places at >= 1 XMR,
6 at >= 0.001, 8 below that — so a small daily estimate isn't truncated to zeros.
Expand Down
8 changes: 8 additions & 0 deletions build/dashboard/mining_dashboard/service/control_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ def submit(action, cfg=None, actor="", intent_id=None, version=None):
rid = str(uuid.UUID(intent_id)) if intent_id else str(uuid.uuid4())
request = {"id": rid, "action": action, "actor": actor}
if cfg is not None:
# read_config's own metadata injections (#529/#613) ride back with the editor's POST —
# both modes round-trip the fetched doc wholesale — and the host gate's closed-schema
# check would refuse a commit carrying them (#679). Shed them at the one choke point
# every config intent passes through; everything else unknown still fails closed host-side.
# A non-dict cfg passes through untouched: the host runner already rejects it with its
# own "config must be a JSON object" result, which the UI knows how to surface.
if isinstance(cfg, dict):
cfg = {k: v for k, v in cfg.items() if k not in ("_core_keys", "_editable_keys")}
request["config"] = cfg
if version is not None:
request["version"] = version
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from mining_dashboard.helper.utils import (
effective_hashrate,
format_disk_size,
format_duration,
format_hashrate,
format_xmr,
Expand Down Expand Up @@ -285,13 +286,15 @@ def format_system(system, host_label=""):
"""Host resource usage — the answer to '/system'. Reads the ``system`` snapshot the dashboard
already collects (disk / RAM / CPU / load / HugePages)."""
disk = system.get("disk", {})
disk_used, disk_total, disk_unit = format_disk_size(
disk.get("used_gb", 0), disk.get("total_gb", 0)
)
mem = system.get("memory", {})
hp_status, _hp_class, hp_value = system.get("hugepages", ["Unknown", "", "0/0"])
return "\n".join(
[
f"{_prefix(host_label)}\U0001f5a5️ System",
f"Disk: {disk.get('used_gb', 0):.1f}/{disk.get('total_gb', 0):.1f} GB "
f"({disk.get('percent_str', '0%')})",
f"Disk: {disk_used}/{disk_total} {disk_unit} ({disk.get('percent_str', '0%')})",
f"RAM: {mem.get('used_gb', 0):.1f}/{mem.get('total_gb', 0):.1f} GB "
f"({mem.get('percent_str', '0%')})",
f"CPU: {system.get('cpu_percent', '0%')} · load {system.get('load', 'n/a')}",
Expand Down
19 changes: 11 additions & 8 deletions build/dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function Header({ state }) {
<span class=${s.hugepages.variant === "ok" ? "status-ok" : "status-bad"}>Huge Pages: ${s.hugepages.status} (${s.hugepages.value})</span>
</div>
<div class="flex items-center">
<span class=${(s.disk.level === "high" ? "status-bad" : "text-muted") + " mr-2"}>Disk: ${s.disk.used} / ${s.disk.total} GB (${s.disk.percent})</span> <${HighUsage} level=${s.disk.level} />
<span class=${(s.disk.level === "high" ? "status-bad" : "text-muted") + " mr-2"}>Disk: ${s.disk.used} / ${s.disk.total} ${s.disk.unit} (${s.disk.percent})</span> <${HighUsage} level=${s.disk.level} />
<div class="disk-bar">
<div class="progress-bg">
<div class=${"progress-fill " + s.disk.fill} style=${{ width: s.disk.width }}></div>
Expand Down Expand Up @@ -786,14 +786,17 @@ function WorkersTable({ workers, summary, ui, onSort, hostIp, stratumPort, onIns
<tr>${WORKER_COLUMNS.map(
// Sorted column carries the direction, visibly (arrow) and for AT (aria-sort);
// the title makes clickability discoverable without hovering rows (#656).
(c, i) => html`<th onClick=${() => onSort(i)}
// The click target is a real <button> so keyboard users can sort too (#671):
// native buttons are focusable and activate on Enter/Space without extra wiring.
(c, i) => html`<th
class=${i === ui.sortIndex ? "sorted" : null}
aria-sort=${i === ui.sortIndex ? (ui.sortAsc ? "ascending" : "descending") : null}
title=${"Sort by " + c.label}>${c.label}${
i === ui.sortIndex
? html`<span class="sort-arrow">${ui.sortAsc ? " ▲" : " ▼"}</span>`
: ""
}</th>`,
aria-sort=${i === ui.sortIndex ? (ui.sortAsc ? "ascending" : "descending") : null}><button
type="button" class="th-sort-btn" onClick=${() => onSort(i)}
title=${"Sort by " + c.label}>${c.label}${
i === ui.sortIndex
? html`<span class="sort-arrow">${ui.sortAsc ? " ▲" : " ▼"}</span>`
: ""
}</button></th>`,
)}</tr>
</thead>
<tbody id="workers-tbody">
Expand Down
18 changes: 17 additions & 1 deletion build/dashboard/mining_dashboard/web/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,27 @@ th {
padding: 10px;
border-bottom: 1px solid var(--border);
text-transform: uppercase;
cursor: pointer;
}
th.sorted {
color: var(--text);
}
/* Sortable headers (#671): the sort control is a real <button>, so it is keyboard-operable.
* It absorbs the th padding and inherits the th typography — the whole cell stays the click
* target and the header looks unchanged. */
th:has(.th-sort-btn) {
padding: 0;
}
.th-sort-btn {
display: block;
width: 100%;
padding: 10px;
background: none;
border: none;
font: inherit;
color: inherit;
text-align: inherit;
cursor: pointer;
}
.sort-arrow {
font-size: 0.6rem;
}
Expand Down
9 changes: 7 additions & 2 deletions build/dashboard/mining_dashboard/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
from mining_dashboard.helper.utils import (
detect_host_ipv4,
format_disk_size,
format_duration,
format_hashrate,
format_time_abs,
Expand Down Expand Up @@ -785,6 +786,9 @@ def build_system(data):
system = data.get("system", {})

disk_usage = system.get("disk", {})
disk_used, disk_total, disk_unit = format_disk_size(
disk_usage.get("used_gb", 0), disk_usage.get("total_gb", 0)
)
disk_percent = disk_usage.get("percent", 0)
disk_fill = "critical" if disk_percent > 90 else "warning" if disk_percent > 70 else ""

Expand Down Expand Up @@ -814,8 +818,9 @@ def build_system(data):
"level": _usage_level(mem_usage.get("percent", 0)),
},
"disk": {
"used": f"{disk_usage.get('used_gb', 0):.1f}",
"total": f"{disk_usage.get('total_gb', 0):.1f}",
"used": disk_used,
"total": disk_total,
"unit": disk_unit,
"percent": disk_usage.get("percent_str", "0%"),
"width": f"{disk_percent}%",
"fill": disk_fill,
Expand Down
2 changes: 1 addition & 1 deletion build/dashboard/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "mining-dashboard"
# Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version
# (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version
# from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only.
version = "1.9.2"
version = "1.9.3"
description = "Monitoring dashboard and XvB switching engine for Pithead"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
31 changes: 28 additions & 3 deletions build/dashboard/tests/config/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,10 @@ def test_valid_watts_loads_and_bad_watts_drops_entry(self, tmp_path):

class TestWorkerEndpointsDualRead:
"""workers.list[] (#506) is the current sub-key; dashboard.workers[] (#172) is read as a
deprecated fallback only when workers.list is unset. pithead's apply-time validation refuses
a config that sets both, so the loader only has to pick whichever is present and prefer the
new shape."""
deprecated fallback when workers.list is unset or an empty array. pithead's apply-time
validation refuses a config that populates both, but an empty array is a schema default
(config.reference.json ships both keys as []) and must never shadow the populated shape
(#679)."""

def _load(self, tmp_path, payload):
from mining_dashboard.config.config import load_worker_endpoints
Expand Down Expand Up @@ -269,6 +270,30 @@ def test_legacy_fallback_used_when_new_shape_unset(self, tmp_path):
got = self._load(tmp_path, {"dashboard": {"workers": [{"name": "legacy-rig"}]}})
assert got == [{"name": "legacy-rig"}]

def test_legacy_fallback_used_when_new_shape_empty(self, tmp_path):
# The editor round-trip shape (#679): config.reference.json's workers.list: [] merged
# beside a populated legacy key — the empty schema default must not shadow the entries.
got = self._load(
tmp_path,
{
"workers": {"list": []},
"dashboard": {"workers": [{"name": "legacy-rig"}]},
},
)
assert got == [{"name": "legacy-rig"}]

def test_empty_legacy_default_beside_populated_new_shape_reads_new(self, tmp_path):
# The mirror round-trip shape (#679): dashboard.workers: [] injected by the reference
# merge beside a populated workers.list.
got = self._load(
tmp_path,
{
"workers": {"list": [{"name": "new-rig"}]},
"dashboard": {"workers": []},
},
)
assert got == [{"name": "new-rig"}]

def test_legacy_fallback_logs_a_deprecation_notice(self, tmp_path, caplog):
import logging

Expand Down
15 changes: 13 additions & 2 deletions build/dashboard/tests/frontend/components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

import { App } from '../../mining_dashboard/web/static/components.mjs';
import { WORKER_COLUMNS } from '../../mining_dashboard/web/static/logic.mjs';
import { StatsTable } from '../../mining_dashboard/web/static/workerview.mjs';
import { render } from './helpers/render.mjs';

Expand Down Expand Up @@ -492,9 +493,19 @@ test('WorkersTable marks the sorted column, visibly and via aria-sort (#656)', (
// No sort chosen (server order): no column claims a direction.
assert.doesNotMatch(renderApp(), /aria-sort/);
const asc = renderApp({ ui: { ...UI, sortIndex: 0, sortAsc: true } });
assert.match(asc, /<th[^>]*class="sorted"[^>]*aria-sort="ascending"[^>]*>Worker<span class="sort-arrow"> ▲<\/span>/);
assert.match(asc, /<th[^>]*class="sorted"[^>]*aria-sort="ascending"[^>]*><button[^>]*>Worker<span class="sort-arrow"> ▲<\/span>/);
const desc = renderApp({ ui: { ...UI, sortIndex: 0, sortAsc: false } });
assert.match(desc, /aria-sort="descending"[^>]*>Worker<span class="sort-arrow"> ▼<\/span>/);
assert.match(desc, /aria-sort="descending"[^>]*><button[^>]*>Worker<span class="sort-arrow"> ▼<\/span>/);
});

test('WorkersTable sort headers are real buttons, so keyboard can sort (#671)', () => {
// A native <button> is focusable and activates on Enter/Space, firing the same onClick
// (onSort) path the mouse takes — keyboard operability rides on the element choice, so
// the component-tier assertion is that every header's click target IS a native button.
const html = renderApp();
const btns = html.match(/<button type="button" class="th-sort-btn" title="Sort by /g) || [];
assert.equal(btns.length, WORKER_COLUMNS.length);
assert.match(html, /<th><button type="button" class="th-sort-btn" title="Sort by Worker">Worker</);
});

test('WorkersTable with no workers shows the connect hint instead of a bare table (#385)', () => {
Expand Down
18 changes: 18 additions & 0 deletions build/dashboard/tests/helper/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
PPLNS_BLOCK_TIME_DEFAULT,
PPLNS_BLOCK_TIME_NANO,
detect_host_ipv4,
format_disk_size,
format_duration,
format_hashrate,
format_time_abs,
Expand Down Expand Up @@ -120,6 +121,23 @@ def test_bad_data(self):
assert format_hashrate(None) == "0 H/s"


class TestFormatDiskSize:
"""#677: GB below 1 TB, TB at or above it; both values switch together."""

def test_stays_gb_below_1024(self):
assert format_disk_size(408.6, 931.5) == ("408.6", "931.5", "GB")

def test_switches_to_tb_at_boundary(self):
assert format_disk_size(512, 1024) == ("0.5", "1.0", "TB")

def test_large_volume_reads_in_tb(self):
assert format_disk_size(408.6, 3666.4) == ("0.4", "3.6", "TB")

def test_bad_data(self):
assert format_disk_size(None, None) == ("0.0", "0.0", "GB")
assert format_disk_size("invalid", "invalid") == ("0.0", "0.0", "GB")


class TestFormatXmr:
"""#387: mirrors formatXmr in web/static/logic.mjs so dashboard and Telegram agree."""

Expand Down
19 changes: 19 additions & 0 deletions build/dashboard/tests/service/test_control_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,25 @@ def test_garbage_intent_id_rejected(self, spool):
with pytest.raises(ValueError):
control_service.submit("commit", intent_id="../../etc/passwd")

def test_read_config_metadata_stripped_from_the_intent(self, spool):
# The editor round-trips the fetched doc wholesale, so read_config's own metadata
# injections (_core_keys/_editable_keys) ride back with the POST; the host gate's
# closed-schema check refuses a commit carrying them (#679). submit is the choke point.
rid = control_service.submit(
"preview",
{"p2pool": {"pool": "main"}, "_core_keys": ["a"], "_editable_keys": ["b"]},
actor="admin",
)
req = json.loads((spool / "requests" / f"{rid}.json").read_text())
assert req["config"] == {"p2pool": {"pool": "main"}}

def test_non_dict_config_passes_through_for_the_host_to_reject(self, spool):
# Malformed client payloads keep their host-side rejection ("config must be a JSON
# object") instead of dying in the container on the metadata strip.
rid = control_service.submit("preview", ["not", "a", "config"], actor="admin")
req = json.loads((spool / "requests" / f"{rid}.json").read_text())
assert req["config"] == ["not", "a", "config"]


class TestResult:
def test_result_pending_then_ready(self, spool):
Expand Down
6 changes: 6 additions & 0 deletions build/dashboard/tests/service/test_telegram_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ def test_system_reads_snapshot():
assert "HugePages: Enabled (3072/3072)" in out


def test_system_disk_reads_in_tb_on_large_volumes():
# Threshold mechanics live in test_utils.py; this proves /system wires the unit through.
out = tc.format_system({"disk": {"used_gb": 408.6, "total_gb": 3666.4, "percent_str": "11.1%"}})
assert "Disk: 0.4/3.6 TB (11.1%)" in out


@pytest.mark.parametrize(
"n,expected",
[
Expand Down
Loading