Turn a Microsoft Fabric capacity into a city you can walk through. FabricSimCity draws your tenant as an atlas of capacities and each capacity as a city — items are buildings sized by real OneLake storage and CU consumption, operations are traffic, and throttling is a power grid running short.
Every shape maps to a measurement you can verify. Nothing is invented, and unavailable evidence is drawn as a wireframe rather than a guess.
It runs as a Fabric App built on Rayfin, so it renders the capacity it
lives in — AppBackend is itself an item type, which means FabricSimCity appears as a building in
its own city.
Rebuilt from SQLSimCity, which drew a SQL Server the same way. Heavily inspired by PGSimCity.
A Fabric capacity is literally a power grid, which makes the city read more honestly than it did over SQL Server:
| Fabric | Drawn as |
|---|---|
| Tenant | The atlas — every capacity as its own city |
| Capacity (F2…F8192) | A city. The real contention boundary. |
| Workspace | A neighbourhood |
| Item (Lakehouse, Notebook, Warehouse, Semantic model…) | A building |
| Operation family | A road, with traffic on it |
| Interactive vs background operations | Cars vs freight |
| SKU CU budget | The power plant, and the size of the ground the city is built on |
| CU smoothing | Reservoirs — 5–64 min interactive, 24 h background |
| Carry-forward | A debt heap that grows and drains, with a burndown ETA |
| Throttle stages | Delay gate → rejection gate → background embargo |
| Overload state | Weather: brownout tinting, then blackout |
Plot size is the SKU's CU budget; tower height is the CU seconds actually consumed. The ratio
between them is mean utilization, drawn rather than stated: a capacity comfortably inside its
budget is a low city on wide ground, and a small SKU being hammered is a thin skyscraper on a tiny
plot. In the fixture tenant, Fabrikam Dev is an F2 with the tallest towers in the atlas, and it
is rejecting everything.
Footprint within a city comes from OneLake bytes, so a cold Lakehouse is a wide flat warehouse and a runaway Notebook is a spire on a minimum lot. Compute-only items get a minimum lot, which is correct rather than a fallback.
A missing measurement renders as wireframe, never as zero.
A paused capacity emits no telemetry at all, and an idle one emits zeroes. Those are completely
different things and drawing them the same way would be a lie the picture tells confidently. So
capacityHeight() returns null rather than 0 for unknown CU, an unrecognised SKU gets no plot
size rather than a default one, and both end up as bare ground.
No Fabric tenant required. The app ships with a deterministic fixture tenant — six capacities covering every state the city can render — and that is the primary development loop, not a fallback.
npm install
npm run devOpen http://localhost:5173. You get Contoso Ltd: two healthy capacities, one at each of the three
throttle stages, and one suspended.
Fabric Apps and the capacity metrics connectors are in private preview, and the connectors are delegated-auth only — every user signs in as themselves and sees only the capacities they already have access to. There is no service-principal path where the app reads once for everyone.
npx rayfin login
npx rayfin up --workspace "<your capacity-backed workspace>"
npx rayfin up statusThe workspace has to be named on the first deploy — rayfin up records the binding in
rayfin/.deployments.json (gitignored, so it never arrives with a clone) and reuses it afterwards,
at which point a bare npx rayfin up is enough. Without it the CLI stops with
No workspace targeting context. Add --dry-run to validate the configuration and print the
planned operations without making any API calls.
The deploy appends the live hosting URL to allowedRedirectUris in rayfin/rayfin.yml, so expect
that tracked file to change.
Then set VITE_RAYFIN_API_URL, VITE_RAYFIN_PUBLISHABLE_KEY, VITE_FABRIC_WORKSPACE_ID,
VITE_FABRIC_ITEM_ID and VITE_FABRIC_PORTAL_URL. Leaving VITE_RAYFIN_API_URL unset is what
selects fixture mode. In practice rayfin env --framework vite writes all of these into
.env.local for you, and npm run build:fabric runs it.
Real CU numbers come from the Fabric Capacity Metrics app's semantic model, over DAX. Today that
works from npm run dev and not from the deployed app. Three separate things block the
deployed path, and any one of them is sufficient:
- Rayfin 1.34.0 ships no semantic-model connector —
discover_packagesreturns only the core SDK and the docs server. - Fabric refuses to deploy
functions, which is the only server-side seam the app has. That is whyservices.functions.enabledisfalse; a deploy with it on fails the whole runtime-settings sync. - The Power BI
executeQueriesREST API sends no CORS headers, so a browser cannot call it directly whatever token it holds. It needs something server-side to relay the call — and Fabric static hosting has nowhere to put one.
The dev server is that relay. It proxies /powerbi to api.powerbi.com and attaches the token, so
the token stays in Node and never enters the bundle.
az login --scope https://analysis.windows.net/powerbi/api/.default
$env:POWERBI_TOKEN = (az account get-access-token `
--resource https://analysis.windows.net/powerbi/api --query accessToken -o tsv)
$env:VITE_FABRIC_SOURCE = "semantic-model"
$env:VITE_FABRIC_METRICS_DATASET_ID = "<semantic model id>"
npm run devTo find the dataset id: open the Microsoft Fabric Capacity Metrics app in the Fabric portal, go
to the workspace it installed into, open the semantic model, and take the GUID from the URL
(/groups/<workspace>/datasets/<dataset>). It does not live in your app's own workspace, which is
why it cannot be inferred from rayfin env output and has to be set explicitly.
The token is short-lived — roughly an hour — and re-exporting it means restarting npm run dev,
because the proxy reads it once at startup.
Two things to expect on a first real run. The Capacity Metrics schema has already changed once, so
the source probes it before asking for numbers and reports Unsupported rather than crashing if it
has moved again; and the 30-day retention the source declares is an assumption that has not yet been
checked against a live model.
For the deployed app, this is what the ingest notebook below is for. It moves the read off the browser entirely, which is the only thing that clears all three walls at once.
A scheduled Fabric notebook runs the same DAX and writes the rows into the app's own SQL database,
where the configured ingest source reads them through client.data. Nothing new interprets those rows: the
notebook stores them verbatim and the app replays them into the parser it already has.
The app uses Rayfin's built-in typed GraphQL adapter (client.data.IngestRun and
client.data.IngestRow), including cursor pagination. In Fabric, startup adopts the portal
session through the SDK's initEmbeddedAuth before reading protected data. There is no custom
login page, popup, token handling or handwritten GraphQL. Open the app through the Fabric portal;
a standalone page without an existing session reports that requirement rather than using fixtures.
Each atlas refresh creates a new ingest reader, pins one completed run, and publishes that reader
with its atlas so the city uses the same run. Failed reads keep the last good data visible.
Without a configured backend (or with VITE_FABRIC_SOURCE=fixture), development still uses fixtures
without authentication. The dev-only semantic-model source continues using the Vite DAX proxy.
npx rayfin up # creates the IngestRun / IngestRow tables
npm run dax:manifest # only if you changed the DAX
npm run fabric:notebook # only if you changed the notebook's PythonThen, in the Fabric portal:
- Import
fabric/ingest_capacity_metrics.ipynbas a notebook. - Upload
fabric/dax-queries.generated.jsonto that notebook's built-in resources. - Fill in the parameters cell: the metrics dataset and workspace ids, and the SQL server and database from your app's SQL Database child item (Settings → Connection strings).
- Give the notebook's identity Read and Build access to the metrics semantic model, plus
permission to discover model metadata for the schema probe. Separately, give it a SQL user on
the app's database with
SELECT,INSERT,UPDATEandDELETEon the two ingest tables. - Schedule it, then point the app at it:
VITE_FABRIC_SOURCE=ingested
VITE_FABRIC_TENANT_ID=<same TENANT_ID as the notebook>
VITE_FABRIC_METRICS_DATASET_ID=<same dataset id>
VITE_FABRIC_INGEST_INTERVAL_MINUTES=60 # must match the schedule you set
VITE_FABRIC_INGEST_WINDOW_DAYS=3 # must match INGEST_WINDOW_DAYS
The interval is not decoration. The app reports the model's own lag plus this interval as its latency, so a city built from an hourly ingest says it may be an hour old rather than claiming to be live. Setting it lower than the real schedule makes the app lie about freshness.
Everyone signed in to the app can read everything the notebook writes. The semantic model checks each user's own capacity permissions; a table does not. Only ingest capacities all of your app's users are entitled to see. The app is granted read only — the notebook writes over direct SQL, not the data API, so nothing a user does in the app can forge telemetry.
Live read-only execution of the notebook's queries on 2026-09-09 retrieved 679 items and 2,458 operation families, with measured consumption in two of six capacities. The complete orchestration staged 3,192 records using an in-memory SQL substitute, and all items and operation families passed through the app's actual replay/parser. Persistence to the tenant's SQL database has not yet been verified; that diagnostic did not write to or modify the model or database.
This generation reads Metrics By Item Operation And Day using Datetime and Operation name,
then resolves metadata from Items and Capacities. It aggregates CU-seconds, durations, operation
counts and measured throttling; Throttling (min) is converted to the reader's seconds contract.
Item lookups use capacity, workspace and item ids together, without multiplying fact rows by
dimension rows. Ambiguous dimension labels remain unknown.
The facts are DirectQuery, unlike the imported capacity/item metadata. Every fact query must
declare MPARAMETER 'CapacitiesList' = { @CapacityId } and
MPARAMETER 'RegionName' = @RegionName; ordinary DAX row filters do not activate the source.
The notebook first discovers capacities from imported metadata, then queries each capacity in its
own region. Routing uses Capacities[Region without default], never the display label Default.
This follows the capacity-list convention in
Microsoft's FUAM extraction.
The model can return a SUM of zero for an empty fact table, so summaries also require actual fact
rows before treating consumption as measured.
These are daily aggregates, not live utilization samples. A rolling window excludes the partial first day and reports the next midnight as its start. Observation time is the latest daily bucket, not notebook execution time, and is emitted as explicit UTC text. Per-item OneLake storage, distinct users, operation classification and 30-second utilization/throttle gauges remain unavailable rather than inferred. Workspace storage and item memory are not substituted for item OneLake bytes, nor are daily totals divided into synthetic timepoints. Storage-bearing buildings can therefore remain wireframe even when CU totals are known. Autoscale-specific fact tables are not combined into these totals.
Updating an existing installation: this generation uses manifest version 3. Save your
notebook parameter values, reimport the updated fabric/ingest_capacity_metrics.ipynb, restore
those values, and replace its Built-in dax-queries.generated.json with the matching file from
this revision. Restart the notebook session and run all cells. Updating only the manifest is not
enough: the notebook must discover routing, bind source parameters, retain the dimension schema
rows and skip unsupported queries. A v2 notebook/manifest pair does not bind the DirectQuery source.
The stored SQL row format remains compatible with the previous ingested reader, so this notebook
update needs no app redeployment or SQL migration. Deploy app updates with npx rayfin up, keeping
ingest settings in the root
.env.production.local. Rayfin generates the API and Fabric handoff settings in .env.local;
do not put access tokens in either file. No SQL entity change is required. Run the notebook once
before enabling its schedule, and confirm any schedule still points to the updated notebook with
the intended parameters.
Rayfin creates IngestRuns and IngestRows. Earlier notebooks looked only for the singular entity
names, so this error was a notebook bug, not a missing deployment. Update the notebook; do not
rename those tables or redeploy the app to fix this error. The resolver now accepts either naming
form, validates the required columns, and refuses to choose arbitrarily if multiple tables match.
An operator can authorize read-only diagnostics from a local machine with Azure CLI:
az login --scope https://analysis.windows.net/powerbi/api/.default --allow-no-subscriptionsUse the same account and tenant that can read the Capacity Metrics semantic model. The Execute Queries API requires model Read + Build permissions and the tenant's Dataset Execute Queries REST API setting. Keep tokens in the local credential cache/process memory, never in chat or committed files. This is an operator diagnostic path, not a replacement for the app's built-in Fabric sign-in.
First supply the capacity-list and routing-region M parameters above. Then compare visible fact
counts and earliest/latest Datetime against the requested UTC window. An unbound DirectQuery
count of zero, or a populated imported capacity roster, does not establish actual usage. Do not
substitute older data or widen the window silently to make a city appear populated.
Pandas represents a missing datetime cell as NaT. It is a Python datetime subclass, but
timezone conversion is invalid. The ingest now preserves it as JSON null and SQL NULL;
it never substitutes the current time or zero. Known timestamps are normalized to UTC.
Update the complete notebook and matching v3 manifest as described above, preserve your parameters,
and rerun the cells. The null-handling correction alone lives in fabric/simcity_ingest.py, but
replacing only that cell does not update the DirectQuery routing or SQL table lookup.
This is semantic-model access, not SQL access. Verify that METRICS_WORKSPACE_ID and
METRICS_DATASET_ID identify the metrics model, and check permissions for the identity named
in the exception. Viewing the Capacity Metrics report does not imply
Build permission for XMLA queries.
Scheduled notebooks run as the user who created or last updated the schedule.
After running the notebook's parameters cell, run this separately from the ingest:
evaluate_dax('EVALUATE ROW("AccessCheck", 1)')If that fails too, check Read/Build permissions and XMLA access with the model owner. If it succeeds but the schema probe fails, ordinary querying works while metadata discovery is denied. Microsoft documents model-admin permissions for INFO metadata queries; use an identity authorized for that operation rather than granting tenant-wide admin or changing SQL permissions.
The probe uses INFO.VIEW.COLUMNS() because it exposes [Table] and [Name]. The original
manifest incorrectly selected those fields from INFO.COLUMNS(), which exposes [TableID] and
[ExplicitName] instead. Replace the notebook's built-in dax-queries.generated.json with the
regenerated file when updating. That corrects the query, not the caller's permissions.
This means metadata discovery succeeded, but no supported generation has all its required fact and dimension columns. The table-name preview shows only the first ten alphabetically; it is not the complete schema. The error identifies each expected table as absent or lists its missing columns.
The notebook also writes builtin/capacity-metrics-schema.json with all discovered table
and column names. Download it from Resources > Built-in to diagnose the mapping. It contains
no metric values or credentials. Matching still fails before any SQL write; a previous
completed ingest remains intact.
For an already-imported notebook without this diagnostic, run the following after the parameters cell, then download the same file:
schema = probe_tables(
evaluate_dax(MANIFEST["generations"][0]["queries"]["schemaProbe"])
)
Path("builtin/capacity-metrics-schema.json").write_text(
json.dumps(
{table: sorted(columns) for table, columns in sorted(schema.items())},
indent=2,
),
encoding="utf-8",
)The manifest cell already imports json and Path. Review the exported metadata before
sharing it. Do not rename model tables, force a generation, or remove required-column
checks: those would turn a visible incompatibility into incorrect telemetry. The adapter's
queries must be corrected against the actual model schema instead.
Topology — capacities, workspaces, items — comes from api.fabric.microsoft.com/v1, which is fully
supported and needs no connector.
CU telemetry does not exist on any REST endpoint. It comes from one of three sources behind a single
CapacitySource interface:
- Capacity Metrics semantic model, over DAX. This is where the real per-item CU breakdown lives.
Microsoft documents programmatic access to it as unsupported and its schema has already changed
once, so the implementation probes supported table/column shapes, including the exported
daily fact plus dimensions. Reachable from
npm run devonly. - The same model, ingested — a scheduled notebook writes its rows into the app's SQL database and the app replays them through the identical parser. This is the only path the deployed app has.
- Eventhouse, over KQL, reading
Microsoft.Fabric.Capacity.Summaryevents on their documented 30-second cadence. Fully supported, but it carries no per-item breakdown, so the city degrades to live infrastructure over static buildings — which the evidence model already knows how to draw.
The semantic-model source and its DAX transport are written; see above for what it takes to reach a real model. The ingest path has reached live schema discovery but not a successful ingest. The Eventhouse source is written but has no transport yet.
Refresh is client-side polling: Rayfin has no cron, no timers and no background workers, so there is no in-app collector and never will be.
The atlas is on screen and runs on fixtures. The city view is not yet ported — 29 modules sit in
src/pending-port/ with a README explaining what each one needs. They were kept rather than
deleted because each is a solved rendering problem waiting on a Fabric field to read.
Ported and working: capacity atlas, SKU-sized plots, CU-driven massing, throttle and state rendering, flat-map toggle, kiosk mode, day/night, the sidebar rail and its bottom-sheet form.
npm run dev # Vite on fixtures
npx tsc -b # the correct typecheck -- see AGENTS.md on TS6305
npx vitest run # 610 tests / 31 files
npm run build # tsc -b + vite buildAGENTS.md carries the conventions, and they are not decorative — most of them exist because
something passed a green test suite while visibly broken. The short version: layout changes get
measured in a real browser at both breakpoints, and a new guard has to be shown failing against
the broken state before it counts.
tools/measure-browser/ is the workbench for both kinds of measurement — what the city costs the
GPU, and whether the rail beside it can actually be read and clicked.
Not affiliated with or endorsed by Microsoft. "Microsoft Fabric" and "OneLake" are trademarks of Microsoft Corporation.