Skip to content

feat(monero-dashboard): add 90d history and rebuild the interface - #4394

Merged
Tanguille merged 8 commits into
mainfrom
feat/monero-dashboard-ui
Aug 9, 2026
Merged

feat(monero-dashboard): add 90d history and rebuild the interface#4394
Tanguille merged 8 commits into
mainfrom
feat/monero-dashboard-ui

Conversation

@Tanguille

@Tanguille Tanguille commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The logger kept one tier — 10s samples, 24h, then evicted — so no range longer than a day had data behind it.

Retention

Before After
Tiers 10s / 24h 10s / 24h + 5min / 90d
Rollup points at steady state 25920
Chart payload, 24h 8640×5 every 10s ≤720 points
Chart payload, 90d n/a ≤720 points

/stats_history.json?hours=N mean-aggregates whichever tier covers the range to a fixed 720-point budget. Mean not decimation: a miner gated off for part of a bucket pulls that bucket down instead of vanishing between samples. On first start the rollup back-fills from the 24h log, so 7d is populated immediately rather than blank for three months.

Chart ranges: 1h/6h/24h/7d/30d/90d, persisted in localStorage. /stats_log.json and the earnings/luck maths are untouched.

Interface

Finding Fix
Muted text #4b5563 on #0a0a0a ≈ 3:1, under the 4.5:1 floor every ink step clears 4.5:1 on --surface
Red accent read as error state accent is Monero orange; red/green now mean state only
Status dot pulsed red regardless of miner state header pill reports whether the solar gate has the miner running
Gradient stripe on every card, cards nested inside the luck card removed
id-pinned 12-column placement named breakpoints
Loading… strings, no focus rings, no reduced-motion path skeletons, :focus-visible, prefers-reduced-motion

Two display bugs fixed in passing: accumulated XMR printed 12 decimals (one atomic unit), and the all-time counters repeated their own labels.

Verification

  • test_server.py, 5 tests: bucket close, retention eviction, point cap, window trim, empty source. Caught a real error during development.
  • node --check, oxfmt, kustomize build pass; nginx route regex covers the new endpoint.
  • Rendered against a local harness with synthetic solar-shaped data at 1500px, 390px, and the 90d range.

Not verified: no interaction testing (headless Firefox, no extension) — range buttons and the period select are verified by reading. Nothing has run against the live pod.

Known limitation

At 90d the hashrate reads as a picket fence: 90 daily solar cycles land in 720 buckets. Honest but dense. A daily-average overlay would be the follow-up if the trend matters more than the cycles.

Summary by CodeRabbit

  • New Features

    • Introduced a redesigned responsive dashboard with a dark instrument-panel interface.
    • Added selectable, persistent chart time ranges with improved tooltips and empty states.
    • Added expanded metrics for earnings, luck, shares, payments, pool status, and workers.
    • Added historical chart data with downsampling for efficient long-range viewing.
    • Added miner connection status and clearer loading, focus, and accessibility states.
  • Bug Fixes

    • Improved dashboard startup, data restoration, polling, and graceful shutdown behavior.
    • Extended proxy support for historical statistics requests.
    • Improved monitoring alert consistency for node and deployment relationships.

The logger kept one tier — 10s samples for 24h — so no range longer than a
day could ever be drawn. Adds a 5-minute rollup retained for 90d (25920
points, the same order as the fine buffer) and a /stats_history.json endpoint
that mean-aggregates either tier down to a fixed 720-point budget, so a 90d
request costs the same on the wire as a 1h one. On first start the rollup is
back-filled from the 24h log rather than showing three months of blank chart.

Charts now read that endpoint through a 1h/6h/24h/7d/30d/90d control; the
earnings and luck maths still use /stats_log.json unchanged.

Interface rebuilt on OKLCH tokens:
- muted text was #4b5563 on #0a0a0a, about 3:1 — every ink step now clears 4.5:1
- accent moved from red to Monero orange; red and green now mean state only,
  and the header pill reports whether the solar gate has the miner running
- drops the per-card gradient stripe and the card-inside-a-card luck sections
- replaces id-pinned 12-column placement with named breakpoints
- loading skeletons in place of "Loading…", focus-visible rings, and a
  reduced-motion path

Fixes two display bugs found while rendering: accumulated XMR printed 12
decimals (one atomic unit), and the all-time share counters repeated their
own labels.
@deepsource-io

deepsource-io Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

DeepSource Code Review

We reviewed changes in f70fc97...d598962 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
JavaScript Aug 9, 2026 10:29a.m. Review ↗
Shell Aug 9, 2026 10:29a.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

@tanguille-cluster

tanguille-cluster Bot commented Aug 7, 2026

Copy link
Copy Markdown
@@ spec.values.persistence.dashboard-html.advancedMounts.dashboard.web @@
# helm.toolkit.fluxcd.io/v2/HelmRelease/web3/dashboard
! - three list entries removed:
- - path: /usr/share/nginx/html/index.html
-   subPath: dashboard.html
- - path: /usr/share/nginx/html/dashboard.css
-   subPath: dashboard.css
- - path: /usr/share/nginx/html/dashboard.js
-   subPath: dashboard.js
! + one list entry added:
+ - path: /usr/share/nginx/html

@@ data.dashboard.css @@
# v1/ConfigMap/web3/dashboard
! ± value change in multiline text (100 inserts, 94 deletions)
  /* =========================================================
-    P2POOL DASHBOARD - INSTRUMENT PANEL THEME
-    Black background, red accents, refined typography
+    P2POOL DASHBOARD
+    Dark instrument panel. One accent (Monero orange) for the
+    live series and current selection; red and green are
+    reserved for state, never decoration.
     ========================================================= */
  
