Skip to content

Demo console (tally-console) ​

tally-console is a read-only web console over the data Tally already holds: the projects, resources and lifecycles the Reporting API serves, and the billing periods, runs, statements and pricing catalogs the engine stored. It is a demo instrument. It runs on a developer's machine, writes nothing on either side, is deployed nowhere, and has no sign-in, so whoever reaches its port reads everything its token reads. Every page is HTML the process rendered; it ships no JavaScript and loads one stylesheet. The process is assembled in cmd/tally-console/main.go.

Invocation ​

The process takes no flags and reads no arguments. Every setting comes from the environment, under the names the settings table below lists.

What it serves ​

RouteWhat it reads
/Reporting API: resource counts by cloud, resource type and state; event counts of the last 24 hours per hour; the five newest refused events. Engine: the billing periods, each linking its own page and the run that finalized it; the 20 newest runs.
/projectsAPI: one page of projects, filtered by platform, cloud, cursor.
/project?id= or /project?cloud=&external_id=API: the project, addressed by its id or resolved from the pair a resource names it with, its relations, the projects a traversal reaches, its summary over the window from and to, this month by default, and one page of the project's resources, which the summary rows fold open into; for a meta-project, the member_of relations that reach it at the first and at the last instant of every billing period, and each member project. Engine: every statement of the project, grouped into the periods that were billed, for a partner what every run settles for it, and for a meta-project the billing periods, their runs, and the statements of every run that stands, through the export's own read.
/resourcesAPI: one page of up to 1000 resources under status, filtered by cloud, project_id, resource_type, state, cursor. The console reads that page one of two ways, chosen with mode: at the instant at, now by default, or over the window from and to. It prints each kept row's creation, or the first event of a history that starts without a create, and its lifetime in hours from that instant, links its project by cloud and external id, and folds its last payload.
/resource?cloud=&type=&id=API: the lifecycle. Engine: the newest run that metered the resource, or the one run= names, and its rated segments; a timeline of both.
/pricingEngine: the imported catalog versions.
/catalog?version=Engine: one catalog, parsed by the engine's own parser.
/period?month=Engine: the billing period, every run of it, and what each run's statements add up to per currency.
/run?id=Engine: the run, linking its period; the stats it stored; its statements; the files the JSON export writes for it, run.json and kickbacks.json, and what it settles for each partner; its correction deltas.
/statement?run=&key=Engine: the run, and one statement document rendered as a bill: its head, the file the JSON export writes for it, its adjustments, a summary table of its line items sorted by total, and a folding detail block per item with one row per metric and period. The document of a correction run is a credit note and is rendered as one: its head carries the deltas, its adjustments what changed, and each item's block one row per dimension.
/statement.json?run=&key=Engine: the run and the statement. Not a page: the bytes tally-engine export --format json writes for the statement, served as application/json, and with download set as an attachment.
/run.json?run=Engine: the run, its statements and what it settles. Not a page: the run.json tally-engine export --format json writes for the run, served as application/json, and with download set as an attachment.
/kickbacks.json?run=Engine: the same read. Not a page: the kickbacks.json tally-engine export --format json writes for the run, served the same way.
/static/console.cssThe embedded stylesheet, the only asset the pages load.
/themeNothing. Not a page: it takes the theme form's POST, keeps the choice in a cookie, and sends the viewer back.

Every identifier travels in a query parameter rather than in a path segment, because a cloud name, a resource id and a statement key may each carry a slash.

A resource names its project by cloud and external id, not by the id the API assigns, so the project links on the resource pages carry that pair. The project page resolves it through the project list filtered by both, an exact match on each, and a pair nothing is registered under is answered 404. The relations of a project link either end that is not the project of the page, so a relation another project leaves leads to it and one this project leaves leads to what it reaches. A relation folds open into the pricing adjustments its metadata carries, one row per adjustment with its type, its scope, its rate and its description, so what a managed_by relation grants a reseller and what it owes them is read where the relation is. The filter matches a relation on its type and on the adjustments under it. The registry holds every adjustments document to the schema when it is written, so one this console cannot read is reported in the row rather than as a failed page.

What a project ran is read over a window, from and to, with the same inputs and the same spans the fleet is read over: the last 24 hours, the last 7 days, this month, last month. The window is this month while the request names neither bound. The summary route takes both bounds, so a request carrying one and not the other is answered 400 naming the missing one, and so is a to that is not after from. Each bound is read as RFC 3339 or as the form the inputs write. The rest of the page is read as it stands now, whatever the window is: the relations, the projects a traversal reaches, and every statement a run wrote for the project.

Each resource type of that summary folds open into the resources of the project that lived in the window, by the rule the fleet reads a window by, sorted by their ids, each with its state, its creation, its deletion and its lifetime in hours, and each linking its own page; a resource whose history starts without a create shows its first event in place of its creation and counts its lifetime from it, as the resource list does. The filter above the table matches a row on its type and on the resources folded under it, so a resource id finds the type it sits in. The two numbers come from two places: the summary counts by folding the events of the window, and the fold lists what the projection holds today, so a resource the project has since handed on is counted and not listed, and a type the projection holds nothing of is drawn without a fold. The resources are read as one page of at most 1000, the widest the API serves, and a project that holds more says under the table that a type may have run resources the fold does not name.

The statements are one row per billing period rather than one per statement. A period accumulates them: the regular run that billed it, every run that replaced one of those, and every correction booked against the run that closed it. The row carries what the project is charged for the period, which is the sum of the statements whose run stands, and folds open into all of them, each with its kind, its status, its own total and a link to the statement itself. A statement that counts for nothing is drawn in the muted colour and says it was replaced.

A run stands when its status is completed or finalized, which are the two an export reads; a superseded run was replaced by another of its kind and a failed one billed nothing, and neither is added up. This is why a correction adds to the period rather than replacing it: a correction re-meters the period whole and stores the difference against the run it corrects, so its statement is a credit note over the run that closed the month, and the two together are what the project owes. A period is billed in one currency, so the sum is one amount; a period whose standing statements disagree prints each currency rather than adding them up. The status of the period is the standing regular run's, and a period whose every run was replaced stands at nothing and is charged nothing. The filter matches a period on its instant and on the kinds and statuses folded under it, so correction finds the months that hold one.

A partner carries one more table, kickbacks, which is what the runs settle for it. A partner is never billed, because managed_by attributes no cost, so it has no statement of its own; a kickback is what the operator owes it, and it is a line on the statement of the project that was adjusted. The table is one row per period with what the partner is owed for it, folding open into every record: the run kind, the adjusted project, the scope, the rate, the base and the amount, each with a link to the statement the record was applied to. The arithmetic is the engine's own, read through the settlement tally-engine kickbacks reports: a regular run's records are what it owes, and a correction's are the difference to the run it corrects, so a period adds up the way its statements do. Only the runs that stand are read. The table is drawn for a project of platform partner and for no other, because the beneficiary column holds an external id alone and a project of another platform could carry the same one.

A meta-project carries one more table, rollup, which is what its members are billed. A meta-project owns no resources and has no statement of its own: every project that is member_of it is billed on its own statement. The table is one row per billing period, with the status of the regular run that stands, the number of members and what the runs that stand billed them, folding open into one row per member and run: the member's project id, linking its page, its cloud, a link to its statement, the run kind and the total. The sum is the one tally-engine export --rollup member_of writes, by the engine's own function: a member is counted one relation deep, once per meta-project however often its membership was closed and opened again, under each of two meta-projects it belongs to, and with its statement's total, related costs included. A period adds up the runs that stand the way the statements table does, so a correction's credit notes move the group's period the way they move each member's. The window of the page does not apply to the table.

The membership is read through the Reporting API when the page is read: the member_of relations that reach the meta-project at the first instant of the period and at its last microsecond. A relation created or closed after the fact therefore changes what an earlier period shows, the way it changes an export, and a membership that began and ended inside a period is valid at neither instant and is not counted, although the export counts it; the page says so under the table. A run whose members the engine refuses to sum, one that billed them in two currencies, is reported in place of the table, and a run that stops standing while the page is read is left out. The table is drawn for a project of platform meta and for no other.

Paging is one page per request. A listing the API answered with a cursor carries a next link that repeats the filters and adds that cursor, and nothing follows a cursor on its own.

Sorting and filtering ​

Every listing table can be sorted by a column and filtered by a text, and both happen on the console's side of the wire. The console ships no JavaScript, so a heading is a link that reloads the page with the order in the query string, and the filter box above a table is a form that reloads it with the text. Two parameters carry the state of one table, named after the table so that the tables of one page keep their own:

ParameterValue
<table>.sortThe key of a column, which is its heading with spaces as underscores: resource_type. A leading hyphen sorts descending: -count. A key no column carries leaves the rows in the order they were read.
<table>.qA text. A row stays when any of its cells contains the text, compared without regard to case. Whitespace around the text is dropped.

So /?stats.sort=-count&stats.q=os-sim shows the resource counts of os-sim with the largest first.

The first click on a heading sorts text ascending and a number descending, and the next click flips the direction. Text sorts by its lower-cased form; a number sorts by its value, so 10 comes after 2. A column that draws a bar neither sorts nor filters. A filtered table says how many of its rows match, and one the filter emptied says so in place of the rows. The filter form carries every other parameter of the page along as hidden inputs, so applying a filter keeps the page's identifiers, its cursor, and the order and filter of every other table.

The tables and their names per page:

PageTables
/stats, events, rejected, periods, runs
/projectsprojects
/projectrelations, related, activity, kickbacks, rollup, statements
/resourcesresources
/resourcesegments, events
/pricingmodels
/catalogdimensions
/periodruns
/runstatements, settlement, deltas; the lists of what the run reported, warnings, metering_warnings, counter_warnings, attribution_warnings, adjustment_warnings, unpriced, unreadable, unregistered_projects and violations
/statementadjustments; items and rc-<n>, the summaries of the line items and of the n-th related cost; li-<n> and rc-<n>-li-<n>, the metric tables of the items, which sort and carry no filter. A credit note carries the same names, its li-<n> tables holding one row per dimension

A paged listing, /projects and /resources, is sorted and filtered within the page the API answered, which its row count says, and its next link carries both parameters along. The timeline of a resource page draws every segment whatever the segment table is filtered to.

A statement is rendered in sections, the project's own line items and then one per related cost. A section opens with a summary table of its items, resource type, resource, description, hours and total, sorted by total descending until the viewer sorts it otherwise. The resource of a row leads to the item's detail block below, and the blocks follow the summary's order and filter, so filtering the summary to one resource shows that resource's detail alone. Every block starts folded. The resource of a summary row leads to its block unfolded: the link names the block in open and in its fragment, so the page comes back with that block open and scrolled to, and any other block unfolds by hand. A block's heading links the resource's page, and its table holds one row per metric of every period, with the period's total as a row of its own. A description that only repeats the item's type and id is left out. A value of exactly zero is printed in the muted colour, so the amounts that are not zero stand out.

A credit note is rendered the same way, and the run decides which of the two a stored document is: a correction run stores a credit note under every key, and the page reads it as one, which is the rule tally-engine export decodes a document by. The head names the run the note corrects and carries the base, net and kickback deltas where the note holds them. The adjustments table shows each adjustment's rate beside what the corrected run applied, what the correction applied and the difference. A section's summary lists resource type, resource and total, and it opens in the order the note lists its items: a note credits some items and debits others, so neither direction of the total puts the largest movement first, and a heading still sorts it. An item's block holds one row per dimension, with the amount the corrected run billed, the amount the correction rated and the delta. A document stored under a correction run that names no run it corrects is not a credit note and is answered 503.

A billing period ​

A billing period has a page of its own, /period?month=, addressed by the month in the YYYY-MM form tally-engine reads --period in: /period?month=2026-07. A month that is missing or not of that form is answered 400, and a month no run ever opened, which has no billing period, 404. The overview's period table links every month to its page, and every run page links the month the run billed.

The head says where the month stands: its status, open, grace or finalized, when it was finalized and by which run, linking that run's page, and what the month bills. What it bills is the sum of the statements of the runs that stand, by the rule the project page adds one project up by: a run stands when it is completed or finalized, which is the regular run and every correction booked against it. A month billed in two currencies prints each sum on its own.

The runs table lists every run the engine recorded for the month, in the order the runs started, so a correction stands under the run it corrects. Each row carries the run's kind, status, pricing version, start and completion, how many statements it wrote and what they add up to, and links the run's page. A superseded, failed or running run is drawn in the muted colour and says it counts for nothing.

The export of a statement ​

The statement page carries, under its head, the file tally-engine export --format json writes for the statement: statement-<key>.json for a regular run and credit-note-<key>.json for a correction, with its size in bytes. The document is folded, and two links lead to /statement.json, which serves the same bytes on their own: open shows them in the browser, and download saves them.

The console does not print the stored document. The engine database keeps it as JSONB, which holds the members in an order of its own, and the export renders every document again in the order the export formats list, indented by two spaces and closed by a newline. The console hands the stored document to the export's own renderer, so what the page and the route show is the file an export of the run writes, byte for byte.

Only a run that stands is exported, completed or finalized, which is the rule tally-engine export applies. The statement of any other run, a superseded or a failed one, says in place of the file that its run is not exported, and /statement.json answers 404 for it. A stored document the export refuses, one holding a member the engine's types do not have, shows the export's error in place of the file, and /statement.json answers 503.

The file is named the way the export names it, in two parameters of Content-Disposition. filename* carries the name percent-encoded and is the one a browser reads, so the %2F between the cloud and the project reaches the saved file as it is; filename carries it plain for a client that reads nothing else. One name differs from the export's: of two statements of one run whose file names differ in ASCII case alone, the export writes the second under the SHA-256 digest of its key, and the console, which reads one statement at a time, names each after its key.

The export of a run ​

The run page carries, below its statements, the two files that tally-engine export --format json writes for the run beside them: run.json, the index naming every statement file with its cloud, its project and its total, and kickbacks.json, what the run settles for its partners. Each is folded under its name and its size in bytes, and two links lead to /run.json and /kickbacks.json, which serve the same bytes on their own: open shows them in the browser, and download saves them under the name the export gives the file. The files of every run share those two names, so two downloads into one directory collide there the way two exports into one directory would. The index is the one an export writes without --rollup, and it names no rollup document.

The console reads the run through the export's own read and hands it to the export's own renderers, so the page and the routes show what an export of the run writes, byte for byte. That includes every statement document: a statement the export refuses, one holding a member the engine's types do not have, makes the export refuse the whole run. The page then shows the export's error in place of the files, and both routes answer 503. A run that does not stand, a superseded, failed or running one, is not exported, which is the rule tally-engine export applies: its page says so in place of the files, and both routes answer 404.

Below the files, the settlement table lays kickbacks.json out: one row per partner and currency, with the number of projects the kickbacks came off and what the run owes the partner, read off the document rather than added up again. A partner links its own page. A correction's rows are the difference to the run it corrects, negative where usage was corrected down, which the page says above the table. A run that owes no partner says so in place of the rows.

What a run reported ​

The run page carries, under its head, what the run stored about itself. The counts come first: the snapshot, the instant up to which the run read the events; the candidates it considered; the usage and rated records it wrote; its statements, which a correction calls credit notes; its adjustment records; and for a correction the deltas and the adjustment deltas it wrote. A run that failed names the error it failed with above them.

Every list of findings follows as a table of its own, drawn when it holds a row and named after the member of the stats it lists, which is the name the list carries in the run.json of an export:

TableWhat it listsA row leads to
warningswhat the run found about itself: a period that had not endednothing
metering_warningsresources the metering pass warned about, a history that starts without a create for examplethe resource
counter_warningscounters that could not be read for a resource, with the metric and the windowthe resource
attribution_warningsprojects claimed twice or sitting in a cycle, with the relation that lostthe project
adjustment_warningsrelations whose kickbacks were dropped because their target is not a partnernothing
unpricedresource types the pricing model does not price, counting the resources skippednothing
unreadableusage fields no quantity could be read from, counting the draftsnothing
unregistered_projectsprojects the run met resources of and no registry row namesthe project's resources
violationsinvariant violations, one row per violation of a resourcethe resource

An unregistered project has no project page, so its row leads to the resource list filtered to its cloud and project under every status. A run whose lists are all empty says it reported no finding.

A run that is still running has stored no stats, and its page says so. Stats the console cannot read, a value that is not JSON or a member the engine's types do not have, are shown as they were stored beside the reason, and the rest of the page stands. The page reports them and does not log them.

The fleet at an instant and over a window ​

