Skip to content

Latest commit

 

History

History
1747 lines (1420 loc) · 113 KB

README.md

File metadata and controls

1747 lines (1420 loc) · 113 KB

Fluxtracker

Real-time performance dashboard for the Flux decentralized cloud network: revenue, nodes, cloud resources, apps, games and decentralization, with daily history, Discord KPI reports and anomaly alerts.

Features

  • Live network overview -- Nodes by tier (Cumulus, Nimbus, Stratus) with operator wallets, cloud resource use (CPU, RAM, storage), and apps: how many ordered deployments are actually running, what was deployed or expires today, and the top games
  • Revenue -- Every app payment synced from the Flux blockchain, attributed to its app, valued in USD at the price on the day it was paid, and split by who paid (customers vs the Flux team), how they paid (card vs FLUX) and what was bought (new apps, renewals and updates, private enterprise apps)
  • Comparison periods -- One vs D button cycles the dashboard between Day, Week, Month, Quarter and Year comparisons
  • Median time left -- How many days of paid time the typical running app has left, shown live and recorded daily
  • Game server revenue -- What game servers earned over the last 30 days -- deployed through the Flux game sites, or by hand from a game image -- and their share of revenue over time
  • Geolocation demand and decentralization -- Per continent, its share of the network's CPU, its share of the CPU apps are using, and how full it is; plus the share of nodes hosted in datacenters and which providers host them
  • Node block rewards -- FLUX and $ per node per day, by tier, from the on-chain payout schedule (block rewards only; no yearly return or APY)
  • Historical Performance chart -- One chart, seven categories (Revenue, Node Distribution, Cloud Resources, Applications, Decentralization, Gaming, Revenue Sources), daily/weekly/monthly views, CSV export
  • Utilization Projection -- What the network would still be running over the next 180 days if no app renewed
  • App cohorts and paying customers -- New apps registered per month and how many are still running 3 months later; paying wallets per month, new vs returning
  • Revenue concentration -- How much of all-time revenue comes from the top 10 apps
  • Terminal header -- An animated terminal that shows the latest deployed and expiring apps with per-game art; click an app (here, in the ticker or on the Busiest Node card) to jump to its payments
  • Shareable views -- ?app=<name> opens an app's payments and ?metric=<id>&tf=<period> a chart view, so a link reproduces what you are looking at
  • KPI Discord reports -- Manual (footer button, any webhook) and scheduled (env-configured) reports comparing two completed periods
  • Anomaly alerts -- Optional Discord alerts when payments stop syncing or a day's revenue is far above normal
  • CSV export -- Revenue transactions and every chart series
  • Price history -- FLUX/USD daily closes via Binance, CoinGecko and (with a key) CryptoCompare
  • Automated sync -- Background schedulers for revenue sync (5 min), the services cycle (5 min), carousel updates (10 min), decentralization (5 min) and the daily snapshot check (30 min) -- see Background schedulers
  • Automated backups -- Daily backup of critical tables to Cloudflare R2 with 30-day retention and one-click restore
  • Auto-failover -- Circuit breaker switches to a failover Supabase instance when the primary is unreachable
  • Resilient outbound fetches -- One shared retry + per-endpoint circuit breaker for every external API read
  • Docker repository snapshots -- Still collected and queryable via the API, but not shown on the dashboard (see "App Categorisation")

The Dashboard

The page reads top to bottom: the terminal header, the app ticker, three headline cards, three detail cards, the Historical Performance chart, and the revenue transactions table. The numbers in this section are examples to show the format, not real figures.

Comparison period

Next to Performance Overview is a small button reading vs D. It sets what every card's up/down badge compares against. Each click moves to the next period: D (day) -> W (week) -> M (month) -> Q (quarter) -> Y (year) -> back to D. Hover over it to see what the next click does.

The cards compare the period so far with the whole previous period (this month to date vs all of last month). KPI reports work differently: they compare two completed periods.

App ticker

A scrolling ticker under the header with four tabs: Latest Deployed Apps, Expiring Soon, Missing Deployments (apps running fewer instances than they ordered -- an empty list is good news) and Top Network Stats. In the three app tabs every name is a button that opens that app's payments in the transactions table. The ticker is one Tab stop: the arrow keys, Home and End move between names, and it pauses while a name has focus or the pointer is over it.

Revenue card

The title follows the comparison period (Daily Revenue, Weekly Revenue, ...). It shows three figures and the change in FLUX against the previous period:

MONTHLY REVENUE
Payments   1.2K
USD        $38,420
FLUX       112,905.40
           ↑ +6.20%
Team-funded       21,300.00 FLUX · $7,250 · 18.9% of total
Median time left  16 days · running apps
  • Team-funded is what Flux team addresses (FLUX_TEAM_ADDRESSES) paid in the same period. It is already included in the totals above and is never subtracted. A period with none reads none this month.
  • Median time left takes every app running on Flux, works out how many days of paid time it has left, and shows the middle value: half the apps have less left, half have more. It is the same whatever the comparison period. It is a median because a few one-year apps would pull an average far up (one measurement: median 15.5 days, average 46.8). The line is hidden until the first reading, which on Supabase needs migration 023.

Nodes and Cloud Resources cards

Node counts by tier with the number of distinct operator wallets behind them, and CPU, RAM and storage used against total capacity (RAM and storage in TB). The Cloud Resources badge names the demand level from CPU in use -- Low (under 25%), Moderate (25-50%), High (50-75%) or Very high (75%+), with the percentage on hover. The Geolocation Demand card colours each continent by the same levels (src/lib/utils/demandLevel.js).

Apps card

APPS
94.2%                          New/Updated Today   212  ↑ 8.1%
11,210 of 11,900 ordered         118 new · 94 renewed
13,480 containers  ↑ 1.4%      Expiring Today      185  ↓ 3.0%

GAMING                                   ↑ 2.3%   4,870
  RuneScape: Dragonwilds   2,910  ↑
  Palworld                   960  ↓
  Minecraft                  520  ↑
Game revenue  $9,840  last 30 days
  • 94.2% / 11,210 of 11,900 ordered: how many of the deployments customers ordered are actually running right now, counting only apps that have not expired (an expired app will never be filled, so it is not "ordered"). The Missing Deployments tab and the chart's Deployments running (% of ordered) use the same rule. Hover the figure for its definition.
  • new / renewed: of today's deployments, how many are brand-new apps -- the app's most recent REGISTER message is within the last day of blocks, or it has never been registered -- and how many renew or update an app that was already running. Hidden while the app messages are not loaded, never guessed.
  • containers: every running container. A compose app runs one container per component, so this is higher than the number of deployments.
  • Gaming: running game-server instances, recognised by app name as well as image, so private (encrypted) game deployments are included. The top 3 games are listed with an arrow against the comparison period.
  • Game revenue: what was paid for game servers over the last 30 days, whatever the comparison period, in USD at the price on the day of each payment. A game server is recognised by its game-site app name (palworld1790087212677) or, for servers deployed by hand (rustserver, vrising), by the game image in its payment's on-chain message. Private (encrypted) apps hide their image, so they count only with a game-site name. Needs migration 024 (and 026 for the hand-deployed servers) on Supabase; hidden otherwise.

Busiest Node card

The node running the most app instances: its identity, the apps on it, and the CPU, RAM and SSD they use against that node's own benchmarked capacity. Each app chip is a button that opens that app's payments.

Geolocation Demand / Decentralization card

Two views, switched with the Demand / Datacenters buttons in the card header. The title follows the view: Geolocation Demand (the default) or Decentralization.

Demand: CPU by continent -- supply against load, from each node's own report in the node list:

CPU BY CONTINENT
Continent        Resource  Demand   Used
Europe              74.5%   59.7%  21.6%     (grey: Low)
North America       21.2%   34.8%  44.3%     (yellow: Moderate)
Asia                 2.6%    3.7%  37.7%     (yellow: Moderate)
Network: 27.0% of CPU used. Used: Low · Moderate · High · Very high
  • Resource -- the continent's share of all CPU cores on the network, used or not.
  • Demand -- its share of all the CPU apps are using (locked) on the network. Every running app counts, encrypted or not, whether or not it restricted its region.
  • Used -- how much of that continent's own CPU is in use, coloured by the demand levels above.

It used to compare region-locked app counts with node counts (#268), which weighed a small app on a big node the same as a heavy one; #463 replaced it. The per-continent cores and locked cores are recorded daily (cpu_cores_<continent> / cpu_locked_<continent>, migration 029).

Datacenters: the share of Flux nodes hosted in a datacenter, with a bar, the change against the comparison period, and the top datacenter providers. Several nodes on one IP (UPnP, ip:port) each count. A node is in a datacenter when its own hosting flag in Flux's node list says so (geolocation.dataCenter, which FluxOS sets from ip-api.com's hosting flag), or when its provider is on DATACENTER_OVERRIDES in config.js. PROVIDER_GROUPS there join spellings of one company (Hetzner Online GmbH / Hetzner / HETZNER-DC). Nodes with no hosting data are left out of the percentage; the header's geo counter shows how many have it. Until #457 (the date is DECENTRALIZATION_PER_NODE_SINCE) this counted unique IPs on a keyword list, which read higher -- the chart notes the step.

Historical Performance chart

Pick a category with the buttons under the chart, then a metric from the dropdown (some categories group their metrics). View sets daily, weekly or monthly points, Period how far back (7 days to All), and the export button downloads the series as CSV. The line under the title explains the selected metric in plain words.

How weekly and monthly views are built:

  • Amounts (revenue in $ and FLUX) are added up.
  • Readings of a level (nodes, utilization, median time left) are averaged.
  • Percentages are recalculated from the summed parts, never averaged: a month's "Organic %" is the month's organic FLUX divided by the month's total FLUX.

Metrics counted per calendar month (marked "per month") switch View to Monthly while selected. The selected metric and period are mirrored in the URL (?metric=<id>&tf=<period>, left out when they are the defaults), so a link opens the same view. If a metric needs a database migration that has not been applied, the chart says which one -- for example "not available yet (database migration 022 not applied)" -- instead of drawing zeros.

Category Metrics
Revenue Daily Revenue (FLUX / $), Cumulative Revenue (FLUX / $)
Node Distribution Total / Cumulus / Nimbus / Stratus nodes, Unique Wallets, locked collateral (total and per tier, FLUX). Block rewards per node: Cumulus / Nimbus / Stratus node rewards (FLUX/day and $/day)
Cloud Resources CPU / RAM / Storage utilization %, total and used CPU cores, RAM TB, storage TB, Utilization Projection (below)
Applications Daily: Total Applications, New/Updated Today, Expiring Today, Unique App Owners, Median time left on running apps (days) -- recorded daily since September 2026, so earlier dates are a gap, not zero, Deployments running (% of ordered), Private (enterprise) apps (% of running apps). By month registered: New apps registered (per month), Still running after 3 months (%)
Decentralization Quantity datacenter / independent, % datacenter, % independent, Decentralization %; CPU in use by continent: one line per continent; plus search-and-trend for a single country, continent or datacenter
Gaming Instances: All Game Instances, then one line per game (biggest first). Revenue: Game server revenue ($), Game server revenue (% of Revenue)
Revenue Sources Who paid: Organic ($ / FLUX / %), Flux team ($ / FLUX / %). How they paid: Paid by card ($ / %), Paid in FLUX ($ / %). What was bought: New deployments, Renewals & updates, Private (enterprise) apps (each $ and %). Paying customers: Paying / New / Returning customers (per month)

Reading Revenue Sources. The groups are separate ways of splitting the same total:

  • Who paid -- Organic + Flux team = all revenue. Organic is everyone except the Flux team, whether they paid by card or in FLUX.
  • How they paid -- Card + FLUX = all revenue. Card payments arrive through the fiat on-ramp, which buys the FLUX for the customer. Card is a payment method, not a separate kind of customer, so card payers are counted as Organic too.
  • What was bought -- from each payment's on-chain app message: New deployments registered a brand-new app, Renewals & updates extended or changed a running one, Private (enterprise) apps have encrypted specs. About 3% of payments cannot be matched to a message, so New + Renewals comes to just under 100%.
  • Paying customers -- distinct wallets that paid in FLUX that month, not counting the Flux team. "New" means the wallet's first-ever payment fell in that month; "Returning" means it had paid before. Card customers all arrive from the one on-ramp address, so they cannot be counted one by one.

Example: a monthly view reading Organic 63.8%, Flux team 36.2% and Paid by card 37.8%, Paid in FLUX 62.2% means customers paid just under two-thirds of that month's revenue, and a bit over a third of all revenue came in by card.

Reading the cohort metrics. Each point is the month an app was first registered. New apps registered counts them (a name reused after it expired counts again). Still running after 3 months is the share of that month's apps still paid for 3 months later. Months less than 3 months ago are left out rather than shown as 0%, so pick a period of 6 months or more. Example: Mar 2026: 24.8% means about a quarter of the apps registered in March were still paid for three months later.

Metrics recorded from September 2026. Deployments running (% of ordered), Private (enterprise) apps and CPU in use by continent are recorded daily from the day their migration (028, 030, 029) was applied; earlier dates are a gap. The weekly and monthly views of the percentages divide summed counts rather than average daily percentages.

Node block rewards. Derived at read time for the whole history from each day's tier count and the payout schedule measured on-chain (NODE_REWARD_SCHEDULE in config.js): a tier's payout per block x blocks that day / its nodes. At block 2,020,000 (25 Oct 2025) the block time went from 2 minutes to 30 seconds and the miner's share moved to nodes, so the lines step up that day. The $ line uses each day's closing price from flux_price_history. Block rewards only -- not app income, and before hardware costs.

Decentralization method change. From DECENTRALIZATION_PER_NODE_SINCE (2026-09-29) the datacenter share counts nodes and uses the node list's own hosting flag; earlier days counted unique IPs on a keyword list and read higher. The chart's note says so rather than rewrite them.

Utilization Projection. This metric looks forward, not back. Selecting it renames the chart Utilization Projection and hides View and Period. It draws two lines over the next 180 days assuming no app renews: app instances still running, and CPU cores still ordered. Every app's paid-until block is known, so the instance line is exact; the CPU line covers only apps whose specs can be read (private, encrypted apps -- mostly game sites -- hide their CPU), and the note under the chart says how many. Example summary:

If no app renews: 2,198 instances gone in 7 days, 6,242 of 8,955 within 30 days

It shows when renewals matter most. It is not a forecast of what will actually happen.

Revenue transactions and App analytics

Below the chart, the transactions table lists every payment with its time (UTC), app, FLUX and USD. It can be searched, filtered with the TEAM / FIAT payer badges, and exported to CSV. Amounts show 2 decimals on screen; the CSV keeps all 8. Hover an app name or address for the full value; an address links to its page on the Flux explorer. On a phone (768px and narrower) each payment is a two-line card -- app, FLUX and $, then time, type, payer badge and a short transaction link.