- /* =========================================================
-    RESET & BASE STYLING
-    ========================================================= */
- * {
-   margin: 0;
-   padding: 0;
+ :root {
+   /* Neutrals carry a trace of the accent's hue rather than a default warm tint */
+   --bg: oklch(0.16 0.006 60);
+   --surface: oklch(0.2 0.007 60);
+   --surface-raised: oklch(0.245 0.008 60);
+   --line: oklch(0.3 0.01 60);
+   --line-strong: oklch(0.38 0.012 60);
+ 
+   /* Every ink step clears 4.5:1 on --surface */
+   --ink: oklch(0.97 0.004 60);
+   --ink-2: oklch(0.8 0.006 60);
+   --ink-3: oklch(0.66 0.008 60);
+ 
+   --accent: oklch(0.72 0.175 55);
+   --accent-dim: color-mix(in oklch, var(--accent) 14%, transparent);
+   --accent-line: color-mix(in oklch, var(--accent) 35%, transparent);
+   /* Chart.js reads these through getComputedStyle, so they live here too */
+   --chart-fill: color-mix(in oklch, var(--accent) 12%, transparent);
+   --chart-grid: color-mix(in oklch, var(--line) 60%, transparent);
+ 
+   --positive: oklch(0.76 0.15 155);
+   --positive-line: color-mix(in oklch, var(--positive) 40%, transparent);
+   --negative: oklch(0.68 0.19 25);
+ 
+   --font-ui:
+     system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica", "Arial",
+     sans-serif;
+   /* Figures are the content here: a mono keeps columns and deltas aligned */
+   --font-num:
+     ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", "Menlo",
+     "Consolas", monospace;
+ 
+   --step--2: 0.6875rem;
+   --step--1: 0.75rem;
+   --step-0: 0.8125rem;
+   --step-1: 0.875rem;
+   --step-2: 1rem;
+   --step-3: 1.25rem;
+   --step-4: 1.625rem;
+   --step-5: 2rem;
+ 
+   --gap: 1rem;
+   --pad: 1.125rem;
+   --radius: 10px;
+   --radius-sm: 6px;
+ 
+   --chart-h: 240px;
+   --chart-h-tall: 300px;
+ 
+   /* ease-out-quart: fast departure, no overshoot */
+   --ease: cubic-bezier(0.25, 1, 0.5, 1);
+   --speed: 160ms;
+ 
+   --z-sticky: 10;
+ }
+ 
+ *,
+ *::before,
+ *::after {
    box-sizing: border-box;
  }
  
  body {
-   font-family:
-     -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-   background: #000000;
-   color: #e5e7eb;
-   overflow-x: hidden;
-   font-weight: 400;
+   margin: 0;
+   background: var(--bg);
+   color: var(--ink-2);
+   font-family: var(--font-ui);
+   font-size: var(--step-0);
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
  
+ h1,
+ h2,
+ h3,
+ h4,
+ p,
+ dl,
+ dd,
+ figure {
+   margin: 0;
+ }
+ 
+ :where(a, button, select, [tabindex]):focus-visible {
+   outline: 2px solid var(--accent);
+   outline-offset: 2px;
+   border-radius: var(--radius-sm);
+ }
+ 
+ .visually-hidden {
+   position: absolute;
+   width: 1px;
+   height: 1px;
+   overflow: hidden;
+   clip-path: inset(50%);
+   white-space: nowrap;
+ }
+ 
  /* =========================================================
-    HEADER
+    MASTHEAD
     ========================================================= */
- header {
+ .masthead {
+   position: sticky;
+   top: 0;
+   z-index: var(--z-sticky);
    display: flex;
-   flex-direction: column;
+   flex-wrap: wrap;
    align-items: center;
-   padding: 1.5rem 1rem 0;
-   border-bottom: 1px solid #1f2937;
-   margin-bottom: 1.5rem;
+   justify-content: space-between;
+   gap: var(--gap);
+   padding: 0.875rem clamp(0.75rem, 3vw, 2rem);
+   background: var(--bg);
+   border-bottom: 1px solid var(--line);
  }
  
- header h1 {
-   margin: 0;
-   color: #f9fafb;
-   font-size: 1.5rem;
-   font-weight: 300;
-   letter-spacing: 2px;
-   text-transform: uppercase;
+ .masthead h1 {
+   font-size: var(--step-3);
+   font-weight: 600;
+   letter-spacing: -0.01em;
+   color: var(--ink);
  }
  
- .header-status {
+ .masthead__sub {
+   font-size: var(--step--1);
+   color: var(--ink-3);
+ }
+ 
+ .masthead__state {
    display: flex;
    align-items: center;
-   gap: 0.5rem;
-   font-size: 0.8rem;
-   color: #6b7280;
-   margin-top: 0.75rem;
-   margin-bottom: 0.75rem;
+   gap: 0.75rem;
  }
  
- .status-dot,
- .status-indicator {
-   width: 8px;
-   height: 8px;
+ .masthead__stamp {
+   font-family: var(--font-num);
+   font-size: var(--step--2);
+   color: var(--ink-3);
+ }
+ 
+ /* State pill: the one place colour means status rather than brand */
+ .pill {
+   display: inline-flex;
+   align-items: center;
+   gap: 0.4rem;
+   padding: 0.25rem 0.6rem 0.25rem 0.5rem;
+   border: 1px solid var(--line);
+   border-radius: 999px;
+   font-size: var(--step--1);
+   color: var(--ink-2);
+   white-space: nowrap;
+ }
+ 
+ .pill__dot,
+ .dot {
+   width: 7px;
+   height: 7px;
    border-radius: 50%;
+   background: var(--ink-3);
+   flex: none;
  }
  
- .status-indicator {
+ .dot {
    display: inline-block;
-   margin-right: 6px;
-   vertical-align: middle;
+   margin-right: 0.4rem;
+   vertical-align: 0.05em;
  }
  
- .status-active,
- .status-dot {
-   background: #ef4444;
-   box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
-   animation: pulse 2s ease-in-out infinite;
+ .is-live .pill__dot,
+ .dot.is-live {
+   background: var(--positive);
+   animation: breathe 2.4s var(--ease) infinite;
  }
  
- .status-inactive {
-   background: #6b7280;
-   box-shadow: none;
+ .is-down .pill__dot,
+ .dot.is-down {
+   background: var(--negative);
+ }
+ 
+ .is-live {
+   border-color: var(--positive-line);
  }
  
- @keyframes pulse {
-   0%,
-   100% {
-     opacity: 1;
-   }
+ @keyframes breathe {
    50% {
-     opacity: 0.5;
+     opacity: 0.35;
    }
  }
  
  /* =========================================================
-    SMALL TEXT / LEGEND STYLING
+    PAGE SHELL
     ========================================================= */
- #earnLegend,
- #lastRefreshed {
-   color: #6b7280;
-   font-size: 0.75rem;
-   margin-top: 0.25rem;
-   letter-spacing: 0.5px;
+ main {
+   display: flex;
+   flex-direction: column;
+   gap: 1.75rem;
+   max-width: 100rem;
+   margin: 0 auto;
+   padding: 1.5rem clamp(0.75rem, 3vw, 2rem) 3rem;
  }
  
- /* =========================================================
-    TOOLTIP ICON STYLING
-    ========================================================= */
- .tooltip-icon {
-   display: inline-block;
-   margin-left: 4px;
-   color: #ef4444;
-   font-weight: normal;
-   font-size: 0.7rem;
-   vertical-align: super;
-   cursor: help;
-   opacity: 0.7;
-   transition: opacity 0.2s;
+ .section-head {
+   display: flex;
+   flex-wrap: wrap;
+   align-items: center;
+   justify-content: space-between;
+   gap: 0.75rem;
+   margin-bottom: 0.75rem;
  }
  
- .tooltip-icon:hover {
-   opacity: 1;
+ .section-head h2 {
+   font-size: var(--step-2);
+   font-weight: 600;
+   color: var(--ink);
  }
  
  /* =========================================================
-    MAIN GRID LAYOUT
+    OVERVIEW STRIP
     ========================================================= */
- main.grid {
+ .overview {
    display: grid;
-   grid-template-columns: repeat(12, 1fr);
-   gap: 1.5rem;
-   padding: 0 1.5rem 2rem;
-   width: 100%;
-   max-width: 150rem;
-   margin: 0 auto;
-   box-sizing: border-box;
+   grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+   gap: 1px;
+   background: var(--line);
+   border: 1px solid var(--line);
+   border-radius: var(--radius);
+   overflow: hidden;
  }
  
- /* =========================================================
-    CARD STYLING
-    ========================================================= */
- .card {
-   background: #0a0a0a;
-   padding: 1.25rem;
-   border-radius: 8px;
-   border: 1px solid #1f2937;
+ .metric {
    display: flex;
    flex-direction: column;
-   justify-content: space-between;
-   box-sizing: border-box;
-   max-width: 100%;
-   position: relative;
-   overflow: hidden;
-   transition: border-color 0.2s;
+   gap: 0.25rem;
+   padding: var(--pad);
+   background: var(--surface);
  }
  
- .card:hover {
-   border-color: #374151;
+ .metric__label {
+   font-size: var(--step--1);
+   color: var(--ink-3);
  }
  
- /* Red accent line at top of each card */
- .card::before {
-   content: "";
-   position: absolute;
-   top: 0;
-   left: 0;
-   right: 0;
-   height: 2px;
-   background: linear-gradient(90deg, transparent, #ef4444, transparent);
-   opacity: 0.8;
+ .metric__value {
+   font-family: var(--font-num);
+   font-size: var(--step-5);
+   font-weight: 500;
+   line-height: 1.1;
+   /* Mono runs wide at display sizes; pull it back so "2.41 kH/s" reads as one figure */
+   letter-spacing: -0.03em;
+   color: var(--ink);
+   font-variant-numeric: tabular-nums;
  }
  
- .card h2 {
-   margin-top: 0;
-   margin-bottom: 0.75rem;
-   font-size: 0.7rem;
-   text-transform: uppercase;
-   letter-spacing: 1.5px;
-   color: #6b7280;
-   font-weight: 500;
+ .metric__value--sm {
+   font-size: var(--step-4);
  }
  
- /* Large numeric values */
- .big {
-   font-size: 1.75rem;
-   font-weight: 200;
-   margin: 0.5rem 0;
-   color: #f9fafb;
+ .metric__note {
+   font-size: var(--step--1);
+   color: var(--ink-3);
    font-variant-numeric: tabular-nums;
-   line-height: 1.2;
  }
  
- .big-med {
-   font-size: 1.2rem;
-   font-weight: 300;
-   margin: 0.4rem 0;
-   color: #e5e7eb;
+ /* =========================================================
+    RANGE CONTROL
+    ========================================================= */
+ .range {
+   display: inline-flex;
+   padding: 2px;
+   gap: 2px;
+   background: var(--surface);
+   border: 1px solid var(--line);
+   border-radius: var(--radius-sm);
+ }
+ 
+ .range__opt {
+   appearance: none;
+   border: 0;
+   background: transparent;
+   color: var(--ink-3);
+   font: inherit;
+   font-size: var(--step--1);
    font-variant-numeric: tabular-nums;
+   padding: 0.3rem 0.7rem;
+   border-radius: 4px;
+   cursor: pointer;
+   transition:
+     color var(--speed) var(--ease),
+     background-color var(--speed) var(--ease);
  }
  
- /* Smaller info text */
- .sub,
- .small-stat,
- .stat {
-   font-size: 0.8rem;
-   margin: 0.25rem 0;
-   color: #6b7280;
+ .range__opt:hover {
+   color: var(--ink);
+   background: var(--surface-raised);
  }
  
- .sub-sub {
-   font-size: 0.7rem;
-   margin: 0.125rem 0;
-   color: #4b5563;
+ .range__opt[aria-pressed="true"] {
+   background: var(--accent-dim);
+   color: var(--accent);
+   font-weight: 600;
  }
  
  /* =========================================================
-    GRID POSITIONS
+    PANELS
     ========================================================= */
+ .trends__grid,
+ .panels {
+   display: grid;
+   gap: var(--gap);
+   grid-template-columns: 1fr;
+ }
  
- /* Top row - Charts take full width, split 50/50 */
- #hashrateCard {
-   grid-column: 1 / 7;
-   grid-row: 1;
+ .panel {
+   display: flex;
+   flex-direction: column;
+   gap: 0.5rem;
+   padding: var(--pad);
+   background: var(--surface);
+   border: 1px solid var(--line);
+   border-radius: var(--radius);
+   min-width: 0;
+   transition: border-color var(--speed) var(--ease);
  }
- #priceCard {
-   grid-column: 7 / 13;
-   grid-row: 1;
+ 
+ .panel:hover {
+   border-color: var(--line-strong);
  }
  
- /* Second row */
- #paymentsCard {
-   grid-column: 1 / 4;
-   grid-row: 2;
+ .panel__head {
+   display: flex;
+   align-items: baseline;
+   justify-content: space-between;
+   gap: 0.75rem;
  }
- #combinedLuckCard {
-   grid-column: 4 / 9;
-   grid-row: 2;
+ 
+ .panel__title {
+   font-size: var(--step-1);
+   font-weight: 600;
+   color: var(--ink);
  }
- #earningsCard {
-   grid-column: 9 / 11;
-   grid-row: 2;
+ 
+ /* =========================================================
+    FIGURES & FACTS
+    ========================================================= */
+ .figure {
+   font-family: var(--font-num);
+   font-size: var(--step-4);
+   font-weight: 500;
+   line-height: 1.15;
+   color: var(--ink);
+   font-variant-numeric: tabular-nums;
  }
- #sharesCard {
-   grid-column: 11 / 13;
-   grid-row: 2;
+ 
+ .figure--sm {
+   font-size: var(--step-3);
  }
  
- /* Third row - Old dashboard stats */
- #poolStatusCard {
-   grid-column: 1 / 8;
-   grid-row: 3;
+ .figure__note {
+   font-size: var(--step-0);
+   color: var(--ink-2);
+   font-variant-numeric: tabular-nums;
  }
- #workersCard {
-   grid-column: 8 / 13;
-   grid-row: 3;
+ 
+ .footnote {
+   font-size: var(--step--1);
+   color: var(--ink-3);
+   text-wrap: pretty;
  }
  
- /* =========================================================
-    COMBINED LUCK CARD
-    ========================================================= */
- #combinedLuckCard .luck-grid {
+ .pair + .pair {
+   margin-top: 0.5rem;
+ }
+ 
+ .facts {
+   display: flex;
+   flex-direction: column;
+   gap: 0.35rem;
+   margin-top: auto;
+   padding-top: 0.75rem;
+ }
+ 
+ .facts--grid {
    display: grid;
-   grid-template-columns: 1fr 1fr;
-   gap: 1rem;
+   grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+   gap: 0.75rem 1.25rem;
  }
  
- #combinedLuckCard .luck-section {
-   background: #111827;
-   padding: 0.75rem;
-   border-radius: 6px;
+ .fact {
    display: flex;
+   align-items: baseline;
+   justify-content: space-between;
+   gap: 0.75rem;
+   font-size: var(--step--1);
+ }
+ 
+ .facts--grid .fact {
    flex-direction: column;
-   gap: 0.3rem;
-   border: 1px solid #1f2937;
+   align-items: flex-start;
+   gap: 0.15rem;
  }
  
- #combinedLuckCard .luck-section h3 {
-   margin: 0;
-   font-size: 0.7rem;
-   text-transform: uppercase;
-   letter-spacing: 1px;
-   color: #6b7280;
-   font-weight: 500;
+ .fact dt {
+   color: var(--ink-3);
  }
  
- /* =========================================================
-    TRUE LUCK WINDOW LABEL
-    ========================================================= */
- #trueLuckWindow {
-   font-size: 0.75rem;
-   color: #9ca3af;
-   margin-top: 0.25rem;
-   margin-bottom: 0.25rem;
+ .fact dd {
+   font-family: var(--font-num);
+   color: var(--ink-2);
    font-variant-numeric: tabular-nums;
+   text-align: right;
  }
  
- /* =========================================================
-    CANVAS / CHART STYLING
-    ========================================================= */
- .chart-container {
-   position: relative;
-   width: 100%;
-   height: 200px;
-   margin-top: 0.75rem;
+ .facts--grid .fact dd {
+   font-size: var(--step-0);
+   color: var(--ink);
+   text-align: left;
  }
  
- canvas {
-   border-radius: 4px;
+ /* Help affordance — a badge, not a superscript glyph */
+ .info {
+   display: inline-grid;
+   place-items: center;
+   width: 14px;
+   height: 14px;
+   margin-left: 0.3rem;
+   border: 1px solid var(--line-strong);
+   border-radius: 50%;
+   font-family: var(--font-ui);
+   font-size: 9px;
+   font-style: italic;
+   line-height: 1;
+   color: var(--ink-3);
+   cursor: help;
+   vertical-align: 0.15em;
+   transition:
+     color var(--speed) var(--ease),
+     border-color var(--speed) var(--ease);
+ }
+ 
+ .info:hover,
+ .info:focus-visible {
+   color: var(--accent);
+   border-color: var(--accent-line);
  }
  
  /* =========================================================
-    DROPDOWN STYLING
+    CHARTS
     ========================================================= */
- select#earnPeriod {
-   padding: 0.4rem 0.6rem;
-   background: #111827;
-   color: #e5e7eb;
-   border: 1px solid #1f2937;
-   border-radius: 4px;
-   font-size: 0.8rem;
-   margin-top: 0.5rem;
-   margin-bottom: 0.5rem;
-   appearance: none;
-   cursor: pointer;
-   font-variant-numeric: tabular-nums;
-   transition: border-color 0.2s;
+ .chart {
+   position: relative;
+   width: 100%;
+   height: var(--chart-h);
+   margin-top: 0.25rem;
  }
  
- #earnPeriod:hover {
-   border-color: #374151;
+ .chart--tall {
+   height: var(--chart-h-tall);
  }
  
- #earnPeriod:focus {
-   outline: none;
-   border-color: #ef4444;
-   box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15);
+ /* An explicit `display` beats the UA rule for [hidden], so restore it */
+ [hidden] {
+   display: none !important;
  }
  
- #earnPeriod::-ms-expand {
-   display: none;
+ .chart__empty {
+   position: absolute;
+   inset: 0;
+   display: grid;
+   place-content: center;
+   text-align: center;
+   padding: 1.5rem;
+   max-width: 34ch;
+   margin: auto;
+   font-size: var(--step--1);
+   color: var(--ink-3);
+   text-wrap: pretty;
  }
  
  /* =========================================================
-    EARNINGS CARD
+    LUCK
     ========================================================= */
- #earnXMR {
-   font-size: 2rem;
-   font-weight: 200;
-   margin-bottom: 0.1rem;
-   font-variant-numeric: tabular-nums;
+ .luck {
+   display: grid;
+   gap: 1rem;
+   grid-template-columns: 1fr;
  }
  
- #earnEUR {
-   font-size: 0.9rem;
-   color: #6b7280;
-   display: block;
-   margin-top: 0.05rem;
-   margin-bottom: 0.7rem;
-   font-variant-numeric: tabular-nums;
+ /* A rule, not a nested card: the panel is already the container */
+ .luck__part + .luck__part {
+   padding-top: 1rem;
+   border-top: 1px solid var(--line);
  }
  
- #eurThisWindow {
-   font-size: 0.75rem;
-   color: #6b7280;
-   margin-top: 0.05rem;
-   margin-bottom: 0.5rem;
-   font-variant-numeric: tabular-nums;
+ .luck__label {
+   font-size: var(--step--1);
+   font-weight: 500;
+   color: var(--ink-3);
+   margin-bottom: 0.25rem;
  }
  
- #totalEurEarned {
-   font-size: 0.9rem;
-   color: #6b7280;
-   margin: 0;
-   font-variant-numeric: tabular-nums;
+ /* =========================================================
+    SELECT
+    ========================================================= */
+ .select {
+   position: relative;
+   display: inline-flex;
  }
  
- #paymentsCard .total-block {
-   display: flex;
-   flex-direction: column;
-   gap: 0.1rem;
+ .select::after {
+   content: "";
+   position: absolute;
+   right: 0.6rem;
+   top: 50%;
+   width: 6px;
+   height: 6px;
+   border-right: 1.5px solid var(--ink-3);
+   border-bottom: 1.5px solid var(--ink-3);
+   transform: translateY(-70%) rotate(45deg);
+   pointer-events: none;
  }
  
- .bottom-stats {
-   margin-top: auto;
-   display: flex;
-   flex-direction: column;
-   gap: 0.25rem;
-   font-size: 0.8rem;
-   color: #6b7280;
+ .select select {
+   appearance: none;
+   padding: 0.3rem 1.75rem 0.3rem 0.6rem;
+   background: var(--surface-raised);
+   color: var(--ink-2);
+   border: 1px solid var(--line);
+   border-radius: var(--radius-sm);
+   font: inherit;
+   font-size: var(--step--1);
+   cursor: pointer;
+   transition: border-color var(--speed) var(--ease);
+ }
+ 
+ .select select:hover {
+   border-color: var(--line-strong);
  }
  
  /* =========================================================
-    PAYMENTS TABLE
+    TABLE
     ========================================================= */
+ .table-scroll {
+   overflow-x: auto;
+ }
+ 
  #paymentsTable {
    width: 100%;
-   margin-top: 0.75rem;
    border-collapse: collapse;
- }
- 
- #paymentsTable thead tr {
-   border-bottom: 1px solid #1f2937;
+   font-variant-numeric: tabular-nums;
  }
  
  #paymentsTable th {
-   font-size: 0.65rem;
-   text-transform: uppercase;
-   letter-spacing: 1px;
-   color: #4b5563;
-   text-align: left;
-   padding: 0.5rem 0;
+   position: sticky;
+   top: 0;
+   padding: 0.4rem 0.5rem;
+   background: var(--surface);
+   border-bottom: 1px solid var(--line);
+   font-size: var(--step--2);
    font-weight: 500;
+   color: var(--ink-3);
+   text-align: left;
+   white-space: nowrap;
  }
  
- #paymentsTable th:nth-child(2),
- #paymentsTable th:nth-child(3) {
-   text-align: right;
+ #paymentsTable td {
+   padding: 0.45rem 0.5rem;
+   border-bottom: 1px solid var(--line);
+   font-family: var(--font-num);
+   font-size: var(--step--1);
+   color: var(--ink-2);
+   white-space: nowrap;
  }
  
- #paymentsTable td {
-   padding: 0.5rem 0;
-   font-size: 0.8rem;
-   color: #9ca3af;
-   border-bottom: 1px solid #111827;
-   font-variant-numeric: tabular-nums;
+ #paymentsTable tbody tr:last-child td {
+   border-bottom: 0;
  }
  
- #paymentsTable td:nth-child(2),
- #paymentsTable td:nth-child(3) {
-   text-align: right;
+ #paymentsTable tbody tr:hover td {
+   background: var(--surface-raised);
  }
  
- /* Utility class for right-aligned table cells */
  .cell-right {
    text-align: right;
  }
  
- #paymentsTable tbody tr:last-child td {
-   border-bottom: none;
- }
- 
  /* =========================================================
-    POOL STATUS & STATS
+    WORKERS
     ========================================================= */
- .stat-row {
-   display: grid;
-   grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
-   gap: 1rem;
-   margin-top: 0.75rem;
- }
- 
- .stat-item {
+ .workers {
    display: flex;
    flex-direction: column;
-   gap: 0.25rem;
- }
- 
- .stat-label {
-   font-size: 0.65rem;
-   color: #4b5563;
-   text-transform: uppercase;
-   letter-spacing: 1px;
-   font-weight: 500;
- }
- 
- .stat-item .stat-value {
-   font-size: 0.9rem;
-   font-weight: 400;
-   color: #e5e7eb;
-   font-variant-numeric: tabular-nums;
- }
- 
- /* =========================================================
-     WORKERS LIST
-     ========================================================= */
- .workers-list {
-   margin-top: 0.75rem;
-   max-height: 300px;
+   gap: 0.35rem;
+   max-height: 320px;
    overflow-y: auto;
    scrollbar-width: thin;
-   scrollbar-color: #1f2937 transparent;
- }
- 
- .workers-list::-webkit-scrollbar {
-   width: 4px;
- }
- 
- .workers-list::-webkit-scrollbar-track {
-   background: transparent;
- }
- 
- .workers-list::-webkit-scrollbar-thumb {
-   background: #1f2937;
-   border-radius: 2px;
+   scrollbar-color: var(--line-strong) transparent;
  }
  
  .worker-item {
-   margin: 0.5rem 0;
-   padding: 0.75rem;
-   background: #111827;
-   border-radius: 4px;
-   font-size: 0.85rem;
    display: flex;
-   justify-content: space-between;
    align-items: center;
-   border: 1px solid #1f2937;
-   transition: border-color 0.2s;
- }
- 
- .worker-item:hover {
-   border-color: #374151;
+   justify-content: space-between;
+   gap: 0.75rem;
+   padding: 0.55rem 0.7rem;
+   background: var(--surface-raised);
+   border-radius: var(--radius-sm);
+   font-size: var(--step--1);
  }
  
  .worker-name {
-   color: #e5e7eb;
-   font-weight: 500;
+   color: var(--ink-2);
+   overflow: hidden;
+   text-overflow: ellipsis;
+   white-space: nowrap;
  }
  
  .worker-hash {
-   color: #9ca3af;
+   font-family: var(--font-num);
+   color: var(--ink);
    font-variant-numeric: tabular-nums;
+   white-space: nowrap;
  }
  
  .no-workers {
+   padding: 1.25rem 0;
    text-align: center;
-   opacity: 0.5;
-   font-style: italic;
-   padding: 1.5rem;
-   color: #6b7280;
-   font-size: 0.85rem;
+   font-size: var(--step--1);
+   color: var(--ink-3);
  }
  
  /* =========================================================
-    MOBILE RESPONSIVENESS
+    LOADING SKELETON
+    Replaced by real text on first paint; never gates content
+    visibility, so a headless render still shows the markup.
     ========================================================= */
- @media (max-width: 1024px) {
-   main.grid {
-     grid-template-columns: repeat(6, 1fr);
-     gap: 1rem;
-     padding: 0 1rem 1.5rem;
-   }
- 
-   #hashrateCard,
-   #priceCard {
-     grid-column: 1 / 7;
-     grid-row: auto;
-   }
- 
-   #paymentsCard,
-   #combinedLuckCard,
-   #earningsCard,
-   #sharesCard {
-     grid-column: 1 / 7;
-     grid-row: auto;
-   }
+ .skeleton {
+   display: inline-block;
+   width: 5ch;
+   height: 0.7em;
+   vertical-align: middle;
+   border-radius: 3px;
+   background: linear-gradient(
+       90deg,
+       var(--surface-raised),
+       var(--line),
+       var(--surface-raised)
+     )
+     0 0 / 200% 100%;
+   animation: sweep 1.4s linear infinite;
+ }
  
-   #poolStatusCard,
-   #workersCard {
-     grid-column: 1 / 7;
-     grid-row: auto;
+ @keyframes sweep {
+   to {
+     background-position: -200% 0;
    }
  }
  
- @media (max-width: 600px) {
-   main.grid {
-     grid-template-columns: 1fr;
-     gap: 0.75rem;
-     padding: 0 0.75rem 1rem;
+ /* =========================================================
+    BREAKPOINTS
+    Structural only: column counts and chart height, never
+    fluid type.
+    ========================================================= */
+ @media (min-width: 40rem) {
+   .luck {
+     grid-template-columns: 1fr 1fr;
    }
  
-   #hashrateCard,
-   #priceCard,
-   #paymentsCard,
-   #earningsCard,
-   #sharesCard,
-   #combinedLuckCard,
-   #poolStatusCard,
-   #workersCard {
-     grid-column: auto;
-     grid-row: auto;
+   .luck__part + .luck__part {
+     padding-top: 0;
+     padding-left: 1rem;
+     border-top: 0;
+     border-left: 1px solid var(--line);
    }
  
-   .card {
-     padding: 1rem;
+   .panels {
+     grid-template-columns: repeat(2, minmax(0, 1fr));
    }
+ }
  
-   header h1 {
-     font-size: 1.25rem;
+ @media (min-width: 64rem) {
+   :root {
+     --chart-h-tall: 340px;
    }
  
-   .card h2 {
-     font-size: 0.65rem;
+   .trends__grid {
+     grid-template-columns: 1.7fr 1fr;
    }
  
-   .big {
-     font-size: 1.5rem;
+   .panels {
+     grid-template-columns: repeat(3, minmax(0, 1fr));
    }
  
-   .big-med {
-     font-size: 1rem;
+   .panel--wide {
+     grid-column: span 2;
    }
+ }
  
-   .sub,
-   .small-stat,
-   .stat {
-     font-size: 0.75rem;
+ @media (prefers-reduced-motion: reduce) {
+   *,
+   *::before,
+   *::after {
+     animation-duration: 0.01ms !important;
+     animation-iteration-count: 1 !important;
+     transition-duration: 0.01ms !important;
    }
  
-   #earnXMR {
-     font-size: 1.5rem;
-   }
- 
-   #earnEUR {
-     font-size: 0.8rem;
-   }
- 
-   #combinedLuckCard .luck-grid {
-     grid-template-columns: 1fr;
-   }
- 
-   #combinedLuckCard .luck-section {
-     padding: 0.5rem;
-   }
- 
-   #earnPeriod {
-     width: 100%;
-   }
- 
-   .stat-row {
-     grid-template-columns: 1fr 1fr;
+   .skeleton {
+     background: var(--surface-raised);
    }
  }

@@ data.dashboard.html @@
# v1/ConfigMap/web3/dashboard
! ± value change in multiline text (20 inserts, 20 deletions)
  <!doctype html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
  
  [two lines unchanged)]
  
      <!-- Favicon for browser tab -->
      <link
        rel="icon"
        type="image/png"
-       href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAeAAAAHgCAYAAAB91L6VAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAGhJJREFUeNrs3U+MXdV9wPH7xraMbRg/goVkKTGPxkGqZJWBLGg2YdxNJYLDIFbdlPEiUSO1GoYlkWp7w6KL2lYixaILP29gVWGXNlIXiOdV1UXDIHnTVjEvhIrKIvHY2NCI4Nd7PHfC2NieefP+3XPO5yNZQ6Iowvfde7/zO/fPKwoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYuYZNAPVw9a/+qFX+aN32XzfLPzP9/n9t+84Lxef/9o+dtf/dlseeKnYuvr7UaDSWbW0QYEg9qjNVRNfG9embB9/9DzZ71y7PTOzg37l7uffplaWb/zy9Z7l39eP3boZ634GlLz64sLz92YVu+ONTBAGGOgZ2dUJdDezja6bWZhIniV3Nbu/6crex++Fu78qlX4VAF/ftWt718hsdewAIMIwjtrNVWB+pfiYT2UHiXExNdXuf/Pb81N5vdcs/Szt/+NMlewsIMAwU28b0nqd7Vz9uFZu4Dpu5MB2HEIdl7aXpUxdFGQQYvhLbENgQ3Mern2I77BPNjumi99nVEOXz1TXmThllN4MhwJBpcJ+ufrZslYlYqibl8+GnICPAkF5wm1VonxPcege58eDeTu/yR+fKGHdsDgQY4ozuzNRDX5/r/e76c5N81IdNnphWlqzPlv94bvuzCx2PQyHAUO/ozhUry8pzptz0puPyF6pzU4/8yVl3WSPAUJ/oPldFt2mLZCFMw2E6PuPuagQYRJcJxnjrk8+cMRkjwDCa6IbruC82du6e7316RXS5kxDgM9ufXTjrmjECDINFN4R2vrGrudC7vtyyRejD2am93zp3/5F/bdsUCDBs0PVXvz93Y/l/X+xd/XjO1mBA4dniEOGT06cumooRYLjbtFv+WSjcwcxodIqVG7dMxQgwVNd2F6r4wuhPeitfw3hy+7MLbdeKEWCyc+3Yn8/f+Oi/XyxW3kwFkxKm4ZMeZ0KASVqv12t+8qNvhuu6RwrLzNRLZ8u+Ayd3vfJPZ20KBJhkVNd3XypWlpo9QkSddcs/x1wnRoARXhBiBBiEFyEGAUZ4QYgRYIRXeBFiEGDG5trf/tlLNy51jwgvWZ04dzW7vevLh8sQd2wNBJjxhnflOV6PE5G7EOBFzxEjwIzc9b//i9kv/uvfQ3hnbQ34g/BWrWPerIUAM3TVdd7jhVdGwt2EL34Ib9U6alMgwAzFtR9/9+iN33zoBivYmDAFuz6MADPQ1Dvb2NU87bt4YVPCay0XfQ0iAkw/4bXcDMNhWRoBZmOqu5tDfC03w/AsbXnsqcVdL7/RsSkQYG6felvlj9OFu5thlE4UKy/yWLYpEGBCfMNbrLxMA8ajW7hJCwE29Zp6wTSMAGPqBdMwAkyy4W02Hvjam71Pfmvqhfo45k5pASbt+M4VK0vOpl6on6VqGvZe6UxM2QTp6/V6zTK+4dGiN8UXamum/PNOdXkIEzAJTL0z1dQ7Y2tANM5W07AbtASYSOM7X6y80crUC/Hpln+etySdLkvQCaqWnE8XrvdCzFrln3ctSZuAicSnr/31zO9/8XNLzpCWdrHyxQ6WpAWYOrr+6vfnvvjggqkX0uQuaQGmjqrv7D1iS0DSlqsIn7UpBJgJu/lijek9p3tXP56zNSAbXtwhwEzS7/75ZKv8E57tdb0X8tMuXBcWYCYy+d58aL9wvRdyFq4HHxThOHkMKc74zjd2TL8rvpC98Iv4+9Uv5AgwoxRutip/nO59dtXGAIrqF/HwCstZmyIulqDjmnzDI0bztgRwF+EO6bbNIMAML7zudAY2yh3SAsyw4lus3GzlGg+wUe0ywodtBgFGfAERRoCjie/M1J5vvHPj41+70xnYLI8pCTD9xrfwjC8gwgKM+AIijACLL4AICzDiC4gwAiy+ACIswIgvIMIIsPgCiLAAi6/4AiIswIgvIMKMla8jHH98m42du8UXqIswELxpMwhw8vENk2/v0yviC9TJbPV1p4yRJegxx7fwxQpAffkCBxNwgr/pTO85Lb5Azc2Xw8JRm8EEnNL0G+I7b0sAkThcTsJtm8EEHLVrP/7uUfEFInO6HBxmbQYTcMyTbwivGxuAGIXHksLjSUs2hQDHFt+Zxo7pd3ufXbUxgFh1yz9PeEZ4NCxBjya+rSI8biS+QNxunstsBgGOJb7hcaPwULtnfYEUzHhGWICj4HEjIEHh8aSXbIYh98ImGOr0e7T8ccSWABIVbsrq2AwCXLf4zhXepwqkLdyMFW7K6toUAlyX+LbKH+8WrvsC6fPtSUPiGvDg8XXTFZCTcI/LcZtBgOvgeOGmKyAv89WLhhiAJejBpt+wA7o9H8iRN2UJ8MTiG6be8IC6pWcgV64HD8AS9Obi26wmX/EFcuZ6sACP3ZHCdV+AwPXgTbIE3f/063lfgFt5PtgEPPL4ri49A/ClpsFEgEfN874AdzZTvY6XDbIEvfHpN7yI3M0GAPf2hEeTBHiY8W0VXjUJsBEeTdogS9Ab45EjgI0JT4j4VjgT8FCmX0vPAP3z1YUm4IHi2/KbHMCmGFwEeCCWngE2x13R67AEfffpd77wzC/AoNwVbQLuK75h6rV8AjA451IB7nuHsfQMMLjZ6mZWBHjd6Xe2/DFf53/HLY89VWz7zgs+LCCWc8GRamURAb6nKK773veXfyfCkLmb54E/jeI84LKeAK87/R4tf7SiOvhEGPKNb1zH/3y1wogAfyW+4Te0BQch4LgfGVOwAN91x2g6GAHH+8jMVI94IsB/mH5ni5rfeCXCQCLH+XE3ZAnwWkccnIDjeyxCfD2WJMA3p9+58sesgxRwXI9v6KnetS/AmTvuYAUcz+OPsADnPf2GZZCWgxZwHI9d9o8lZRvg6iaAIw5ewPFrChbg8QrTb9NBDDhuJ2Y25yk4ywDH+tINBzM4Xk3BAmz6dVADjtNhTcHzAmz6dXADjk9TsACbfh3k4LjMQivHKTirAOc8/TrYwfFoChZg06+DHnAc3mkKnhNg06+DH3D8jV9W5+icJmDTr5MAOO7qLavngrMIsOnXyQAcb9HI5lpwLhPwnOnXSQEcZ9FMwTMC7DcqJwfA8TV+WaxYJh/g6tmylv3ZSQIcV9GYz+H7gnOYgF+0LztZgOMpvggLcNzTb7iOMGs/dtIAx1F0FqobaAU41g/QPuzkAY6fKIX4Jv1ijmQDXP3mNG8fdhIBx020kr6BNuUJWHydTMDxErdWyi/mSDnAlp+dVMBxEr9kb6RNMsDVC71b9lsnF3B8RG8+1ZuxUp2An7PPOsmA4yKdCAtwHNOvm6+cbMDxkJYkLymmOAGLr5MOOA7S0krx/dApBtibr5x8wP5vChbgcap+Q5qxnzoJgf0+Ocm9lCO1Cdj062QE9vc0Nasv1xHgmpq3jzopgf08WUk94ZJMgKtnf5v2TycnsH8nay6lZ4JTmoA9++skBfbrDCIswD4UnKywP2PYyjvAlp+dtMB+nM8EnMoydCoTsOVnJy+w/2YUYQH2YeAkhv0WQ1eeAbb87GQG9tf8JuAUlqFTmICfti86qYH9NL8IC7APASc37J8YvvIKcPXu55b90EkO7JcmYAH2AeBkh/2R0Qvvhp4V4Mnx+JGTHtgP8xV1A6INcHUHnK8edPID+1++TMA2PE6C2O+YgJlyGGsJ8PhZfnYyBPsb0Q5jJmCcFLGfEbNoH0eKMsDVkkPLfufkCPYvE7AA2+A4SWK/YvxasV4HjjXAXj/pZAn2J6IeykzAOGliPyJ2UQ5l0QXY9V8nT7D/YAK2oXESxX7D5EV5HTjGAD9uX3MyBfsLt4nuzYgmYJxUsZ+QguiuA8cYYO9/dnIF+wcm4HGK/auncJLFfsHIRNeH2CZg0y9OttgfuNuQFlUjYguwG7Bw0sV+QBJDmgkYJ198/qQiqiFNgHESxueOCViA784NWDgZ4/NmHVF1IqYJuGXfwknZ5+xzZp1hLZpWxBRgN2Dh5OzztSFYTzTL0FM2Kk7S+FwRYAEWYJys8XkyiGhWS2MKcNN+hZO2zxFSaUUUAXYHNE7ePj/YoGh6EcsE3LJP4STuc4MNDm1RTMECjJM5Pi9SE8U9Q7EE+BH7E07qPidIaWgzAePkjs8HARbgu/IIEk7yPhfYqCgeRYolwB5Bwsne5wFJNaP2AY7tC5Zx0sfnwMS1BNj0i5M/tj8CLMAgArY7eYjhWeAYAmwJGjGwvSG5dkz5jEAUbGcQ4DvxPcCIg+0LJuAJcA0YkbBdIbl2WIIGsbA9QYDvyE1YiIbtCP2q/eVLS9AgHrYfKbIEDSJiuwGRBbjX65l+ERPbCwR43D750Tdd/0VUbCfYDI8hgbjYPjABrgGDyNgugACD2NgeIMAgOrYDCHANbX3ymZaPCPHx9wcBHrPf/+LnAowI+XvDpnz+H/9S6zuhLUGDGPn7kqTP/uFvan0ntACDKPl7ggCTokuXLomwv599FgSYcVtYWCjef/99Efb3isLbb79dnDx50oGLABO/69evF6+88ooI+/uILwgwIixa4iu+CDAiLML+/cUXAQYRFjHxBQFGhEXYv6/4IsAgwmlHTXxJyc7F1wV4szzwL8Ii7N9PfNmsLY891RXgTbrxP/+5ZBcSYREWX/FlMxqNhgBv1hcfXFi2C4mwCIuv+JIi14AR4YyjJ74gwCDC4iu+CDCIcNoRFF8Q4Hva8YOfuAYswiIsvuJL/3F7uNUV4AFs+/b33AUtwiIsvuJL325c6gowiHA94ii+IMAgwmOOpPiSm8YDXxPggTfirmbXriTCIiy+4ktftm6v/SXM2ge4d31ZgBFh8RVf+mvH5Y+uCDCI8ETiKb4gwANp7H7YBIwIi6/40pct+w5Ygh54GeHKpV/ZlRDhF0b2vxdfUhTDdwnUPsBTD33dnoQIbzCq4gsrtj75jAAP6sZvPuzYlRDh9eMqvvClnT/8qSVoEOHRR1h84UuNHdNR/HvWPsAP/OyXXkeJCN8jtuILt9myJYpu1P8u6EbDFzIgwneJsPjCV/WuXY6iG1EsQXsbFiK8/iQsvlA148G9JuCh/TbjbViIcLbEl76bEcFbsOKZgCP5bQYRRnyZvBhewhHPBBzJbzOIMOJLDdy3yzXgIerYoxBh8YWN2PXyG1E0I4oAx/BGE0QY8aUWurH8i0YR4BjeaIIII74IcHIBrogwIiy+cE8x3bQbU4C7di1EWHzhngHetj2ab9CLKcDv2bUQYfGFe7lxqWsCHgFL0Iiw+MI9TZ+62BFgAUaEEV/GqxvTv2w0AS5/q+nG8hVTiDDii0EtpQm46H12tWP/QoTFF+4iqnuFpiLbuJahEWHxhbuJakiLLcDuhEaExReSGNKiCvDWJ58xASPC4gt30p0+dTGq1xZHFeDqlZTeC40Iiy9EPf1GF+BYNzIiLL4wcucF2EZGhBFfDGdJBrhjP0OExRfWiukNWNEG+IGf/dISNCIsvhD9YBZdgBuNRrgJS4QRYfGFVVFempyKdGN37G+IsPiCCdhvO4iw+IovExLj9V8TMIiw+GL6FeC+fttZbtz/oOvAiLD4QrQrorFOwEXv2uVz9jtEWHwxAQuwjY4Iiy+M13Ks13+jDnC10b0XGhEWX0y/AmzjI8LiC2MU9aXIKRsfRFh8MYQJcL/O2v8QYfElS0vTpy52BXhCqi9f9jgSIiy+5Cf6FdCpBD6EM/ZDRFh8yU70K6BTPgQQYfElMt3pUxejX/2MPsDVNYCu/RERFl9MvwLsw4BoIyy+RCCJS49bU/hL7PjBT8407n/opVz2vMbOaYdfhBF+9dVXi0cffVR8Ge4U9Y0/LnYuvp7N37f3+f91tx2YTeLm2yQCvO3b31vq9Xrd8h9bDkdEWHxz0tgxXWx57Kmc/srJrHhOJfShuBuaKCJcx+Vo8cW5XoAH0bZfIsLiS9K6jUYjmXc/JBPg8kPpFl7KgQiLLylL6obbqcQ+HMvQiLD4kq6kdtjUAty2fyLC4kuSlqqVTgGuo/LDCe+G9kwwIiy+pCe5Fc4pHxLkE2HxJWJtAa7/FBwm4GX7KiIsvqQT32qFU4D9pgRxRVh8iVySK5upBtiZBhEWX9IQnv3tCHAkqjvlOvZbco+w+GKgEuBJcDMWWUdYfElEW4Djm4LDh9a175JjhMWXVOKb4s1XOUzApmCyjLD4kpCkd+TUA3zC/ktOERZfEtJJ6YsXsgtwtXTRth+TQ4TFl8Qkv4I5lcGHeMx+TOoRFl8S063u4xHgyKfgbuGRJBKOsPiSoCx26KlMPkxTMElGWHxJUDaXDrfm8JcMb1Hp9XrhYv6MfZuUIhx+QmrTb8qPHuU4Ad/8UO3XpBZhSFA2T69kE2Av5gCovXYu029uE3DgWjCAc7QAm4IBWDP9ZnV+nsrwQzYFAzg3C7ApGMD0m9v0m+sEbAoGcE4WYFMwgOlXgP3GBcD4hEeOFnP9y2cb4GoK7tj/ASbmZE7P/QqwKRigLtNv1t/ZnnWAwzuiTcEAkxmAcp5+TcArDtsEAGMVvu/3RO4bIfsAV3fftR0PAGOzaBMI8NqdYdlmABi5Tjn4nLUZBHh1Cg7xdUMWgOlXgCcQ4XA9omtLAIzMifJcu2QzCPCduCELYDSsNN5mq01wyxTcuXz58tkPP/xwztaAfF27dq24cOGCDTFEBw4cWMz9saOvNMcmuNWhQ4da5Y93yz9NWwNgKDpvvfXWQZvhVpagb1PuJN3CMgnAMLnxSoA3HOFwQ5YbBQAGd6w8pzqfCnBf3JAFMJhukfn7ngV4c1Nw+I3NUjTAAINMeS5145UAb4pngwE2ef4s49uxGQR4s1Nw+M3NUjRAf8LgYgVRgAeOcKdwDQOgH5aeBXhowm9y7uIDWJ+lZwEe6hRsKRpgfd3C0rMAjyDC7ooGuDdLz33wKso+HTp0KLymcsaWALhFeOHGUZvBBDxKzxcr3+oBwIol8RXgkaveFe29pgArlqvBBAEeS4Tb5Y+2LQFQLFaDCQI8vp2u8GgSkLd2NZAgwGOdglcfTXI9GMjRUuFynABPMMJ2QCBHNwcQjxwJ8KQj3C5cDwYysm/fvkXf8Ts4zwEPieeDgUyEV01a+TMB14rng4HUdcRXgGunug3fs3BAqpzjBLjWEe4UvrQBSM/Nl2246UqA6x7hduGmLCAtbroaATdhjYibsoBE+JIFE3B0DhbelAXErS2+JuBYp+AwAb9T/mnaGkBkwjccPWEzmICjVF0zOWhLALHF17lLgFOJsDujgVh4zeSYWIIek0OHDs2XP07bEkDN43vQHc8m4NQm4Xbh8SSgxvbv339YfE3AKU/CYQqetyWAmjnsu30FWIQBxDd5lqAnoNzRD09PT1vmASZu7969i+IrwFm5evWqF3UAk9Z+7bXXTtgMk2EJeoIOHToUXtARXtThlZXA2OMbVuNsBgEWYREGxFeAEWFAfBFgEQYQXwFGhAHxRYBFGEB8o+QxpJqpXoB+cPfu3R1bAxhU9Zyv+JqA6XMa9sYsYBDecCXAiDAgvqxlCbrmwtLRnj17vKkG2Kjl/fv3Py++JmCGNwmHKdj3CQP3jG/h+3xNwAx9Em7v27fvcHWAAdxuSXxNwIx2Eg6PJ4XHlJq2BnBbfP2CLsCMOMKt8sebhWeFAc/4RssSdITKg60bftst/5y1NSBrx8TXBMzkpuHj5Y+XbAnISlhqXnSnswAz+QjPF+6Qhlx0yz/Pu9lKgKlPhN2cBenrVPF1s5UAU7MIh/iGm7NmbQ1IzokyvIs2gwBT7xC7LgzpCNNueK2kmy4FmEgiPFesXBe2JA3xWqri63qvABNZhFuF54UhVu1i5U5n13sFmIhDbEka4uERIwEmsQjPVtOwJWmor7DU/Hz1sh0EmIQiHOIbrgvP2RpQO+GtVkdtBgEm7RCH5egjpmGohTDthhutOjaFAJNHhFvVNDxra8DEnKgmXzdaCTAZhvhoNQ0Dpl4EmDFHODymdNw0DKZeBJjJhNi1YTD1IsBMKMKtwrVhGLZjxcq7nE29CDDrhjg8qhSWpVu2BmxamHYXvUoSAabfCIel6LAk7S1a0B9vs0KAGUqIwxRsWRo2xk1WCDBDD7Flabi7TmG5GQFmxCE+Wv5YKNwtDUG3Cq/v60WAGUuEm9U0PG9rkKmwxByWmk/YFAgwkwhxq1i5UUuIySm8JwuPFSHA1CTEs1WIZ20NEtYuVpabhRcBRohhTOE95nt6EWCEGIQXAQYhRnhBgKlriMM3LoVHl+ZtDWrKzVUIMEmHuFVNxOGlHp4jpg66VXjbwosAk0OIQ3zDO6ZfLLxZi8nolH/OeF8zAkzOMZ6vQjxrazAG7Sq8HZsCAYbC8jQj1S0sMyPAsG6Im1WEw01bM7YIpl0EGCYzFS9UQW7ZImxA+DaiM6ZdBBiGF+MQ4ecKS9R8Vbf8c7aadn0dIAIMI4zx/JoYk6cw3bZFFwGGyYR49XpxiPGsyTibSfec67oIMNQryGtj3LJFkhCm245JFwGGeGI8s2Y6djd1PJar4J4P0673MSPAEHeMm9VUbDqu75R7LoTX0jICDGkHuVWF+GlBnohOFd3zVXQ9LoQAQ+YT8syaKDMcq0vK75lwQYBhI1GeqYL8ePVTlNfXrf6crybcJddwQYBhGFFuFSvL1SHGj6z55xyn2qXqz6/WxNZSMggwjDXMzWpKXg10mJqba/5zjJbWhPbK6n+2hAwCDDEGuqh+rr4w5PE1/7z2fzPKqXVVt5peV/85/CkEFgQYKG5Z9u5rerUcDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJC9/xdgALly1jc0M7N2AAAAAElFTkSuQmCC"
+       href="data:image/png;base64,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"
      />
  
      <!-- Responsive design for mobile and tablet -->
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  
  [nine lines unchanged)]
  
      <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1"></script>
    </head>
  
    <body>
-     <header>
-       <!-- Dashboard title displayed at the top -->
-       <h1>P2Pool Dashboard</h1>
-       <div class="header-status">
-         <span class="status-dot"></span>
-         <span>Mining Performance Monitor</span>
+     <header class="masthead">
+       <div class="masthead__title">
+         <h1>P2Pool</h1>
+         <p class="masthead__sub">Solar-gated Monero mining</p>
+       </div>
+       <div class="masthead__state">
+         <span class="pill" id="minerState">
+           <span class="pill__dot"></span>
+           <span id="minerStateText">Connecting</span>
+         </span>
+         <span class="masthead__stamp" id="lastRefreshed"></span>
        </div>
      </header>
  
-     <main class="grid">
-       <!-- ==========================
-        HASHRATE CARD (Top row)
-        ========================== -->
-       <div class="card" id="hashrateCard">
-         <h2>Your Hashrate</h2>
-         <div class="big" id="myHashrate">Loading…</div>
-         <div class="sub">Pool share: <span id="poolShare">–</span></div>
-         <div class="chart-container">
-           <canvas id="hashrateChart"></canvas>
+     <main>
+       <!-- Headline figures, glanceable without scrolling -->
+       <section class="overview" aria-label="Current figures">
+         <div class="metric">
+           <span class="metric__label">Your hashrate</span>
+           <span class="metric__value" id="myHashrate"
+             ><span class="skeleton"></span
+           ></span>
+           <span class="metric__note">
+             <span id="poolShare">–</span> of pool
+           </span>
+         </div>
+         <div class="metric">
+           <span class="metric__label">XMR price</span>
+           <span class="metric__value" id="price"
+             ><span class="skeleton"></span
+           ></span>
+           <span class="metric__note">EUR, spot</span>
          </div>
-       </div>
- 
-       <!-- ==========================
-        XMR PRICE CARD (Top row)
-        ========================== -->
-       <div class="card" id="priceCard">
-         <h2>XMR Price</h2>
-         <div class="big" id="price">Loading…</div>
-         <div class="chart-container">
-           <canvas id="priceChart"></canvas>
+         <div class="metric">
+           <span class="metric__label">Total earned</span>
+           <span class="metric__value" id="totalEarned"
+             ><span class="skeleton"></span
+           ></span>
+           <span class="metric__note" id="totalEurEarned">–</span>
          </div>
-       </div>
- 
-       <!-- ==========================
-        RECENT PAYMENTS CARD (middle row)
-        ========================== -->
-       <div class="card" id="paymentsCard">
-         <div class="total-block">
-           <h2>Recent Payments</h2>
-           <div class="sub">Total earned</div>
-           <div class="big" id="totalEarned">Loading…</div>
-           <div class="sub" id="totalEurEarned">–</div>
+         <div class="metric">
+           <span class="metric__label">Pool hashrate</span>
+           <span class="metric__value metric__value--sm" id="poolHashrate"
+             ><span class="skeleton"></span
+           ></span>
+           <span class="metric__note">
+             network <span id="netHashrate">–</span>
+           </span>
          </div>
-         <div id="paymentsStatus" class="small-stat">Loading…</div>
-         <table id="paymentsTable">
-           <thead>
-             <tr>
-               <th>Time</th>
-               <th>Amount (XMR)</th>
-               <th>Amount (€)</th>
-             </tr>
-           </thead>
-           <tbody></tbody>
-         </table>
-       </div>
+       </section>
  
-       <!-- ==========================
-        EARNINGS CARD (middle row)
-        ========================== -->
-       <div class="card" id="earningsCard">
-         <h2>Estimated Earnings</h2>
-         <div class="big" id="earnXMR">
-           Loading…
-           <span class="tooltip-icon" id="earnTooltip">ⓘ</span>
-         </div>
-         <div class="sub" id="earnEUR">Loading…</div>
-         <div class="small-stat" id="earnLegend">–</div>
-         <div class="small-stat" id="lastRefreshed">–</div>
-         <select id="earnPeriod">
-           <option value="hour">per hour</option>
-           <option value="day" selected>per day</option>
-           <option value="week">per week</option>
-           <option value="month">per month</option>
-           <option value="year">per year</option>
-         </select>
-         <div class="bottom-stats">
-           <div class="stat">
-             <span>Payout Interval:</span>
-             <span id="payoutInterval">Loading…</span>
-             <span class="tooltip-icon">ⓘ</span>
-           </div>
-           <div class="small-stat">
-             Network hashrate: <span id="netHashrate">–</span>
-           </div>
-           <div class="small-stat">
-             Pool hashrate: <span id="poolHashrate">–</span>
-           </div>
-           <div class="small-stat">
-             Block reward: <span id="blockReward">–</span> XMR
+       <!-- Trends: one range control drives both charts -->
+       <section class="trends" aria-labelledby="trendsHeading">
+         <div class="section-head">
+           <h2 id="trendsHeading">Trends</h2>
+           <div
+             class="range"
+             id="rangeControl"
+             role="group"
+             aria-label="Time range"
+           >
+             <button type="button" class="range__opt" data-hours="1">1h</button>
+             <button type="button" class="range__opt" data-hours="6">6h</button>
+             <button
+               type="button"
+               class="range__opt"
+               data-hours="24"
+               aria-pressed="true"
+             >
+               24h
+             </button>
+             <button type="button" class="range__opt" data-hours="168">
+               7d
+             </button>
+             <button type="button" class="range__opt" data-hours="720">
+               30d
+             </button>
+             <button type="button" class="range__opt" data-hours="2160">
+               90d
+             </button>
            </div>
          </div>
-       </div>
  
-       <!-- ==========================
-        SHARES & UNCLES CARD (middle row)
-        ========================== -->
-       <div class="card" id="sharesCard">
-         <h2>Shares & Uncles</h2>
-         <div class="big">Shares: <span id="sharesSinceLastPayout">–</span></div>
-         <div class="sub-sub">Shares since the last payout</div>
-         <div class="big">Uncles: <span id="unclesSinceLastPayout">–</span></div>
-         <div class="sub-sub">Uncles since the last payout</div>
-         <div class="bottom-stats">
-           <div class="small-stat" id="totalSharesMined">–</div>
-           <div class="small-stat" id="totalUnclesMined">–</div>
-         </div>
-       </div>
- 
-       <!-- ==========================
-      COMBINED LUCK CARD
-   ========================== -->
-       <div class="card" id="combinedLuckCard">
-         <h2>Luck Overview</h2>
-         <div class="luck-grid">
-           <!-- PPLNS Window Luck -->
-           <div class="luck-section" id="pplnsLuck">
-             <h3>PPLNS Window Luck</h3>
-             <div class="big">
-               <span id="luckFactor">–</span>⨯
-               <span class="tooltip-icon" id="luckTooltip">ⓘ</span>
-             </div>
-             <div class="big-med">Accumulated XMR:</div>
-             <div class="sub"><span id="xmrThisWindow">0.000000</span> XMR</div>
-             <div class="sub" id="eurThisWindow">–</div>
-             <div class="sub-sub" id="luckInfo">
-               Only true if pool mines expected blocks based on net share this
-               window and avg pool effort; minimum payout 0.00027 XMR
-             </div>
-             <div class="bottom-stats">
-               <div class="small-stat">
-                 Start of PPLNS window: <span id="pplnsStart">Loading...</span>
-               </div>
-               <div class="small-stat">
-                 PPLNS Window Hashrate: <span id="dayHash">–</span>
-               </div>
-               <div class="small-stat">
-                 Current pool effort: <span id="currentEffort">–</span>%
-               </div>
+         <div class="trends__grid">
+           <article class="panel">
+             <h3 class="panel__title">Hashrate</h3>
+             <div class="chart chart--tall">
+               <canvas id="hashrateChart"></canvas>
+               <p class="chart__empty" id="hashrateEmpty" hidden>
+                 No samples in this range yet. Longer ranges fill in as the
+                 logger runs.
+               </p>
              </div>
-           </div>
+           </article>
  
-           <!-- Estimated True Luck -->
-           <div class="luck-section" id="trueLuck">
-             <h3>Estimated True Luck</h3>
-             <div class="big">
-               <span id="trueLuckFactor">–</span>⨯
-               <span class="tooltip-icon" id="trueLuckTooltip">ⓘ</span>
-             </div>
-             <div class="sub" id="trueLuckWindow">–</div>
-             <div class="sub-sub" id="trueLuckInfo">
-               Longer time windows reduce accuracy; uses a 24h moving average of
-               hashrates.
+           <article class="panel">
+             <h3 class="panel__title">XMR price</h3>
+             <div class="chart chart--tall">
+               <canvas id="priceChart"></canvas>
+               <p class="chart__empty" id="priceEmpty" hidden>
+                 No samples in this range yet.
+               </p>
              </div>
-           </div>
+           </article>
          </div>
-       </div>
+       </section>
  
-       <!-- ==========================
-        POOL STATUS & STATS CARD (from old dashboard)
-        ========================== -->
-       <div class="card" id="poolStatusCard">
-         <h2>Pool Status & Statistics</h2>
-         <div class="stat-row">
-           <div class="stat-item">
-             <div class="stat-label">Pool Status</div>
-             <div class="stat-value">
-               <span class="status-indicator" id="pool-status"></span>
-               <span id="pool-status-text">Unknown</span>
+       <section class="panels" aria-label="Mining detail">
+         <!-- Estimated earnings -->
+         <article class="panel" id="earningsCard">
+           <div class="panel__head">
+             <h3 class="panel__title">Estimated earnings</h3>
+             <label class="select">
+               <span class="visually-hidden">Earnings period</span>
+               <select id="earnPeriod">
+                 <option value="hour">per hour</option>
+                 <option value="day" selected>per day</option>
+                 <option value="week">per week</option>
+                 <option value="month">per month</option>
+                 <option value="year">per year</option>
+               </select>
+             </label>
+           </div>
+           <p class="figure">
+             <span id="earnXMR"><span class="skeleton"></span></span>
+             <span class="info" id="earnTooltip" tabindex="0" role="note"
+               >i</span
+             >
+           </p>
+           <p class="figure__note" id="earnEUR">–</p>
+           <p class="footnote" id="earnLegend">–</p>
+           <dl class="facts bottom-stats">
+             <div class="fact">
+               <dt>Payout interval <span class="info">i</span></dt>
+               <dd id="payoutInterval">–</dd>
+             </div>
+             <div class="fact">
+               <dt>Block reward</dt>
+               <dd><span id="blockReward">–</span> XMR</dd>
              </div>
+           </dl>
+         </article>
+ 
+         <!-- Luck -->
+         <article class="panel panel--wide" id="combinedLuckCard">
+           <h3 class="panel__title">Luck</h3>
+           <div class="luck">
+             <section class="luck__part">
+               <h4 class="luck__label">
+                 PPLNS window
+                 <span class="info" id="luckTooltip" tabindex="0" role="note"
+                   >i</span
+                 >
+               </h4>
+               <p class="figure figure--sm">
+                 <span id="luckFactor">–</span>&#8288;⨯
+               </p>
+               <p class="figure__note">
+                 <span id="xmrThisWindow">0.000000</span> XMR accumulated
+               </p>
+               <p class="figure__note" id="eurThisWindow">–</p>
+               <p class="footnote" id="luckInfo">
+                 Only true if the pool mines its expected blocks for this
+                 window's network share and average effort; minimum payout
+                 0.00027 XMR.
+               </p>
+               <dl class="facts">
+                 <div class="fact">
+                   <dt>Window start</dt>
+                   <dd id="pplnsStart">–</dd>
+                 </div>
+                 <div class="fact">
+                   <dt>Window hashrate</dt>
+                   <dd id="dayHash">–</dd>
+                 </div>
+                 <div class="fact">
+                   <dt>Pool effort</dt>
+                   <dd><span id="currentEffort">–</span>%</dd>
+                 </div>
+               </dl>
+             </section>
+ 
+             <section class="luck__part">
+               <h4 class="luck__label">
+                 Estimated true
+                 <span class="info" id="trueLuckTooltip" tabindex="0" role="note"
+                   >i</span
+                 >
+               </h4>
+               <p class="figure figure--sm">
+                 <span id="trueLuckFactor">–</span>&#8288;⨯
+               </p>
+               <p class="figure__note" id="trueLuckWindow">–</p>
+               <p class="footnote" id="trueLuckInfo">
+                 Longer windows reduce accuracy; uses a 24h moving average of
+                 hashrates.
+               </p>
+             </section>
            </div>
-           <div class="stat-item">
-             <div class="stat-label">Your Hashrate (24h)</div>
-             <div class="stat-value" id="user-hashrate-24h">–</div>
+         </article>
+ 
+         <!-- Shares -->
+         <article class="panel" id="sharesCard">
+           <h3 class="panel__title">Shares &amp; uncles</h3>
+           <div class="pair">
+             <p class="figure figure--sm" id="sharesSinceLastPayout">–</p>
+             <p class="figure__note">Shares since last payout</p>
            </div>
-           <div class="stat-item">
-             <div class="stat-label">Shares Found (Total)</div>
-             <div class="stat-value" id="shares-found">–</div>
+           <div class="pair">
+             <p class="figure figure--sm" id="unclesSinceLastPayout">–</p>
+             <p class="figure__note">Uncles since last payout</p>
            </div>
-           <div class="stat-item">
-             <div class="stat-label">Shares Failed (Total)</div>
-             <div class="stat-value" id="shares-failed">–</div>
-           </div>
-           <div class="stat-item">
-             <div class="stat-label">Active Connections</div>
-             <div class="stat-value" id="connections">–</div>
-           </div>
-           <div class="stat-item">
-             <div class="stat-label">Block Reward Share</div>
-             <div

This message was truncated. Download full message

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Tanguille, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d6bc3914-bded-4758-bb72-f70b377cf944

📥 Commits

Reviewing files that changed from the base of the PR and between d121ad3 and d598962.

📒 Files selected for processing (1)
  • kubernetes/apps/web3/monero/dashboard/resources/server.py
📝 Walkthrough

Walkthrough

The Monero dashboard now uses a responsive dark theme, structured panels, selectable chart ranges, persisted chart history, and a new server endpoint with tiered retention and downsampling. Nginx proxies the history endpoint. Server tests cover rollups and filtering. Prometheus alerts use a shared node-to-Deployment recording rule.

Changes

Monero dashboard history and redesign

Layer / File(s) Summary
Chart history storage and API
kubernetes/apps/web3/monero/dashboard/resources/server.py, kubernetes/apps/web3/monero/dashboard/resources/test_server.py
The server stores fine and five-minute statistics, persists both tiers, serves bounded downsampled history, and handles startup and shutdown cleanup. Tests cover rollups, retention, downsampling, filtering, and empty data.
Range selection and chart updates
kubernetes/apps/web3/monero/dashboard/resources/dashboard.js, kubernetes/apps/web3/monero/dashboard/resources/nginx.conf
The dashboard requests history for the selected range, persists range state, updates charts, handles empty responses, and displays miner status. Nginx proxies non-static requests to the Python server.
Responsive dashboard presentation
kubernetes/apps/web3/monero/dashboard/resources/dashboard.html, kubernetes/apps/web3/monero/dashboard/resources/dashboard.css, kubernetes/apps/web3/monero/dashboard/helmrelease.yaml
The page now uses structured metric, chart, earnings, luck, payment, pool, and worker panels. The stylesheet adds centralized theme tokens, responsive layouts, status states, loading skeletons, focus styles, and reduced-motion handling. The ConfigMap mounts as the document root directory.

Monero guard alert mapping

Layer / File(s) Summary
Shared Deployment mapping
kubernetes/apps/web3/monero/guard/prometheusrule.yaml
The rules add xmrig_guard_deployment_node and use it for enforcement-bypass and thermal-panic alert joins.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator as Dashboard operator
  participant Browser as dashboard.js
  participant Nginx as nginx
  participant Server as server.py
  Operator->>Browser: Select chart range
  Browser->>Nginx: Request stats_history.json
  Nginx->>Server: Proxy chart-history request
  Server-->>Nginx: Return downsampled history
  Nginx-->>Browser: Return chart data
  Browser-->>Operator: Render charts and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two primary changes: adding 90-day history and rebuilding the Monero dashboard interface.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/monero-dashboard-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Tanguille
Tanguille marked this pull request as ready for review August 9, 2026 09:23
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

AI Automated Review (incremental)

Analysis engine: omniroute@http://litellm.ai.svc.cluster.local/v1 (openai)

Recommendation: Approve. The incremental fixes address input validation and alert consistency without introducing regressions.

Change-by-change findings

  • server.py: Added math.isfinite guard for the hours query parameter, preventing infinite loops or NaN propagation in serve_history. This satisfies the test with edge-case paths must_check item by handling non-finite numeric inputs gracefully.
  • prometheusrule.yaml: Extracted label_replace into a shared recording rule (xmrig_guard_deployment_node). This improves maintainability and ensures consistent deployment-to-node mapping across alerts. No syntax errors detected in the PromQL expressions.

Standards Compliance

  • Changes follow repository conventions (Conventional Commit style implied by PR title; manifests use standard Flux/Helm structures).
  • No shell scripts modified; no SOPS secrets touched.

Tool Harness Findings

  • No tool harness output present.

Unknowns or Needs Verification

  • None. The delta is self-contained and verifiable from the corpus.

Unaddressed required checks

The classifier marked these checks as required for this PR's risk profile, but the review above does not appear to discuss them:

  • check for resource quota / limit changes
  • review for path traversal vulnerabilities

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
kubernetes/apps/web3/monero/dashboard/resources/test_server.py (1)

49-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the hours clamping in serve_history.

The tests cover accumulate_rollup and downsample. They do not cover the tier-selection and clamping logic in serve_history, which is the contract the dashboard depends on. A small unit test on the parsing branch would catch a regression in the MAX_LOG_AGE boundary or in the default value.

Do you want me to draft that test?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@kubernetes/apps/web3/monero/dashboard/resources/test_server.py` around lines
49 - 82, Extend DownsampleTest with a focused unit test for serve_history’s
hours parsing branch, covering both the default hours value and clamping
requested hours to MAX_LOG_AGE. Assert the selected history window or resulting
response reflects the clamped boundary, while preserving existing behavior for
valid in-range input.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@kubernetes/apps/web3/monero/dashboard/resources/dashboard.css`:
- Around line 32-37: Quote the named font families flagged by value-keyword-case
in the --font-ui and --font-num declarations, including Roboto, Helvetica,
Arial, Menlo, and Consolas, while preserving the existing fallback order and
font stacks.

In `@kubernetes/apps/web3/monero/dashboard/resources/dashboard.html`:
- Around line 152-157: Update the earnings markup and its text-update target so
`#earnTooltip` is outside `#earnXMR` and remains available after refreshes. Add or
use a dedicated value span inside `#earnXMR` for setTextContent, and change the
corresponding update logic to target that span while preserving
updateEarningsTooltip() access to `#earnTooltip`.

In `@kubernetes/apps/web3/monero/dashboard/resources/dashboard.js`:
- Around line 644-663: Update fetchChartWindow and the fetch path in
fetchDashboardData to capture the selected range alongside each request, then
have applyChartWindow accept that range and ignore responses whose range no
longer matches state.currentRangeHours. Rename applyChartWindow’s window
parameter to avoid shadowing the global window object, while preserving
valid-response handling for the currently selected range.

In `@kubernetes/apps/web3/monero/dashboard/resources/server.py`:
- Around line 270-287: Update load_log_disk’s rollup seeding loop to replay only
up to the shortest column length across timestamps and all SERIES values,
preventing indexing beyond ragged data while preserving normal seeding behavior.

---

Nitpick comments:
In `@kubernetes/apps/web3/monero/dashboard/resources/test_server.py`:
- Around line 49-82: Extend DownsampleTest with a focused unit test for
serve_history’s hours parsing branch, covering both the default hours value and
clamping requested hours to MAX_LOG_AGE. Assert the selected history window or
resulting response reflects the clamped boundary, while preserving existing
behavior for valid in-range input.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9bedc3d1-bc80-4de9-84b5-b731db5a2c6c

📥 Commits

Reviewing files that changed from the base of the PR and between f70fc97 and daeaba8.

📒 Files selected for processing (6)
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.css
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.html
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.js
  • kubernetes/apps/web3/monero/dashboard/resources/nginx.conf
  • kubernetes/apps/web3/monero/dashboard/resources/server.py
  • kubernetes/apps/web3/monero/dashboard/resources/test_server.py

Comment thread kubernetes/apps/web3/monero/dashboard/resources/dashboard.css Outdated
Comment thread kubernetes/apps/web3/monero/dashboard/resources/dashboard.html Outdated
Comment thread kubernetes/apps/web3/monero/dashboard/resources/dashboard.js
Comment thread kubernetes/apps/web3/monero/dashboard/resources/server.py
Merge 3 per-file configMap subPath mounts into one directory mount,
enable sendfile (no reason it was off under readOnlyRootFilesystem),
and inline three single-call wrapper functions in dashboard.js.
@tanguille-cluster

Copy link
Copy Markdown
@@ spec.template.spec.containers.web.volumeMounts @@
# apps/v1/Deployment/web3/dashboard
! - three list entries removed:
- - name: dashboard-html
-   mountPath: /usr/share/nginx/html/index.html
-   subPath: dashboard.html
- - name: dashboard-html
-   mountPath: /usr/share/nginx/html/dashboard.css
-   subPath: dashboard.css
- - name: dashboard-html
-   mountPath: /usr/share/nginx/html/dashboard.js
-   subPath: dashboard.js
! + one list entry added:
+ - name: dashboard-html
+   mountPath: /usr/share/nginx/html

…nx routes

log_loop fired xmrig and monerod HTTP calls sequentially (5s timeout
each, worst case stacked); fetch them concurrently via a small
ThreadPoolExecutor instead.

nginx also carried its own hand-synced copy of server.py's do_GET
route list. Replace it with a single fallback location: unmatched
static paths proxy to the Python backend, so nginx no longer needs
to know the route set at all.
- Quote value-keyword font-family names in dashboard.css (stylelint
  value-keyword-case)
- Move #earnTooltip out of #earnXMR so setTextContent("earnXMR", ...)
  no longer wipes the tooltip badge on the first refresh
- Stamp chart-window fetches with the requested range and drop stale
  responses, so a range switch mid-poll can't get overwritten by an
  in-flight response for the old range
- Guard rollup seeding against a ragged stats_log.json column instead
  of indexing past its length and crashing startup
…g rule

XmrigGuardEnforcementBypassed and XmrigGuardThermalPanic each ran
their own label_replace encoding the "xmrig-<node>" Deployment naming
convention; a rename required editing both in lockstep. Move it into
a single xmrig_guard_deployment_node recording rule both alerts join
through instead.
@Tanguille

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
kubernetes/apps/web3/monero/dashboard/resources/server.py (1)

236-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject non-finite hours values.

float("nan") succeeds at Line 239. The clamp at Line 242 keeps NaN. Line 245 then selects rollup, and downsample receives an invalid window instead of the default window.

Proposed fix
         try:
             hours = float(parse_qs(query).get("hours", ["24"])[0])
         except ValueError:
             hours = 24.0
+        if not math.isfinite(hours):
+            hours = 24.0
         hours = min(max(hours, 0.1), ROLLUP_MAX_AGE / 3600)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@kubernetes/apps/web3/monero/dashboard/resources/server.py` around lines 236 -
242, Update serve_history to reject non-finite parsed hours values, including
NaN and infinities, before applying the min/max clamp. Fall back to the default
24.0-hour window for any non-finite value while preserving the existing handling
and bounds for valid finite inputs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@kubernetes/apps/web3/monero/dashboard/resources/server.py`:
- Around line 236-242: Update serve_history to reject non-finite parsed hours
values, including NaN and infinities, before applying the min/max clamp. Fall
back to the default 24.0-hour window for any non-finite value while preserving
the existing handling and bounds for valid finite inputs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 39df189f-34b3-4a6a-9d19-9a5c63bec1a4

📥 Commits

Reviewing files that changed from the base of the PR and between daeaba8 and d121ad3.

📒 Files selected for processing (7)
  • kubernetes/apps/web3/monero/dashboard/helmrelease.yaml
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.css
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.html
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.js
  • kubernetes/apps/web3/monero/dashboard/resources/nginx.conf
  • kubernetes/apps/web3/monero/dashboard/resources/server.py
  • kubernetes/apps/web3/monero/guard/prometheusrule.yaml
🚧 Files skipped from review as they are similar to previous changes (2)
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.html
  • kubernetes/apps/web3/monero/dashboard/resources/dashboard.css

NaN/Infinity survives float() and the min/max clamp, sending
downsample() an invalid window instead of the 24h default.
@Tanguille

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Tanguille
Tanguille merged commit d232bf5 into main Aug 9, 2026
15 checks passed
@Tanguille
Tanguille deleted the feat/monero-dashboard-ui branch August 9, 2026 10:51
Tanguille added a commit that referenced this pull request Aug 9, 2026
…iers

xmrig reports hashrate.total[0] as null while the solar gate holds the
miner idle. That null was appended straight into the log, where it broke
accumulate_rollup's += and downsample's sum(). The 24h file on the PVC
already held 14 of them, so the rollup back-fill added in #4394 crashed
on every start — dashboard-server had 299 restarts and every endpoint it
owns returned 502.

Coerce to a finite float at the two points values enter the tiers
(append_log for live samples, load_tier for files written before this),
so idle reads as 0 H/s rather than poisoning both retention tiers. The
existing ragged-column guard checked lengths, not values.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant