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.
- 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 Dbutton 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 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.
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.
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.
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 readsnone 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.
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
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.
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.
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.
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 / |
| 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.
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 specin its Type column, andUnregistered specas 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.
| 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 |
+-------------------+
| 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.
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 |
+----------------------+ +----------------------+
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.
- 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=sqliteneeds no external database at all (see Database Modes above).
Copy the example file and fill in your Supabase credentials:
cp .env.example .envDatabase 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. |
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:
-
001_initial_schema.sql-- Creates all tables, indexes, and seed data -
002_rpc_functions.sql-- Creates RPC functions for aggregation queries -
003_enable_rls.sql-- Enables Row Level Security on all tables (service_role bypasses it) -
004_partial_index_usd_null.sql-- Partial index speeding up the USD-backfill scan -
005_app_name_index.sql-- Index onapp_namefor analytics/search queries -
006_update_usd_batch.sql-- RPC function for the USD backfill's batched updates -
007_node_ip_classification.sql-- Table for per-IP datacenter/independent classification -
008_decentralization_snapshots.sql-- Table for daily datacenter/independent history -
009_decentralization_country_continent.sql-- Country/continent columns + snapshot tables -
010_daily_revenue_from_addresses.sql-- RPC functions behind the Team-funded line and Revenue Sources chart -
011_transactions_source_filter.sql-- Replacesget_transactions_paginatedwith a new signature takingp_from_addresses; this is what the TEAM/FIAT payer filter needs -
012_gaming_instances_total.sql--gaming_instances_totalcolumn (app-name-aware total) -
013_game_snapshots.sql--game_snapshotstable: the per-game daily history -
014_unique_wallets.sql--unique_walletscolumn -
015_unique_app_owners.sql--unique_app_ownerscolumn -
016_locked_collateral.sql--locked_collateral*columns -
017_transactions_paginated_index_order.sql-- faster, totally orderedget_transactions_paginated+ composite index (#294). Same signature: optional for correctness of existing callers, but the transaction table and CSV export are slower without it -
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) -
019_message_metadata.sql-- four nullable columns onrevenue_transactions(msg_type,enterprise,expire_blocks,instances) plus a batch-update function. After running it, callPOST /api/admin/backfill-message-metadataonce to fill existing payments; new payments are filled by the sync -
020_revenue_mix.sql-- read-only daily revenue mix (Revenue Sources -> What was bought). Needs 019 and its backfill to show anything -
021_run_rate.sql-- not used by the current dashboard; safe to skip -
022_app_cohorts.sql-- read-only app cohorts by registration month (Applications -> By month registered). Needs 019 -
023_median_days_left.sql--median_days_leftcolumn ondaily_snapshotsandcurrent_metrics(Revenue card line and Revenue chart metric). History starts the day it is applied; nothing is backfilled -
024_game_revenue.sql-- read-only daily game-server revenue (Apps card "Game revenue", Gaming -> Revenue) -
025_database_size.sql-- read-only database size for the header (DB 411 MB) -
026_game_name.sql--game_namecolumn onrevenue_transactionsso game revenue also counts hand-deployed game servers (by their image). After running it, callPOST /api/admin/backfill-game-namesonce to fill existing payments -
027_revenue_sources.sql-- read-onlyget_daily_revenue_sources: the Revenue Sources chart in one query. Optional -- without it/api/history/revenue/sources/dailyfalls back to six queries -
028_deployment_fill.sql--deployments_ordered,deployments_runninganddeployment_fill_percentondaily_snapshotsandcurrent_metrics(Applications -> Deployments running). History starts the day it is applied -
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 -
030_enterprise_apps.sql--enterprise_appsandenterprise_apps_percenton 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 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 prepareLinux, Docker and CI are unaffected (they have a compiler, and the build step is a no-op there too).
npm run build
npm run start:allstart: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 onlySupabase (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.
| 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. |
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) |
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.
Base URL: /api
| 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 |
| 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
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/revenue/:period |
Revenue for a period with previous-period comparison |
Valid periods: daily, weekly, monthly, quarterly, yearly
| 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) |
| 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 |
| 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 } ] }| 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 }| 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 |
| 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 |
| 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. |
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_CONFIGkeywords, minusCATEGORY_EXCLUDE - What the row is called, and which rows merge —
DISPLAY_NAME_OVERRIDES(per image, used in history) andCANONICAL_NAME_OVERRIDES(groups image variants into one card row)
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.
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 labelIt 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.
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.
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.
- Edit
CATEGORY_CONFIG,CATEGORY_EXCLUDE,DISPLAY_NAME_OVERRIDESorCANONICAL_NAME_OVERRIDESinsrc/lib/config.js - Check the new keyword against a live sample before trusting it — confirm it only picks up currently-uncategorised images and moves nothing between categories
- Run the tests:
npx vitest run src/lib/__tests__/categorization.test.js - Re-apply to stored history:
curl -X POST localhost:3000/api/admin/recategorize-reposThe 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.
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.
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)
expiremeans 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.
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.
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.
The header is an animated terminal in one fixed-size box that never grows or shrinks.
- Boot -- it types out real
/api/headerdata (API and database checks, a block height counting up, version and network stats), then wipes into the FLUX ASCII logo. - 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
fluxanywhere 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.
npm test # vitestThe 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.
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/restorewith{ "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
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/healthreportsstatus: "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
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.
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;problemslists 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.
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:
- Binance —
FLUXUSDTdaily klines, 1000 candles per call, paged withstartTime(data from 2021-12-10) - CoinGecko —
market_chart, last 365 days - CryptoCompare/CoinDesk — only used when
CRYPTOCOMPARE_API_KEYis 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 rowsbackfill-usd returns coverage (oldest/newest price date and day count) alongside
missingPriceDates, so an updated: 0 result says why.
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/aon 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 assum(deployments_running) / sum(deployments_ordered), not an average of daily percentages. It is recorded from 2026-09-28, so longer reports shown/auntil 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()inkpiService.js. Each falls back ton/a, never0, when its data is missing.
The full dataset is available without sending anything from GET /api/kpi/preview?timeframe=.
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.
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.
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.
% 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 |
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.
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.
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_statustable, 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/healthexposeskpiScheduler: { 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.
| 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.
docker build -t fluxtracker .# 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 \
fluxtrackerThe container uses a multi-stage build (Node 24 Alpine). The startup.sh entrypoint:
- Starts the Express API server on port 3000
- Waits for the health check to pass (up to 30 seconds)
- Starts the SvelteKit production server on port 5173
- 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.
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_URLandSUPABASE_SERVICE_ROLE_KEYas environment variables in the app spec - On a real domain, also set
ORIGINandCORS_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.
| 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.
| 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 |
| 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) |
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
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.
This project is maintained by 2ndTLMining.