The resource list is read under one status, and that page is then read one of two ways. The status is the API's own filter, chosen with the switch on the show line above the table: active serves the rows whose state is not deleted and is the default, deleted serves those alone, and all serves both. A status that is none of the three is answered 400. Switching the status drops the cursor, because a cursor positions a walk through one status and means nothing in another. A deleted resource is only on the page when the status admits it, so looking back starts with switching the status to all.

The two ways are the console's own filters, applied to the rows the API served, and the switch on the when line chooses between them: at an instant, which answers what runs right now or what ran at one moment, and over a window, which answers what lived between two moments. The page draws the inputs and the presets of the chosen way alone, so a window and an instant are never asked for at once. Which way a request means is mode, either instant or window; a request that names no mode means the window when it carries from or to, and the instant otherwise. A mode that is neither is answered 400. What the other way would be asked with is ignored, so a stale at on a window page changes nothing, and switching ways drops the parameters of the way being left.

Each of at, from and to is read as RFC 3339 or as the form the inputs write, 2026-03-15T12:00, which carries no zone and is read as UTC; one that does not parse is answered 400.

The instant is at, and the page opens on it: a resource existed at it when it was created at or before it and not deleted at or before it; a resource whose history starts without a create is taken to have existed from its first event, the first_event_at the API serves on every row, and only a row the API serves without one is taken to have existed all along. Without at the instant is now, so the page opens on what runs right now. The instant presets are now, which is no parameter at all, 24 hours ago, 7 days ago, the start of this month and the start of last month. The instant input stays empty while nothing is pinned, so the page opens on now without claiming an instant was chosen.

The window is from and to, half-open: a resource existed in it when it was created before to, or had its first event before to when its history starts without a create, and was not deleted at or before from. Either bound may be left out, which leaves the window open on that side, and a to that is not after from is answered 400. A window with neither bound holds every row of the page, whenever it lived. The window presets are the last 24 hours, the last 7 days, this month, last month, and any time, which is the window with neither bound.

The page asks the API for 1000 rows, the most one page carries, so that the filters are applied to as much of the fleet as one call holds; the fleet of the simulated month fits. A page the API followed with a cursor, or one reached by a cursor, counts its rows as one page, and its next link carries the status, the mode, the window and the instant along. The line beside the status switch says how many of the rows the API served the filters kept and what they were kept for, and a page the filters emptied says so in place of the rows.

Every row prints its lifetime in hours at two places: from its creation to its deletion, or to now for a resource still there, whatever the page is read at. A resource whose history starts without a create has no creation time, which the API leaves null and the fold reports as history_starts_without_create. Its row prints the instant of its first event in the created column, followed by , first event, and counts its lifetime from that instant, which is where the fold starts the intervals a run bills; the line above the table counts such rows. The instant leads the cell, so sorting by created keeps such a row in time order among the creations, and filtering for first event finds such rows alone. A row the API serves without first_event_at, which only a Reporting API older than that field does, prints unknown for its creation and its lifetime and is counted on a line of its own. The resource page names the event such a history starts with and counts the lifetime from it. The last payload of a row is folded under a line that counts its keys, so the listing stays one line per row until a payload is opened.

Theme ​

The pages follow the operating system's light or dark setting on their own. The form at the right end of the navigation bar pins one side: it posts theme as light, dark or auto to /theme, together with back, the path of the page it stands on. light and dark are kept in the cookie theme for a year, and auto deletes it. The console answers 303 to back, or to / when back is not a path of the console, so the form cannot send the viewer elsewhere. A page renders the cookie's value as the data-theme attribute of its root element, which the stylesheet reads, and a request without the cookie renders no attribute. A theme that is none of the three is answered 400 on the error page, and a GET of /theme 405.

Every page ends with a provenance panel naming the reads it was built from: each Reporting API request as its method and path with the query string, and each engine read under the name its query carries in internal/console/store/queries.sql.

A failure renders one error page carrying the wrapped error: 400 for a parameter that is missing or unreadable, 404 for a lookup that found nothing, for an API 404 and for a file of a statement or of a run whose run is not exported, 502 for a Reporting API call that failed or that the API refused the token for, and 503 for an engine query that failed, a stored document that does not decode or that the export refuses, and the files of a run whose statements the export refuses. A path the console has no page for is answered 404 on that same error page, and a route asked with a method it does not answer 405.

