The web dashboard for Pool.Energy, a Chia farming pool. It gives farmers and visitors a live view of the pool: space and member count, leaderboard, blocks, rewards and payouts, partials, Chia blockchain state, pool logs, and the public API documentation — all updated over WebSockets.
The application is a classic (NgModule-based) Angular SPA. It talks to the Pool.Energy REST/WebSocket API and is shipped as a static bundle served by Caddy inside a Docker image.
- Tech stack
- Features
- Getting started
- Available scripts
- Project structure
- Architecture
- Configuration
- Testing
- Docker image
- Continuous integration
- Troubleshooting
- License
| Concern | Choice |
|---|---|
| Framework | Angular 22 (NgModules, standalone: false components) |
| Language | TypeScript 6.0 (strict, strictTemplates) |
| UI | Bootstrap 5.3 + @ng-bootstrap/ng-bootstrap 21 + custom SCSS theme |
| Charts | ApexCharts 8 via ng-apexcharts |
| Terminal | ng-terminal (+ @xterm/xterm) for the live pool log |
| i18n | @ngx-translate/core 18 (standalone, provider-based API) |
| Reactive | RxJS 7.8, zone.js 0.16 |
| Unit tests | Vitest 5 through the Angular CLI (ng test) |
| E2E tests | Playwright against the production Docker image |
| Build | @angular/build (esbuild) + gzipper (gzip/brotli pre-compression) |
| Runtime | Node.js 26 during build, Caddy 2.11 Alpine at runtime |
| License | AGPL-3.0 |
- Live farmers dashboard (
/farmers, also/): pool space, active/total farmers, effort, estimate to win, current fee and XCH price, a top-3 leaderboard and a paginated, searchable farmers table. - Farmer detail (
/farmer/:id): per-launcher stats, estimated size, difficulty history, partials, payouts and harvester list with charts. - Blocks (
/blocks): farmed blocks with pool space, proof and timeline. - Rewards (
/rewards,/reward/:id): payouts, payout addresses and payout transactions, with charts and CSV export. - Partials (
/partials): live partial submissions and per-launcher chart. - Chia (
/chia): blockchain state, consensus/fork tracking, netspace, mempool and XCH price. - Pool (
/pool/status,/pool/stats,/pool/logs): pool health, historical pool size and a live log stream rendered in an in-browser terminal. - Info pages (
/info/faq,/info/fee,/info/team) and Join (/join) instructions for new farmers. - Login (
/login,/settings): launcher-owner login and settings form. - 7 languages: English, French, German, Spanish, Dutch, Turkish, Chinese, selected from the browser or the topbar and persisted in a cookie.
- Node.js
^22.22.3 || ^24.15.0 || >=26.0.0(constraint from Angular 22; the Docker image pinsnode:26.12.1) - npm 11 (or any npm ≥ 8 supported by Angular)
- Optional: Docker for the container and E2E workflow
npm ci # reproducible install from package-lock.jsonnpm start # https://localhost:4200 (production configuration, self-signed TLS)
npm run start:dev # https://localhost:4200 (development configuration)Both dev configurations use src/proxy.conf.json, which forwards /api,
/static and /_proxy_ws (WebSocket) to https://pool.energy. A network
connection to the upstream API is required for the pages to display data.
npm run buildThis runs ng build --configuration=production and then gzipper to generate
.gz/.br variants next to the bundles. Output: dist/poolenergy/browser.
| Script | What it does |
|---|---|
npm start |
ng serve --ssl --configuration=production (https://localhost:4200) |
npm run start:dev |
ng serve --ssl --configuration=development |
npm run build |
Production build + gzip/brotli pre-compression |
npm test |
Unit tests in watch mode (Vitest via ng test) |
npm run test:e2e |
Build the Docker image, run it, then execute the Playwright suite |
npm run watch |
Development build in watch mode |
npm run install:clean |
Remove node_modules/package-lock.json, reinstall and serve |
npm run lint |
Not configured — the project has no ESLint target |
web/
├── .github/workflows/ # Build & publish workflow (GHCR image)
├── docker/
│ ├── caddy/Caddyfile # SPA fallback + /api and /ws reverse proxy
│ └── entrypoint.sh # Renders the Caddyfile from env vars
├── docs/
│ ├── screenshot-light.png # README screenshot (light theme)
│ └── screenshot-dark.png # README screenshot (dark theme)
├── e2e/ # Playwright smoke suite (separate package)
│ ├── playwright.config.ts
│ └── tests/smoke.spec.ts
├── scripts/e2e-docker.sh # Build image → run container → run Playwright
├── src/
│ ├── app/
│ │ ├── core/
│ │ │ ├── interceptors/cd-kick.interceptor.ts
│ │ │ └── services/{language,pagination}.service.ts
│ │ ├── layouts/ # Shell: layout, topbar, footer, horizontal menu
│ │ ├── pages/ # 15 lazy-loaded feature pages + routing
│ │ ├── shared/ # SharedModule, breadcrumbs, chart helpers
│ │ ├── app.component.ts # Root component
│ │ ├── app.module.ts # Root module (providers, translate loader)
│ │ ├── app.routing.ts # Lazy-loads PagesModule behind the layout
│ │ └── data.service.ts # REST + WebSocket API client
│ ├── assets/
│ │ ├── i18n/ # de, en, es, fr, nl, tr, zh JSON dictionaries
│ │ ├── images/, fonts/, scss/
│ ├── environments/ # environment.ts / environment.prod.ts
│ ├── index.html
│ ├── main.ts # platformBrowserDynamic().bootstrapModule()
│ ├── proxy.conf.json # Dev-server proxy to pool.energy
│ ├── styles.scss # Global overrides (sticky footer, theme fixes)
│ └── test-setup.ts
├── angular.json
├── Dockerfile # Multi-stage: Node build → Caddy runtime
├── package.json
├── tsconfig.json # strict + strictTemplates
├── vitest-base.config.mts # Vitest tweaks (CJS deps of ng-terminal)
└── README.md
src/main.tscallsplatformBrowserDynamic().bootstrapModule(AppModule, { applicationProviders: [provideZoneChangeDetection({ eventCoalescing: true })] }).AppModulewires the shell and the global providers:BrowserModule,AppRoutingModule,HttpClientModuleSharedModule,LayoutsModuleprovideTranslateService({ fallbackLang: 'en', loader: provideTranslateHttpLoader({ prefix: 'assets/i18n/', suffix: '.json' }) })CdKickInterceptoras anHTTP_INTERCEPTORSentry (see the change detection note)
AppRoutingModuledeclares a single route: theLayoutComponentshell withloadChildren: () => import('./pages/pages.module'), so all pages live in one lazily loaded chunk.PagesModuledeclares the 15 page components and importsCommonModule,FormsModule, ng-bootstrap modules,NgApexchartsModule,NgTerminalModule,SharedModuleand the standaloneTranslatePipe.SharedModuledeclares/exportsBreadcrumbsComponent(which uses thetranslatepipe).LayoutsModuledeclaresLayoutComponent,TopbarComponent,FooterComponent,HorizontalComponent,HorizontalTopbarComponentand providesLanguageService.
All routes are children of the layout, defined in
src/app/pages/pages-routing.module.ts:
| Path | Component | Purpose |
|---|---|---|
'' |
FarmersComponent |
Home dashboard (same as /farmers) |
farmers |
FarmersComponent |
Farmers list, leaderboard, pool KPIs |
farmer/:id |
FarmerComponent |
Single launcher detail |
blocks |
BlocksComponent |
Farmed blocks |
rewards |
RewardsComponent |
Payouts list |
reward/:id |
RewardComponent |
Single payout detail |
partials |
PartialsComponent |
Live partial submissions |
chia |
ChiaComponent |
Chia blockchain state |
pool/status |
PoolStatusComponent |
Pool health |
pool/stats |
PoolStatsComponent |
Historical pool statistics |
pool/logs |
PoolLogsComponent |
Live log terminal |
info/faq |
InfoFaqComponent |
FAQ |
info/fee |
InfoFeeComponent |
Fee explanation |
info/team |
InfoTeamComponent |
Team |
login |
LoginComponent |
Launcher login |
settings |
LoginComponent |
Alias of /login |
join |
JoinComponent |
How to join the pool |
No wildcard route is defined; unknown paths render the layout with an empty router outlet.
LayoutComponent sets the theme attributes on <html> during ngOnInit
(data-layout="horizontal", data-bs-theme="light", data-layout-width="fluid",
data-topbar="dark", …). The horizontal layout is the only one used in
production; the theme also ships vertical/sidebar variants that are not enabled.
Global styles are loaded in this order (see angular.json):
src/styles.scss— project overrides (notably the flexbox sticky-footer fix forhtml[data-layout="horizontal"])src/assets/scss/config/material/bootstrap.scsssrc/assets/scss/config/material/app.scsssrc/assets/scss/icons.scss
src/app/data.service.ts is a root-provided singleton wrapping every API call.
The REST base URL is hard-coded to /api/v1.0 and is therefore always resolved
against the current origin (dev-server proxy or Caddy).
Main REST calls:
| Method | Endpoint |
|---|---|
getStats() |
GET /stats |
getMessages() |
GET /message |
getBlocks(attrs) |
GET /block/ |
getLaunchers(attrs) |
GET /launcher/ |
getLauncher(id) |
GET /launcher/{id}/ |
updateLauncher(id, body) |
PUT /launcher/{id}/ |
getHarvesters(attrs) |
GET /harvester/ |
getPartials(launcher, …) |
GET /partial/ |
getRewards(attrs) |
GET /payout/ |
getReward(id) |
GET /payout/{id}/ |
getRewardAddrs(attrs) |
GET /payoutaddress/ |
getRewardTxs(attrs) |
GET /payouttransaction/ |
getPoolSize/ getMempool/ getNetspace/ getXchPrice(days) |
GET /pool_size/, /stats/mempool/, /stats/netspace/, /stats/xchprice/ |
getLauncherSize(launcher, days) |
GET /launcher_size/ |
getPartialTs(attrs) |
GET /stats/partial/ |
doLogin(body) |
POST /login |
getLoggedIn() |
GET /loggedin |
getNext(url) |
Follows an API pagination URL |
Live data uses RxJS WebSocketSubjects created by _connectLive() and exposed
as *Live$ observables with connect*/disconnect* helpers. The pool log uses a
dedicated connectLog() socket that merges incoming lines into log$:
| Channel | Payload |
|---|---|
pool_status |
Pool KPIs (unwrap: true) |
pool_stats |
Historical pool size series |
blocks |
Latest blocks |
rewards |
Latest payouts |
farmers |
Farmers list updates |
partials |
Incoming partials |
farmer/{id} |
Single-launcher updates |
log/ |
Pool log lines for the /pool/logs terminal |
The WebSocket URL is built from the current host: ws://…/ws/<channel> in
production (Caddy proxies /ws/* to the API upstream) and
ws://…/_proxy_ws/<channel> in development (proxied by ng serve, rewritten to
/ws). Most channels are close to page-specific components, which subscribe in
ngOnInit and disconnect in ngOnDestroy.
- Dictionaries live in
src/assets/i18n/{en,fr,de,es,nl,tr,zh}.jsonand are loaded at runtime byprovideTranslateHttpLoader. - Keys are namespaced per feature (
MENUITEMS.*,FARMERS.*,FARMER.*,BLOCKS.*,REWARDS.*,POOL_STATS.*,COMMON.*, …). The UI uses the| translatepipe; chart series names andhumanize-durationlocale are resolved throughTranslateService. LanguageService(provided byLayoutsModule) registers the supported languages, picks the cookie value or the browser language (falling back toen) and persists the choice in thelangcookie.- ngx-translate v18 removed
TranslateModule: use the standaloneTranslatePipeinimportsandprovideTranslateService()in tests.
Angular 22 has a regression affecting NgModule-based apps whose components are
not standalone (see
angular/angular#69530):
after bootstrapModule(), zone.js never enters the "angular" zone, so views
stop refreshing after async work (HTTP responses, WebSocket messages, timers…).
Two explicit workarounds are in place until the app moves to
bootstrapApplication():
CdKickInterceptorschedulesApplicationRef.tick()on the next macrotask after every HTTP response (deferred to avoidNG0101re-entrancy errors).AppComponentdoes the same after eachNavigationEndso router-driven bindings (routerLinkActive, breadcrumbs) keep updating.
Keep both in mind when touching change detection: removing them will make the UI look frozen in production.
src/environments/environment.ts (used by the development configuration) and
environment.prod.ts (swapped in by the production fileReplacements) expose:
export const environment = {
production: true,
pools: [
{ name: 'Global', url: 'chia.pool.energy', region: 'fr-par', latency: 0 },
],
};production only selects the WebSocket path prefix (ws vs _proxy_ws); the
API base URL is always relative (/api/v1.0).
src/proxy.conf.json forwards to https://pool.energy:
| Prefix | Target | Notes |
|---|---|---|
/api |
https://pool.energy |
REST |
/static |
https://pool.energy |
Static files (e.g. avatars) |
/_proxy_ws |
https://pool.energy/ws |
WebSocket (ws: true) |
The Caddyfile template is rendered by docker/entrypoint.sh at startup:
| Variable | Default | Description |
|---|---|---|
API_UPSTREAM |
https://pool.energy |
Upstream for /api/* and /ws/* |
LOGLEVEL |
INFO |
Caddy log level |
LOGFORMAT |
json |
Caddy log format (json, console, …) |
npm test # watch mode
npx ng test --watch=false # single run (CI style)Specs are colocated with components (*.spec.ts, 22 files / 24 tests). The
Vitest base config (vitest-base.config.mts) inlines @xterm/xterm and
ng-terminal because those packages ship CommonJS. Specs that render templates
using the translate pipe must provide the service:
TestBed.configureTestingModule({
imports: [PagesModule],
providers: [provideTranslateService()],
});npm run test:e2e # build image + run + test
npm run test:e2e -- --grep farmer # forward Playwright argsscripts/e2e-docker.sh builds the production image, starts it on 8081
(so a local instance can keep 8080), waits for it to answer, installs
e2e/node_modules on first run, and executes the Playwright smoke suite. The
suite loads all 16 routes, asserts an HTTP 200, the page title, the layout and a
route-specific selector, fails on console/page errors and verifies that charts
rendered on chart pages.
| Variable | Default | Description |
|---|---|---|
E2E_PORT |
8081 |
Public container port |
E2E_BASE_URL |
http://127.0.0.1:$PORT |
Tested URL (existing instance) |
E2E_SKIP_BUILD |
0 |
1 reuses the existing image |
E2E_KEEP_CONTAINER |
0 |
1 keeps the container for debugging |
E2E_READY_TIMEOUT |
90 |
Seconds to wait for the app |
PLAYWRIGHT_CHANNEL |
chrome |
chrome, chromium, msedge |
Dockerfile is a two-stage build:
- build —
node:26.12.1-bookworm, copies the repository, runsnpm ci && npm run build. - runtime —
caddy:2.11.7-alpine, exposes8080, copiesdist/poolenergy/browser, the Caddyfile template and the entrypoint.
docker build -t pool-energy-web .
docker run --rm -p 8080:8080 -e API_UPSTREAM=https://pool.energy pool-energy-webCaddy serves the static bundle with an SPA fallback (index.html for unknown
paths, excluding assets, /api/*, /ws/*, robots.txt, security.txt,
sitemap.xml) and reverse-proxies /api/* and /ws/* to API_UPSTREAM,
preserving the upstream Host header.
.github/workflows/build-and-publish.yml runs on pushes to main,
feature/*, bugfix/*, hotfix/* and v*.*.* tags, and on pull requests
targeting main, feature/*, bugfix/* or hotfix/*.
- Checkout, QEMU and Buildx setup.
- Login to GHCR (skipped for pull requests).
- Docker metadata (tags: commit SHA, branch,
vX.Y.Z,vX.Y,vX,latest). docker/build-push-actionforlinux/amd64withno-cache: true, pushing toghcr.io/pool-energy/webunless the event is a pull request.
Because the build runs inside Docker, any compile error fails the workflow —
run npm run build locally before pushing.
| Symptom | Fix |
|---|---|
npm ci fails with a lockfile/EUSAGE error |
package-lock.json is out of sync: run npm install, commit the lock |
EBADENGINE / unsupported Node version |
Use Node ^22.22.3 || ^24.15.0 || >=26.0.0 |
| Pages render but stay empty | The dev proxy needs access to https://pool.energy |
NG1010 / TS2724 about TranslateModule |
ngx-translate v18: import TranslatePipe and use provideTranslateService() |
| UI freezes after HTTP/WebSocket updates | Keep the Angular 22 tick() workarounds described above |
ng lint prints “Cannot find lint target” |
Expected: no ESLint target is configured |
Commits follow Conventional Commits
(feat|fix|docs|refactor|test|chore(scope): summary, English only). Work on a
<type>/<short-description> branch and open a pull request against main;
main is protected from direct pushes.
Distributed under the GNU Affero General Public License v3.0 — see LICENSE.