Skip to content

Commit 3581a12

Browse files
committed
Show blocks won, rewards, lost races and the node log on the dashboard
The dashboard now reads the chain over IPC for blocks mined by the payout address and keeps them, with the last block read, in an owner-only dashboard-index.json in the node folder, so a restart only reads new blocks. When the hash it kept for a height no longer matches the node's chain, it drops the entries from there and reads again. Each block's reward is the payout address's balance change across that block. New endpoints behind the dashboard key: /api/rewards (blocks won, today's and total count and earnings, payout balance, and the count of block lost lines from the node log with a plain explanation) and /api/log (the last 200 lines of node.log, newest first, with plain meanings for common lines). The log is only read from inside the node folder; a node.log linking elsewhere is refused.
1 parent a25b7ef commit 3581a12

10 files changed

Lines changed: 1659 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ The page refreshes every 5 seconds without reloading. Each panel says "Node not
3232
- **Mining:** mining or paused; cores used (`<node-dir>/miner-threads`, else every core) of the computer's cores; the node's hash rate when it reports one; the payout address with a copy button; and estimates of your share of the network and the time to your next block.
3333
- **Network:** height; average block time over the last 100 blocks; difficulty; an estimated network hash rate (difficulty ÷ the target block time); and the last 20 blocks, with your own in violet.
3434

35-
The estimates use a target block time of 15 seconds. Each panel reads one endpoint, behind the dashboard key: `/api/node`, `/api/mining` and `/api/network`.
35+
- **Rewards:** the blocks your payout address won (number, time and reward), how many you won and what they earned today (this computer's time) and in total, and the payout address balance. A block's reward is the payout address's balance change across that block, read from the node; when the node no longer keeps the balances of an old block its reward shows as unknown and is left out of the sums. The panel also counts the node log's "block lost" lines: each one is a lost race, where your node found a block but another node's block at the same height was accepted first. That is normal and earns nothing.
36+
- **Node log:** the last 200 lines of `<node-dir>/node.log`, newest first, with a plain meaning beside common lines (mined potential block, block lost, imported new chain segment, looking for peers). The log is only read, and only from inside the node folder: a `node.log` that links elsewhere is refused.
37+
38+
The dashboard finds your blocks by reading the chain over IPC and keeps what it found in `<node-dir>/dashboard-index.json` (owner-only), with the last block it read, so after a restart it only reads new blocks. On a long chain the first reading is spread over several refreshes. When the chain is reorganised it drops the blocks the new chain replaced and reads again from there; when the payout address changes it starts over.
39+
40+
The estimates use a target block time of 15 seconds. Each panel reads one endpoint, behind the dashboard key: `/api/node`, `/api/mining`, `/api/network`, `/api/rewards` and `/api/log`.
3641

3742
### Access
3843

‎internal/server/rewards_test.go‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"os"
7+
"path/filepath"
8+
"strconv"
9+
"strings"
10+
"testing"
11+
12+
"github.com/ghostcoin-core/ghost-node-dashboard/internal/ipc/ipctest"
13+
"github.com/ghostcoin-core/ghost-node-dashboard/internal/status"
14+
)
15+
16+
func TestRewardsAndLogNeedKey(t *testing.T) {
17+
h := panelHandler(t, nil, panelOpts{})
18+
for _, target := range []string{"/api/rewards", "/api/log"} {
19+
rec := do(h, http.MethodGet, target, goodHost, nil)
20+
if rec.Code != http.StatusUnauthorized {
21+
t.Errorf("%s without cookie: status %d, want 401", target, rec.Code)
22+
}
23+
rec = do(h, http.MethodGet, target, "evil.example:8580", withSession)
24+
if rec.Code != http.StatusForbidden {
25+
t.Errorf("%s on another host: status %d, want 403", target, rec.Code)
26+
}
27+
}
28+
}
29+
30+
func TestRewardsPanelEndpoint(t *testing.T) {
31+
// Blocks 0 to 4; the node won block 2, which paid 3 coins.
32+
const coin = "de0b6b3a7640000"
33+
_, node := startFake(t, map[string]ipctest.Handler{
34+
"web3_clientVersion": ipctest.Value("Ghost/test"),
35+
"eth_coinbase": ipctest.Value(ownAddress),
36+
"eth_blockNumber": ipctest.Value("0x4"),
37+
"eth_getBlockByNumber": func(params json.RawMessage) (any, *ipctest.Error) {
38+
var args []any
39+
_ = json.Unmarshal(params, &args)
40+
tag, _ := args[0].(string)
41+
n, _ := strconv.ParseUint(strings.TrimPrefix(tag, "0x"), 16, 64)
42+
if n > 4 {
43+
return nil, nil
44+
}
45+
miner := otherAddress
46+
if n == 2 {
47+
miner = ownAddress
48+
}
49+
return map[string]string{
50+
"number": tag,
51+
"hash": "0xh" + tag,
52+
"parentHash": "0xh0x" + strconv.FormatUint(n-1, 16),
53+
"timestamp": "0x" + strconv.FormatUint(1_700_000_000+n, 16),
54+
"miner": miner,
55+
}, nil
56+
},
57+
"eth_getBalance": func(params json.RawMessage) (any, *ipctest.Error) {
58+
var args []string
59+
_ = json.Unmarshal(params, &args)
60+
if args[1] == "0x1" {
61+
return "0x" + coin, nil
62+
}
63+
// 4 coins from block 2 on: 1 before plus the 3 it paid.
64+
return "0x3782dace9d900000", nil
65+
},
66+
})
67+
dir := t.TempDir()
68+
if err := os.WriteFile(filepath.Join(dir, "node.log"), []byte("INFO 😱 block lost number=3\n"), 0o600); err != nil {
69+
t.Fatal(err)
70+
}
71+
got := getPanel(t, panelHandler(t, node, panelOpts{nodeDir: dir}), "/api/rewards")
72+
expect(t, got, map[string]any{
73+
"running": true,
74+
"payoutAddress": ownAddress,
75+
"wonTotal": 1,
76+
"lostRaces": 1,
77+
"indexing": nil,
78+
})
79+
blocks, _ := got["blocks"].([]any)
80+
if len(blocks) != 1 {
81+
t.Fatalf("blocks %v, want block 2", got["blocks"])
82+
}
83+
b := blocks[0].(map[string]any)
84+
if reward, _ := b["reward"].(map[string]any); b["number"] != 2.0 || reward["coins"] != "3" {
85+
t.Errorf("block won %v, want block 2 paying 3 coins", b)
86+
}
87+
if bal, _ := got["balance"].(map[string]any); bal["coins"] != "4" {
88+
t.Errorf("balance %v, want 4 coins", got["balance"])
89+
}
90+
if _, err := os.Stat(status.IndexPath(dir)); err != nil {
91+
t.Errorf("index not written: %v", err)
92+
}
93+
}
94+
95+
func TestLogPanelEndpoint(t *testing.T) {
96+
dir := t.TempDir()
97+
if err := os.WriteFile(filepath.Join(dir, "node.log"), []byte("one\nINFO Looking for peers peercount=0\n"), 0o600); err != nil {
98+
t.Fatal(err)
99+
}
100+
got := getPanel(t, panelHandler(t, nil, panelOpts{nodeDir: dir}), "/api/log")
101+
if got["available"] != true {
102+
t.Fatalf("log panel %v", got)
103+
}
104+
lines, _ := got["lines"].([]any)
105+
if len(lines) != 2 {
106+
t.Fatalf("lines %v", got["lines"])
107+
}
108+
first := lines[0].(map[string]any)
109+
if first["text"] != "INFO Looking for peers peercount=0" || first["meaning"] == nil {
110+
t.Errorf("newest line %v, want the peers line with its meaning", first)
111+
}
112+
}

‎internal/server/server.go‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ type handler struct {
5252
node status.Caller
5353
nodeEnv status.NodeEnv
5454
mining status.MiningEnv
55+
rewards *status.Rewards
56+
nodeDir string
5557
mux *http.ServeMux
5658
}
5759

@@ -68,8 +70,10 @@ func New(cfg Config) http.Handler {
6870
AutoUpdate: cfg.AutoUpdate,
6971
Now: cfg.Now,
7072
},
71-
mining: status.MiningEnv{NodeDir: cfg.NodeDir, LogicalCores: cfg.LogicalCores},
72-
mux: http.NewServeMux(),
73+
mining: status.MiningEnv{NodeDir: cfg.NodeDir, LogicalCores: cfg.LogicalCores},
74+
rewards: status.NewRewards(status.RewardsEnv{NodeDir: cfg.NodeDir, Now: cfg.Now}),
75+
nodeDir: cfg.NodeDir,
76+
mux: http.NewServeMux(),
7377
}
7478
if h.nodeEnv.AutoUpdate == nil {
7579
h.nodeEnv.AutoUpdate = status.AutoUpdateInstalled
@@ -96,6 +100,8 @@ func New(cfg Config) http.Handler {
96100
h.mux.HandleFunc("GET /api/node", h.nodePanel)
97101
h.mux.HandleFunc("GET /api/mining", h.miningPanel)
98102
h.mux.HandleFunc("GET /api/network", h.networkPanel)
103+
h.mux.HandleFunc("GET /api/rewards", h.rewardsPanel)
104+
h.mux.HandleFunc("GET /api/log", h.logPanel)
99105
return h
100106
}
101107

@@ -204,6 +210,16 @@ func (h *handler) networkPanel(w http.ResponseWriter, r *http.Request) {
204210
writeJSON(w, status.FetchNetwork(ctx, h.node))
205211
}
206212

213+
func (h *handler) rewardsPanel(w http.ResponseWriter, r *http.Request) {
214+
ctx, cancel := context.WithTimeout(r.Context(), StatusTimeout)
215+
defer cancel()
216+
writeJSON(w, h.rewards.Fetch(ctx, h.node))
217+
}
218+
219+
func (h *handler) logPanel(w http.ResponseWriter, r *http.Request) {
220+
writeJSON(w, status.ReadLog(h.nodeDir))
221+
}
222+
207223
func writeJSON(w http.ResponseWriter, v any) {
208224
w.Header().Set("Content-Type", "application/json")
209225
_ = json.NewEncoder(w).Encode(v)

0 commit comments

Comments
 (0)