Amounts are rendered at two decimal places and quantities at four, the scales the engine rounds them to. A catalog price is rendered with the digits the pricing document carries, because a price of 0.00005 per unit hour rounded to a money scale would print as zero.

Environment ​

Every setting comes from the environment. The two secrets accept the *_FILE companion the rest of Tally uses, the variable's name plus the suffix _FILE holding a path whose content becomes the value, applied by internal/core/envsecret.

VariableTypeDefaultFile-backedGoverns
TALLY_LOG_LEVELstringINFOnoLogLevel is the slog threshold, one of DEBUG, INFO, WARN, or ERROR.
TALLY_CONSOLE_HTTP_PORTinteger8095noHTTPPort is the port the console listens on, bound to the loopback address only. The default is 8095 because 8090 and 8091 are taken by the simulator compose stack.
TALLY_CONSOLE_REPORTING_URLstringnonenoReportingURL is the base URL of the Reporting API without the /api/v1 suffix. It has to be set, and it has to be https unless its host is loopback, because the API token rides on every call.
TALLY_CONSOLE_API_TOKENstringnoneyes (TALLY_CONSOLE_API_TOKEN_FILE)APIToken is the bearer token the console reads the Reporting API with. It has to carry the admin role, because the dead-letter list the overview reads is admin-only. It has to be set. Supports the *_FILE convention.
TALLY_CONSOLE_CA_FILEstringnonenoCAFile is a PEM file holding the CA that signed the Reporting API's certificate. Empty trusts the system store.
TALLY_CONSOLE_ENGINE_DB_URLstringnoneyes (TALLY_CONSOLE_ENGINE_DB_URL_FILE)EngineDBURL is the PostgreSQL connection string of the engine database, opened for reads only. It has to be set. Supports the *_FILE convention.

The token ​

TALLY_CONSOLE_API_TOKEN has to carry the admin role. The overview reads GET /api/v1/rejected-events, the dead-letter list, and that route is admin-only: a read_all token is answered 403 there. Every other read the console makes takes read_all or project, so the dead-letter list alone decides the role. Such a token is issued by tally-reporting-admin create-api-token.

The console sends nothing but GET and binds the loopback address. That is what makes an admin token acceptable here, and why the process belongs on a laptop rather than in a deployment.

Startup ​

make console starts the console against the dev cluster. It issues a token with tally-reporting-admin create-api-token --role admin, writes the dev CA to tally-ca.crt, and runs the process against https://api.tally.127-0-0-1.nip.io:8443 and the dev engine database, printing http://127.0.0.1:8095/. The token is issued fresh per run and comes before the CA, so on a machine without a dev cluster the target fails on the admin CLI's connection error.

The process binds 127.0.0.1 on TALLY_CONSOLE_HTTP_PORT, so the console is reachable from the machine it runs on and nowhere else. The engine pool connects lazily, so the process comes up while that database is unavailable and the pages reading it report the outage.

A configuration the console cannot honor exits 1 before the port is bound, with the message naming the variable. TALLY_CONSOLE_REPORTING_URL, TALLY_CONSOLE_API_TOKEN and TALLY_CONSOLE_ENGINE_DB_URL have to be set. A reporting URL that is not https and whose host is not loopback is refused, because the token rides on every call and would travel in cleartext. A TALLY_CONSOLE_CA_FILE holding no PEM certificate is refused with the file named.

Signals and exit status ​

SIGINT and SIGTERM begin a graceful shutdown. The server stops accepting connections and in-flight requests get 10 seconds to finish. The process then exits 0.

Every other failure exits 1: a configuration that was refused, a Reporting API client that could not be built, an engine pool that could not be opened, a router that could not be built, and a listener that ended with anything but a closed server.

Logging ​

The process writes JSON lines to stdout. Every line carries service=tally-console, and the level is the one TALLY_LOG_LEVEL names: DEBUG, INFO, WARN or ERROR. Every error page is logged with the wrapped error it shows, at WARN for a request that asked for something wrong and at ERROR for a failure of the console or of a side it reads. A stored row whose amount is not a number is left out of the listing that read it and logged at WARN, naming the query and the row's key columns.