Two payments have no app name, and say why instead of showing a blank:

  • FLUXDRIVE -- a FluxDrive storage payment. Its on-chain message is an order reference, not an app spec (#188).
  • UNREGISTERED SPEC -- a payment for an app spec Flux never accepted: over a day old, and no app message has its hash (#412). It still counts towards revenue. The CSV writes FluxDrive / Unregistered spec in its Type column, and Unregistered spec as the App Name.

The App analytics view groups revenue by app, shows each app's share of all-time revenue, and opens with a concentration line such as:

All-time revenue: the top 10 apps earn 17.3%; 80% comes from 630 of 6,730 apps.

Clicking an app in the terminal header, the ticker or the Busiest Node card switches to this table and shows that app's payments. The URL gets ?app=<name>, so the view can be shared; typing your own search removes it.

Tech Stack

Layer Technology
Frontend SvelteKit 2 (Svelte 5, Vite 8)
Backend Express.js 5
Database Supabase (PostgreSQL) or SQLite via better-sqlite3
Backup Cloudflare R2 via @aws-sdk/client-s3
Charts Chart.js 4
Icons @lucide/svelte + custom Simple Icons components
Runtime Node.js 24 (22.12+ minimum)
Deployment Docker (multi-stage Alpine), Flux Cloud

Architecture

                            +-------------------+
                            |   Flux Cloud      |
                            |   (port 37000)    |
                            +--------+----------+
                                     |
                            +--------v----------+
                            |  Docker Container |
                            +-------------------+
                            |                   |
         +------------------+  startup.sh       +------------------+
         |                  |  (entrypoint)     |                  |
         v                  +-------------------+                  v
+--------+--------+                                     +----------+-------+
| SvelteKit        |                                     | Express.js API   |
| (port 5173)      | --- /api/* proxy (hooks.server.js) --> (port 3000)    |
| Frontend + SSR   |                                     | Background sync  |
+------------------+                                     +--------+---------+
                                                                  |
                                                         +--------v---------+
                                                         | Supabase         |
                                                         | (PostgreSQL)     |
                                                         | PostgREST + RPC  |
                                                         +------------------+

Both processes run inside a single Docker container. SvelteKit serves the frontend on port 5173 and proxies all /api/* requests to the Express backend on port 3000 via hooks.server.js. In production on Flux Cloud, only one port (37000) is exposed externally and mapped to the SvelteKit server.

Database Modes

The app supports two database backends via an adapter layer (src/lib/db/database.js):

  • Supabase mode (DB_TYPE=supabase, default) — The primary instance connects to Supabase (PostgreSQL). Complex queries use RPC functions. Backs up critical tables to Cloudflare R2.
  • SQLite mode (DB_TYPE=sqlite) — Docker/Flux instances use a local embedded SQLite database. On first start, optionally bootstraps historical data from R2. All RPC functions are translated to equivalent raw SQL.
PRIMARY (local)                          DOCKER/FLUX INSTANCES
+----------------------+                 +----------------------+
|  DB_TYPE=supabase    |   R2 Backup     |  DB_TYPE=sqlite      |
|  Supabase instance   | --------------> |                      |
|  Backs up to R2      |                 |  Bootstrap from R2   |
|                      |                 |  Run independently   |
+----------------------+                 +----------------------+

Background schedulers

All run inside the Express process and start once the database is ready.

Job Interval What it does
Revenue sync (revenueScheduler.js) 5 min Scans new blocks for app payments, prices them, names them. Owns revenue alone -- the services cycle has no revenue step (#430), and a call made while a sync is running joins it
Revenue audit 5 min after start, then every 4 h Re-scans the last 4,320 blocks (about 1.5 days at 30-second blocks) for anything missed, then backfills missing USD values
Services cycle (servicesScheduler.js -> test-allServices.js) 5 min In order: nodes, wallets (hourly-gated), app owners (hourly-gated), cloud, continent CPU, gaming, crypto, WordPress, today's repo snapshot. One step failing never skips the rest; a step that only served stored figures counts as failed, and /api/health names a step that failed 3 cycles in a row
Carousel 10 min The ticker's lists (deployed, expiring, missing, network stats)
Decentralization 5 min Recomputes the card from the node list (fetched hourly for the Busiest Node card); no calls of its own
Daily snapshot check (snapshotManager.js) 30 min Takes the day's snapshot, then backs it up to R2. On later checks the same day it fills columns that were empty, including a missed decentralization reading (#454)
KPI scheduler / anomaly alerts 10 min Only when configured -- see Scheduled reports

Shared caches keep this cheap: the block height is cached for 20 s and refreshed in the background, with the last good height used for up to 5 minutes if the daemon fails (#415); the full list of app messages (/apps/permanentmessages, ~91 MB) is downloaded at startup and then at most daily (REVENUE_SYNC.PERMANENT_MESSAGES_FULL_REFRESH_MS, #414), with a per-hash lookup naming new payments in between.

Getting Started

Prerequisites

  • Node.js 24 (22.12+ minimum, enforced by engines + engine-strict). Node 20 is end-of-life and current supabase-js, vitest and better-sqlite3 need 22+
  • A Supabase project (self-hosted or Supabase Cloud) — only for the default Supabase mode. DB_TYPE=sqlite needs no external database at all (see Database Modes above).

Environment Variables

Copy the example file and fill in your Supabase credentials:

cp .env.example .env

Database mode:

Variable Description Default
DB_TYPE supabase or sqlite supabase
DB_PATH SQLite file path (SQLite mode only) data/fluxtracker.sqlite3

Required for Supabase mode (DB_TYPE=supabase or unset):

Variable Description
SUPABASE_URL Your Supabase project URL
SUPABASE_SERVICE_ROLE_KEY Service role key (bypasses RLS)
SUPABASE_DB_URL Direct postgresql:// connection string. Only the startup schema migrator uses it, and only it can issue DDL -- the REST API cannot. Without it, a newly configured gaming/crypto column is never added and the failure is only a console.warn.

Optional -- ports, logging and process:

Variable Description Default
PORT Port the Express API listens on. This, not API_PORT. 3000
API_PORT Where start:all, Docker and the SvelteKit /api/* proxy expect to find the API. Ignored by npm run api. 3000
API_BASE Full base URL the SvelteKit /api/* proxy forwards to (hooks.server.js). Overrides API_PORT when set -- e.g. to run the frontend against an API on another machine. http://127.0.0.1:${API_PORT}
FRONTEND_PORT Port the built SvelteKit server listens on (start:all) 5173
HOST Interface the frontend binds to 0.0.0.0
ORIGIN adapter-node's CSRF origin. Anyone serving this on a real domain must set it or form posts are rejected. http://localhost:${FRONTEND_PORT}
LOG_LEVEL trace/debug/info/warn/error/fatal info
LOG_FORMAT json or pretty json in production, pretty otherwise

Optional -- Failover (auto-switch when primary DB is unreachable):

Variable Description
SUPABASE_FAILOVER_URL Failover Supabase project URL
SUPABASE_FAILOVER_KEY Failover service role key
FAILOVER_STATE_FILE Where the live instance is remembered across restarts (default data/supabase-active.json)

Optional -- Historical prices:

Variable Description
CRYPTOCOMPARE_API_KEY Third and last price-history source, tried only if set. Binance and CoinGecko need no key and cover almost everything; CryptoCompare returns HTTP 401 without one.

Optional -- Backup to Cloudflare R2 (primary instance, all 4 required):

Variable Description
R2_ENDPOINT R2 endpoint (https://<account-id>.r2.cloudflarestorage.com)
R2_ACCESS_KEY_ID R2 API token access key (read-write)
R2_SECRET_ACCESS_KEY R2 API token secret key
R2_BUCKET_NAME R2 bucket name (e.g. fluxtracker-backups)

Optional -- Bootstrap from R2 (Docker/SQLite instances, all 4 required):

Variable Description
BOOTSTRAP_R2_ENDPOINT R2 endpoint
BOOTSTRAP_R2_ACCESS_KEY_ID R2 API token access key (read-only recommended)
BOOTSTRAP_R2_SECRET_ACCESS_KEY R2 API token secret key
BOOTSTRAP_R2_BUCKET_NAME R2 bucket name

Optional -- Scheduled KPI reports (all unset = feature off):

Variable Description
KPI_WEBHOOK_URL Discord webhook the scheduled reports are posted to. Unset or invalid = scheduler disabled.
KPI_SCHEDULE Which timeframes to auto-send, comma-separated: daily, weekly, monthly, quarterly, yearly (e.g. daily,weekly)
KPI_SCHEDULE_HOUR_UTC Hour of day (UTC) the daily run fires; the other timeframes go out in the same hour once their period has completed (weekly on Mondays, monthly on the 1st, quarterly on the quarter's first day, yearly on Jan 1; default 2)

When configured, the scheduler checks every 10 minutes and sends the report once per period (a restart never double-sends; a server that was down at the scheduled hour catches up on boot). The footer KPI button keeps working independently with any webhook you enter in the dialog.

Optional -- anomaly alerts to the same webhook (issue #269), off by default:

Variable Description
ANOMALY_ALERTS on to enable. Needs a valid KPI_WEBHOOK_URL.
ANOMALY_OUTAGE_HOURS Hours without a newly synced payment before the outage alert (default 6).

Two alerts, each sent once: payments stopped syncing (and a follow-up when they resume), and an unusual revenue day -- a completed UTC day at least 3 standard deviations above the previous 28 days (about 14 days a year), naming the biggest payment's app and whether the Flux team, a card or FLUX paid it. Restart-safe via sync_status receipts; /api/health shows anomalyAlerts.

Optional -- CORS allowed origins (production only):

Variable Description
CORS_ALLOWED_ORIGINS Comma-separated list of scheme://host[:port] entries allowed to make cross-origin requests to the API, e.g. https://your-domain.example,http://203.0.113.10:37000. Local dev origins (localhost/127.0.0.1) are always allowed and don't need to be listed. Unset = only local dev origins work — a deployed instance logs a startup warning and rejects everything else.

Database Setup

Run every file in supabase/migrations/ in your Supabase SQL Editor, in numeric order -- unlike SQLite's self-healing ALTER TABLE ADD COLUMN IF NOT EXISTS schema, Supabase migrations are not auto-applied, so a fresh project or one that's fallen behind needs each file run manually:

  1. 001_initial_schema.sql -- Creates all tables, indexes, and seed data

  2. 002_rpc_functions.sql -- Creates RPC functions for aggregation queries

  3. 003_enable_rls.sql -- Enables Row Level Security on all tables (service_role bypasses it)

  4. 004_partial_index_usd_null.sql -- Partial index speeding up the USD-backfill scan

  5. 005_app_name_index.sql -- Index on app_name for analytics/search queries

  6. 006_update_usd_batch.sql -- RPC function for the USD backfill's batched updates

  7. 007_node_ip_classification.sql -- Table for per-IP datacenter/independent classification

  8. 008_decentralization_snapshots.sql -- Table for daily datacenter/independent history

  9. 009_decentralization_country_continent.sql -- Country/continent columns + snapshot tables

  10. 010_daily_revenue_from_addresses.sql -- RPC functions behind the Team-funded line and Revenue Sources chart

  11. 011_transactions_source_filter.sql -- Replaces get_transactions_paginated with a new signature taking p_from_addresses; this is what the TEAM/FIAT payer filter needs

  12. 012_gaming_instances_total.sql -- gaming_instances_total column (app-name-aware total)

  13. 013_game_snapshots.sql -- game_snapshots table: the per-game daily history

  14. 014_unique_wallets.sql -- unique_wallets column

  15. 015_unique_app_owners.sql -- unique_app_owners column

  16. 016_locked_collateral.sql -- locked_collateral* columns

  17. 017_transactions_paginated_index_order.sql -- faster, totally ordered get_transactions_paginated + composite index (#294). Same signature: optional for correctness of existing callers, but the transaction table and CSV export are slower without it

  18. 018_payer_base.sql -- read-only functions for paying customers per month and all-time revenue concentration (Revenue Sources -> Paying customers; App analytics share column)

  19. 019_message_metadata.sql -- four nullable columns on revenue_transactions (msg_type, enterprise, expire_blocks, instances) plus a batch-update function. After running it, call POST /api/admin/backfill-message-metadata once to fill existing payments; new payments are filled by the sync

  20. 020_revenue_mix.sql -- read-only daily revenue mix (Revenue Sources -> What was bought). Needs 019 and its backfill to show anything

  21. 021_run_rate.sql -- not used by the current dashboard; safe to skip

  22. 022_app_cohorts.sql -- read-only app cohorts by registration month (Applications -> By month registered). Needs 019

  23. 023_median_days_left.sql -- median_days_left column on daily_snapshots and current_metrics (Revenue card line and Revenue chart metric). History starts the day it is applied; nothing is backfilled

  24. 024_game_revenue.sql -- read-only daily game-server revenue (Apps card "Game revenue", Gaming -> Revenue)

  25. 025_database_size.sql -- read-only database size for the header (DB 411 MB)

  26. 026_game_name.sql -- game_name column on revenue_transactions so game revenue also counts hand-deployed game servers (by their image). After running it, call POST /api/admin/backfill-game-names once to fill existing payments

  27. 027_revenue_sources.sql -- read-only get_daily_revenue_sources: the Revenue Sources chart in one query. Optional -- without it /api/history/revenue/sources/daily falls back to six queries

  28. 028_deployment_fill.sql -- deployments_ordered, deployments_running and deployment_fill_percent on daily_snapshots and current_metrics (Applications -> Deployments running). History starts the day it is applied

  29. 029_cpu_by_continent.sql -- cpu_cores_<continent> / cpu_locked_<continent> on both tables (Decentralization -> CPU in use by continent). History starts the day it is applied

  30. 030_enterprise_apps.sql -- enterprise_apps and enterprise_apps_percent on both tables (Applications -> Private (enterprise) apps). History starts the day it is applied

Skipping 011-016 is not a partial degradation: a fresh project without them has no game_snapshots table, no payer filter, and none of the newer snapshot columns, so several shipping cards fail outright.

Skipping 018-030 is safe but hides features. The revenue sync keeps working without 019 (it retries inserts without the new columns). The endpoints behind 018/020/022/024 answer { "available": false, "reason": "Apply supabase/migrations/0NN_....sql" } instead of an error, the chart shows "not available yet (database migration 0NN not applied)", and the card lines that need 023, 024 or 025 are simply not shown. Without 028-030 the cards still work but the new daily series are not recorded (the snapshot writer only names those columns once they hold a value, so a deploy before the migration never loses a snapshot). All of them are read-only functions or nullable columns and are safe to re-run. SQLite mode needs none of this: its schema and queries are built in.

The schema migrator (src/lib/db/schemaMigrator.js) also runs on startup to add any dynamic columns needed by the current config (e.g., new gaming or crypto repo columns) -- that part is automatic in SQLite mode. Against hosted Supabase it needs SUPABASE_DB_URL (a direct postgresql:// connection string — the REST API cannot issue DDL). Without it the migrator falls back to a local-dev Postgres URL and the failure is swallowed as a console.warn, so a newly configured column silently never appears. Only the files above (tables, indexes, RPC functions) need to be run by hand against Supabase.

Install and Run

# Install dependencies (npm ci on a deploy box: it installs exactly the lockfile and never
# rewrites it -- run it again after any pull that changes package.json)
npm install

# Run both frontend and API in development
npm run dev:all

# Or run them separately:
npm run dev      # SvelteKit frontend (port 5173)
npm run api      # Express API server (port 3000)

Windows without Visual Studio Build Tools: better-sqlite3 13 ships a prebuilt Windows binary and loads it directly, but npm still runs its implicit node-gyp rebuild install step (the package contains a binding.gyp), and node-gyp refuses to start without Visual Studio -- so npm ci / npm install fail and leave node_modules half-installed. It is the only dependency with an install step, so skip install scripts and run the one project hook by hand:

npm ci --ignore-scripts && npm run prepare

Linux, Docker and CI are unaffected (they have a compiler, and the build step is a no-op there too).

Build for Production

npm run build
npm run start:all

start:all runs the same two processes the Docker image runs, in the same order: the Express API on port 3000, then the built SvelteKit server on 5173 once the API answers. Override with API_PORT / FRONTEND_PORT. Ctrl+C stops both, and if either process dies the other is stopped too — half the stack running is never useful.

API_PORT is read by start:all, the Docker image and the SvelteKit /api/* proxy — not by the Express server itself, which reads PORT. npm run api alone therefore ignores API_PORT; use PORT=3100 npm run api for that, or API_PORT=3100 npm run start:all for the whole stack.

Before printing its banner it makes one request to /api/health/live through the frontend, which is the same path the browser takes. Two healthy processes that cannot reach each other is exactly what a port mismatch looks like, and checking them separately misses it entirely.

API_PORT is passed to both processes, because hooks.server.js has to proxy to the port the API was actually started on. Changing it requires npm run build — hooks.server.js is compiled into build/, so a stale build keeps proxying to the old port and every request fails with API proxy error: fetch failed, which reads like the API is down rather than misaddressed.

Two processes are required, not one. The browser only ever talks to the SvelteKit server; src/hooks.server.js proxies every /api/* request onward to Express. That is what makes the single-port setup work on Flux, and it means npm run start on its own serves pages whose API calls all fail. Use it alone only if you are running the API separately.

npm run start    # SvelteKit server only — needs the API running elsewhere
npm run api      # Express API only

Database

Supabase (PostgreSQL) by default, or SQLite via better-sqlite3 when DB_TYPE=sqlite -- see Database Modes above; the table list below describes both. All database functions in src/lib/db/database.js are async. Deduplication is handled by ON CONFLICT DO NOTHING on upsert -- no in-memory txid sets needed.

Tables

Table Primary Key Description
daily_snapshots id (BIGSERIAL) One row per day with all metrics (revenue, nodes, apps, cloud, median_days_left, deployment fill, cpu_cores_*/cpu_locked_* per continent, enterprise_apps*). Unique on snapshot_date.
revenue_transactions id (BIGSERIAL) Individual blockchain payments with app attribution and, from migration 019, what the payment bought: msg_type (register/update), enterprise, expire_blocks, instances (NULL when no message matched). game_name (026) is the game a payment was for. app_type is git/docker, or fluxdrive (storage payment) / unregistered (a spec Flux never accepted) with app_name NULL. Unique on txid.
failed_txids txid (TEXT) Tracks failed transaction fetches for retry with attempt counts
current_metrics id (INTEGER) Singleton row (id=1) holding the latest live metrics, including median_days_left. updateCurrentMetrics() writes only the columns it is given, so overlapping writers never overwrite each other (#430)
flux_price_history date (DATE) Daily FLUX/USD closes from Binance, falling back to CoinGecko, then CryptoCompare (with a key)
sync_status id (BIGSERIAL) Tracks last sync time, block height, and status per service. Unique on sync_type.
repo_snapshots id (BIGSERIAL) Daily Docker image instance counts with category labels. Unique on (snapshot_date, image_name).
node_ip_classification ip (TEXT) Per-IP classification from the old method. No longer written since #457 (decentralization reads the node list); kept as history.
decentralization_snapshots id (BIGSERIAL) Daily node count per datacenter/org. Unique on (snapshot_date, org).
decentralization_country_snapshots id (BIGSERIAL) Daily node count per country. Unique on (snapshot_date, country).
decentralization_continent_snapshots id (BIGSERIAL) Daily node count per continent. Unique on (snapshot_date, continent).
game_snapshots id (BIGSERIAL) Daily instance count per game, by canonical game name. Unique on (snapshot_date, game_name). The authoritative per-game record: the gaming_* columns on daily_snapshots are derived from it.

RPC Functions

Defined in supabase/migrations/002_rpc_functions.sql, with more added in later migrations (006, 010, 011, 017 and 018-027):

Function Purpose
get_daily_revenue Sum revenue by day since a start date
get_daily_revenue_in_range Sum revenue by day within a date range
get_daily_revenue_usd Sum USD revenue by day with coverage stats
get_daily_revenue_usd_in_range Sum USD revenue by day within a date range
get_app_analytics Revenue grouped by app_name, paginated + search
get_transactions_paginated Paginated transactions with multi-field search. Migration 011 replaces this with a new signature taking p_from_addresses -- running 001-010 alone leaves the old one and the TEAM/FIAT payer filter cannot work.
update_transaction_usd_batch Batched USD updates for the price backfill (migration 006)
get_top_repos_by_category Top Docker images by category from latest snapshot
get_category_history Aggregated daily totals for a category
get_repos_by_category Distinct base image names in a category
get_repo_history_merged Instance count history merging tagged images
get_distinct_repos All distinct image names
get_distinct_repo_count Count of distinct image names
get_daily_revenue_from_addresses_in_range Sum revenue by day within a date range, filtered to a given address list (Revenue card Team-funded line, Revenue Sources chart)
get_daily_revenue_usd_from_addresses_in_range Same, in USD
get_monthly_payer_stats Paying wallets per month, new vs returning, team and fiat on-ramp excluded (018)
get_app_revenue_concentration All-time revenue total, top-10 apps' share, number of apps making up 80% (018)
update_transaction_metadata_batch Batched fill of the message-metadata columns (019)
get_daily_revenue_mix Per day: new-app, renewal/update and enterprise revenue (020)
get_app_cohorts Per registration month: apps started and how many were still paid N days later (022)
get_daily_game_revenue Per day: all revenue and the part paid for game servers, by app-name pattern (024); redefined in 026 to count game_name too
get_database_size Database size in bytes for the header (025)
update_transaction_game_batch Batched fill of game_name for the game-names backfill (026)
get_daily_revenue_sources Per day: total, team and fiat revenue in FLUX and USD in one scan (027)

Row Level Security

RLS is enabled on all tables via 003_enable_rls.sql. No permissive policies are defined, so the anon key gets zero access. The backend uses the service_role key, which bypasses RLS entirely.

API Endpoints

Base URL: /api

Health and Stats

Method Endpoint Description
GET /api/health Combined health (DB, snapshot, backup, price history, live price, services cycle, KPI scheduler, anomaly alerts, wallets) -- see Health Endpoint
GET /api/health/live Liveness only (process is running)
GET /api/health/ready Readiness (DB reachable)
GET /api/stats Database row counts and last snapshot date
GET /api/header Everything the terminal header renders in one call: network figures, block height, ArcaneOS codename, tracker stats, host location, DB status

Metrics

Method Endpoint Description
GET /api/metrics/current Current live metrics (nodes, cloud, apps, revenue, etc.)
GET /api/metrics/category/:category/top Top repos for a category with 7-day comparison

Valid categories: gaming, crypto, wordpress

Revenue

Method Endpoint Description
GET /api/revenue/:period Revenue for a period with previous-period comparison

Valid periods: daily, weekly, monthly, quarterly, yearly

Transactions

Method Endpoint Description
GET /api/transactions/summary Total count and revenue for today, 7 days, and 30 days
GET /api/transactions/paginated Paginated list with search (?page=&limit=&search=&appName=&source=). source filters to a payer group (team, fiat), comma-separated for several. Max limit is 1000 -- PostgREST's db-max-rows, not a choice.
GET /api/transactions/:date Transactions for a specific date (YYYY-MM-DD)

Analytics

Method Endpoint Description
GET /api/analytics/apps Revenue grouped by app name, paginated
GET /api/analytics/comparison/:days Period-over-period comparison for all metrics
GET /api/games/live?limit=&days= Per-game running instance counts, identified by app name as well as image -- what the Gaming card reads. Includes the previous reading per game for the comparison arrows.
GET /api/apps/deployment-fill?limit= How many ordered deployments are actually running, over apps that have not expired, plus the per-app shortfall breakdown. available:false when the app specs or the block height are unavailable
GET /api/apps/expired-running Apps still running at least 24 h after their subscription ended: count, instances (nodes), and the 3 longest expired. available:false when the census, specs or block height are unavailable — see Expired Running Apps
GET /api/games/revenue Game-server revenue over the last 30 days: USD at payment time, FLUX, share of all revenue. available:false before migration 024
GET /api/cloud/utilization-projection?days= What would still run each day for the next days (default 180, 7-365) if no app renewed: instances and readable CPU, drops at 7/30/90 days, the biggest week, CPU coverage
GET /api/analytics/apps/concentration All-time revenue concentration: top-10 share and how many apps make up 80%. available:false before migration 018

History and Charts

Method Endpoint Description
GET /api/history/snapshots Daily snapshots (summarized for chart use)
GET /api/history/snapshots/full Daily snapshots (full data, all columns), each with flux_price_day -- that day's close from flux_price_history. ?format=columns names each key once ({ count, columns, rows }); the chart uses it
GET /api/history/revenue/daily Daily revenue aggregated from transactions
GET /api/history/revenue/daily-usd Daily revenue in USD from transactions
GET /api/history/repos/list All distinct Docker image names
GET /api/history/repos/history?image= Instance count history for an image
GET /api/history/repos/latest Latest snapshot of all repos
GET /api/history/category/:category Category history (aggregated daily totals)
GET /api/history/category/:category/repos Repos belonging to a category
GET /api/history/revenue/team-funded/daily?start_date=&end_date= Daily FLUX + USD revenue from FLUX_TEAM_ADDRESSES, merged by date (kept for API users; the chart now reads /revenue/sources/daily)
GET /api/history/games?days= Per-game daily instance history from game_snapshots, plus the game list ordered by latest count (the chart's Gaming series)

| GET | /api/history/revenue/sources/daily | Per day, FLUX and USD: total, team, fiat (card), organic (= total - team), crypto (= total - fiat) | | GET | /api/history/revenue/mix/daily | Per day: new-app, renewal/update and enterprise revenue. Migration 020 | | GET | /api/history/revenue/games/daily | Per day: total FLUX and game-server FLUX / USD. Migration 024 | | GET | /api/history/revenue/payers/monthly | Per month: paying wallets, new, returning (team and fiat on-ramp excluded; no addresses returned). Migration 018 | | GET | /api/history/apps/cohorts | Per registration month: new apps and 3-month survival counts. Migration 022 |

Query parameters for history endpoints: limit, start_date, end_date (the five endpoints just above require start_date and end_date as YYYY-MM-DD).

Example responses (trimmed; values illustrative):

// GET /api/games/revenue
{ "available": true, "days": 30, "usd": 5524.80, "flux": 95791.7, "sharePercent": 43.2 }

// GET /api/history/revenue/sources/daily?start_date=2026-09-24&end_date=2026-09-24
{ "count": 1, "data": [ { "date": "2026-09-24",
  "total_flux": 11279.5, "team_flux": 1310.0, "fiat_flux": 5124.3,
  "organic_flux": 9969.5, "crypto_flux": 6155.2, "total_usd": 777.4 } ] }

// GET /api/history/revenue/payers/monthly?start_date=2026-09-01&end_date=2026-09-30
{ "available": true, "data": [ { "month": "2026-09-01", "payers": 51, "new_payers": 17, "returning_payers": 34 } ] }

// GET /api/history/apps/cohorts?start_date=2026-06-01&end_date=2026-06-30
{ "available": true, "data": [ { "date": "2026-06-01", "new_apps": 376,
  "eligible_90": 346, "survived_90": 65, "survival_90_percent": 18.8 } ] }

// GET /api/cloud/utilization-projection
{ "available": true, "horizonDays": 180,
  "today": { "apps": 1791, "instances": 8955, "cpu": 7675.2 },
  "drops": { "d7": { "instances": 2198, "cpu": 1483.2 }, "d30": { "instances": 6242, "cpu": 6427.2 } },
  "biggestDrop": { "from": "2026-09-25", "to": "2026-10-02", "instances": 2198, "cpu": 1483.2 },
  "coverage": { "cpuReadableApps": 979, "cpuUnreadableApps": 812, "unknownExpiry": 43 },
  "points": [ { "day": 0, "date": "2026-09-25", "instances": 8955, "cpu": 7675.2 } ] }

Decentralization

Method Endpoint Description
GET /api/decentralization Current datacenter vs. independent split of nodes (with a hosting flag or override), coverage, plus top datacenter providers (grouped by PROVIDER_GROUPS)
GET /api/decentralization/history?days= Historical datacenter/independent, country and continent breakdowns, for the Historical Performance chart's search-and-trend view
GET /api/decentralization/demand Per continent, in CPU cores: share of the network's capacity, share of the CPU in use, and how full it is. available:false until the node list has loaded
// GET /api/decentralization/demand (trimmed)
{ "available": true,
  "continents": [ { "code": "EU", "name": "Europe", "cores": 39965, "lockedCores": 8648,
                    "capacityPercent": 74.5, "demandPercent": 59.7, "inUsePercent": 21.6 },
                  { "code": "NA", "name": "North America", "cores": 11379, "lockedCores": 5039,
                    "capacityPercent": 21.2, "demandPercent": 34.8, "inUsePercent": 44.3 } ],
  "networkInUsePercent": 27.0, "nodesLocated": 6488, "nodesTotal": 6563 }

Categories

Method Endpoint Description
GET /api/categories/gaming Gaming app counts and history
GET /api/categories/crypto Crypto node counts
GET /api/categories/nodes Flux node distribution by tier

Carousel

Method Endpoint Description
GET /api/carousel/stats Cached carousel statistics
GET /api/carousel/deployed Recently deployed apps
GET /api/carousel/expiring Apps expiring soon
GET /api/carousel/missing Apps running fewer instances than they ordered. An empty response here is good news, not a failure
GET /api/busiest-node The network's busiest node (most running instances), its resolved app names, and CPU/RAM/SSD used vs. its own benchmarked capacity (issue #108)
GET /api/apps/activity 24h deployed/expiring counts (the same data behind the KPI scorecard's Deployed / expiring tile). deployedToday.newCount / updatedCount split the deployments into new apps and renewals/updates -- null while the app messages are not loaded

Admin

Method Endpoint Description
GET /api/admin/snapshot-status Snapshot system health and state
GET /api/admin/revenue-status Revenue sync status, block height, tx count
GET /api/admin/test-status Services-cycle scheduler status, including services: each step's last success and failures in a row
GET /api/admin/price-history-status FLUX/USD price history coverage + last sync outcome
GET /api/admin/host-location Where this server resolved its own location (diagnostic)
GET /api/kpi/availability Which KPI timeframes have enough history
GET /api/kpi/preview?timeframe= Computed KPI numbers without sending anything
POST /api/kpi-report Build and deliver a KPI report
POST /api/admin/revenue-sync Trigger manual revenue sync
POST /api/admin/clear-revenue-data Delete all transactions and reset sync (destructive)
POST /api/admin/reset-revenue-sync Reset sync block to trigger full re-scan
POST /api/admin/backfill-app-types Backfill git/docker app type
POST /api/admin/backfill-app-names Backfill app names from OP_RETURN data. A hash no app message knows, on a payment over a day old, is labelled app_type = 'unregistered' (#412)
POST /api/admin/backfill-usd Backfill USD amounts using price history
POST /api/admin/sync-price-history Force a gap-filling price history sync
POST /api/admin/backfill Backfill revenue-only daily snapshots. Body { from?, to? } as YYYY-MM-DD; defaults to the year ending yesterday (UTC). Writes daily_revenue only -- every other column stays NULL.
POST /api/admin/backfill-repo-categories Backfill NULL repo categories
POST /api/admin/recategorize-repos Reset and re-apply all repo categories
POST /api/admin/snapshot Trigger manual daily snapshot
POST /api/admin/repo-snapshot Trigger manual repo-only snapshot
POST /api/admin/test-services Run the services cycle, a revenue sync and a carousel update now
POST /api/admin/audit-transactions Audit recent transactions for missed entries
GET /api/admin/backup-status Backup configuration and health status
POST /api/admin/backup Trigger manual backup to R2
GET /api/admin/backups List available backup dates in R2
POST /api/admin/restore Restore from backup ({ "date": "YYYY-MM-DD" })
POST /api/admin/failover Manually switch between primary/failover DB
GET /api/admin/failover-status Active instance and circuit breaker state
POST /api/admin/backfill-collateral Backfill locked_collateral* columns from node-tier history
POST /api/admin/backfill-message-metadata Fill msg_type/enterprise/expire_blocks/instances on stored payments from one download of the permanent messages. Only touches rows with no metadata yet; safe to re-run. Needs migration 019 on Supabase
POST /api/admin/backfill-game-names Record which game each stored payment was for (game-site name, or a game image in its message) so game revenue covers hand-deployed servers. Only rows with no game yet; safe to re-run. Needs migration 026 on Supabase
POST /api/admin/repair-snapshot-revenue Correct daily_snapshots.daily_revenue on past days that stored a partial start-of-day figure. ?dryRun=1 first; never touches today, never lowers a figure. Body { from?, to? }
POST /api/admin/repair-game-columns Bring the per-game gaming_* columns onto the app-name definition: rewrite the days game_snapshots covers, NULL the ones before it. ?dryRun=1 reports without writing. Idempotent.

App Categorisation

Not currently shown on the dashboard. FluxOS v8.18 (Sept 2026) removed the per-instance image field the running-apps census used to return (issue #106); apps are now resolved by name via appSpecsCache.js, which only covers the ~76-78% of running instances whose spec is still public. An undercounted or (for the old Git/Docker split) actively wrong category total is worse than none, so the Gaming/Crypto/WordPress metric cards, the category cards, the Docker Repos history graph and the Git/Docker split were all removed (issue #109) rather than shown with a caveat. total_apps is unaffected — it comes from the running-instance census, not per-app resolution — and is the one app-count figure still displayed.

The logic below is unchanged and still runs: repo_snapshots, the /api/categories/* endpoints and gamingService/cryptoService/wordpressService keep collecting this data (issue #108 explores what to build with it next), so this section remains accurate as a reference for that code — just read "card" below as "category row in the API/database," not "something on the dashboard."

Every category total resolves its numbers through one function, categorizeImage() in src/lib/config.js. It matches keywords against the whole Docker image string, tag included.

Two things decide what you see on a card:

  • Which category an image counts toward — CATEGORY_CONFIG keywords, minus CATEGORY_EXCLUDE
  • What the row is called, and which rows merge — DISPLAY_NAME_OVERRIDES (per image, used in history) and CANONICAL_NAME_OVERRIDES (groups image variants into one card row)

Why one game or chain can appear under several images

A game or chain is often published as several unrelated Docker images, and the card merges them:

Card row Merged from
Palworld thijsvanloef/palworld-server-docker + runonflux/palworld-server-flux (Flux's own packaging)
Minecraft itzg/minecraft-server (Java) + itzg/minecraft-bedrock-server
Valheim mbround18/valheim + littlestache/valheim-flux + lloesche/valheim-server
Rust littlestache/rust-server + pfeiffermax/rust-game-server
Beldex ghcr.io/girderworks/edge + ghcr.io/girderworks/feather

repo_snapshots still stores a row per image, so history and charts are unaffected by grouping — only the card view merges.

Palworld is the common question. It used to render as two rows, "Palworld" and "Palworld Server", which read as two different products. They are the same game: the second is Flux's own packaging (runonflux/palworld-server-flux), and it only got a separate label because the fallback namer strips the trailing -flux and stops. Both now merge into one Palworld row.

An image name doesn't always name the thing

The biggest source of undercounting is an image whose name mentions neither the chain nor the game. Beldex is the worst case found so far: beldex was in the crypto keyword list the whole time and never matched, because ~890 masternodes run as ghcr.io/girderworks/{edge,feather} — a third party packaging Beldex for Flux, with no chain identifier anywhere in the name. That is roughly 12% of everything running on the network, sitting in "uncategorised" (issue #74).

When an image name is opaque, its labels usually are not:

docker inspect <image> --format '{{json .Config.Labels}}'   # org.opencontainers.image.title=beldex-node
docker history --no-trunc <image> | grep -i label

It is worth periodically running the top uncategorised images through that before assuming they can't be classified — a handful of large owners account for a large share of the network, so one missed image can move a total by an order of magnitude.

What "Crypto Nodes" deliberately excludes

The card is labelled Crypto Nodes, so it counts infrastructure for a named chain: nodes, indexers and block explorers. These are crypto-adjacent but are not counted, on purpose:

Not counted Why
runonflux/fluxos, fluxoshashes, fluxcloud, runonflux/titan Flux platform containers, not a node for a chain
runonflux/ipfs Content-addressed storage — no chain state
wirewrex/nostr-rs-relay Nostr is a social protocol, not a blockchain
patpi93/beam105-worker A miner, not a node
smartico/aave, liquity/dev-frontend, honsontran/sushiswap-interface and other dApp UIs Web frontends, not nodes

There are tests pinning each of these to "uncategorised" so the scope line doesn't drift by accident. If you want a broader definition, that's a deliberate decision to change the card's label along with its contents.

Helper images that would be counted as the thing they help

CATEGORY_EXCLUDE is checked before the keyword match:

  • *-server-website — companion web frontends shipped next to the game servers. Counting them inflated every game total and produced a "MINECRAFT SERVER WEBSITE" label that overflowed the card.
  • wirewrex/flux-dns-fdm — a monitoring sidecar that runs one instance per app it watches and names the watched app in its tag (:minecraft-ping, :wordpress). Since matching includes the tag, those leaked straight into the totals — 47 phantom gaming instances on one day.

This is the trap to remember when adding a keyword: any bare single-word keyword also matches tags. presearch/node is safe because it's qualified; a bare wordpress is not.

Changing the configuration

  1. Edit CATEGORY_CONFIG, CATEGORY_EXCLUDE, DISPLAY_NAME_OVERRIDES or CANONICAL_NAME_OVERRIDES in src/lib/config.js
  2. Check the new keyword against a live sample before trusting it — confirm it only picks up currently-uncategorised images and moves nothing between categories
  3. Run the tests: npx vitest run src/lib/__tests__/categorization.test.js
  4. Re-apply to stored history:
curl -X POST localhost:3000/api/admin/recategorize-repos

The category endpoint also re-validates stored rows against the current config at read time, so a newly excluded image disappears from the cards immediately — the admin call fixes history.

Adding a featured breakdown column (the named entries inside a category, e.g. gaming_palworld) is separate: add the repo to GAMING_REPOS/CRYPTO_REPOS and schemaMigrator creates the column at startup, because METRIC_COLUMNS is derived from that config. Note its imageMatch must list every image that merges into the row, or the featured number and the card disagree — gaming_palworld read 170 against a card showing 266 for exactly this reason. Historical values keep their old basis, so expect a step in the trend line on the day a change lands.

Expired Running Apps

The Apps card's Expired running view lists apps that are still running on Flux nodes after their subscription has ended — work the network should have stopped. It shows how many there are, on how many nodes, and the three that ended longest ago. The count is recorded daily, so the Historical Performance chart (Applications → Expired running apps) shows whether cleanup is getting better or worse. Live figure: GET /api/apps/expired-running.

How an app's end is calculated

Every app registration and update message carries the block it was mined in (height) and the number of blocks paid for (expire). Blocks are 30 seconds, so 2,880 a day:

end block      = height of the app's latest register/update + expire
blocks expired = current block − end block
days expired   = floor(blocks expired / 2,880)

Two adjustments, both copied from FluxOS's own expiry rule (registryManager.js), because the Proof of Node fork at block 2,020,000 made blocks 4× faster (2 minutes → 30 seconds):

  • A missing (or 0) expire means the default for the era the app was registered in: 22,000 blocks before the fork, 88,000 after — about a month either way.
  • An app registered before the fork whose paid time ran past it has the post-fork part of that time multiplied by 4: end block = 2,020,000 + (height + expire − 2,020,000) × 4. Without this, long-running pre-fork apps look like they expired hundreds of days ago.

The height and expire come from the app's spec in globalappsspecifications while Flux still lists it (it keeps some for a while after they expire). Once the spec is gone, they come from the app's last message on permanentmessages?appname=<name> — looked up once per app and cached, since an ended app's end block never changes. A running container with no spec and no permanent message at all is a local container that never had a subscription, and is not counted.

The 24-hour grace period

Containers take a while to be removed after an app ends, and the stats crawl that reports running containers lags too. To keep normal cleanup out of the figure, an app only counts once it is at least 2,880 blocks (24 hours) past its end block.

Worked example (real data, 6 October 2026)

palworld1785555251684's last message was an update at block 2,867,975 with expire 100 — that is how an owner cancels an app: they set it to end 100 blocks later.

end block      = 2,867,975 + 100        = 2,868,075
current block  =                          3,012,176
blocks expired = 3,012,176 − 2,868,075  = 144,101
days expired   = floor(144,101 / 2,880) = 50

144,101 blocks is well past the 2,880-block grace period and the app was still running on one node, so it counts: 1 app, 1 instance, 50 days expired.

Counter-example: an app whose end block was 1,500 blocks ago (about 12.5 hours) is past its end but still inside the grace period, so it is not counted yet. If it is still running 1,380 blocks later, it will be.

"Instances" counts nodes, not containers: a WordPress app running its wp, mysql and operator containers on 3 nodes is 3 instances.

Terminal Header

The header is an animated terminal in one fixed-size box that never grows or shrinks.

  1. Boot -- it types out real /api/header data (API and database checks, a block height counting up, version and network stats), then wipes into the FLUX ASCII logo.
  2. Rotation -- it then cycles logo -> latest expiring app -> latest deployed app -> logo, skipping any slot with nothing to show. Deployments walk through the whole day's list rather than repeating the newest (#3 OF 212 IN 24H).

On desktop (1280px and wider) each app gets one wide frame (example):

>>>>>>>>>>>>>>>>>>>>> NEW APP DEPLOYED <<<<<<<<<<<<<<<<<<<<<<
  NAME   palworld1790087212677      GAME   Palworld
  AGO    12m ago                    IMAGE  private (enterprise)
  INST   3                          PAID   42.50 FLUX · $14.20 · Sep 26
  RES    4 CPU / 16 GB / 60 GB      TERM   1 month · ends Oct 26
>>>>>>>>>>>>>>>>>>>>> #3 OF 212 IN 24H <<<<<<<<<<<<<<<<<<<<<<

Narrower screens show the left column only.

  • Intros -- before its details, a deployment plays a short animation. Games have their own (a Valheim longship, a Minecraft build, a Dragonwilds dragon, Palworld, FiveM, Project Zomboid, V Rising, with a second variant for some games, picked per app); other games get a self-playing gamepad. A game is recognised by its app name or, for servers deployed from an image (mbround18/valheim, itzg/minecraft-server, ...), by the same image rule the games count uses. Services get their own art (Git/Orbit, Folding@home, crypto nodes including Firo masternodes, AI agents including Hermes Pro, WordPress, VPN, uptime probes), and anything else gets a crane landing containers.
  • Expiring apps play an outro in orange: the ship sails off, the dragon flies away, or a fuse burns down showing the real time left.
  • Large deployments (10+ instances or 8+ CPU) are labelled LARGE DEPLOYMENT, play their intro twice and glow brighter.
  • Hover pauses the rotation on the current frame. Click an app frame to jump to that app's payments in the transactions table; click the logo to replay the last intro.
  • Attract mode -- type flux anywhere on the page (or enter the Konami code) to play every piece of art back to back (27 pieces), each captioned.
  • Seasonal sky -- bats and a moon in Halloween week, snow in December, fireworks on New Year's Day.
  • The build line shows the version (green) and ArcaneOS codename (purple), both API-driven, and the stats beside it include the FLUX price and the database size.
  • Reduced-motion users get each intro's first frame as a still instead of the animation.
  • Covered by the headless-browser acceptance harness in scripts/header-smoke/ (see its README) -- run it before and after any header change.

Tests

npm test            # vitest

The pure logic is deliberately separated from the components so it is unit-testable: KPI period arithmetic, aggregation and Discord formatting (src/lib/kpi/), the terminal header animation (src/lib/utils/terminalAnimation.js), app categorisation (src/lib/__tests__/categorization.test.js), the fetch breaker and resilient fetch, the scheduler time math, the adapter layer, and the dashboard maths (revenue sources, cohorts, game revenue, CPU demand vs supply, demand levels, node block rewards, utilization projection, median time left, anomaly detection, seasonal sky). A test also fails on any toLocaleString() without a locale in a component: numbers on screen go through src/lib/utils/format.js (fixed en-US). The terminal header additionally has the headless-browser harness in scripts/header-smoke/ that drives a real browser against a dev server and asserts size/style/timing invariants.

Backup & Resilience

Automated Backups (Cloudflare R2)

These tables hold point-in-time observations that cannot be re-derived later from the blockchain or an external API, so all of them are backed up. The list lives in one place, src/lib/services/backupTables.js, and backup, restore and the SQLite bootstrap all read it:

Table Required
daily_snapshots, repo_snapshots yes -- a restore without them fails before writing anything
game_snapshots, flux_price_history no
node_ip_classification no -- history of the per-IP method, no longer written (#457)
decentralization_snapshots, decentralization_country_snapshots, decentralization_continent_snapshots no
  • Trigger: Automatically after each successful daily snapshot (fire-and-forget, never blocks the snapshot)
  • Manual: POST /api/admin/backup
  • Storage: Cloudflare R2 at backups/{YYYY-MM-DD}/<table>.json, one object per table
  • Retention: 30 days (older backups pruned automatically)
  • Restore: POST /api/admin/restore with { "date": "2026-03-18" } -- upserts data into the current DB. Optional tables missing from an older backup are skipped
  • Health: Backup is "healthy" if not configured (not expected) OR last backup is less than 48 hours old. A backup missing any table is reported as partial. At startup the newest backup is read back from R2; if it is partial (for example, taken before a table joined the set) a top-up backup runs straight away
  • Bootstrap (SQLite mode): imports every table that is EMPTY locally -- checked per table, so one that failed to import is retried on the next boot -- from the newest COMPLETE backup, falling back to the newest one that has the required tables
  • No-op without config: If R2 env vars are not set, backup is silently disabled -- no errors, no log spam

Auto-Failover

If a failover Supabase instance is configured (SUPABASE_FAILOVER_URL + SUPABASE_FAILOVER_KEY):

  • The circuit breaker monitors consecutive DB failures (threshold: 5)
  • On a CLOSED -> OPEN trip while on the primary, the app switches to the failover instance. Automatic failover only ever goes primary -> failover: a failed probe or a new outage while on the failover never switches back
  • The live instance is remembered in FAILOVER_STATE_FILE, so a restart during a failover resumes on the failover (and says so in the log) instead of silently returning to the primary
  • Switching back is manual (POST /api/admin/failover). Anything written while on the failover -- snapshots, transactions, receipts -- is only on the failover and has to be reconciled into the primary first
  • /api/health reports status: "degraded" with the problem "running on the failover database" for as long as the failover is live
  • All existing supabase.from(...) calls route transparently through a Proxy
  • Status: GET /api/admin/failover-status

Circuit Breakers

Two independent breakers protect the app from hammering an unreachable dependency:

Database (src/lib/db/circuitBreaker.js): CLOSED (normal) -> OPEN (DB unreachable, all requests blocked) -> HALF_OPEN (probing with one request)

  • Failure threshold: 5 consecutive failures
  • Cooldown: 60 seconds before probing
  • On the first transition to OPEN it triggers the auto-failover described above

Outbound API fetches (src/lib/services/fetchBreaker.js): the same state machine, keyed per endpoint, wired into resilientFetch.js — the shared HTTP GET primitive every external API read goes through (retry + timeout + optional shape validation). One dead upstream (a Flux stats endpoint, an exchange) is short-circuited for the cooldown instead of being timed out against on every cycle, and one open endpoint never blocks another. Threshold 5 / cooldown 60s via FETCH_CIRCUIT_BREAKER_CONFIG. The Discord webhook POST is deliberately outside this path — a retry could duplicate a user-visible report.

Health Endpoint

GET /api/health returns one overall verdict built from every sub-check:

  • ok -- nothing is wrong (HTTP 200)
  • degraded -- the database answers but something else is wrong; problems lists what: a stale or partial backup, a failing or overdue daily snapshot, stale price history, a revenue sync that has stalled (3 missed intervals) or failed 3 times running, a services-cycle step that failed 3 cycles in a row (named, e.g. cloud refresh failed 3 cycles in a row (last success 15 min ago)), no live FLUX price and no usable fallback (checked after 15 minutes of uptime), or the failover being live (HTTP 200 -- the dashboard is still serving)
  • down -- the database is unreachable (HTTP 503)

Backup and snapshot state survive restarts: the last backup time is read back from R2 at boot, and the snapshot check looks at the table, so a deploy no longer reports a false stale backup until the next midnight.

{
  "status": "ok",
  "problems": [],
  "db": { "status": "connected", "circuit": "CLOSED", "activeInstance": "primary", "activeSince": 1790120000000 },
  "revenueSync": { "lastCompleted": 1790127389663, "consecutiveFailures": 0 },
  "snapshot": { "healthy": true, "todaySnapshotExists": true },
  "backup": { "enabled": true, "healthy": true, "lastBackup": 1710720300000, "partial": false, "ageHours": 2.1 },
  "priceHistory": { "days": 1720, "oldest": "2021-12-10", "newest": "2026-08-20", "healthy": true },
  "livePrice": { "price": 0.0683, "ageMinutes": 1, "fallbackUsable": true },
  "services": { "nodes": { "lastSuccess": 1790645573729, "consecutiveFailures": 0, "lastError": null },
                "cloud": { "lastSuccess": 1790645573729, "consecutiveFailures": 0, "lastError": null } },
  "kpiScheduler": { "configured": true, "schedule": ["daily", "yearly"], "hourUtc": 2,
                    "lastRuns": { "daily": { "at": "2026-09-06T02:00:14.512Z", "ok": true },
                                  "yearly": { "at": "2026-09-29T02:10:02.000Z", "ok": true, "skipped": "not enough data" } } },
  "anomalyAlerts": { "configured": true, "reason": null, "outageHours": 6, "lastCheck": "2026-09-29T02:10:00.000Z", "lastAlert": null },
  "uniqueWallets": { "uniqueWallets": 821, "uniqueAppOwners": 1288, "lastSyncStatus": "completed", "ageHours": 0 }
}

services lists every services-cycle step (nodes, wallets, appOwners, cloud, continentCpu, gaming, crypto, wordpress, repoSnapshot); livePrice is the age of the last live FLUX price, whose fallback stops being used after 6 hours.

priceHistory.healthy is false when the newest stored FLUX/USD price is more than 2 days old. That means the historical price sources are failing and new transactions will be stored with a NULL amount_usd — check lastSync.error and see "Historical prices" below.

Historical Prices

Daily FLUX/USD closes live in flux_price_history and are what turn amount into amount_usd for every transaction older than 24 hours. Today's transactions use the live price instead.

Sources are tried in order, and none needs an API key:

  1. Binance — FLUXUSDT daily klines, 1000 candles per call, paged with startTime (data from 2021-12-10)
  2. CoinGecko — market_chart, last 365 days
  3. CryptoCompare/CoinDesk — only used when CRYPTOCOMPARE_API_KEY is set; the endpoint returns HTTP 401 without one

The sync is gap-aware: every 5 minutes it looks for missing days between the oldest revenue transaction and yesterday and fills them, so a hole in the middle of the table heals on its own. If every source fails it backs off for an hour and records the reason in GET /api/admin/price-history-status.

If USD revenue looks empty on a chart:

curl -s localhost:3000/api/admin/price-history-status   # is `newest` recent? any lastSync.error?
curl -X POST localhost:3000/api/admin/sync-price-history # force a sync past the cooldown
curl -X POST localhost:3000/api/admin/backfill-usd       # then fill in the NULL amount_usd rows

backfill-usd returns coverage (oldest/newest price date and day count) alongside missingPriceDates, so an updated: 0 result says why.

KPI Report

A KPI button in the footer opens a dialog where you pick a time frame and a Discord webhook, and FluxTracker posts an executive scorecard comparing two completed periods. Reports can also send themselves on a schedule (see Scheduled reports).

The scorecard is one Discord message, written for an executive reader: a headline in plain words, then nine tiles -- three rows of money, network and apps. Discord shows the tiles three to a row on desktop and stacks them on a phone. A real daily report (24 Sep 2026) reads:

Flux Network · Daily KPIs · Sep 24, 2026
Revenue up 27% on Wednesday; nodes steady.
Most deployed: RuneScape: Dragonwilds 87 · Valheim 6 · Palworld 4 · other 41

Revenue            Game revenue        New vs renewal
$777               $489                51% / 49%
▲ 27.0%            62.8% of revenue    of revenue

Nodes              CPU in use          Ordered vs supplied
6,508              26.7%               83.0%
▼ 0.9%             ▲ 0.5 pts           ▲ 0.1 pts

Apps running       Deployed / expiring Median time left
7,908              138 / 40            15 days
▼ 0.4%             last 24 hours       running apps

vs Sep 23, 2026 · data complete
  • The colour bar is green when revenue rose and red when it fell.
  • Weekly, monthly, quarterly and yearly reports add a revenue trend line under the headline, drawn with block characters -- by day for a week or month, by week for a quarter, by month for a year -- e.g. Revenue by day: `▁▄█▂▂▂▅` ($101 to $1,127). On those reports, nodes and apps are period averages and say so (Nodes (average)), and deployments are the period's total (Deployed / expired).
  • Game revenue, New vs renewal and Median time left come from the newer data (migrations 019-024). Anything that cannot be backed with data for the whole period shows n/a on its tile -- never a made-up zero -- and the footer says some figures lack full data.
  • No emoji: direction is shown by plain ▲ / ▼ triangles. Percentages change in points (▲ 0.5 pts), other figures in percent.

Where each tile comes from (src/lib/kpi/scorecard.js):

  • Revenue, Nodes, CPU in use, Ordered vs supplied, Apps running -- the metric dataset below (buildKpiDataset), under its coverage rules. Ordered vs supplied (#482) is the Apps card's figure -- of the deployments ordered by unexpired specs, the share running -- taken over the period as sum(deployments_running) / sum(deployments_ordered), not an average of daily percentages. It is recorded from 2026-09-28, so longer reports show n/a until a full comparison period exists. It replaced In datacenters; the datacenter share is still in the dataset and /api/kpi/preview.
  • Game revenue, New vs renewal, Median time left, Deployed / expiring, Most deployed, trend line -- getExecutiveExtras() in kpiService.js. Each falls back to n/a, never 0, when its data is missing.

The full dataset is available without sending anything from GET /api/kpi/preview?timeframe=.

Time frames

Reports always compare two completed periods and never include the period in progress — a part-finished week would always look like a collapse next to a full one.

Time frame Period Worked example (today = Fri 21 Aug 2026)
Daily Yesterday (UTC) Aug 20 vs Aug 19
Weekly Monday-Sunday (ISO week) Aug 10-16 vs Aug 3-9
Monthly 1st to last day of the calendar month Jul 2026 vs Jun 2026
Quarterly Calendar quarter (Q1 Jan-Mar, Q2 Apr-Jun, Q3 Jul-Sep, Q4 Oct-Dec) Q2 2026 vs Q1 2026
Yearly Jan 1 - Dec 31 2025 vs 2024

All boundaries are UTC, matching how revenue_transactions.date and daily_snapshots.snapshot_date are stored. The logic lives in src/lib/kpi/periods.js.

How each metric is calculated

Two aggregation rules, chosen to match the live dashboard:

Section Metrics Aggregation Source
Revenue Flux, USD, Team-funded, Team-funded %, Fiat, Fiat % Sum across the period revenue_transactions
Revenue FLUX price (avg) Average of daily snapshots daily_snapshots.flux_price_usd
Nodes Total, Cumulus, Nimbus, Stratus Average of daily snapshots daily_snapshots
Resource Utilization CPU used (cores), RAM used, SSD used Average of daily snapshots daily_snapshots
Resource Utilization CPU used %, RAM used %, SSD used % Average of daily snapshots daily_snapshots
Nodes Unique wallets Average of daily snapshots daily_snapshots.unique_wallets
Applications Total Apps Average of daily snapshots daily_snapshots
Applications Deployments running (% of ordered) -- the Ordered vs supplied tile Ratio of summed daily counts daily_snapshots.deployments_running / deployments_ordered
Decentralization % Datacenter (no longer a tile since #482) Average of daily snapshots daily_snapshots.decentralization_datacenter_percent

The scorecard's extra tiles (getExecutiveExtras()):

Tile Calculation Source
Game revenue Sum across the period, shown in USD with its share of revenue get_daily_game_revenue (app-name pattern + game_name)
New vs renewal Share of the period's FLUX paid by new registrations vs updates/renewals revenue_transactions.msg_type (migration 019)
Median time left Last recorded day of each period -- a point-in-time reading, not an average daily_snapshots.median_days_left
Deployed / expiring Daily: live 24h counts at report generation. Weekly+: sum of the daily snapshot counts (Deployed / expired) live app data / daily_snapshots.apps_deployed_today, apps_expiring_today
Most deployed The day's deployments grouped by game -- daily only live app data

The daily Deployed / expiring figures have no comparison: both are read when the report is generated, so yesterday's live figure cannot be known and a change would be invented. On the scorecard they are the Deployed / expiring tile, and the day's deployments are summarised by game in one Most deployed line. The daily report used to follow with a second "Flux Cloud Activity" message of per-app tables; the scorecard redesign (2026-09-26) replaced it with that one line.

Daily reports read differently from the rest. A daily report compares two single days, so nothing is summed or averaged: nodes and apps are that day's readings (the tiles say Nodes, not Nodes (average)), and it carries the live Deployed / expiring counts and the Most deployed line instead of a trend line.

FLUX price is the one averaged row in a summed section. It is there because without it the two rows above it cannot be read: FLUX revenue flat while USD revenue falls is a price move, not a drop in demand, and nothing else in the report distinguishes those. A period total of daily prices would be meaningless, so the row is averaged, and its label says so (FLUX price (avg) in /api/kpi/preview). On daily reports it is simply that day's price. The scorecard itself has no price tile; the Revenue tile is USD.

Utilization percentages sit alongside the raw figures, not instead of them. "9,031 cores used" is the same number whether the network grew or capacity collapsed; the percentage is what separates those. Both are shown so a reader can see which one moved.

RAM and SSD are reported in TB. The daily_snapshots columns are named used_ram_gb and used_storage_gb, but the values they hold are terabytes — fluxbench reports node RAM/SSD in GB and cloudService.js divides by 1000. Numerator and denominator are scaled alike so the utilization percentages were always right, but the raw figures were labelled GB, which understated the network by 1000x ("RAM used 17.2 GB" across 6,400 apps). The column names are left alone — renaming them needs a migration for no behaviour change — and every display site says TB instead.

Reading the report: revenue is a flow, everything else is a stock

The most common way to misread this report is to expect revenue to track apps and utilization. It often won't, for two independent reasons, and neither is a fault in the numbers.

Revenue is a flow; apps and resources are a stock. Revenue counts payments that landed during the period. Apps, nodes and utilization are point-in-time readings of what exists. Those answer different questions. 6,400 apps do not renew on an even schedule, so a week's revenue reflects whose subscriptions happened to fall due that week — lumpy by nature, and capable of falling while the installed base grows. A period where apps rise and revenue drops is ordinary.

Team-funded revenue moves with Flux's own budget, not with demand. The Flux team's hosting spend is real revenue and is included in the headline, but it is one payer making internal decisions. It can swamp everything else. A worked example from the week of Aug 10-16 2026:

Bucket Previous Current Change
Team-funded 25,352 16,814 -8,539
Fiat gateway 27,982 25,291 -2,691
Everything else 2,927 5,844 +2,917
Total 56,262 47,948 -8,313

The headline fell 14.8%, which reads as a bad week. But the team-funded decline alone is 103% of the total drop, and third-party crypto revenue nearly doubled — consistent with apps being up 3.5% in the same period. This is exactly why the report breaks Team-funded and Fiat out as separate lines rather than only showing a total: subtract them before reading the headline as a demand signal.

To measure demand directly, count apps deployed during the period rather than revenue received. The Deployed / expiring tile is a step in that direction: on a daily report it counts the apps deployed in the last 24 hours, and on longer reports it sums the daily counts over the period. It is still a count of deployments, not of payments, so it is not directly comparable against revenue.

Worked examples:

USD Revenue - sum across the period. The daily revenue values for the week are added together to give the period total, then compared to the previous week's total the same way. This uses getRevenueForDateRange(), the same query behind the dashboard's revenue card, so the two can never disagree.

CPU used - average of daily snapshots. If daily CPU usage for the week was 61%, 63%, 59%, 64%, 62%, 60%, 65%, the period value is the mean = 62.0%, compared against the previous week's mean.

Team-funded is revenue from FLUX_TEAM_ADDRESSES; Fiat is revenue arriving through the Flux fiat gateway (FLUX_FIAT_ADDRESSES). Both are reported as a FLUX value and as a share of total FLUX revenue for the same period, and both are included in the Flux/USD totals above them rather than being separate buckets. A change in a percentage is not itself a percentage, so it is given in percentage points: on the scorecard 42.5% moving to 44.0% is ▲ 1.5 pts (CPU in use, In datacenters), not ▲ 3.5%. /api/kpi/preview carries both the absolute change (points) and the relative one for every metric.

Revenue is summed because it accrues; everything else is a point-in-time reading that moves daily. Averaging rather than taking the last day matters here: roughly 37 days in the history have zeroed service values from a failed collection run, and an end-of-period reading landing on one of those would define the entire metric instead of nudging it.

A failed query is never reported as a number. The four reads behind this report (getSnapshotsInRange, getRevenueForDateRange, getDailyRevenueUSDInRange, getRevenueFromAddressesForDateRange) throw on error rather than returning 0 or []. They used to swallow it, and because revenue coverage is judged only on whether the period predates our transaction history, a database failure came back "covered" with a value of zero and rendered as a genuine collapse — Flux 0.00 / -12,400.00 / -100.0% — which was then posted to Discord as fact. The report is now refused instead. A period that genuinely earned nothing is still valid data.

A day whose value is 0 is treated as missing, not zero for the snapshot metrics — a live network never truly has zero nodes or zero apps, so a zero means collection failed that day.

Percentage change

% change = ((current - comparison) / comparison) x 100, rounded to one decimal, always signed.

Case Shown as
comparison > 0 +12.4% / -3.1%
comparison = 0, current = 0 No change
comparison = 0, current > 0 New (not infinity)
either period lacks data Insufficient data

Insufficient data

A metric is only reported when every day in both periods has data. Full coverage is required on purpose: a 29-of-30-day average silently understates or overstates the period with no way for the reader to tell it happened. Coverage is tracked per metric, not per report, because columns were added to daily_snapshots at different times:

Metric group Data available from
Revenue 2024-05-13
Nodes, CPU/RAM/SSD, CPU/RAM/SSD %, FLUX price 2024-06-07
Total Apps 2025-11-10
Deployments running (% of ordered) 2026-09-28

Metrics that fall short are marked Insufficient data with the number of missing days (in /api/kpi/preview: change.note is Insufficient data and coverage.missing holds the count), and the rest of the report still sends: on the scorecard the tile shows n/a and the footer reads some figures lack full data (shown as n/a). When nothing is missing the footer says data complete, so a complete report is never ambiguous. A report is only refused outright when nothing at all is computable — which is currently the case for Yearly, since 2024 only has data from June onward. The modal greys out unavailable time frames up front, and the server re-checks on submit, so a disabled button is never the only thing standing between a user and a misleading report.

Delivery

Discord — posted to a user-supplied incoming webhook as one rich embed: the scorecard shown at the top of this section (src/lib/kpi/scorecard.js). Tiles are inline embed fields, so there are no code-block tables to wrap badly on a phone. Deliberately plain: no emoji, direction carried by ▲ / ▼, and the embed colour is green or red by revenue direction.

Email — designed for but not yet enabled: this instance has no mail transport configured, so the option is disabled in the dialog and the API rejects medium: "email".

To enable it you need three things: an SMTP account to send through (any mailbox provider, or a transactional service such as Resend/SendGrid/SES), a transport library (nodemailer) plus an XLSX writer (exceljs) added as dependencies, and four environment variables — SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, with SMTP_FROM for the sender address. No separate application is required; the existing Express server sends directly. The KPI computation is already delivery-agnostic, so only the transport and the attachment builder are new.

Scheduled reports

Any of the five timeframes can send itself. Configure three environment variables (see Environment Variables): KPI_WEBHOOK_URL (the Discord webhook), KPI_SCHEDULE (any comma-separated list of daily, weekly, monthly, quarterly, yearly) and KPI_SCHEDULE_HOUR_UTC (default 2). All unset or invalid = scheduler off, one log line, everything else unaffected.

  • Checks every 10 minutes and sends once per period, all in the configured hour: the daily report covers yesterday; weekly the last completed ISO week (Mondays); monthly fires on the 1st, quarterly on the quarter's first day, yearly on Jan 1 — each covering the period that has just completed. A report missed while the server was down catches up on boot or the next tick.
  • A restart never double-sends: a success receipt is recorded in the existing sync_status table, and a server that was down at the scheduled hour catches up at boot (or on the next tick) because the receipt proves which period was last delivered.
  • Failures are visible, never silent: a failed run is retried on the next tick, a failure notice is posted to the same webhook once per period, and /api/health exposes kpiScheduler: { configured, schedule, hourUtc, lastRuns }.
  • The footer KPI button keeps working independently — enter any webhook in the dialog and send manually; the manual path has its own rate limiting and never interferes with the schedule.

Rate limits and abuse prevention

Limit Value
Per client, per hour 5 reports
Per client, per day 20 reports
Per destination 1 report per 5 minutes

The slot is claimed in the same synchronous pass as the check (consumeRateLimit()), not after the outbound POST: checking and recording as two separate calls left a gap the width of the Discord request, so two requests fired in parallel both passed before either was counted. If delivery fails the destination's slot is handed back — nothing was delivered, so a mistyped webhook should be correctable immediately — while the client's attempt still counts, since a failed attempt cost real work and making failures free would let a retry loop hammer the endpoint.

Enforced server-side in src/lib/kpi/rateLimiter.js; the UI only reflects the result. Limits are in-memory, so a restart clears them — acceptable for a single-process deployment, but this needs to move to the database if the app is ever run multi-process.

Outbound requests are restricted to Discord webhook URLs (discord.com / discordapp.com, with the canary/ptb subdomains), validated both at the API boundary and again immediately before the request, with redirects disabled. Without that, the endpoint would be an open relay that could be pointed at any host. A hidden honeypot field catches basic bots, and webhook URLs are masked to their numeric ID in logs — the token half is a credential.

Deployment

Docker Build

docker build -t fluxtracker .

Docker Run

# Supabase mode (primary instance)
docker run -d \
  -p 3000:3000 -p 5173:5173 \
  -e SUPABASE_URL=https://your-project.supabase.co \
  -e SUPABASE_SERVICE_ROLE_KEY=... \
  fluxtracker

# SQLite mode with bootstrap from R2
docker run -d \
  -p 3000:3000 -p 5173:5173 \
  -e DB_TYPE=sqlite \
  -e BOOTSTRAP_R2_ENDPOINT=... \
  -e BOOTSTRAP_R2_ACCESS_KEY_ID=... \
  -e BOOTSTRAP_R2_SECRET_ACCESS_KEY=... \
  -e BOOTSTRAP_R2_BUCKET_NAME=... \
  fluxtracker

# SQLite mode with persistent storage
docker run -d \
  -v ./data:/app/data \
  -p 3000:3000 -p 5173:5173 \
  -e DB_TYPE=sqlite \
  fluxtracker

The container uses a multi-stage build (Node 24 Alpine). The startup.sh entrypoint:

  1. Starts the Express API server on port 3000
  2. Waits for the health check to pass (up to 30 seconds)
  3. Starts the SvelteKit production server on port 5173
  4. Exits if either process dies

A Docker HEALTHCHECK probes /api/health/live every 30 seconds through the frontend (FRONTEND_PORT), so it fails if either process is down or they cannot reach each other.

Flux Cloud

When deploying on Flux Cloud, only one port is exposed externally (typically 37000). Configure the Flux app specification to:

  • Map port 37000 to the SvelteKit frontend port (5173)
  • Pass SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY as environment variables in the app spec
  • On a real domain, also set ORIGIN and CORS_ALLOWED_ORIGINS (see Environment Variables)

The SvelteKit hooks.server.js proxy handles forwarding all /api/* requests to the internal Express server on port 3000. From the browser's perspective, everything comes from a single origin.

Environment Variables for Docker

Variable Required Description
DB_TYPE No supabase (default) or sqlite
SUPABASE_URL Supabase mode Supabase project URL
SUPABASE_SERVICE_ROLE_KEY Supabase mode Service role key
BOOTSTRAP_R2_ENDPOINT No R2 endpoint for bootstrap
BOOTSTRAP_R2_ACCESS_KEY_ID No R2 read-only access key
BOOTSTRAP_R2_SECRET_ACCESS_KEY No R2 read-only secret key
BOOTSTRAP_R2_BUCKET_NAME No R2 bucket name
R2_ENDPOINT No R2 endpoint for backups (primary only)
R2_ACCESS_KEY_ID No R2 read-write access key
R2_SECRET_ACCESS_KEY No R2 secret key
R2_BUCKET_NAME No R2 bucket name
API_PORT No Where the frontend proxy and startup script find the API (default: 3000)
FRONTEND_PORT No SvelteKit port (default: 5173)

Any other variable from Environment Variables works the same way in Docker -- for example ORIGIN, CORS_ALLOWED_ORIGINS, SUPABASE_DB_URL, the failover pair, KPI_WEBHOOK_URL / KPI_SCHEDULE / KPI_SCHEDULE_HOUR_UTC, and ANOMALY_ALERTS / ANOMALY_OUTAGE_HOURS.

Scripts

npm Scripts

Script Command Description
dev vite dev SvelteKit dev server (port 5173)
api node src/server.js Express API server (port 3000)
dev:all concurrently "npm run dev" "npm run api" Run both servers for development
build vite build Build SvelteKit for production
start node build/index.js Production SvelteKit server only
start:all node scripts/start-all.mjs Production API + frontend together
test vitest run Run tests
db:import-repo-json node scripts/import-repo-json.mjs Import repo snapshot data from JSON
db:import-wallets node scripts/import-wallet-history.mjs Import historical unique-wallet counts

Utility Scripts

Script Description
scripts/import-repo-json.mjs Import Docker repo snapshot data from a JSON file
scripts/import-wallet-history.mjs Import historical unique-wallet counts
scripts/apply-rpc-to-cloud.mjs Runs 002_rpc_functions.sql only against a remote Supabase over a direct Postgres connection (DATABASE_URL). Later migrations still have to be run by hand
scripts/header-smoke/ Terminal header acceptance harness (headless Edge/Chrome; see its README)

Project Structure

src/
  server.js                    # Express bootstrap: middleware, CORS, router mounting, startup
  routes/
    +page.svelte               # Main dashboard page
    api/                       # Express route modules (feature-based, issue #123)
      core.js                  # /api/header, /api/stats, /api/health*
      dashboard.js             # /api/carousel/*, /api/apps/activity, /api/busiest-node, /api/decentralization*
      kpi.js                   # /api/kpi/*, /api/kpi-report
      revenue.js               # /api/revenue/*, /api/transactions/*
      history.js               # /api/history/*
      analytics.js             # /api/analytics/*, /api/metrics/*, /api/categories/*
      admin.js                 # /api/admin/* (sync triggers, status/diagnostic reads, manual snapshots)
      admin/
        backup.js               # /api/admin/* failover + Cloudflare R2 backup/restore
        backfill.js             # /api/admin/* data backfills + transaction audit
  lib/
    serverHelpers.js            # Shared route helpers: withDbFallback, createCache, calculateChange
    config.js                   # All configuration (addresses, intervals, categories, API URLs)
    decentralizationDimensions.js # One entry per breakdown dimension: group-by field, sentinel, table, columns
    components/                # Svelte components (RevenueCard, NodeCard, CloudCard, Chart, RevenueTransactions, KpiModal, TerminalHeaderAnimation, etc.)
    kpi/
      periods.js               # KPI period arithmetic (pure, unit-tested)
      metrics.js               # KPI metric definitions, aggregation, formatting
      scorecard.js             # The Discord executive scorecard (one embed, nine tiles)
      discord.js               # Webhook URL guard (isValidDiscordWebhook) + scheduler failure notice
      rateLimiter.js           # Manual report rate limiting
      schedulerTime.js         # Scheduler due-date math (pure, unit-tested)
    utils/                     # Pure, unit-tested logic behind the components
      terminalAnimation.js     # Header frames: boot, rotation, wide app frames, outros
      introArt.js, introArt2.js, introVariants.js, headerExtras.js, seasons.js  # Header art
      format.js                # One number/size formatter for the whole page
      revenueSources.js        # Who paid / how they paid / revenue mix shaping
      appCohorts.js            # Cohort rows (new apps, 3-month survival)
      gameRevenue.js           # Game-server revenue rows and 30-day summary
      geoDemand.js             # CPU supply vs demand by continent
      demandLevel.js           # Low / Moderate / High / Very high, from CPU in use
      nodeRewards.js           # Block rewards per node per day, by tier
      urlState.js              # ?app= / ?metric= / ?tf= shareable view state
      utilizationProjection.js # What would still run if no app renewed
      appTimeLeft.js           # Median time left on running apps
      anomalies.js             # Anomaly-alert checks and Discord payloads
      gameSeries.js            # Gaming chart metrics and series
    stores/
      refresh.js               # Footer Refresh button -> every card re-fetches
      appFocus.js              # "Show this app's payments" (header, ticker, Busiest Node)
    db/
      database.js              # Adapter router (selects Supabase or SQLite)
      adapters/
        supabaseAdapter.js     # Supabase (PostgreSQL) implementation
        sqliteAdapter.js       # SQLite (better-sqlite3) implementation
      supabaseClient.js        # Supabase client initialization (guarded for SQLite mode)
      schemaMigrator.js        # Dynamic column migrations (both backends)
      snapshotManager.js       # Daily snapshot scheduler + backup trigger
      circuitBreaker.js        # DB circuit breaker with auto-failover
    services/
      revenueService.js        # Thin re-export hub for revenue/* (issue #124) -- every
                                # existing import from this file keeps working unchanged
      revenue/
        transactionSync.js      # Fetch/process/progressive sync/initial sync/audit
        revenueReporting.js     # Revenue calculation & reporting (date ranges, comparisons)
        revenueBackfill.js      # One-off app_name/app_type backfill utilities
        revenueSyncState.js     # In-memory sync state & failed-tx stats
        messageMetadata.js      # What a payment bought, from its permanent message
      fluxNetworkData.js       # Generic FLUX price / block height reads (shared network data)
      revenueScheduler.js      # Revenue sync interval manager
      kpiService.js            # KPI report computation + Discord delivery
      kpiScheduler.js          # Env-configured scheduled KPI reports
      anomalyAlerts.js         # Sync-outage and unusual-revenue-day Discord alerts
      resilientFetch.js        # Shared HTTP GET (retry, timeout, shape validation)
      fetchBreaker.js          # Per-endpoint circuit breaker for outbound fetches
      runningAppsProvider.js   # Shared running-apps payload (one fetch per cycle)
      appSpecsCache.js         # globalappsspecifications cache: name/hash lookup, app-name resolution
      backupService.js         # Cloudflare R2 backup/restore service
      bootstrapService.js      # R2 bootstrap for SQLite mode (first-start data import)
      cloudService.js          # Cloud utilization metrics (CPU, RAM, Storage)
      nodeService.js           # Flux node counts (Cumulus, Nimbus, Stratus)
      gamingService.js         # Gaming app instance tracking
      cryptoService.js         # Crypto node instance tracking
      wordpressService.js      # WordPress instance counting
      priceHistoryService.js   # FLUX/USD price history sync
      hostLocationService.js   # Server geolocation (6h cache)
      busiestNodeService.js    # Network's busiest node: identity, resources, resolved apps
      decentralizationService.js # Per-node datacenter share from the node list's geolocation + snapshots
      carouselService.js       # Carousel feed data (deployed/expiring apps)
      servicesScheduler.js     # Services cycle and carousel scheduler
supabase/
  migrations/
    001_initial_schema.sql     # Tables, indexes, seed data
    002_rpc_functions.sql      # PostgreSQL RPC functions
    003_enable_rls.sql         # Row Level Security
    004-006                    # Partial index, app-name index, USD batch update
    007_node_ip_classification.sql          # Per-IP datacenter/independent classification
    008_decentralization_snapshots.sql      # Daily datacenter/independent history
    009_decentralization_country_continent.sql # Country/continent columns + snapshot tables
    010_daily_revenue_from_addresses.sql    # Revenue by payer address (Team-funded, Revenue Sources)
    011_transactions_source_filter.sql      # Replaces get_transactions_paginated (payer filter)
    012_gaming_instances_total.sql          # App-name-aware gaming total
    013_game_snapshots.sql                  # Per-game daily history table
    014-016                                 # unique_wallets, unique_app_owners, locked_collateral
    017_transactions_paginated_index_order.sql # Faster, totally ordered transaction paging
    018_payer_base.sql                      # Paying customers, revenue concentration
    019_message_metadata.sql                # What each payment bought (+ backfill function)
    020_revenue_mix.sql                     # New vs renewal vs enterprise revenue per day
    021_run_rate.sql                        # Not used by the current dashboard
    022_app_cohorts.sql                     # Cohorts by registration month
    023_median_days_left.sql                # Median time left column
    024_game_revenue.sql                    # Game-server revenue per day
    025_database_size.sql                   # Database size for the header
    026_game_name.sql                       # game_name on payments (hand-deployed game servers)
    027_revenue_sources.sql                 # Revenue Sources chart in one query
    028_deployment_fill.sql                 # Deployments running (% of ordered) columns
    029_cpu_by_continent.sql                # CPU cores / locked cores per continent
    030_enterprise_apps.sql                 # Private (enterprise) apps columns
scripts/                       # Utility scripts
  header-smoke/                # Terminal header acceptance harness (headless browser)
docs/
  improvement-plan.md          # What's next, in order, and the standing rules behind it
  superpowers/                 # Design records and implementation plans for shipped work
Dockerfile                     # Multi-stage production build
startup.sh                     # Container entrypoint (starts both servers)
.env.example                   # Environment variable template

What's Next

docs/improvement-plan.md holds the ordered backlog — what to pick up next and why — alongside the standing rules each learned from a specific outage (silent query caps, what counts as verification, min-width: 0, secure-context APIs). GitHub issues are the queue; that file is the order and the reasoning.

License

This project is maintained by 2ndTLMining.