▐▛███▜▌ Claude Code v2.1.193
▝▜█████▛▘ Opus 4.8 · Claude Team
▘▘ ▝▝ ~/.superset/worktrees/Arius7/pricing-view
❯ /effort
⎿ Set effort level to ultracode (this session only): xhigh + dynamic workflow
orchestration
❯ 1. in the overview tab, we need a new section "Storage Accounts" that lists
all the stroage accounts. When adding a new storage account (/repos/create >
Add new account) there should be a dropdown to select the region (sourced
from the pricingcalculator options). an Unknown/Not in the list option should
be there. Mark the mandatory fields red. In the list of all storage
accounts, when i click on one I should be edit account key and location. It
should be a flyout (like the properties page no the /repos/files page). in
the database, ensure that account name | key | location are together. The
locatino will be the programmatic name eg. northeurope/westeurope
2. on the repos/files properties page, remove the 'account key', the account
key is not a property of the repository (it cannot change independenly of the
account), reflect that structure also in the database. Rename 'Encryption
passphrase' to 'Passphrase'
3. on the rpeos/add screen mark the mandatory fields red. Friendly alias
should not be manadatory; if unset it s just the name of the container. for
the local path, woudl it be possible to see a [...] and then i browse the
local filesystem to seelect the folder?
1. make a shared component 'CostCalculator'. it is region aware and owns the
current pricing json. keep the restorecostcalculator - that is specifically
for the restore costs. i want another cost calculator that feeds this
component (exposed via a simple Mediator CostQuery
'/Users/wouter/Downloads/Arius.Web codebase access.zip') i think all the data
it s needs is in the json. It should replace the 'Stored size by tier'
component
⏺ I'll start by orienting myself to the codebase structure, then launch parallel
exploration. Let me first understand what I'm working with.
Searched for 1 pattern, listed 1 directory, ran 1 shell command (ctrl+o to expand)
⏺ This is the Arius7 codebase (a .NET app). The web UI is in src/Arius.Web. Let
me look deeper at the web project and the referenced zip.
Searched for 1 pattern, ran 1 shell command (ctrl+o to expand)
⏺ Key insight: Arius.Web is an Angular frontend that talks to the .NET Arius.Api
(which uses the Mediator/CQRS pattern — CostQuery). The zip is a design
handoff for the statistics tier costs (Group 2). Let me extract it and read
the conventions.
Searched for 1 pattern, read 1 file, ran 2 shell commands (ctrl+o to expand)
⏺ This is a clear picture now. The architecture:
- Arius.Web = Angular frontend (overview, repos, wizards, drawer/flyout,
statistics tab)
- Arius.Api / Arius.Core = .NET backend with Mediator/CQRS (CostQuery),
pricing.json + RestoreCostCalculator under Features/RestoreCommand/
- The zip is a design handoff redesigning the "Stored size by tier" section
with per-tier cost (Group 2's target)
Let me read the core reference files directly while launching parallel
exploration of the rest.
Read 2 files (ctrl+o to expand)
⏺ Now let me read pricing.json and locate the Mediator/CQRS infrastructure and
the DB model for repositories/storage accounts.
Read 1 file, ran 2 shell commands (ctrl+o to expand)
⏺ Shell glob issue (zsh expanding *.cs). Let me quote and use absolute paths.
Note: pricing.json is currently single-region (West Europe, EUR) — relevant to
the "region aware" requirement.
Ran 1 shell command (ctrl+o to expand)
⏺ Excellent — I now have a clear backend map. Key files identified:
AppData/AppDatabase.cs + Records.cs (DB), AccountEndpoints.cs,
RepositoryEndpoints.cs, BrowseEndpoints.cs, StatisticsQuery.cs, and the
Features/*Query Mediator pattern. pricing.json is single-region today (West
Europe, EUR) — so "region aware" is a real structural gap.
Now I'll launch a comprehensive parallel exploration via a Workflow of
read-only Explore agents (plan-mode safe), each returning structured findings
on one area.
⏺ Workflow(Parallel read-only exploration of Arius7 for the storage-accounts +
cost-calculator plan)
⎿ /workflows to view dynamic workflow runs
⏺ The workflow is running. While it explores, let me read the linchpin backend
files directly so I can verify the agents' findings against ground truth — the
DB schema and the Mediator/Statistics pattern drive the hardest design
decisions.
Read 2 files (ctrl+o to expand)
⏺ The DB schema is very informative. Critical finding for Group 1 #2:
account_key is already on the storage_accounts table (not on repositories —
repos only have passphrase). So the DB is already structured correctly; the
"remove account key from repo" change is mostly UI/DTO. And there's no
UpdateAccount method and no location column yet — both needed for Group 1 #1.
Let me read the account/repository endpoints and the Mediator/Statistics
pattern to ground the plan while the workflow runs.
Read 3 files (ctrl+o to expand)
⏺ The Mediator pattern is clear: it uses the source-generated Mediator package
(ICommand / ICommandHandler<,>), with per-repository handlers registered in
AddArius(...) with accountName/containerName baked in. Notably,
AccountEndpoints has only GET + POST — no update endpoint, confirming the
"edit account key + location" flyout needs new backend support.
Let me read the DTOs and the browse endpoint (the local-path picker
feasibility is a real open question) while the workflow finishes.
Read 2 files (ctrl+o to expand)
⏺ Two more key findings:
- BrowseEndpoints is misnamed — it's snapshot/stats browsing, not filesystem
browsing. There is no local-directory browse endpoint, so Group 1 #3's [...]
folder picker needs either a new server-side endpoint (only viable if the API
runs on the user's machine) or a native browser approach. Deployment model
matters here.
- The API DTOs are already clean: RepositoryDto/UpdateRepositoryRequest carry
no account key — so Group 1 #2 ("remove account key, reflect in DB") is
essentially already true in the backend; the account key lives on the account.
The change is mainly frontend + adding account update support.
Let me read the frontend conventions doc and the create wizard (the "Add new
account" flow is central) while the workflow finishes the rest.
Read 2 files (ctrl+o to expand)
⏺ Dynamic workflow "Parallel read-only exploration of Arius7 for the
storage-accounts + cost-calculator plan" completed · 1m 48s
⏺ The workflow completed (8 agents, 383k tokens). The create wizard confirms the
"Add new account" flow lives in step 1 (mode() === 'new', fields
newName/newKey) — that's where the region dropdown goes, and friendly alias is
currently mandatory via canCreate(). Let me read the full structured
findings.
Read 1 file (ctrl+o to expand)
⏺ Comprehensive findings. Let me read the remaining pages (agents 7-8 on the API
layer and the region-list/pricing question — the linchpin connecting both
groups).
Read 1 file (ctrl+o to expand)
⏺ I now have a thorough picture. Before finalizing, let me verify the most
decision-critical files with my own eyes — the properties tab (exact
removal/rename), the overview placement, the drawer mechanism, the API layer,
and how repository mutations evict cached providers (region-change
implications).
Read 3 files (ctrl+o to expand)
⏺ I've verified the core UI files. The properties tab confirms: account-key
block at lines 25-29 (+ accountKey field/reset) to remove, "Encryption
passphrase" label at line 31 to rename. Overview's Repositories table (lines
44-80) is the exact template for a Storage Accounts section. DrawerStore needs
a new 'account' type + accountId + openAccount().
Let me read the last three integration files (API service, models, add wizard)
to nail exact signatures before finalizing.
Read 3 files (ctrl+o to expand)
⏺ I now have complete ground truth across the frontend and backend. Two design
decisions genuinely depend on your intent and can't be resolved from the code
(the codebase has no region list and no filesystem browser today, and
pricing.json is single-region). Let me confirm these before writing the plan.
⏺ User answered Claude's questions:
⎿ · There's no Azure region list in the code today, and pricing.json holds
rates for one region only (West Europe, EUR). The region dropdown will be a
curated list of Azure regions (+ an 'Unknown / Not in list' option). How
should the per-tier COST RATES vary by the account's selected region? →
Only regions in the json
· The Arius.Api backend runs in Docker and can only see paths mounted into
the container — it cannot browse your host filesystem. The local path
stored is the path AS THE API SEES IT. How should the local-path '[...]'
picker work? → Server-side browser
⏺ Both decisions are clear:
- Region pricing → "Only regions in the json": restructure pricing.json to be
region-keyed; the dropdown is derived from its region keys + "Unknown". This
makes pricing.json the single source of truth ("owns the pricing json").
- Folder picker → "Server-side browser": new backend directory-listing
endpoint + an Angular folder picker over paths the API can see.
Let me verify how the Mediator is wired at the app level vs per-repo (this
determines how the CostQuery and regions endpoints dispatch without needlessly
authenticating to blob storage).
Read 1 file (ctrl+o to expand)
⏺ I now have full ground-truth on every relevant file and both design decisions.
One decisive architectural finding: IMediator exists only inside
per-repository providers (AddMediator() runs per-repo in
RepositoryProviderRegistry.BuildAsync, line 135) — there is no app-level
mediator. So the region list (needed before any repo exists, during account
creation) must come from a plain app-level endpoint reading the embedded
pricing.json, while the per-repo CostQuery dispatches through the repo
provider like StatisticsQuery does.
Let me write the plan.
⏺ Updated plan
⎿ /plan to preview
⏺ The plan is written. Let me present it for approval.
⎿ User rejected Claude's plan:
╭─────────────────────────────────────────────────────────────────────────╮
│ Plan: Storage Accounts management + region-aware Cost Calculator │
│ │
│ Context │
│ │
│ Two related changes to the Arius.Web Angular SPA and its Arius.Api / │
│ Arius.Core .NET backend (Mediator/CQRS, SQLite app DB): │
│ │
│ - Group 1 — Storage accounts & repos: Today storage accounts are │
│ second-class — they're only created inline in the New/Add-repo wizards, │
│ can never be edited, and carry no Azure region. The Overview has no │
│ place to see or manage them. Meanwhile the repo Properties page shows │
│ an "Account key" field that doesn't even belong to a repo (and isn't │
│ persisted). We surface accounts on the Overview, add a region to each │
│ account, make accounts editable via a flyout, and clean up the │
│ Properties page. │
│ - Group 2 — Cost calculator: The Statistics tab's "Stored size by tier" │
│ section shows size but no cost. We add a region-aware storage-cost │
│ calculator in Core (exposed via a Mediator CostQuery, alongside — not │
│ replacing — the existing RestoreCostCalculator), and a shared Angular │
│ CostCalculator component that renders the redesigned cost-aware tier │
│ breakdown (per the design handoff in │
│ scratchpad/design_handoff/.../Statistics Tab v2.dc.html). │
│ │
│ Decisions confirmed with the user │
│ │
│ 1. Region pricing = "only regions in the json." pricing.json becomes │
│ region-keyed and is the single source of truth. The region dropdown │
│ lists exactly the regions present in the json + an "Unknown / Not in │
│ list" option. Today that means [westeurope, Unknown]; the list grows as │
│ regions are added to the json. Rates select by the account's region; │
│ Unknown/missing → fall back to the default region. │
│ 2. Local-path picker = server-side browser. A new backend endpoint │
│ lists directories as the Arius.Api host/container sees them (mounted │
│ volumes under Docker; real folders when run locally). A [...] button │
│ opens a small folder picker over that. │
│ │
│ Key facts that shape the plan (verified) │
│ │
│ - Account key is already account-scoped in the DB │
│ (storage_accounts.account_key) and the API DTOs. The repo Properties │
│ "Account key" field is vestigial — save() never sends it. So Group 1 │
│ #2's "reflect in the DB" needs no DB change; it's a frontend removal. │
│ - No UpdateAccount exists (DB method, endpoint, or DTO) and no location │
│ column — both are net-new for the edit-account flyout + region. │
│ - Mediator lives only per-repo (AddMediator() in │
│ RepositoryProviderRegistry.BuildAsync). The regions list (used during │
│ account creation, before any repo) must be a plain app-level endpoint. │
│ CostQuery is per-repo (region resolved from the repo's account), │
│ dispatched like StatisticsQuery. │
│ - pricing.json currently has no storagePerGBPerMonth for the archive │
│ tier — must be added (design handoff: ~0.0010), since the cost-by-tier │
│ view needs all four tiers. │
│ - The statistics endpoint caches StatisticsDto keyed by snapshot │
│ fingerprint (region-independent), so cost is kept separate (its own │
│ CostQuery, multiplied client-side) — this matches the user's explicit │
│ "Mediator CostQuery" and avoids cache-invalidation issues. │
│ │
│ --- │
│ Group 1 — Storage Accounts, Repos, Properties │
│ │
│ 1a. Region-keyed pricing as the single source of truth (backend) │
│ │
│ - Move src/Arius.Core/Features/RestoreCommand/pricing.json → │
│ src/Arius.Core/Shared/Pricing/pricing.json; update the │
│ path in src/Arius.Core/Arius.Core.csproj. │
│ - Restructure to region-keyed, add currency per region, and add the │
│ missing archive storagePerGBPerMonth: │
│ { "regions": { "westeurope": { │
│ "currency": "EUR", │
│ "archive": { "retrievalPerGB":0.0204, "retrievalHighPerGB":0.1102, │
│ "readOpsPer10000":6.6094, "readOpsHighPer10000":71.6011, │
│ "storagePerGBPerMonth":0.0010 }, │
│ "hot": { "writeOpsPer10000":0.05, "storagePerGBPerMonth":0.018 }, │
│ "cool": { "writeOpsPer10000":0.10, "storagePerGBPerMonth":0.01 }, │
│ "cold": { "writeOpsPer10000":0.18, "storagePerGBPerMonth":0.0045 } │
│ } } } │
│ - Refactor PricingConfig (currently internal sealed record in │
│ RestoreCostCalculator.cs) into │
│ src/Arius.Core/Shared/Pricing/PricingCatalog.cs: │
│ - PricingCatalog.LoadEmbedded() → │
│ IReadOnlyDictionary Regions, │
│ IReadOnlyList RegionNames, and RegionPricing GetRegion(string? │
│ region) (returns the region's rates; falls back to the default — │
│ first/westeurope — when null/"Unknown"/missing). Keep the existing │
│ LoadEmbedded resource-stream idiom. │
│ - RegionPricing holds Currency + the four tiers (same fields as │
│ today, plus archive storage rate). │
│ - RestoreCostCalculator stays as the restore-only calculator but │
│ consumes the new shape (GetRegion(default) → identical numbers for WE). │
│ Minimal change; behavior preserved. │
│ │
│ 1b. Account location end-to-end (backend) │
│ │
│ - src/Arius.Api/AppData/AppDatabase.cs: add location TEXT to the │
│ storage_accounts CREATE TABLE, plus an additive migration in │
│ CreateOrUpgradeSchema (check PRAGMA table_info(storage_accounts), ALTER │
│ TABLE ... ADD COLUMN location if absent). Update │
│ ReadAccount/ListAccounts/GetAccount, extend InsertAccount(name, key, │
│ location), and add UpdateAccount(id, encryptedKey?, location?) using │
│ the COALESCE($x, col) "null leaves unchanged" idiom already used by │
│ UpdateRepository. │
│ - src/Arius.Api/AppData/Records.cs: add string? Location to │
│ AccountRecord. │
│ - src/Arius.Api/Contracts/Dtos.cs: AccountDto += string? Location; │
│ CreateAccountRequest += string? Location; new │
│ UpdateAccountRequest(string? AccountKey, string? Location). │
│ - src/Arius.Api/Endpoints/AccountEndpoints.cs: include Location in │
│ ToDto/POST; add GET /accounts/{id} (flyout load) and PATCH │
│ /accounts/{id} (key+location; secrets.Protect the key only when │
│ supplied; on key change, registry.Evict the account's repos so the new │
│ key takes effect). Optional DELETE /accounts/{id} guarded by │
│ CountRepositoriesForAccount for the flyout danger-zone. │
│ - New GET /pricing/regions (app-level, no provider): a small │
│ PricingEndpoints.cs returning PricingCatalog.RegionNames (needs a │
│ public accessor on the catalog). Frontend appends "Unknown / Not in │
│ list". Map it in Program.cs. │
│ │
│ 1c. Server-side folder browser (backend) │
│ │
│ - New src/Arius.Api/Endpoints/FilesystemEndpoints.cs: GET │
│ /fs/list?path= returns immediate subdirectories ({ name, path }[]) of │
│ path on the API host, with a default root and graceful handling of │
│ not-found/no-permission. Reuse traversal patterns from │
│ Arius.Core/Features/ListQuery/LocalDirectoryReader.cs │
│ (enumerate-with-degradation). Map in Program.cs. │
│ │
│ 1d. Overview "Storage Accounts" section (frontend) │
│ │
│ - src/app/features/overview/overview.component.ts: add a new .ar-card │
│ section below the Repositories table (clone the markup at lines 44–80). │
│ Columns: Account | Region | Repositories. accounts = │
│ toSignal(this.api.listAccounts()); each row (click)="editAccount(a.id)" │
│ → drawer.openAccount(a.id); show a.location ?? 'Unknown' and │
│ a.repositories. │
│ │
│ 1e. Edit-account flyout (frontend) │
│ │
│ - src/app/core/state/drawer.store.ts: extend DrawerType with 'account'; │
│ add accountId = signal(0) and openAccount(accountId) (mirrors │
│ openProperties). │
│ - New src/app/features/drawer/account-drawer.component.ts (clone │
│ properties-drawer.component.ts scrim/ar-drawer/animations). Header │
│ "Edit storage account". Loads via api.getAccount(id). Fields: Account │
│ name (read-only), Account key (password, "replace to rotate"), Region │
│ ( from api.getRegions() + "Unknown / Not in list"). Save → │
│ api.updateAccount(id, {accountKey?, location}). Mandatory fields shown │
│ red (see 1g). Optional danger-zone delete. │
│ - Mount in src/app/app.component.ts alongside │
│ the existing drawers. │
│ │
│ 1f. Region dropdown in the New/Add wizards (frontend) │
│ │
│ - In create-repo-wizard.component.ts (step 1 mode()==='new', after │
│ Account key ~lines 37–39) and add-repo-wizard.component.ts (~lines │
│ 42–43): add a Region sourced from api.getRegions() (+ "Unknown │
│ / Not in list"); pass it to createAccount(name, key, region). │
│ - Extend api.createAccount + CreateAccountRequest with location (see 3, │
│ API layer below). │
│ │
│ 1g. Add-repo screen polish (frontend, Group 1 #3) │
│ │
│ - Mandatory fields red: add a shared .ar-req convention (red asterisk │
│ on the label, and/or red border when required-and-empty). Apply to │
│ genuinely required fields (Account name, Account key, Container │
│ selection, Passphrase). Region is not required (Unknown is valid). │
│ - Friendly alias optional: in add-repo-wizard.component.ts change the │
│ create button guard [disabled]="!selectedContainer() || !alias" │
│ (line 71) → [disabled]="!selectedContainer()", and in add() default │
│ alias: this.alias || this.selectedContainer(). (In the create wizard, │
│ alias seeds the generated container name, so it stays effectively │
│ required there — only add red markers.) │
│ - Local path [...]: new shared src/app/shared/folder-picker/ (a [...] │
│ button + popover that calls api.listDirectories(path), lets the user │
│ navigate, returns the chosen API-side path). Wire it into the │
│ local-path input of add-repo-wizard (line 67); reuse in │
│ create-repo-wizard (line 49) and properties-tab (line 44) for │
│ consistency. │
│ │
│ 1h. Properties page cleanup (frontend, Group 1 #2) │
│ │
│ - src/app/features/repo/properties/properties-tab.component.ts: remove │
│ the "Account key" field block (lines 25–29), the accountKey field │
│ (line 112) and its reset() assignment (line 163) — save() already omits │
│ it. Rename label "Encryption passphrase" → "Passphrase" (line 31). │
│ Update the component doc comment (line 8). No backend/DTO change needed │
│ (key was never a repo property). │
│ │
│ --- │
│ Group 2 — Region-aware Cost Calculator │
│ │
│ 2a. CostQuery Mediator query (Core) │
│ │
│ - New src/Arius.Core/Features/CostQuery/CostQuery.cs: │
│ - record CostQuery(string? Region) : ICommand; │
│ - record StorageCost(string Region, string Currency, │
│ IReadOnlyList Tiers); with record │
│ TierStorageRate(string Tier, double StoragePerGbMonth); │
│ - CostQueryHandler : ICommandHandler │
│ depending only on the shared PricingCatalog (no account/container) — │
│ returns per-tier storagePerGBPerMonth for hot/cool/cold/archive of the │
│ resolved region. │
│ - Register in src/Arius.Core/ServiceCollectionExtensions.cs next to the │
│ StatisticsQuery handler (~line 177). │
│ - This is the "another cost calculator, exposed via a Mediator │
│ CostQuery." RestoreCostCalculator is kept untouched in purpose; both │
│ now read the same shared PricingCatalog (which "owns the pricing │
│ json"). │
│ │
│ 2b. Cost endpoint + DTO (API) │
│ │
│ - src/Arius.Api/Contracts/Dtos.cs: new StorageCostDto(string Region, │
│ string Currency, IReadOnlyList Tiers) + TierCostDto(string │
│ Tier, double CostPerGbMonth). (TierStatisticsDto stays as-is.) │
│ - New GET /repos/{id}/storage-cost (in BrowseEndpoints.cs or a new │
│ CostEndpoints.cs): read repo→account→location from AppDatabase, get the │
│ repo provider's IMediator (registry.GetReadProviderAsync, already warm │
│ on the Statistics tab), Send(new CostQuery(location)), map to │
│ StorageCostDto. No caching (trivial lookup). │
│ │
│ 2c. Shared CostCalculator component (frontend) replacing "Stored size │
│ by tier" │
│ │
│ - New src/app/shared/cost-calculator/cost-calculator.component.ts │
│ implementing the design handoff (Statistics Tab v2.dc.html): a │
│ 100%-stacked hero bar (total-scaled %), legend, detail table Tier | │
│ Stored | Chunks | Est. cost/mo, and a grand-total footer. Inputs: │
│ repoId + tiers (StatisticsDto.storedByTier). It fetches rates via │
│ api.getStorageCost(repoId); per-tier cost = (storedSize / 1e9) * │
│ costPerGbMonth, total = sum; currency from the response. Reuse │
│ TIER_COLORS, formatBytes, formatCount; add formatCurrency to │
│ src/app/shared/format.ts. │
│ - src/app/features/repo/statistics/statistics-tab.component.ts: replace │
│ the inline tier block (lines 24–40) with (keep the │
│ @if (tiers().length) guard semantics). Update the info-banner copy per │
│ the handoff. │
│ │
│ --- │
│ Shared API layer (frontend) — both groups │
│ │
│ src/app/core/api/api-models.ts: AccountDto += location: string | null; │
│ add StorageCostDto/TierCostDto; optional UpdateAccountRequest. │
│ src/app/core/api/api.service.ts: extend createAccount(name, key, │
│ location); add getAccount(id), updateAccount(id, {accountKey?, │
│ location?}) (PATCH), getStorageCost(id), getRegions(), │
│ listDirectories(path). │
│ │
│ --- │
│ Files at a glance │
│ │
│ Area: Pricing (Core) │
│ Create: Shared/Pricing/PricingCatalog.cs, Shared/Pricing/pricing.json │
│ (moved) │
│ Modify: Arius.Core.csproj (resource path), │
│ Features/RestoreCommand/RestoreCostCalculator.cs │
│ ──────────────────────────────────────── │
│ Area: CostQuery (Core) │
│ Create: Features/CostQuery/CostQuery.cs │
│ Modify: ServiceCollectionExtensions.cs │
│ ──────────────────────────────────────── │
│ Area: DB / API │
│ Create: Endpoints/PricingEndpoints.cs, Endpoints/FilesystemEndpoints.cs │
│ (+ │
│ CostEndpoints.cs or fold into Browse) │
│ Modify: AppData/AppDatabase.cs, AppData/Records.cs, Contracts/Dtos.cs, │
│ Endpoints/AccountEndpoints.cs, Endpoints/BrowseEndpoints.cs, │
│ Program.cs │
│ ──────────────────────────────────────── │
│ Area: Frontend │
│ Create: features/drawer/account-drawer.component.ts, │
│ shared/cost-calculator/cost-calculator.component.ts, │
│ shared/folder-picker/* │
│ Modify: overview.component.ts, core/state/drawer.store.ts, │
│ app.component.ts, │
│ wizards/create/*, wizards/add/*, │
│ repo/properties/properties-tab.component.ts, │
│ repo/statistics/statistics-tab.component.ts, │
│ core/api/api.service.ts, │
│ core/api/api-models.ts, shared/format.ts │
│ │
│ Reuse (don't reinvent) │
│ │
│ - Drawer flyout: DrawerStore + properties-drawer.component.ts pattern │
│ (scrim/ar-drawer/ar-fade/ar-slide). │
│ - List section markup: Overview Repositories table (lines 44–80). │
│ - Form styling: .ar-field/.ar-input/.ar-seg/.ar-radio (already in every │
│ wizard/properties component). │
│ - Mediator query recipe: StatisticsQuery.cs + its registration; │
│ endpoint dispatch in BrowseEndpoints.cs. │
│ - DB idioms: COALESCE partial-update (UpdateRepository), ReadAccount │
│ reader, CountRepositoriesForAccount guard, PricingConfig.LoadEmbedded │
│ resource-stream loader. │
│ - Formatting: shared/format.ts (formatBytes/formatCount), TIER_COLORS. │
│ │
│ Verification │
│ │
│ - Backend: dotnet build (Arius.Api/Core; note Arius.Web is not in the │
│ slnx). Run the API on :5080. Check existing Core tests for │
│ pricing/RestoreCost (the pricing.json move + restructure will touch │
│ them) — dotnet test src/Arius.Core.Tests. │
│ - Frontend: cd src/Arius.Web && npm start (API on :5080). Manually │
│ verify: Overview shows Storage Accounts; New/Add wizards' "Add new │
│ account" shows a region dropdown; mandatory fields are red; clicking an │
│ account opens the flyout to edit key + region; Properties has no │
│ Account key field and shows "Passphrase"; Add-repo alias is optional │
│ (defaults to container) and local path has a working [...] picker; │
│ Statistics tab shows the cost-aware tier breakdown with per-tier Est. │
│ cost/mo + grand total. │
│ - e2e/unit (Playwright + Karma): update specs whose markup/flow changed │
│ — overview.spec, create-wizard.spec, add-wizard.spec, properties.spec, │
│ statistics.spec, statistics-tiers.spec (preserve/extend data-testids; │
│ add ids for the region select, folder picker, account drawer, cost │
│ table). Run npm run e2e and ng test. │
╰─────────────────────────────────────────────────────────────────────────╯
⏺ Good questions — and I agree with your instinct. Let me verify the Mediator
library exposes IQuery/IQueryHandler (the codebase currently only uses
ICommand and IStreamQuery), then I'll rework the plan so the calculation lives
in Core.
Ran 1 shell command (ctrl+o to expand)
⏺ Confirmed: Mediator 3.0.2 (martinothamar) — it exposes
IQuery/IQueryHandler<,> (the codebase just hasn't used them yet; it uses
ICommand + IStreamQuery). mediator.Send(IQuery) works the same way. So
IQuery is the right, read-correct choice. And I fully agree the math belongs
in Core. Let me answer your questions and rework the plan.
Who owns what:
- Rates → Core's new Shared/Pricing/PricingCatalog (region-keyed pricing.json,
single source of truth).
- Per-tier stored sizes → the existing StatisticsQuery (chunk index).
- The cost math → the new CostQuery handler in Core: it dispatches
StatisticsQuery, multiplies each tier's stored size by that region's
storagePerGBPerMonth (using the same 1024³ GiB convention as
RestoreCostCalculator), and returns finished per-tier costs + grand total +
currency. The frontend does zero arithmetic — it just renders.
Let me update the plan accordingly.
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan ⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Plan updated to reflect both points: CostQuery now uses IQuery/IQueryHandler,
and the entire cost calculation lives in the Core handler (composing
StatisticsQuery + PricingCatalog, GiB-consistent with RestoreCostCalculator),
with the frontend rendering finished numbers only. Re-presenting for approval.
⎿ User rejected Claude's plan:
╭─────────────────────────────────────────────────────────────────────────╮
│ Plan: Storage Accounts management + region-aware Cost Calculator │
│ │
│ Context │
│ │
│ Two related changes to the Arius.Web Angular SPA and its Arius.Api / │
│ Arius.Core .NET backend (Mediator/CQRS, SQLite app DB): │
│ │
│ - Group 1 — Storage accounts & repos: Today storage accounts are │
│ second-class — they're only created inline in the New/Add-repo wizards, │
│ can never be edited, and carry no Azure region. The Overview has no │
│ place to see or manage them. Meanwhile the repo Properties page shows │
│ an "Account key" field that doesn't even belong to a repo (and isn't │
│ persisted). We surface accounts on the Overview, add a region to each │
│ account, make accounts editable via a flyout, and clean up the │
│ Properties page. │
│ - Group 2 — Cost calculator: The Statistics tab's "Stored size by tier" │
│ section shows size but no cost. We add a region-aware storage-cost │
│ calculator in Core (exposed via a Mediator CostQuery, alongside — not │
│ replacing — the existing RestoreCostCalculator), and a shared Angular │
│ CostCalculator component that renders the redesigned cost-aware tier │
│ breakdown (per the design handoff in │
│ scratchpad/design_handoff/.../Statistics Tab v2.dc.html). │
│ │
│ Decisions confirmed with the user │
│ │
│ 1. Region pricing = "only regions in the json." pricing.json becomes │
│ region-keyed and is the single source of truth. The region dropdown │
│ lists exactly the regions present in the json + an "Unknown / Not in │
│ list" option. Today that means [westeurope, Unknown]; the list grows as │
│ regions are added to the json. Rates select by the account's region; │
│ Unknown/missing → fall back to the default region. │
│ 2. Local-path picker = server-side browser. A new backend endpoint │
│ lists directories as the Arius.Api host/container sees them (mounted │
│ volumes under Docker; real folders when run locally). A [...] button │
│ opens a small folder picker over that. │
│ │
│ Key facts that shape the plan (verified) │
│ │
│ - Account key is already account-scoped in the DB │
│ (storage_accounts.account_key) and the API DTOs. The repo Properties │
│ "Account key" field is vestigial — save() never sends it. So Group 1 │
│ #2's "reflect in the DB" needs no DB change; it's a frontend removal. │
│ - No UpdateAccount exists (DB method, endpoint, or DTO) and no location │
│ column — both are net-new for the edit-account flyout + region. │
│ - Mediator lives only per-repo (AddMediator() in │
│ RepositoryProviderRegistry.BuildAsync). The regions list (used during │
│ account creation, before any repo) must be a plain app-level endpoint. │
│ CostQuery is per-repo (region resolved from the repo's account), │
│ dispatched like StatisticsQuery. │
│ - pricing.json currently has no storagePerGBPerMonth for the archive │
│ tier — must be added (design handoff: ~0.0010), since the cost-by-tier │
│ view needs all four tiers. │
│ - Cost is a separate CostQuery (not folded into the fingerprint-cached │
│ StatisticsDto), and all cost math happens in Core — the CostQuery │
│ handler composes the StatisticsQuery outcome with the region's rates │
│ and returns finished numbers. The frontend does no arithmetic. Mediator │
│ 3.0.2 exposes IQuery/IQueryHandler<,> (used here; the codebase's │
│ existing queries happen to use ICommand/IStreamQuery, but IQuery is the │
│ read-correct interface and mediator.Send handles it identically). │
│ │
│ Data ownership (who owns the calculation) │
│ │
│ ┌──────────────────────────┬─────────────────────────────────────────── │
│ ───┐ │
│ │ Concern │ Owner │
│ │ │
│ ├──────────────────────────┼─────────────────────────────────────────── │
│ ───┤ │
│ │ Pricing rates │ Core Shared/Pricing/PricingCatalog — │
│ single │ │
│ │ (region-keyed │ source of truth │
│ │ │
│ │ pricing.json) │ │
│ │ │
│ ├──────────────────────────┼─────────────────────────────────────────── │
│ ───┤ │
│ │ Per-tier stored size + │ existing StatisticsQuery (chunk index) │
│ │ │
│ │ chunk counts │ │
│ │ │
│ ├──────────────────────────┼─────────────────────────────────────────── │
│ ───┤ │
│ │ Cost calculation (size × │ CostQueryHandler in Core, composing │
│ │ │
│ │ rate → per-tier cost + │ StatisticsQuery + PricingCatalog. Frontend │
│ │ │
│ │ grand total) │ renders only. │
│ │ │
│ └──────────────────────────┴─────────────────────────────────────────── │
│ ───┘ │
│ │
│ --- │
│ Group 1 — Storage Accounts, Repos, Properties │
│ │
│ 1a. Region-keyed pricing as the single source of truth (backend) │
│ │
│ - Move src/Arius.Core/Features/RestoreCommand/pricing.json → │
│ src/Arius.Core/Shared/Pricing/pricing.json; update the │
│ path in src/Arius.Core/Arius.Core.csproj. │
│ - Restructure to region-keyed, add currency per region, and add the │
│ missing archive storagePerGBPerMonth: │
│ { "regions": { "westeurope": { │
│ "currency": "EUR", │
│ "archive": { "retrievalPerGB":0.0204, "retrievalHighPerGB":0.1102, │
│ "readOpsPer10000":6.6094, "readOpsHighPer10000":71.6011, │
│ "storagePerGBPerMonth":0.0010 }, │
│ "hot": { "writeOpsPer10000":0.05, "storagePerGBPerMonth":0.018 }, │
│ "cool": { "writeOpsPer10000":0.10, "storagePerGBPerMonth":0.01 }, │
│ "cold": { "writeOpsPer10000":0.18, "storagePerGBPerMonth":0.0045 } │
│ } } } │
│ - Refactor PricingConfig (currently internal sealed record in │
│ RestoreCostCalculator.cs) into │
│ src/Arius.Core/Shared/Pricing/PricingCatalog.cs: │
│ - PricingCatalog.LoadEmbedded() → │
│ IReadOnlyDictionary Regions, │
│ IReadOnlyList RegionNames, and RegionPricing GetRegion(string? │
│ region) (returns the region's rates; falls back to the default — │
│ first/westeurope — when null/"Unknown"/missing). Keep the existing │
│ LoadEmbedded resource-stream idiom. │
│ - RegionPricing holds Currency + the four tiers (same fields as │
│ today, plus archive storage rate). │
│ - RestoreCostCalculator stays as the restore-only calculator but │
│ consumes the new shape (GetRegion(default) → identical numbers for WE). │
│ Minimal change; behavior preserved. │
│ │
│ 1b. Account location end-to-end (backend) │
│ │
│ - src/Arius.Api/AppData/AppDatabase.cs: add location TEXT to the │
│ storage_accounts CREATE TABLE, plus an additive migration in │
│ CreateOrUpgradeSchema (check PRAGMA table_info(storage_accounts), ALTER │
│ TABLE ... ADD COLUMN location if absent). Update │
│ ReadAccount/ListAccounts/GetAccount, extend InsertAccount(name, key, │
│ location), and add UpdateAccount(id, encryptedKey?, location?) using │
│ the COALESCE($x, col) "null leaves unchanged" idiom already used by │
│ UpdateRepository. │
│ - src/Arius.Api/AppData/Records.cs: add string? Location to │
│ AccountRecord. │
│ - src/Arius.Api/Contracts/Dtos.cs: AccountDto += string? Location; │
│ CreateAccountRequest += string? Location; new │
│ UpdateAccountRequest(string? AccountKey, string? Location). │
│ - src/Arius.Api/Endpoints/AccountEndpoints.cs: include Location in │
│ ToDto/POST; add GET /accounts/{id} (flyout load) and PATCH │
│ /accounts/{id} (key+location; secrets.Protect the key only when │
│ supplied; on key change, registry.Evict the account's repos so the new │
│ key takes effect). Optional DELETE /accounts/{id} guarded by │
│ CountRepositoriesForAccount for the flyout danger-zone. │
│ - New GET /pricing/regions (app-level, no provider): a small │
│ PricingEndpoints.cs returning PricingCatalog.RegionNames (needs a │
│ public accessor on the catalog). Frontend appends "Unknown / Not in │
│ list". Map it in Program.cs. │
│ │
│ 1c. Server-side folder browser (backend) │
│ │
│ - New src/Arius.Api/Endpoints/FilesystemEndpoints.cs: GET │
│ /fs/list?path= returns immediate subdirectories ({ name, path }[]) of │
│ path on the API host, with a default root and graceful handling of │
│ not-found/no-permission. Reuse traversal patterns from │
│ Arius.Core/Features/ListQuery/LocalDirectoryReader.cs │
│ (enumerate-with-degradation). Map in Program.cs. │
│ │
│ 1d. Overview "Storage Accounts" section (frontend) │
│ │
│ - src/app/features/overview/overview.component.ts: add a new .ar-card │
│ section below the Repositories table (clone the markup at lines 44–80). │
│ Columns: Account | Region | Repositories. accounts = │
│ toSignal(this.api.listAccounts()); each row (click)="editAccount(a.id)" │
│ → drawer.openAccount(a.id); show a.location ?? 'Unknown' and │
│ a.repositories. │
│ │
│ 1e. Edit-account flyout (frontend) │
│ │
│ - src/app/core/state/drawer.store.ts: extend DrawerType with 'account'; │
│ add accountId = signal(0) and openAccount(accountId) (mirrors │
│ openProperties). │
│ - New src/app/features/drawer/account-drawer.component.ts (clone │
│ properties-drawer.component.ts scrim/ar-drawer/animations). Header │
│ "Edit storage account". Loads via api.getAccount(id). Fields: Account │
│ name (read-only), Account key (password, "replace to rotate"), Region │
│ ( from api.getRegions() + "Unknown / Not in list"). Save → │
│ api.updateAccount(id, {accountKey?, location}). Mandatory fields shown │
│ red (see 1g). Optional danger-zone delete. │
│ - Mount in src/app/app.component.ts alongside │
│ the existing drawers. │
│ │
│ 1f. Region dropdown in the New/Add wizards (frontend) │
│ │
│ - In create-repo-wizard.component.ts (step 1 mode()==='new', after │
│ Account key ~lines 37–39) and add-repo-wizard.component.ts (~lines │
│ 42–43): add a Region sourced from api.getRegions() (+ "Unknown │
│ / Not in list"); pass it to createAccount(name, key, region). │
│ - Extend api.createAccount + CreateAccountRequest with location (see 3, │
│ API layer below). │
│ │
│ 1g. Add-repo screen polish (frontend, Group 1 #3) │
│ │
│ - Mandatory fields red: add a shared .ar-req convention (red asterisk │
│ on the label, and/or red border when required-and-empty). Apply to │
│ genuinely required fields (Account name, Account key, Container │
│ selection, Passphrase). Region is not required (Unknown is valid). │
│ - Friendly alias optional: in add-repo-wizard.component.ts change the │
│ create button guard [disabled]="!selectedContainer() || !alias" │
│ (line 71) → [disabled]="!selectedContainer()", and in add() default │
│ alias: this.alias || this.selectedContainer(). (In the create wizard, │
│ alias seeds the generated container name, so it stays effectively │
│ required there — only add red markers.) │
│ - Local path [...]: new shared src/app/shared/folder-picker/ (a [...] │
│ button + popover that calls api.listDirectories(path), lets the user │
│ navigate, returns the chosen API-side path). Wire it into the │
│ local-path input of add-repo-wizard (line 67); reuse in │
│ create-repo-wizard (line 49) and properties-tab (line 44) for │
│ consistency. │
│ │
│ 1h. Properties page cleanup (frontend, Group 1 #2) │
│ │
│ - src/app/features/repo/properties/properties-tab.component.ts: remove │
│ the "Account key" field block (lines 25–29), the accountKey field │
│ (line 112) and its reset() assignment (line 163) — save() already omits │
│ it. Rename label "Encryption passphrase" → "Passphrase" (line 31). │
│ Update the component doc comment (line 8). No backend/DTO change needed │
│ (key was never a repo property). │
│ │
│ --- │
│ Group 2 — Region-aware Cost Calculator │
│ │
│ 2a. CostQuery Mediator query — calculation in Core (uses IQuery) │
│ │
│ - New src/Arius.Core/Features/CostQuery/CostQuery.cs: │
│ - record CostQuery(string? Region, string? Version = null, bool │
│ EnsureFullCoverage = false) : IQuery; │
│ - record RepositoryStorageCost(string Region, string Currency, │
│ IReadOnlyList Tiers, double TotalPerMonth); │
│ - record TierStorageCost(string Tier, long StoredSize, long │
│ UniqueChunks, double CostPerMonth); │
│ - CostQueryHandler : IQueryHandler │
│ injects IMediator + PricingCatalog. It dispatches │
│ StatisticsQuery(Version, EnsureFullCoverage) to get StoredByTier, │
│ resolves the region via PricingCatalog.GetRegion(Region), and for each │
│ tier computes CostPerMonth = (StoredSize / 1024³) * │
│ region..StoragePerGbMonth (same GiB convention as │
│ RestoreCostCalculator), summing into TotalPerMonth. Returns finished │
│ numbers — no client math. │
│ - Register in src/Arius.Core/ServiceCollectionExtensions.cs next to the │
│ StatisticsQuery handler (~line 177): │
│ services.AddSingleton>(sp => new │
│ CostQueryHandler(sp.GetRequiredService(), , │
│ logger)). (The per-repo provider already has IMediator + the │
│ StatisticsQuery handler registered, so the in-handler Send resolves.) │
│ - This is the "another cost calculator, exposed via a Mediator │
│ CostQuery." RestoreCostCalculator is kept as the restore-only │
│ calculator; both now read the same shared PricingCatalog (which "owns │
│ the pricing json"). │
│ │
│ 2b. Cost endpoint + DTO (API) │
│ │
│ - src/Arius.Api/Contracts/Dtos.cs: new StorageCostDto(string Region, │
│ string Currency, IReadOnlyList Tiers, double │
│ TotalPerMonth) + TierCostDto(string Tier, long StoredSize, long │
│ UniqueChunks, double CostPerMonth) (mirrors RepositoryStorageCost; │
│ TierStatisticsDto stays as-is for the KPI cards). │
│ - New GET /repos/{id}/storage-cost?full=true (in BrowseEndpoints.cs or │
│ a new CostEndpoints.cs): read repo→account→location from AppDatabase, │
│ get the repo provider's IMediator (registry.GetReadProviderAsync — │
│ already warm from the Statistics tab), Send(new CostQuery(location, │
│ version, full)), map to StorageCostDto. (StatisticsQuery's own work is │
│ reused via the in-handler dispatch; the chunk index is read locally.) │
│ │
│ 2c. Shared CostCalculator component (frontend) replacing "Stored size │
│ by tier" │
│ │
│ - New src/app/shared/cost-calculator/cost-calculator.component.ts │
│ implementing the design handoff (Statistics Tab v2.dc.html): a │
│ 100%-stacked hero bar (total-scaled %), legend, detail table Tier | │
│ Stored | Chunks | Est. cost/mo, and a grand-total footer. Input: just │
│ repoId. It fetches the finished StorageCostDto via │
│ api.getStorageCost(repoId) and renders its tiers (each already carries │
│ storedSize, uniqueChunks, costPerMonth) + totalPerMonth — no arithmetic │
│ in the component. Reuse TIER_COLORS, formatBytes, formatCount; add │
│ formatCurrency(amount, currency) to src/app/shared/format.ts. │
│ - src/app/features/repo/statistics/statistics-tab.component.ts: replace │
│ the inline tier block (lines 24–40) with . The KPI cards keep using StatisticsDto; the │
│ per-tier section is now fully driven by CostQuery. Update the │
│ info-banner copy per the handoff. │
│ │
│ --- │
│ Shared API layer (frontend) — both groups │
│ │
│ src/app/core/api/api-models.ts: AccountDto += location: string | null; │
│ add StorageCostDto/TierCostDto; optional UpdateAccountRequest. │
│ src/app/core/api/api.service.ts: extend createAccount(name, key, │
│ location); add getAccount(id), updateAccount(id, {accountKey?, │
│ location?}) (PATCH), getStorageCost(id), getRegions(), │
│ listDirectories(path). │
│ │
│ --- │
│ Files at a glance │
│ │
│ Area: Pricing (Core) │
│ Create: Shared/Pricing/PricingCatalog.cs, Shared/Pricing/pricing.json │
│ (moved) │
│ Modify: Arius.Core.csproj (resource path), │
│ Features/RestoreCommand/RestoreCostCalculator.cs │
│ ──────────────────────────────────────── │
│ Area: CostQuery (Core) │
│ Create: Features/CostQuery/CostQuery.cs │
│ Modify: ServiceCollectionExtensions.cs │
│ ──────────────────────────────────────── │
│ Area: DB / API │
│ Create: Endpoints/PricingEndpoints.cs, Endpoints/FilesystemEndpoints.cs │
│ (+ │
│ CostEndpoints.cs or fold into Browse) │
│ Modify: AppData/AppDatabase.cs, AppData/Records.cs, Contracts/Dtos.cs, │
│ Endpoints/AccountEndpoints.cs, Endpoints/BrowseEndpoints.cs, │
│ Program.cs │
│ ──────────────────────────────────────── │
│ Area: Frontend │
│ Create: features/drawer/account-drawer.component.ts, │
│ shared/cost-calculator/cost-calculator.component.ts, │
│ shared/folder-picker/* │
│ Modify: overview.component.ts, core/state/drawer.store.ts, │
│ app.component.ts, │
│ wizards/create/*, wizards/add/*, │
│ repo/properties/properties-tab.component.ts, │
│ repo/statistics/statistics-tab.component.ts, │
│ core/api/api.service.ts, │
│ core/api/api-models.ts, shared/format.ts │
│ │
│ Reuse (don't reinvent) │
│ │
│ - Drawer flyout: DrawerStore + properties-drawer.component.ts pattern │
│ (scrim/ar-drawer/ar-fade/ar-slide). │
│ - List section markup: Overview Repositories table (lines 44–80). │
│ - Form styling: .ar-field/.ar-input/.ar-seg/.ar-radio (already in every │
│ wizard/properties component). │
│ - Mediator query recipe: StatisticsQuery.cs + its registration; │
│ endpoint dispatch in BrowseEndpoints.cs. │
│ - DB idioms: COALESCE partial-update (UpdateRepository), ReadAccount │
│ reader, CountRepositoriesForAccount guard, PricingConfig.LoadEmbedded │
│ resource-stream loader. │
│ - Formatting: shared/format.ts (formatBytes/formatCount), TIER_COLORS. │
│ │
│ Verification │
│ │
│ - Backend: dotnet build (Arius.Api/Core; note Arius.Web is not in the │
│ slnx). Run the API on :5080. Check existing Core tests for │
│ pricing/RestoreCost (the pricing.json move + restructure will touch │
│ them) — dotnet test src/Arius.Core.Tests. │
│ - Frontend: cd src/Arius.Web && npm start (API on :5080). Manually │
│ verify: Overview shows Storage Accounts; New/Add wizards' "Add new │
│ account" shows a region dropdown; mandatory fields are red; clicking an │
│ account opens the flyout to edit key + region; Properties has no │
│ Account key field and shows "Passphrase"; Add-repo alias is optional │
│ (defaults to container) and local path has a working [...] picker; │
│ Statistics tab shows the cost-aware tier breakdown with per-tier Est. │
│ cost/mo + grand total. │
│ - e2e/unit (Playwright + Karma): update specs whose markup/flow changed │
│ — overview.spec, create-wizard.spec, add-wizard.spec, properties.spec, │
│ statistics.spec, statistics-tiers.spec (preserve/extend data-testids; │
│ add ids for the region select, folder picker, account drawer, cost │
│ table). Run npm run e2e and ng test. │
╰─────────────────────────────────────────────────────────────────────────╯
⏺ That's a cleaner consolidation — fold the cost into StatisticsQuery (one
query, one endpoint, one cached payload) and switch it to IQuery. The
design handoff actually anticipated this (extending TierStatisticsDto with
cost). Let me check the blast radius of changing StatisticsQuery from ICommand
to IQuery (registration + any test doubles), then rework the plan.
Ran 1 shell command (ctrl+o to expand)
⏺ Blast radius is small and clear: StatisticsQuery.cs (interface swap),
ServiceCollectionExtensions.cs:177 (registration),
StatisticsQueryHandlerTests.cs (constructor + new cost assertions), and
BrowseEndpoints.cs (pass region, map cost). ChunkTierStatistic stays a pure
chunk-index type — I'll add cost on a statistics-specific tier record instead.
Reworking the plan now.
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ Let me verify no dangling references to the old separate-CostQuery design
remain in the plan.
Searched for 1 pattern (ctrl+o to expand)
⏺ Two context lines still describe the old design. Let me fix them.
⏺ Updated plan
⎿ /plan to preview
⏺ Updated plan
⎿ /plan to preview
⏺ The plan is now fully consistent with both refinements. Re-presenting for
approval.
Summary of the two changes you asked for:
- Cost now rides on StatisticsQuery (one query, one /stats endpoint, one
cached payload) instead of a separate query. The StatisticsQuery handler
computes per-tier sizes as today, then calls a new Core StorageCostCalculator
to attach costPerMonth per tier + a grand total + currency — all math in Core,
frontend renders only. RestoreCostCalculator stays as-is; both share the
region-keyed PricingCatalog that owns pricing.json.
- StatisticsQuery switches ICommand → IQuery (and handler →
IQueryHandler<,>). Small, contained blast radius: the type def, the
ServiceCollectionExtensions.cs:177 registration, and
StatisticsQueryHandlerTests.cs (which constructs the handler directly).
- Region freshness is handled by invalidating the stats cache on
account-region change (account PATCH → evict + ClearStatisticsCache for that
account's repos), since the cache key is region-independent.
⏺ User approved Claude's plan
⎿ Plan saved to: ~/.claude/plans/1-in-the-overview-lexical-dijkstra.md ·
/plan to edit
Plan: Storage Accounts management + region-aware Cost Calculator
Context
Two related changes to the Arius.Web Angular SPA and its Arius.Api /
Arius.Core .NET backend (Mediator/CQRS, SQLite app DB):
- Group 1 — Storage accounts & repos: Today storage accounts are
second-class — they're only created inline in the New/Add-repo wizards, can
never be edited, and carry no Azure region. The Overview has no place to
see or manage them. Meanwhile the repo Properties page shows an "Account
key" field that doesn't even belong to a repo (and isn't persisted). We
surface accounts on the Overview, add a region to each account, make
accounts editable via a flyout, and clean up the Properties page.
- Group 2 — Cost calculator: The Statistics tab's "Stored size by tier"
section shows size but no cost. We add a region-aware StorageCostCalculator
in Core (a sibling to — not a replacement for — the existing
RestoreCostCalculator), fold its output into the StatisticsQuery result
(switched to IQuery), and add a shared Angular CostCalculator component
that renders the redesigned cost-aware tier breakdown (per the design
handoff in scratchpad/design_handoff/.../Statistics Tab v2.dc.html).
Decisions confirmed with the user
1. Region pricing = "only regions in the json." pricing.json becomes
region-keyed and is the single source of truth. The region dropdown lists
exactly the regions present in the json + an "Unknown / Not in list"
option. Today that means [westeurope, Unknown]; the list grows as regions
are added to the json. Rates select by the account's region;
Unknown/missing → fall back to the default region.
2. Local-path picker = server-side browser. A new backend endpoint lists
directories as the Arius.Api host/container sees them (mounted volumes
under Docker; real folders when run locally). A [...] button opens a small
folder picker over that.
Key facts that shape the plan (verified)
- Account key is already account-scoped in the DB
(storage_accounts.account_key) and the API DTOs. The repo Properties
"Account key" field is vestigial — save() never sends it. So Group 1 #2's
"reflect in the DB" needs no DB change; it's a frontend removal.
- No UpdateAccount exists (DB method, endpoint, or DTO) and no location
column — both are net-new for the edit-account flyout + region.
- Mediator lives only per-repo (AddMediator() in
RepositoryProviderRegistry.BuildAsync). The regions list (used during
account creation, before any repo) must be a plain app-level endpoint
reading the embedded pricing.json. Cost is per-repo (region resolved from
the repo's account at the API layer and passed into StatisticsQuery), which
is already dispatched through the per-repo provider.
- pricing.json currently has no storagePerGBPerMonth for the archive tier —
must be added (design handoff: ~0.0010), since the cost-by-tier view needs
all four tiers.
- Cost is folded into StatisticsQuery (one query, one /stats endpoint, one
cached payload) rather than a separate query, and all cost math happens in
Core. StatisticsQuery is switched to IQuery (Mediator 3.0.2 exposes
IQuery/IQueryHandler; the codebase's other queries use
ICommand/IStreamQuery, but IQuery is the read-correct interface and
mediator.Send handles it identically). Because the stats payload is
memoized in the app DB keyed by snapshot fingerprint (region-independent),
the cache is invalidated when an account's region changes (account PATCH →
evict + ClearStatisticsCache for that account's repos).
Data ownership (who owns the calculation)
┌───────────────────────┬─────────────────────────────────────────────────┐
│ Concern │ Owner │
├───────────────────────┼─────────────────────────────────────────────────┤
│ Pricing rates │ Core Shared/Pricing/PricingCatalog — single │
│ (region-keyed │ source of truth │
│ pricing.json) │ │
├───────────────────────┼─────────────────────────────────────────────────┤
│ Cost calculator │ │
│ (region + sizes → │ Core Shared/Pricing/StorageCostCalculator │
│ per-tier cost + grand │ (sibling of RestoreCostCalculator) │
│ total) │ │
├───────────────────────┼─────────────────────────────────────────────────┤
│ Per-tier stored size │ StatisticsQuery handler — computes sizes, then │
│ + chunk counts, and │ calls StorageCostCalculator. Frontend renders │
│ now cost │ only. │
└───────────────────────┴─────────────────────────────────────────────────┘
---
Group 1 — Storage Accounts, Repos, Properties
1a. Region-keyed pricing as the single source of truth (backend)
- Move src/Arius.Core/Features/RestoreCommand/pricing.json →
src/Arius.Core/Shared/Pricing/pricing.json; update the
path in src/Arius.Core/Arius.Core.csproj.
- Restructure to region-keyed, add currency per region, and add the missing
archive storagePerGBPerMonth:
{ "regions": { "westeurope": {
"currency": "EUR",
"archive": { "retrievalPerGB":0.0204, "retrievalHighPerGB":0.1102,
"readOpsPer10000":6.6094, "readOpsHighPer10000":71.6011,
"storagePerGBPerMonth":0.0010 },
"hot": { "writeOpsPer10000":0.05, "storagePerGBPerMonth":0.018 },
"cool": { "writeOpsPer10000":0.10, "storagePerGBPerMonth":0.01 },
"cold": { "writeOpsPer10000":0.18, "storagePerGBPerMonth":0.0045 } } }
}
- Refactor PricingConfig (currently internal sealed record in
RestoreCostCalculator.cs) into
src/Arius.Core/Shared/Pricing/PricingCatalog.cs:
- PricingCatalog.LoadEmbedded() →
IReadOnlyDictionary Regions, IReadOnlyList
RegionNames, and RegionPricing GetRegion(string? region) (returns the
region's rates; falls back to the default — first/westeurope — when
null/"Unknown"/missing). Keep the existing LoadEmbedded resource-stream
idiom.
- RegionPricing holds Currency + the four tiers (same fields as today,
plus archive storage rate).
- RestoreCostCalculator stays as the restore-only calculator but consumes
the new shape (GetRegion(default) → identical numbers for WE). Minimal
change; behavior preserved.
1b. Account location end-to-end (backend)
- src/Arius.Api/AppData/AppDatabase.cs: add location TEXT to the
storage_accounts CREATE TABLE, plus an additive migration in
CreateOrUpgradeSchema (check PRAGMA table_info(storage_accounts), ALTER
TABLE ... ADD COLUMN location if absent). Update
ReadAccount/ListAccounts/GetAccount, extend InsertAccount(name, key,
location), and add UpdateAccount(id, encryptedKey?, location?) using the
COALESCE($x, col) "null leaves unchanged" idiom already used by
UpdateRepository.
- src/Arius.Api/AppData/Records.cs: add string? Location to AccountRecord.
- src/Arius.Api/Contracts/Dtos.cs: AccountDto += string? Location;
CreateAccountRequest += string? Location; new UpdateAccountRequest(string?
AccountKey, string? Location).
- src/Arius.Api/Endpoints/AccountEndpoints.cs: include Location in
ToDto/POST; add GET /accounts/{id} (flyout load) and PATCH /accounts/{id}
(key+location; secrets.Protect the key only when supplied). For each repo
under the account: on key change registry.Evict(repoId) (new key takes
effect); on region change also db.ClearStatisticsCache(repoId) (cost
recomputes). Add a small ListRepositoryIdsForAccount(accountId) helper (or
filter ListRepositories()). Optional DELETE /accounts/{id} guarded by
CountRepositoriesForAccount for the flyout danger-zone.
- New GET /pricing/regions (app-level, no provider): a small
PricingEndpoints.cs returning PricingCatalog.RegionNames (needs a public
accessor on the catalog). Frontend appends "Unknown / Not in list". Map it
in Program.cs.
1c. Server-side folder browser (backend)
- New src/Arius.Api/Endpoints/FilesystemEndpoints.cs: GET /fs/list?path=
returns immediate subdirectories ({ name, path }[]) of path on the API
host, with a default root and graceful handling of not-found/no-permission.
Reuse traversal patterns from
Arius.Core/Features/ListQuery/LocalDirectoryReader.cs
(enumerate-with-degradation). Map in Program.cs.
1d. Overview "Storage Accounts" section (frontend)
- src/app/features/overview/overview.component.ts: add a new .ar-card
section below the Repositories table (clone the markup at lines 44–80).
Columns: Account | Region | Repositories. accounts =
toSignal(this.api.listAccounts()); each row (click)="editAccount(a.id)" →
drawer.openAccount(a.id); show a.location ?? 'Unknown' and a.repositories.
1e. Edit-account flyout (frontend)
- src/app/core/state/drawer.store.ts: extend DrawerType with 'account'; add
accountId = signal(0) and openAccount(accountId) (mirrors openProperties).
- New src/app/features/drawer/account-drawer.component.ts (clone
properties-drawer.component.ts scrim/ar-drawer/animations). Header "Edit
storage account". Loads via api.getAccount(id). Fields: Account name
(read-only), Account key (password, "replace to rotate"), Region (
from api.getRegions() + "Unknown / Not in list"). Save →
api.updateAccount(id, {accountKey?, location}). Mandatory fields shown red
(see 1g). Optional danger-zone delete.
- Mount in src/app/app.component.ts alongside the
existing drawers.
1f. Region dropdown in the New/Add wizards (frontend)
- In create-repo-wizard.component.ts (step 1 mode()==='new', after Account
key ~lines 37–39) and add-repo-wizard.component.ts (~lines 42–43): add a
Region sourced from api.getRegions() (+ "Unknown / Not in list");
pass it to createAccount(name, key, region).
- Extend api.createAccount + CreateAccountRequest with location (see 3, API
layer below).
1g. Add-repo screen polish (frontend, Group 1 #3)
- Mandatory fields red: add a shared .ar-req convention (red asterisk on
the label, and/or red border when required-and-empty). Apply to genuinely
required fields (Account name, Account key, Container selection,
Passphrase). Region is not required (Unknown is valid).
- Friendly alias optional: in add-repo-wizard.component.ts change the
create button guard [disabled]="!selectedContainer() || !alias" (line 71) →
[disabled]="!selectedContainer()", and in add() default alias: this.alias
|| this.selectedContainer(). (In the create wizard, alias seeds the
generated container name, so it stays effectively required there — only add
red markers.)
- Local path [...]: new shared src/app/shared/folder-picker/ (a [...]
button + popover that calls api.listDirectories(path), lets the user
navigate, returns the chosen API-side path). Wire it into the local-path
input of add-repo-wizard (line 67); reuse in create-repo-wizard (line 49)
and properties-tab (line 44) for consistency.
1h. Properties page cleanup (frontend, Group 1 #2)
- src/app/features/repo/properties/properties-tab.component.ts: remove the
"Account key" field block (lines 25–29), the accountKey field (line 112)
and its reset() assignment (line 163) — save() already omits it. Rename
label "Encryption passphrase" → "Passphrase" (line 31). Update the
component doc comment (line 8). No backend/DTO change needed (key was never
a repo property).
---
Group 2 — Region-aware Cost Calculator
2a. Extend StatisticsQuery with cost — calculation in Core, switch to
IQuery
- src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs:
- Change StatisticsQuery from ICommand →
IQuery, and the handler from ICommandHandler<,> →
IQueryHandler<,>. Add a region parameter: StatisticsQuery(string? Version =
null, bool EnsureFullCoverage = false, string? Region = null).
- Extend the result: add Currency, Region, TotalStorageCostPerMonth to
RepositoryStatistics, and change StoredByTier from
IReadOnlyList to a statistics-specific
IReadOnlyList where TierStatistic(BlobTier Tier, long
UniqueChunks, long StoredSize, double CostPerMonth). (Leave the chunk-index
domain type ChunkTierStatistic untouched — don't pollute it with pricing.)
- In the handler: after computing byTier (as today), call the new
StorageCostCalculator to map each ChunkTierStatistic → TierStatistic with
CostPerMonth = (StoredSize / 1024³) * region..StoragePerGbMonth (same
GiB convention as RestoreCostCalculator), and sum
TotalStorageCostPerMonth; set Currency/Region from the resolved region.
- New src/Arius.Core/Shared/Pricing/StorageCostCalculator.cs —
region-aware, owns PricingCatalog; sibling to RestoreCostCalculator. This
is "the cost calculator that feeds the component," now invoked inside
StatisticsQuery. Register it as a singleton in AddArius and inject into
StatisticsQueryHandler.
- Update registration in src/Arius.Core/ServiceCollectionExtensions.cs
(line 177): ICommandHandler →
IQueryHandler, and pass the
StorageCostCalculator into the handler factory.
- Update src/Arius.Core.Tests/Features/StatisticsQuery/StatisticsQueryHandl
erTests.cs: the handler is constructed directly (new
StatisticsQueryHandler(snapshot, index, logger)) — add the
StorageCostCalculator arg and assert the new per-tier CostPerMonth /
TotalStorageCostPerMonth.
2b. Cost in the stats DTO + endpoint (API)
- src/Arius.Api/Contracts/Dtos.cs: extend TierStatisticsDto with double
CostPerMonth (and optionally the rate CostPerGbMonth); extend StatisticsDto
with string Currency, string Region, double TotalStorageCostPerMonth. No
new DTO/endpoint.
- src/Arius.Api/Endpoints/BrowseEndpoints.cs (GET /repos/{id}/stats, lines
35–60): read the repo→account→location from AppDatabase, pass it into
mediator.Send(new StatisticsQuery(version, fullFlag, location)), and extend
the TierStatisticsDto projection (line 56) + StatisticsDto with the cost
fields. The existing app-DB memoization is unchanged; freshness on region
change is handled by cache invalidation in the account PATCH (1b).
2c. Shared CostCalculator component (frontend) replacing "Stored size by
tier"
- New src/app/shared/cost-calculator/cost-calculator.component.ts
implementing the design handoff (Statistics Tab v2.dc.html): a 100%-stacked
hero bar (total-scaled %), legend, detail table Tier | Stored | Chunks |
Est. cost/mo, and a grand-total footer. Input: the StatisticsDto (or just
its storedByTier + currency + totalStorageCostPerMonth). Each tier already
carries storedSize, uniqueChunks, costPerMonth, so the component does no
arithmetic — it renders finished numbers. Reuse TIER_COLORS, formatBytes,
formatCount; add formatCurrency(amount, currency) to
src/app/shared/format.ts.
- src/app/features/repo/statistics/statistics-tab.component.ts: replace the
inline tier block (lines 24–40) with . No extra fetch — the tab already loads StatisticsDto,
which now carries cost. KPI cards unchanged. Update the info-banner copy
per the handoff.
---
Shared API layer (frontend) — both groups
src/app/core/api/api-models.ts: AccountDto += location: string | null;
extend StatisticsDto (currency, region, totalStorageCostPerMonth) +
TierStatisticsDto (costPerMonth); optional UpdateAccountRequest.
src/app/core/api/api.service.ts: extend createAccount(name, key, location);
add getAccount(id), updateAccount(id, {accountKey?, location?}) (PATCH),
getRegions(), listDirectories(path). No separate cost call — cost rides on
the existing getStatistics.
---
Files at a glance
Area: Pricing (Core)
Create: Shared/Pricing/PricingCatalog.cs,
Shared/Pricing/StorageCostCalculator.cs, Shared/Pricing/pricing.json
(moved)
Modify: Arius.Core.csproj (resource path),
Features/RestoreCommand/RestoreCostCalculator.cs
────────────────────────────────────────
Area: Statistics + cost (Core)
Create: —
Modify: Features/StatisticsQuery/StatisticsQuery.cs (→ IQuery, +region,
+cost),
ServiceCollectionExtensions.cs:177,
Arius.Core.Tests/.../StatisticsQueryHandlerTests.cs
────────────────────────────────────────
Area: DB / API
Create: Endpoints/PricingEndpoints.cs, Endpoints/FilesystemEndpoints.cs
Modify: AppData/AppDatabase.cs, AppData/Records.cs, Contracts/Dtos.cs,
Endpoints/AccountEndpoints.cs, Endpoints/BrowseEndpoints.cs, Program.cs
────────────────────────────────────────
Area: Frontend
Create: features/drawer/account-drawer.component.ts,
shared/cost-calculator/cost-calculator.component.ts,
shared/folder-picker/*
Modify: overview.component.ts, core/state/drawer.store.ts,
app.component.ts,
wizards/create/*, wizards/add/*,
repo/properties/properties-tab.component.ts,
repo/statistics/statistics-tab.component.ts, core/api/api.service.ts,
core/api/api-models.ts, shared/format.ts
Reuse (don't reinvent)
- Drawer flyout: DrawerStore + properties-drawer.component.ts pattern
(scrim/ar-drawer/ar-fade/ar-slide).
- List section markup: Overview Repositories table (lines 44–80).
- Form styling: .ar-field/.ar-input/.ar-seg/.ar-radio (already in every
wizard/properties component).
- Mediator query recipe: StatisticsQuery.cs + its registration; endpoint
dispatch in BrowseEndpoints.cs.
- DB idioms: COALESCE partial-update (UpdateRepository), ReadAccount
reader, CountRepositoriesForAccount guard, PricingConfig.LoadEmbedded
resource-stream loader.
- Formatting: shared/format.ts (formatBytes/formatCount), TIER_COLORS.
Verification
- Backend: dotnet build (Arius.Api/Core; note Arius.Web is not in the
slnx). Run the API on :5080. Check existing Core tests for
pricing/RestoreCost (the pricing.json move + restructure will touch them) —
dotnet test src/Arius.Core.Tests.
- Frontend: cd src/Arius.Web && npm start (API on :5080). Manually verify:
Overview shows Storage Accounts; New/Add wizards' "Add new account" shows a
region dropdown; mandatory fields are red; clicking an account opens the
flyout to edit key + region; Properties has no Account key field and shows
"Passphrase"; Add-repo alias is optional (defaults to container) and local
path has a working [...] picker; Statistics tab shows the cost-aware tier
breakdown with per-tier Est. cost/mo + grand total.
- e2e/unit (Playwright + Karma): update specs whose markup/flow changed —
overview.spec, create-wizard.spec, add-wizard.spec, properties.spec,
statistics.spec, statistics-tiers.spec (preserve/extend data-testids; add
ids for the region select, folder picker, account drawer, cost table). Run
npm run e2e and ng test.
⏺ The plan is approved. Let me set up task tracking and start implementing,
beginning with the backend Core pricing layer. First, let me load the task
tools and read the remaining files I'll modify.
⏺ Let me create the task list and read the backend files I need to modify
accurately.
⏺ Now let me read the backend files I need before editing —
RestoreCommandHandler's calculator usage, the csproj, the existing tests, and
Program.cs.
Read 3 files (ctrl+o to expand)
⏺ Let me find the RestoreCostCalculator tests and check the RestoreCostEstimate
is public (it's returned via SignalR). Let me also check how
PricingConfig/RestoreCostCalculator are referenced elsewhere.
Ran 1 shell command (ctrl+o to expand)
⏺ There are existing tests directly referencing the internal
PricingConfig/ArchivePricingTier/TierPricingConfig types (via
InternalsVisibleTo). I'll rename PricingConfig → RegionPricing and move to
Shared/Pricing, keeping the tier sub-records. Let me read those two test files
to update them precisely.
Read 2 files (ctrl+o to expand)
⏺ I have enough to preserve the existing tests with minimal churn. Let me verify
the BlobTier namespace and that InternalsVisibleTo exists for the test
project, then write the Core pricing layer.
Ran 1 shell command (ctrl+o to expand)
⏺ BlobTier is Arius.Core.Shared.Storage (public); ChunkTierStatistic is
Arius.Core.Shared.ChunkIndex; tests have InternalsVisibleTo. Starting Task #1
— the Core pricing layer.
⏺ Write(src/Arius.Core/Shared/Pricing/pricing.json)
⎿ Wrote 28 lines to src/Arius.Core/Shared/Pricing/pricing.json
1 {
2 "_comment": "Azure Blob Storage pricing, keyed by programmatic
details/storage/blobs/ — add a region here to make it selectable
in the UI and priced.",
3 "regions": {
5 "currency": "EUR",
6 "_comment": "West Europe. Last updated: 2026-03.",
7 "archive": {
9 "retrievalHighPerGB": 0.1102,
10 "readOpsPer10000": 6.6094,
… +21 lines (ctrl+o to expand)
⏺ Write(src/Arius.Core/Shared/Pricing/PricingCatalog.cs)
⎿ Wrote 138 lines to src/Arius.Core/Shared/Pricing/PricingCatalog.cs
1 using System.Text.Json;
2 using System.Text.Json.Serialization;
4
5 namespace Arius.Core.Shared.Pricing;
6
8 /// The canonical, region-keyed Azure Blob Storage pricing — the
single source of truth loaded from the
9 /// embedded pricing.json . Both the restore-cost path (StorageCostCalculator ) resolve the
ir rates through here, and the set of
… +162 lines (ctrl+o to expand)
⏺ Write(src/Arius.Core/Shared/Pricing/StorageCostCalculator.cs)
⎿ Wrote 47 lines to
src/Arius.Core/Shared/Pricing/StorageCostCalculator.cs
1 using Arius.Core.Shared.ChunkIndex;
2 using Arius.Core.Shared.Storage;
4 namespace Arius.Core.Shared.Pricing;
5
6 ///
ored sizes and the region's published
8 /// storage rates. Region-aware sibling of RestoreCostCalcula
tor (which stays restore-specific);
.
10 ///
… +50 lines (ctrl+o to expand)
⏺ Now refactor RestoreCostCalculator.cs to use the shared
RegionPricing/PricingCatalog (removing the embedded pricing types), and update
the csproj resource path + remove the old json.
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCostCalculator.cs)
⎿ Added 5 lines, removed 4 lines
1 -using System.Text.Json;
2 -using System.Text.Json.Serialization;
1 +using Arius.Core.Shared.Pricing;
2
3 namespace Arius.Core.Features.RestoreCommand;
4
5 ///
6 /// Computes the restore cost estimate shown before downloads o
r rehydration begin.
7 +/// Restore-specific; storage-by-tier cost lives in . Both read
8 +/// their rates from the shared .
9 ///
9 -internal sealed class RestoreCostCalculator(PricingConfig? pric
-ing)
10 +internal sealed class RestoreCostCalculator(RegionPricing? pric
+ing)
11 {
11 - private readonly PricingConfig _pricing = pricing ?? Pricin
-gConfig.LoadEmbedded();
12 + private readonly RegionPricing _pricing = pricing ?? Pricin
+gCatalog.LoadEmbedded().Resolve(null).Pricing;
13
14 ///
15 /// Computes a from class
ified chunk counts and byte totals.
⏺ Now remove the old embedded pricing-config types from RestoreCostCalculator.cs
(lines 65-140, now moved to PricingCatalog.cs). Let me view the current state
of that region.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCostCalculator.cs)
⎿ Removed 77 lines
63 }
64 }
65
66 -// --- PRICING CONFIG
66
68 -///
69 -/// Restore pricing configuration loaded from pricing.json<
-/c>.
70 -///
71 -internal sealed record PricingConfig
72 -{
73 - [JsonPropertyName("archive")]
74 - public ArchivePricingTier Archive { get; init; } = new();
75 -
76 - [JsonPropertyName("hot")]
77 - public TierPricingConfig Hot { get; init; } = new();
78 -
79 - [JsonPropertyName("cool")]
80 - public TierPricingConfig Cool { get; init; } = new();
81 -
82 - [JsonPropertyName("cold")]
83 - public TierPricingConfig Cold { get; init; } = new();
84 -
85 - // ── Loading ────────────────────────────────────────────
-───────────────────
86 -
87 - private static readonly JsonSerializerOptions _jsonOptions
- = new()
88 - {
89 - PropertyNameCaseInsensitive = true,
90 - ReadCommentHandling = JsonCommentHandling.Skip,
91 - AllowTrailingCommas = true,
92 - };
93 -
94 - public static PricingConfig LoadEmbedded()
95 - {
96 - var assembly = typeof(PricingConfig).Assembly;
97 - var resourceName = assembly.GetManifestResourceNames()
98 - .FirstOrDefault(n => n.EndsWith
-("pricing.json", StringComparison.OrdinalIgnoreCase))
99 - ?? throw new InvalidOperationExcept
-ion("Embedded pricing.json resource not found.");
100 -
101 - using var stream = assembly.GetManifestResourceStream(
-resourceName)!;
102 - var result = JsonSerializer.Deserialize
-(stream, _jsonOptions);
103 - return result ?? throw new InvalidOperationException("
-Embedded pricing.json deserialized to null.");
104 - }
105 -}
106 -
107 -///
108 -/// Pricing rates used for archive-tier retrieval and read ope
-rations.
109 -///
110 -internal sealed record ArchivePricingTier
111 -{
112 - /// EUR per GB of data retrieved from Archive at
-Standard priority.
113 - [JsonPropertyName("retrievalPerGB")]
114 - public double RetrievalPerGB { get; init; }
115 -
116 - /// EUR per GB of data retrieved from Archive at
-High priority.
117 - [JsonPropertyName("retrievalHighPerGB")]
118 - public double RetrievalHighPerGB { get; init; }
119 -
120 - /// EUR per 10,000 read operations at Standard pr
-iority.
121 - [JsonPropertyName("readOpsPer10000")]
122 - public double ReadOpsPer10000 { get; init; }
123 -
124 - /// EUR per 10,000 read operations at High priori
-ty.
125 - [JsonPropertyName("readOpsHighPer10000")]
126 - public double ReadOpsHighPer10000 { get; init; }
127 -}
128 -
129 -///
130 -/// Pricing rates used for writing and storing rehydrated chun
-k copies in a target tier.
131 -///
132 -internal sealed record TierPricingConfig
133 -{
134 - /// EUR per 10,000 write operations.
135 - [JsonPropertyName("writeOpsPer10000")]
136 - public double WriteOpsPer10000 { get; init; }
137 -
138 - /// EUR per GB stored per month.
139 - [JsonPropertyName("storagePerGBPerMonth")]
140 - public double StoragePerGBPerMonth { get; init; }
141 -}
142 -
143 -
67 // --- COST ESTIMATE
68
69 ///
⏺ Now update the csproj resource path and remove the old pricing.json.
⏺ Update(src/Arius.Core/Arius.Core.csproj)
⎿ Added 1 line, removed 1 line
7
8
9
10 -
10 +
11
12
13
Ran 1 shell command (ctrl+o to expand)
⏺ Now update the two pricing test files to the renamed RegionPricing (the tier
sub-records keep their names) and add the new using.
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCostCalculatorTests
.cs)
⎿ Added 2 lines, removed 1 line
1 using Arius.Core.Features.RestoreCommand;
2 +using Arius.Core.Shared.Pricing;
3
4 namespace Arius.Core.Tests.Features.RestoreCommand;
5
6 public class RestoreCostCalculatorTests
7 {
8 // Deterministic pricing config for all tests
8 - private static readonly PricingConfig _pricing = new()
9 + private static readonly RegionPricing _pricing = new()
10 {
11 Archive = new ArchivePricingTier
12 {
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/PricingConfigTests.cs)
⎿ Added 1 line
1 using System.Text.Json;
2 using Arius.Core.Features.RestoreCommand;
3 +using Arius.Core.Shared.Pricing;
4
5 namespace Arius.Core.Tests.Features.RestoreCommand;
6
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/PricingConfigTests.cs)
⎿ Added 1 line, removed 1 line
28 [Test]
29 public void JsonRoundTrip_MalformedJson_ThrowsJsonException
()
30 {
31 - Should.Throw(() => JsonSerializer.Deseri
-alize("{ not valid json !!!"));
31 + Should.Throw(() => JsonSerializer.Deseri
+alize("{ not valid json !!!"));
32 }
33
34 [Test]
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/PricingConfigTests.cs)
⎿ Added 2 lines, removed 2 lines
84
85 private static readonly long OneGBBytes = 1L * 1024 * 1024
* 1024;
86
87 - private static PricingConfig CreatePricingConfig(
87 + private static RegionPricing CreatePricingConfig(
88 double retrievalPerGb = 1.0,
89 double retrievalHighPerGb = 5.0,
90 double readOpsPer10000 = 2.0,
...
92 double hotWriteOpsPer10000 = 0.1,
93 double hotStoragePerGbPerMonth = 0.02)
94 {
95 - return new PricingConfig
95 + return new RegionPricing
96 {
97 Archive = new ArchivePricingTier
98 {
⏺ Task #1 done. Now Task #2 — StatisticsQuery → IQuery with region + cost.
⏺ Update(src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs)
⎿ Added 7 lines, removed 2 lines
1 using Arius.Core.Shared.ChunkIndex;
2 +using Arius.Core.Shared.Pricing;
3 using Arius.Core.Shared.Snapshot;
4 using Mediator;
5 using Microsoft.Extensions.Logging;
...
9 // --- QUERY
10
11 ///
11 -/// Mediator command: aggregate repository statistics for the S
-tatistics view.
12 +/// Mediator query: aggregate repository statistics (incl. regi
+on-aware per-tier storage cost) for the Statistics view.
13 ///
14 /// Snapshot version (partial match).
null = latest.
15 ///
...
17 /// figures, so they are complete rather than reflecting only b
rowsed coverage. Slower (downloads the
18 /// whole index, etag-cached on repeat); the caller lazy-loads
the storage section behind this.
19 ///
19 -public sealed record StatisticsQuery(string? Version = null, bo
-ol EnsureFullCoverage = false) : ICommand
-;
20 +///
21 +/// Programmatic Azure region (e.g. westeurope ) used to
+price the per-tier storage cost. null ,
22 +/// "Unknown", or an unknown region falls back to the pricing c
+atalog's default region.
23 +///
24 +public sealed record StatisticsQuery(string? Version = null, bo
+ol EnsureFullCoverage = false, string? Region = null) : IQuery<
+RepositoryStatistics>;
25
26 // --- RESULT
27
⏺ Update(src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs)
⎿ Added 8 lines, removed 2 lines
42 /// and compressed (from the chunk index; repository-wide acros
s all snapshots).
43 ///
44 /// Number of distinct chunks (from
the chunk index; repository-wide).
45 -/// Distinct-chunk count and stored
-size split by storage tier (repository-wide).
45 +/// ISO currency code the cost figures a
+re expressed in (from the pricing catalog, e.g. EUR ).
46 +/// The region actually used to price stor
+age (the requested region, or the catalog default).
47 +/// Estimated total mont
+hly storage cost across all tiers, in .
+param>
48 +/// Distinct-chunk count, stored siz
+e, and estimated monthly cost split by storage tier (repository
+-wide).
49 ///
50 /// An empty repository (no snapshot yet) reports all-zero figu
res. and
51 /// are scoped to the resolved snaps
hot; the deduplicated/stored/chunk figures
...
58 long DeduplicatedSize,
59 long StoredSize,
60 long UniqueChunks,
58 - IReadOnlyList StoredByTier);
61 + string Currency,
62 + string Region,
63 + double TotalStorageCostPerMonth,
64 + IReadOnlyList StoredByTier);
65
66 // --- HANDLER
67
⏺ Update(src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs)
⎿ Added 16 lines, removed 8 lines
72 public sealed class StatisticsQueryHandler(
73 ISnapshotService snapshots,
74 IChunkIndexService chunkIndex,
75 + StorageCostCalculator storageCost,
76 ILogger logger)
76 - : ICommandHandler
77 + : IQueryHandler
78 {
79 public async ValueTask Handle(Statist
icsQuery query, CancellationToken cancellationToken)
80 {
...
83 if (snapshot is null)
84 {
85 logger.LogDebug("[stats] no snapshot for version {V
ersion}; returning empty stats", query.Version ?? "");
85 - return new RepositoryStatistics(0, 0, 0, 0, 0, []);
86 + var emptyCost = storageCost.Compute(query.Region, [
+]);
87 + return new RepositoryStatistics(0, 0, 0, 0, 0, empt
+yCost.Currency, emptyCost.Region, 0, emptyCost.Tiers);
88 }
89
90 // ── Stage 2: chunk-index aggregate (deduplicated orig
inal size + distinct chunks by tier) ──
...
95 var chunkStats = chunkIndex.GetStatistics();
96 var byTier = chunkStats.ByTier;
97
98 + // ── Stage 3: price the per-tier stored size for the
+account's region ──
99 + var cost = storageCost.Compute(query.Region, byTier);
100 +
101 return new RepositoryStatistics(
97 - Files: snapshot.FileCount,
98 - OriginalSize: snapshot.OriginalSize,
99 - DeduplicatedSize: chunkStats.DeduplicatedOriginalS
-ize,
100 - StoredSize: byTier.Sum(t => t.StoredSize),
101 - UniqueChunks: byTier.Sum(t => t.UniqueChunks),
102 - StoredByTier: byTier);
102 + Files: snapshot.FileCount,
103 + OriginalSize: snapshot.OriginalSize,
104 + DeduplicatedSize: chunkStats.DeduplicatedO
+riginalSize,
105 + StoredSize: byTier.Sum(t => t.Stored
+Size),
106 + UniqueChunks: byTier.Sum(t => t.Unique
+Chunks),
107 + Currency: cost.Currency,
108 + Region: cost.Region,
109 + TotalStorageCostPerMonth: cost.TotalPerMonth,
110 + StoredByTier: cost.Tiers);
111 }
112 }
⏺ Now update ServiceCollectionExtensions.cs: register StorageCostCalculator and
switch the handler to IQueryHandler.
⏺ Update(src/Arius.Core/ServiceCollectionExtensions.cs)
⎿ Added 5 lines, removed 1 line
174 sp.GetRequiredService(),
175 sp.GetRequiredService>()));
176
177 - services.AddSingleton>(sp =>
177 + // Shared, region-aware storage pricing (owns the embe
+dded pricing.json catalog).
178 + services.AddSingleton(_ => new
+StorageCostCalculator());
179 +
180 + services.AddSingleton>(sp =>
181 new StatisticsQueryHandler(
182 sp.GetRequiredService(),
183 sp.GetRequiredService(),
184 + sp.GetRequiredService()
+,
185 sp.GetRequiredService>()));
186
187 return services;
⏺ Add the using for the pricing namespace in ServiceCollectionExtensions.cs.
⏺ Update(src/Arius.Core/ServiceCollectionExtensions.cs)
⎿ Added 1 line
11 using Arius.Core.Shared.Compression;
12 using Arius.Core.Shared.Encryption;
13 using Arius.Core.Shared.FileTree;
14 +using Arius.Core.Shared.Pricing;
15 using Arius.Core.Shared.Snapshot;
16 using Arius.Core.Shared.Storage;
17 using Mediator;
⏺ Now update StatisticsQueryHandlerTests.cs — add the StorageCostCalculator
constructor arg and cost assertions.
⏺ Update(src/Arius.Core.Tests/Features/StatisticsQuery/StatisticsQueryHandlerTes
ts.cs)
⎿ Added 1 line
1 using Arius.Core.Features.StatisticsQuery;
2 using Arius.Core.Shared.ChunkIndex;
3 using Arius.Core.Shared.Compression;
4 +using Arius.Core.Shared.Pricing;
5 using Arius.Core.Shared.Snapshot;
6 using Arius.Core.Tests.Fakes;
7 using Arius.Tests.Shared;
⏺ Update(src/Arius.Core.Tests/Features/StatisticsQuery/StatisticsQueryHandlerTes
ts.cs)
⎿ Added 1 line, removed 1 line
35 fixture.Index.AddEntry(new ShardEntry(ContentHashOf("b"
), FakeChunkHash('a'), OriginalSize: 200, ChunkSize: 40, BlobTi
er.Cool));
36 fixture.Index.AddEntry(new ShardEntry(ContentHashOf("c"
), FakeChunkHash('b'), OriginalSize: 300, ChunkSize: 50, BlobTi
er.Cool));
37
38 - var handler = new StatisticsQueryHandler(fixture.Snapsh
-ot, fixture.Index, NullLogger.Instance)
-;
38 + var handler = new StatisticsQueryHandler(fixture.Snapsh
+ot, fixture.Index, new StorageCostCalculator(), NullLogger.Instance);
39 var stats = await handler.Handle(new StatisticsQueryTyp
e(), CancellationToken.None);
40
41 stats.Files.ShouldBe(3);
⏺ Update(src/Arius.Core.Tests/Features/StatisticsQuery/StatisticsQueryHandlerTes
ts.cs)
⎿ Added 10 lines, removed 1 line
71 fixture.Index.AddEntry(new ShardEntry(ContentHashOf("a"
), FakeChunkHash('a'), OriginalSize: 100, ChunkSize: 40, BlobTi
er.Cool));
72 fixture.Index.AddEntry(new ShardEntry(ContentHashOf("b"
), FakeChunkHash('b'), OriginalSize: 400, ChunkSize: 60, BlobTi
er.Archive));
73
74 - var handler = new StatisticsQueryHandler(fixture.Snapsh
-ot, fixture.Index, NullLogger.Instance)
-;
74 + var handler = new StatisticsQueryHandler(fixture.Snapsh
+ot, fixture.Index, new StorageCostCalculator(), NullLogger.Instance);
75 var stats = await handler.Handle(new StatisticsQueryTyp
e(), CancellationToken.None);
76
77 stats.UniqueChunks.ShouldBe(2);
...
84 stats.StoredByTier[0].StoredSize.ShouldBe(40);
85 stats.StoredByTier[1].Tier.ShouldBe(BlobTier.Archive);
86 stats.StoredByTier[1].StoredSize.ShouldBe(60);
87 +
88 + // Cost: no region requested → default region (westeuro
+pe/EUR) from the embedded pricing catalog.
89 + // Each tier's cost = storedSize / 1024³ × that tier's
+storagePerGBPerMonth (West Europe rates).
90 + const double giB = 1024.0 * 1024.0 * 1024.0;
91 + stats.Currency.ShouldBe("EUR");
92 + stats.Region.ShouldBe("westeurope");
93 + stats.StoredByTier[0].CostPerMonth.ShouldBe(40 / giB *
+0.01, tolerance: 1e-18); // Cool @ 0.01
94 + stats.StoredByTier[1].CostPerMonth.ShouldBe(60 / giB *
+0.0010, tolerance: 1e-18); // Archive @ 0.0010
95 + stats.TotalStorageCostPerMonth.ShouldBe(stats.StoredByT
+ier.Sum(t => t.CostPerMonth), tolerance: 1e-18);
96 }
97
98 [Test]
⏺ Update(src/Arius.Core.Tests/Features/StatisticsQuery/StatisticsQueryHandlerTes
ts.cs)
⎿ Added 7 lines, removed 1 line
101 var blobs = new FakeSeededBlobContainerService();
102 await using var fixture = await RepositoryTestFixture.
CreateWithEncryptionAsync(blobs, "acct-stats-empty", "ctr-stat
s-empty", IEncryptionService.PlaintextInstance);
103
104 - var handler = new StatisticsQueryHandler(fixture.Snaps
-hot, fixture.Index, NullLogger.Instanc
-e);
104 + var handler = new StatisticsQueryHandler(fixture.Snaps
+hot, fixture.Index, new StorageCostCalculator(), NullLogger.Instance);
105 var stats = await handler.Handle(new StatisticsQueryTy
pe(), CancellationToken.None);
106
107 stats.Files.ShouldBe(0);
...
109 stats.DeduplicatedSize.ShouldBe(0);
110 stats.UniqueChunks.ShouldBe(0);
111 stats.StoredSize.ShouldBe(0);
112 +
113 + // Even with no snapshot, the region/currency resolve
+and the total cost is zero.
114 + stats.Currency.ShouldBe("EUR");
115 + stats.Region.ShouldBe("westeurope");
116 + stats.TotalStorageCostPerMonth.ShouldBe(0);
117 + stats.StoredByTier.ShouldBeEmpty();
118 }
119 }
⏺ Let me build Arius.Core to catch compile errors early before moving to the API
layer.
Ran 1 shell command (ctrl+o to expand)
⏺ Accessibility errors — RepositoryStatistics (public, the Mediator result
consumed by Arius.Api) references internal
TierStorageCost/StorageCostCalculator. Let me make those public (like the
existing public RestoreCostEstimate).
⏺ Update(src/Arius.Core/Shared/Pricing/StorageCostCalculator.cs)
⎿ Added 1 line, removed 1 line
8 /// storage rates. Region-aware sibling of RestoreCostCalcul
ator (which stays restore-specific);
9 /// both read rates from the shared .
10 ///
11 -internal sealed class StorageCostCalculator(PricingCatalog? cat
-alog = null)
11 +public sealed class StorageCostCalculator(PricingCatalog? catal
+og = null)
12 {
13 // Match RestoreCostCalculator: GB means GiB (1024^3 bytes)
.
14 private const double BytesPerGiB = 1024.0 * 1024.0 * 1024.0
;
⏺ Update(src/Arius.Core/Shared/Pricing/StorageCostCalculator.cs)
⎿ Added 2 lines, removed 2 lines
37 }
38
39 /// Per-tier stored size + distinct-chunk count, paire
d with the estimated monthly storage cost.
40 -internal sealed record TierStorageCost(BlobTier Tier, long Uniq
-ueChunks, long StoredSize, double CostPerMonth);
40 +public sealed record TierStorageCost(BlobTier Tier, long Unique
+Chunks, long StoredSize, double CostPerMonth);
41
42 /// Per-tier storage cost breakdown for one region, wi
th the grand-total monthly cost.
43 -internal sealed record StorageCostBreakdown(
43 +public sealed record StorageCostBreakdown(
44 string Region,
45 string Currency,
46 IReadOnlyList Tiers,
Ran 1 shell command (ctrl+o to expand)
⏺ Core builds clean (0 errors). Task #2 done. Now Task #3 — the API layer. Let
me read Program.cs and RepositoryEndpoints.cs to match the endpoint-mapping +
evict patterns.
Read 2 files (ctrl+o to expand)
⏺ Now I have all the patterns. Let me implement the API changes. First, the DB
records and schema.
⏺ Update(src/Arius.Api/AppData/Records.cs)
⎿ Added 5 lines, removed 2 lines
1 namespace Arius.Api.AppData;
2
3 -/// A configured Azure Storage account. is Data-Protection ciphertext.
4 -public sealed record AccountRecord(long Id, string Name, string
-? EncryptedAccountKey, DateTimeOffset CreatedAt);
3 +///
4 +/// A configured Azure Storage account. is Data-Protection ciphertext.
5 +/// is the programmatic Azure region (e.
+g. westeurope ), or null when unknown.
6 +///
7 +public sealed record AccountRecord(long Id, string Name, string
+? EncryptedAccountKey, string? Location, DateTimeOffset Created
+At);
8
9 /// A managed repository (one blob container). is Data-Protection ciphertext.
10 public sealed record RepositoryRecord(
⏺ Now the AppDatabase.cs schema + migration + account methods.
⏺ Update(src/Arius.Api/AppData/AppDatabase.cs)
⎿ Added 1 line
41 id INTEGER PRIMARY KEY AUTOINCREMENT,
42 name TEXT NOT NULL UNIQUE,
43 account_key TEXT,
44 + location TEXT,
45 created_at TEXT NOT NULL
46 );
47
⏺ Update(src/Arius.Api/AppData/AppDatabase.cs)
⎿ Added 23 lines
94 );
95 """;
96 create.ExecuteNonQuery();
97 +
98 + // Additive migrations for databases created before a
+column existed. SQLite has no
99 + // "ADD COLUMN IF NOT EXISTS", so guard with a column-
+presence check (CREATE above already
100 + // includes these columns for fresh databases).
101 + EnsureColumn(connection, "storage_accounts", "location
+", "TEXT");
102 }
103
104 + /// Adds to if it is not already present.
105 + private static void EnsureColumn(SqliteConnection connecti
+on, string table, string column, string typeAndConstraints)
106 + {
107 + using var info = connection.CreateCommand();
108 + info.CommandText = $"PRAGMA table_info({table});";
109 + using var reader = info.ExecuteReader();
110 + while (reader.Read())
111 + {
112 + if (string.Equals(reader.GetString(1), column, Str
+ingComparison.OrdinalIgnoreCase))
113 + return; // already present
114 + }
115 + reader.Close();
116 +
117 + using var alter = connection.CreateCommand();
118 + alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {
+column} {typeAndConstraints};";
119 + alter.ExecuteNonQuery();
120 + }
121 +
122 private SqliteConnection OpenConnection()
123 {
124 var connection = new SqliteConnection(_connectionStrin
g);
⏺ Now the account read/list/get/insert + new UpdateAccount and
ListRepositoryIdsForAccount, and the ReadAccount reader.
⏺ Update(src/Arius.Api/AppData/AppDatabase.cs)
⎿ Added 49 lines, removed 4 lines
132 {
133 using var connection = OpenConnection();
134 using var command = connection.CreateCommand();
135 - command.CommandText = "SELECT id, name, account_key, c
-reated_at FROM storage_accounts ORDER BY name;";
135 + command.CommandText = "SELECT id, name, account_key, l
+ocation, created_at FROM storage_accounts ORDER BY name;";
136 using var reader = command.ExecuteReader();
137 var result = new List();
138 while (reader.Read())
...
144 {
145 using var connection = OpenConnection();
146 using var command = connection.CreateCommand();
147 - command.CommandText = "SELECT id, name, account_key, c
-reated_at FROM storage_accounts WHERE id = $id;";
147 + command.CommandText = "SELECT id, name, account_key, l
+ocation, created_at FROM storage_accounts WHERE id = $id;";
148 command.Parameters.AddWithValue("$id", id);
149 using var reader = command.ExecuteReader();
150 return reader.Read() ? ReadAccount(reader) : null;
151 }
152
153 - public long InsertAccount(string name, string? encryptedAc
-countKey)
153 + public long InsertAccount(string name, string? encryptedAc
+countKey, string? location)
154 {
155 using var connection = OpenConnection();
156 using var command = connection.CreateCommand();
157 command.CommandText = """
158 - INSERT INTO storage_accounts(name, account_key, cr
-eated_at) VALUES ($name, $key, $createdAt);
158 + INSERT INTO storage_accounts(name, account_key, lo
+cation, created_at) VALUES ($name, $key, $location, $createdAt
+);
159 SELECT last_insert_rowid();
160 """;
161 command.Parameters.AddWithValue("$name", name);
162 command.Parameters.AddWithValue("$key", (object?)encry
ptedAccountKey ?? DBNull.Value);
163 + command.Parameters.AddWithValue("$location", (object?)
+location ?? DBNull.Value);
164 command.Parameters.AddWithValue("$createdAt", DateTime
Offset.UtcNow.ToString("O"));
165 return (long)command.ExecuteScalar()!;
166 }
167
168 + ///
169 + /// Updates an account's connection material. A null leaves the
170 + /// stored key unchanged (so the key need not be resupplie
+d when only the region changes).
171 + /// is always written (pass the current value to leave it;
+ null clears it to "unknown").
172 + ///
173 + public void UpdateAccount(long id, string? encryptedAccoun
+tKey, string? location)
174 + {
175 + using var connection = OpenConnection();
176 + using var command = connection.CreateCommand();
177 + command.CommandText = """
178 + UPDATE storage_accounts SET
179 + account_key = COALESCE($key, account_key),
180 + location = $location
181 + WHERE id = $id;
182 + """;
183 + command.Parameters.AddWithValue("$id", id);
184 + command.Parameters.AddWithValue("$key", (object?)encry
+ptedAccountKey ?? DBNull.Value);
185 + command.Parameters.AddWithValue("$location", (object?)
+location ?? DBNull.Value);
186 + command.ExecuteNonQuery();
187 + }
188 +
189 + public void DeleteAccount(long id)
190 + {
191 + using var connection = OpenConnection();
192 + using var command = connection.CreateCommand();
193 + command.CommandText = "DELETE FROM storage_accounts WH
+ERE id = $id;";
194 + command.Parameters.AddWithValue("$id", id);
195 + command.ExecuteNonQuery();
196 + }
197 +
198 public int CountRepositoriesForAccount(long accountId)
199 {
200 using var connection = OpenConnection();
...
204 return (int)(long)command.ExecuteScalar()!;
205 }
206
207 + /// Repository ids belonging to an account — used
+ to evict providers / clear stats caches after an account chan
+ge.
208 + public IReadOnlyList ListRepositoryIdsForAccount(lon
+g accountId)
209 + {
210 + using var connection = OpenConnection();
211 + using var command = connection.CreateCommand();
212 + command.CommandText = "SELECT id FROM repositories WHE
+RE account_id = $accountId;";
213 + command.Parameters.AddWithValue("$accountId", accountI
+d);
214 + using var reader = command.ExecuteReader();
215 + var result = new List();
216 + while (reader.Read())
217 + result.Add(reader.GetInt64(0));
218 + return result;
219 + }
220 +
221 // ── Repositories ───────────────────────────────────────
───────────────────
222
223 public IReadOnlyList ListRepositories()
⏺ Now update the ReadAccount reader for the new column order (id, name,
account_key, location, created_at).
⏺ Update(src/Arius.Api/AppData/AppDatabase.cs)
⎿ Added 2 lines, removed 1 line
496 reader.GetInt64(0),
497 reader.GetString(1),
498 reader.IsDBNull(2) ? null : reader.GetString(2),
499 - DateTimeOffset.Parse(reader.GetString(3)));
499 + reader.IsDBNull(3) ? null : reader.GetString(3),
500 + DateTimeOffset.Parse(reader.GetString(4)));
501
502 private static RepositoryRecord ReadRepository(SqliteDataR
eader reader) => new(
503 reader.GetInt64(0),
⏺ Now the DTOs.
⏺ Update(src/Arius.Api/Contracts/Dtos.cs)
⎿ Added 6 lines, removed 3 lines
2
3 // ── Accounts ────────────────────────────────────────────────
────────────────
4
5 -/// A storage account as shown to the client. The acco
-unt key is never returned.
6 -public sealed record AccountDto(long Id, string Name, int Repos
-itories, bool HasKey);
5 +/// A storage account as shown to the client. The acco
+unt key is never returned. is the progra
+mmatic Azure region (e.g. westeurope ) or null whe
+n unknown.
6 +public sealed record AccountDto(long Id, string Name, int Repos
+itories, bool HasKey, string? Location);
7
8 -public sealed record CreateAccountRequest(string Name, string?
-AccountKey);
8 +public sealed record CreateAccountRequest(string Name, string?
+AccountKey, string? Location);
9
10 +/// Account-flyout update. A null leaves the stored key unchanged; is written as given (null = unknown).
11 +public sealed record UpdateAccountRequest(string? AccountKey, s
+tring? Location);
12 +
13 // ── Repositories ────────────────────────────────────────────
──────────────────
14
15 /// A repository as shown to the client. Secrets (key,
passphrase) are never returned.
⏺ Update(src/Arius.Api/Contracts/Dtos.cs)
⎿ Added 12 lines, removed 3 lines
41
42 public sealed record SnapshotDto(string Version, DateTimeOffset
Timestamp, long FileCount);
43
44 -public sealed record StatisticsDto(long Files, long OriginalSiz
-e, long DeduplicatedSize, long StoredSize, long UniqueChunks, I
-ReadOnlyList StoredByTier);
44 +public sealed record StatisticsDto(
45 + long Files,
46 + long OriginalSize,
47 + long DeduplicatedSize,
48 + long StoredSize,
49 + long UniqueChunks,
50 + string Currency,
51 + string Region,
52 + double TotalStorageCostPerMonth,
53 + IReadOnlyList StoredByTier);
54
46 -/// Stored size and distinct-chunk count for one stora
-ge tier (Hot/Cool/Cold/Archive).
47 -public sealed record TierStatisticsDto(string Tier, long Unique
-Chunks, long StoredSize);
55 +/// Stored size, distinct-chunk count, and estimated m
+onthly storage cost for one storage tier (Hot/Cool/Cold/Archive
+).
56 +public sealed record TierStatisticsDto(string Tier, long Unique
+Chunks, long StoredSize, double CostPerMonth);
57
58 // ── Jobs / schedules ────────────────────────────────────────
──────────────────
59
⏺ Now AccountEndpoints.cs — location in ToDto/POST, plus GET/{id}, PATCH/{id},
DELETE/{id}.
⏺ Write(src/Arius.Api/Endpoints/AccountEndpoints.cs)
⎿ Added 53 lines, removed 2 lines
1 +using Arius.Api.Composition;
2 using Arius.Api.Contracts;
3 using Arius.Api.AppData;
4
...
14 group.MapGet("/", (AppDatabase db) =>
15 db.ListAccounts().Select(a => ToDto(db, a)).ToList(
));
16
17 + group.MapGet("/{id:long}", (long id, AppDatabase db) =>
18 + {
19 + var account = db.GetAccount(id);
20 + return account is null ? Results.NotFound() : Resul
+ts.Ok(ToDto(db, account));
21 + });
22 +
23 group.MapPost("/", (CreateAccountRequest request, AppDa
tabase db, SecretProtector secrets) =>
24 {
18 - var id = db.InsertAccount(request.Name, secrets.Pro
-tect(request.AccountKey));
25 + var id = db.InsertAccount(request.Name, secrets.Pro
+tect(request.AccountKey), NormalizeLocation(request.Location));
26 var account = db.GetAccount(id)!;
27 return Results.Created($"/accounts/{id}", ToDto(db,
account));
28 });
29 +
30 + // Account-flyout edit: rotate the key and/or change th
+e region. A null key in the request leaves
31 + // the stored key unchanged. Rotating the key invalidat
+es cached providers; changing the region
32 + // invalidates memoized statistics (whose cost figures
+are region-priced) for this account's repos.
33 + group.MapPatch("/{id:long}", (long id, UpdateAccountReq
+uest request, AppDatabase db, SecretProtector secrets, Reposito
+ryProviderRegistry registry) =>
34 + {
35 + var existing = db.GetAccount(id);
36 + if (existing is null)
37 + return Results.NotFound();
38 +
39 + var newLocation = NormalizeLocation(request.Locatio
+n);
40 + var keyChanged = request.AccountKey is not null;
41 + var regionChanged = !string.Equals(existing.Locatio
+n, newLocation, StringComparison.Ordinal);
42 +
43 + db.UpdateAccount(id, secrets.Protect(request.Accoun
+tKey), newLocation);
44 +
45 + if (keyChanged || regionChanged)
46 + {
47 + foreach (var repoId in db.ListRepositoryIdsForA
+ccount(id))
48 + {
49 + if (keyChanged) registry.Evict(repoId);
+ // new key takes effect on rebuild
50 + if (regionChanged) db.ClearStatisticsCache(
+repoId); // cost is region-priced → recompute
51 + }
52 + }
53 +
54 + return Results.Ok(ToDto(db, db.GetAccount(id)!));
55 + });
56 +
57 + group.MapDelete("/{id:long}", (long id, AppDatabase db)
+ =>
58 + {
59 + if (db.GetAccount(id) is null)
60 + return Results.NotFound();
61 + if (db.CountRepositoriesForAccount(id) > 0)
62 + return Results.Conflict("Account still has repo
+sitories.");
63 +
64 + db.DeleteAccount(id);
65 + return Results.NoContent();
66 + });
67 }
68
69 + /// Treat blank / "unknown" as no region.
70 + private static string? NormalizeLocation(string? location)
71 + => string.IsNullOrWhiteSpace(location) || location.Equa
+ls("unknown", StringComparison.OrdinalIgnoreCase)
72 + ? null
73 + : location.Trim();
74 +
75 private static AccountDto ToDto(AppDatabase db, AccountReco
rd account)
25 - => new(account.Id, account.Name, db.CountRepositoriesFo
-rAccount(account.Id), account.EncryptedAccountKey is not null);
76 + => new(account.Id, account.Name, db.CountRepositoriesFo
+rAccount(account.Id), account.EncryptedAccountKey is not null,
+account.Location);
77 }
⏺ Now BrowseEndpoints.cs — resolve the account region and pass it to
StatisticsQuery, mapping the new cost fields.
⏺ Update(src/Arius.Api/Endpoints/BrowseEndpoints.cs)
⎿ Added 8 lines, removed 2 lines
50 var snapshotBlobs = await snapshotService.ListBlo
bNamesAsync(ct);
51 var fingerprint = snapshotBlobs.Count == 0 ? st
ring.Empty : snapshotService.GetVersion(snapshotBlobs[^1]);
52
53 - var statistics = await mediator.Send(new Statistics
-Query(version, fullFlag), ct);
53 + // The per-tier storage cost is priced for the acco
+unt's region; a region change invalidates this
54 + // cache (see AccountEndpoints PATCH), so the cache
+d payload's cost always matches the current region.
55 + var repository = database.GetRepository(id);
56 + var region = repository is null ? null : databa
+se.GetAccount(repository.AccountId)?.Location;
57 +
58 + var statistics = await mediator.Send(new Statistics
+Query(version, fullFlag, region), ct);
59 var dto = new StatisticsDto(
60 statistics.Files, statistics.OriginalSize, stat
istics.DeduplicatedSize, statistics.StoredSize, statistics.Uniq
ueChunks,
56 - statistics.StoredByTier.Select(t => new TierSta
-tisticsDto(t.Tier.ToString(), t.UniqueChunks, t.StoredSize)).To
-List());
61 + statistics.Currency, statistics.Region, statist
+ics.TotalStorageCostPerMonth,
62 + statistics.StoredByTier.Select(t => new TierSta
+tisticsDto(t.Tier.ToString(), t.UniqueChunks, t.StoredSize, t.C
+ostPerMonth)).ToList());
63
64 database.UpsertCachedStatistics(id, versionKey, ful
lFlag, fingerprint, JsonSerializer.Serialize(dto));
65 return dto;
⏺ Now the regions + filesystem endpoints. First add the filesystem DTOs, then
create the two endpoint files and map them.
⏺ Update(src/Arius.Api/Contracts/Dtos.cs)
⎿ Added 8 lines
72 public sealed record ScheduleDto(long Id, long RepoId, string C
ron, string Kind, bool Enabled, DateTimeOffset? NextRun);
73
74 public sealed record CreateScheduleRequest(string Cron, string?
Kind);
75 +
76 +// ── Filesystem browse (local-path picker) ───────────────────
+──────────────────
77 +
78 +/// A directory as the Arius.Api host/container sees i
+t.
79 +public sealed record FsEntryDto(string Name, string Path);
80 +
81 +/// A directory listing: the resolved path, its parent
+ (null at the root), and immediate subdirectories.
82 +public sealed record FsListDto(string Path, string? Parent, IRe
+adOnlyList Entries);
⏺ Write(src/Arius.Api/Endpoints/PricingEndpoints.cs)
⎿ Wrote 16 lines to src/Arius.Api/Endpoints/PricingEndpoints.cs
1 using Arius.Core.Shared.Pricing;
2
3 namespace Arius.Api.Endpoints;
5 /// Exposes the pricing catalog's metadata — currently t
he set of priced regions for the account dropdown.
6 internal static class PricingEndpoints
7 {
8 // The embedded catalog is immutable for the process lifetime
9 private static readonly IReadOnlyList _regions = Pric
ingCatalog.LoadEmbedded().RegionNames;
… +11 lines (ctrl+o to expand)
⏺ Write(src/Arius.Api/Endpoints/FilesystemEndpoints.cs)
⎿ Wrote 52 lines to src/Arius.Api/Endpoints/FilesystemEndpoints.cs
1 using Arius.Api.Contracts;
3 namespace Arius.Api.Endpoints;
4
5 ///
sts directories as the Arius.Api
7 /// host/container sees them — under Docker these are the mo
8 /// folders on the machine running the API. The stored local pat
h must resolve here (not on the
9 /// browser's machine), which is why the picker is server-driven
10 ///
… +56 lines (ctrl+o to expand)
⏺ Now map the two new endpoint groups in Program.cs.
⏺ Update(src/Arius.Api/Program.cs)
⎿ Added 2 lines
72 api.MapRepositoryEndpoints();
73 api.MapBrowseEndpoints();
74 api.MapJobEndpoints();
75 + api.MapPricingEndpoints();
76 + api.MapFilesystemEndpoints();
77 app.MapHub("/hubs/arius");
78
79 // SPA fallback: client-side routes (/overview, /repos/…) s
erve index.html (only when present).
⏺ Let me check for other call sites that construct the changed records/methods
(so the build doesn't break elsewhere) before building.
Ran 1 shell command (ctrl+o to expand)
⏺ Two test files need updates. Let me check CliHarness.cs (it may also mock the
old ICommandHandler interface) and
StatisticsCacheTests.cs.
Ran 1 shell command (ctrl+o to expand)
⏺ CliHarness.cs mocks the old ICommandHandler. Let me read
it to update the interface + registration + the result shape.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Cli.Tests/TestSupport/CliHarness.cs)
⎿ Added 1 line, removed 1 line
43 // CLI-unused snapshot/stats query handlers must be sup
plied too (they otherwise need a real
44 // ISnapshotService the harness has no reason to wire u
p).
45 var snapshotsHandler = Substitute.For>>();
46 - var statsHandler = Substitute.For>();
46 + var statsHandler = Substitute.For>();
47
48 archiveHandler
49 .Handle(Arg.Any(), Arg.Any())
⏺ Update(src/Arius.Cli.Tests/TestSupport/CliHarness.cs) ⎿ Added 1 line, removed 1 line
89
90 statsHandler
91 .Handle(Arg.Any(), Arg.Any())
92 - .Returns(new ValueTask(new Re
-positoryStatistics(0, 0, 0, 0, 0, [])));
92 + .Returns(new ValueTask(new Re
+positoryStatistics(0, 0, 0, 0, 0, "", "", 0, [])));
93
94 ArchiveHandler = archiveHandler;
95 RestoreHandler = restoreHandler;
⏺ Now update the StatisticsCacheTests.cs InsertAccount calls (need the new
location arg).
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Api.Tests/AppData/StatisticsCacheTests.cs)
⎿ Added 1 line, removed 1 line
16 {
17 var path = System.IO.Path.Combine(System.IO.Path.GetTem
pPath(), $"arius-api-tests-{Guid.NewGuid():N}.db");
18 var database = new AppDatabase(path);
19 - var accountId = database.InsertAccount("acc", encrypted
-AccountKey: null);
19 + var accountId = database.InsertAccount("acc", encrypted
+AccountKey: null, location: null);
20 var repositoryId = database.InsertRepository("alias", "
container", accountId, localPath: null, "archive", encryptedPas
sphrase: null);
21 return (database, repositoryId, path);
22 }
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Api.Tests/AppData/StatisticsCacheTests.cs)
⎿ Added 1 line, removed 1 line
95 public async Task Prune_DoesNotAffectOtherRepositories()
96 {
97 var (database, repositoryId, _) = NewDatabase();
98 - var otherAccountId = database.InsertAccount("acc2", nu
-ll);
98 + var otherAccountId = database.InsertAccount("acc2", nu
+ll, null);
99 var otherRepositoryId = database.InsertRepository("ali
as2", "container2", otherAccountId, null, "archive", null);
100
101 database.UpsertCachedStatistics(otherRepositoryId, ver
sion: "", full: true, fingerprint: "v1", payload: "OTHER");
⏺ Now let me build the backend solution to catch any remaining compile errors.
Ran 1 shell command (ctrl+o to expand)
⏺ Arius.Api builds clean (0 errors; the MSG0005 warnings are pre-existing). Now
let me run the affected backend tests and build the other test projects.
Ran 2 shell commands (ctrl+o to expand)
⏺ This project uses TUnit (Microsoft.Testing.Platform), which has different
filter syntax. Let me run the full Core.Tests project (self-contained unit
tests with fakes).
Ran 2 shell commands (ctrl+o to expand)
⏺ All 622 Core tests pass. Now run Api.Tests and Cli.Tests to confirm the
harness/DB test fixes.
Ran 1 shell command (ctrl+o to expand)
⏺ The user refined pricing.json (reordered tiers; archive storage rate now
0.00086). My test hardcoded the old archive rate 0.0010 — I'll make the
assertion derive the rate from the catalog so it's robust to pricing edits.
⏺ Update(src/Arius.Core.Tests/Features/StatisticsQuery/StatisticsQueryHandlerTes
ts.cs)
⎿ Added 5 lines, removed 3 lines
86 stats.StoredByTier[1].StoredSize.ShouldBe(60);
87
88 // Cost: no region requested → default region (westeuro
pe/EUR) from the embedded pricing catalog.
89 - // Each tier's cost = storedSize / 1024³ × that tier's
-storagePerGBPerMonth (West Europe rates).
89 + // Each tier's cost = storedSize / 1024³ × that tier's
+storagePerGBPerMonth. Rates are read back from
90 + // the catalog so the assertion stays correct if the pu
+blished rates in pricing.json are updated.
91 const double giB = 1024.0 * 1024.0 * 1024.0;
92 + var pricing = PricingCatalog.LoadEmbedded().Resolve(nul
+l).Pricing;
93 stats.Currency.ShouldBe("EUR");
94 stats.Region.ShouldBe("westeurope");
93 - stats.StoredByTier[0].CostPerMonth.ShouldBe(40 / giB *
-0.01, tolerance: 1e-18); // Cool @ 0.01
94 - stats.StoredByTier[1].CostPerMonth.ShouldBe(60 / giB *
-0.0010, tolerance: 1e-18); // Archive @ 0.0010
95 + stats.StoredByTier[0].CostPerMonth.ShouldBe(40 / giB *
+pricing.StorageRateFor(BlobTier.Cool), tolerance: 1e-18);
96 + stats.StoredByTier[1].CostPerMonth.ShouldBe(60 / giB *
+pricing.StorageRateFor(BlobTier.Archive), tolerance: 1e-18);
97 stats.TotalStorageCostPerMonth.ShouldBe(stats.StoredByT
ier.Sum(t => t.CostPerMonth), tolerance: 1e-18);
98 }
99
Ran 1 shell command (ctrl+o to expand)
⏺ Backend complete and green (622 Core + 9 Api + 150 Cli tests pass). Task #3
done. Now the frontend — Task #4, the API layer.
⏺ Update(src/Arius.Web/src/app/core/api/api-models.ts)
⎿ Added 2 lines
5 name: string;
6 repositories: number;
7 hasKey: boolean;
8 + /** Programmatic Azure region (e.g. "westeurope"), or null wh
+en unknown. */
9 + location: string | null;
10 }
11
12 export interface RepositoryDto {
⏺ Update(src/Arius.Web/src/app/core/api/api-models.ts)
⎿ Added 8 lines
31 deduplicatedSize: number;
32 storedSize: number;
33 uniqueChunks: number;
34 + /** ISO currency code the cost figures are in (e.g. "EUR"). *
+/
35 + currency: string;
36 + /** Region the storage cost was priced for (the account's reg
+ion, or the catalog default). */
37 + region: string;
38 + /** Estimated total monthly storage cost across all tiers, in
+ `currency`. */
39 + totalStorageCostPerMonth: number;
40 storedByTier: TierStatisticsDto[];
41 }
42
...
44 tier: string;
45 uniqueChunks: number;
46 storedSize: number;
47 + /** Estimated monthly storage cost for this tier, in the Stat
+isticsDto's `currency`. */
48 + costPerMonth: number;
49 }
50
51 export interface StateFlagsDto {
⏺ Now add the filesystem-browse DTOs at the end of api-models.ts.
⏺ Update(src/Arius.Web/src/app/core/api/api-models.ts)
⎿ Added 15 lines
141 localPath: string | null;
142 defaultTier: string | null;
143 }
144 +
145 +// ── Filesystem browse (local-path picker) ──────────────────
+───────────────────
146 +
147 +/** A directory as the Arius.Api host/container sees it. */
148 +export interface FsEntryDto {
149 + name: string;
150 + path: string;
151 +}
152 +
153 +/** A directory listing: the resolved path, its parent (null a
+t the root), and immediate subdirectories. */
154 +export interface FsListDto {
155 + path: string;
156 + parent: string | null;
157 + entries: FsEntryDto[];
158 +}
⏺ Now api.service.ts — extend createAccount and add getAccount, updateAccount,
deleteAccount, getRegions, listDirectories.
⏺ Update(src/Arius.Web/src/app/core/api/api.service.ts)
⎿ Added 1 line, removed 1 line
1 import { HttpClient } from '@angular/common/http';
2 import { Injectable, inject } from '@angular/core';
3 import { map, Observable } from 'rxjs';
4 -import { AccountDto, CreateRepositoryRequest, JobDto, Repository
-Dto, ScheduleDto, SnapshotDto, StatisticsDto } from './api-model
-s';
4 +import { AccountDto, CreateRepositoryRequest, FsListDto, JobDto,
+ RepositoryDto, ScheduleDto, SnapshotDto, StatisticsDto } from '
+./api-models';
5
6 /** Typed REST client for Arius.Api. Entry streaming lives in Re
altimeService (SignalR). */
7 @Injectable({ providedIn: 'root' })
⏺ Update(src/Arius.Web/src/app/core/api/api.service.ts)
⎿ Added 26 lines, removed 2 lines
12 return this.http.get('/api/accounts');
13 }
14
15 - createAccount(name: string, accountKey: string | null): Obser
-vable {
16 - return this.http.post('/api/accounts', { name,
-accountKey });
15 + getAccount(id: number): Observable {
16 + return this.http.get(`/api/accounts/${id}`);
17 }
18
19 + createAccount(name: string, accountKey: string | null, locati
+on: string | null = null): Observable {
20 + return this.http.post('/api/accounts', { name,
+accountKey, location });
21 + }
22 +
23 + /** Edit-account flyout: rotate the key (omit/null to keep th
+e stored one) and/or change the region. */
24 + updateAccount(id: number, body: { accountKey?: string | null;
+ location: string | null }): Observable {
25 + return this.http.patch(`/api/accounts/${id}`, b
+ody);
26 + }
27 +
28 + deleteAccount(id: number): Observable {
29 + return this.http.delete(`/api/accounts/${id}`);
30 + }
31 +
32 + /** Programmatic Azure regions that have pricing (for the acc
+ount-region dropdown). */
33 + getRegions(): Observable {
34 + return this.http.get('/api/pricing/regions');
35 + }
36 +
37 + /** Lists directories as the Arius.Api host/container sees th
+em (server-side local-path picker). */
38 + listDirectories(path?: string | null): Observable
+{
39 + const query = path ? `?path=${encodeURIComponent(path)}` :
+'';
40 + return this.http.get(`/api/fs/list${query}`);
41 + }
42 +
43 listRepositories(): Observable {
44 return this.http.get('/api/repos');
45 }
⏺ Task #4 done. Now Task #6 — the CostCalculator component. Let me read
format.ts to add formatCurrency consistently with existing helpers.
Read 1 file (ctrl+o to expand)
⏺ Now create the shared CostCalculator component + formatCurrency, then swap it
into the statistics tab. First, formatCurrency:
⏺ Update(src/Arius.Web/src/app/shared/format.ts)
⎿ Added 14 lines
23 export function formatCount(n: number | null | undefined): stri
ng {
24 return n == null ? '—' : n.toLocaleString('en-US');
25 }
26 +
27 +const CURRENCY_SYMBOLS: Record = { EUR: '€', US
+D: '$', GBP: '£' };
28 +
29 +/**
30 + * Money → display string. Whole units at/above 10 (e.g. €182),
+ two decimals below (e.g. €8.14),
31 + * matching the cost-breakdown design. Falls back to a "CODE "
+prefix for unknown currencies.
32 + */
33 +export function formatCurrency(amount: number | null | undefine
+d, currency: string | null | undefined = 'EUR'): string {
34 + if (amount == null) return '—';
35 + const code = currency || 'EUR';
36 + const symbol = CURRENCY_SYMBOLS[code] ?? `${code} `;
37 + const digits = amount >= 10 ? 0 : 2;
38 + return symbol + amount.toLocaleString('en-US', { minimumFract
+ionDigits: digits, maximumFractionDigits: digits });
39 +}
⏺ Write(src/Arius.Web/src/app/shared/cost-calculator/cost-calculator.component.t
s)
⎿ Wrote 115 lines to src/Arius.Web/src/app/shared/cost-calculator/cost-c
alculator.component.ts
1 import { ChangeDetectionStrategy, Component, computed, input } f
2 import { StatisticsDto } from '../../core/api/api-models';
3 import { formatBytes, formatCount, formatCurrency } from '../for
4
6 * Region-aware storage-cost breakdown by tier — the shared "Cos
tCalculator" view that replaces the old
a 100%-stacked hero bar, a per-tier detail
8 * table (stored size, chunks, est. cost/mo) and a grand-total f
9 * total costs are computed server-side by the CostQuery/Statist
10 */
… +147 lines (ctrl+o to expand)
⏺ Now swap the inline tier block in statistics-tab.component.ts for the new
component and update the banner copy.
⏺ Update(src/Arius.Web/src/app/features/repo/statistics/statistics-tab.component
.ts)
⎿ Error editing file
⏺ The production statistics-tab differs from the handoff copy I read. Let me
read the actual file.
Read 1 file (ctrl+o to expand)
⏺ This production file is richer (snapshot vs repository-storage scopes; the
tier section uses the lazy storageStats). I'll wire the cost calculator to
storageStats and remove the now-dead tiers()/TIER_COLORS.
⏺ Update(src/Arius.Web/src/app/features/repo/statistics/statistics-tab.component
.ts)
⎿ Added 1 line
3 import { SnapshotStore } from '../../../core/state/snapshot.stor
e';
4 import { StatisticsDto } from '../../../core/api/api-models';
5 import { formatBytes, formatCount } from '../../../shared/format
';
6 +import { CostCalculatorComponent } from '../../../shared/cost-ca
+lculator/cost-calculator.component';
7
8 /**
9 * Statistics tab. Two scopes are shown separately because they
answer different questions:
⏺ Update(src/Arius.Web/src/app/features/repo/statistics/statistics-tab.component
.ts)
⎿ Added 1 line
16 selector: 'arius-statistics-tab',
17 standalone: true,
18 changeDetection: ChangeDetectionStrategy.OnPush,
19 + imports: [CostCalculatorComponent],
20 template: `
21
22
⏺ Update(src/Arius.Web/src/app/features/repo/statistics/statistics-tab.component
.ts)
⎿ Added 3 lines, removed 15 lines
60 }
61
62
63 - @if (!storageLoading() && tiers().length) {
64 -
65 -
Stored size by tier
66 -
67 - @for (t of tiers(); track t.tier) {
68 -
69 -
{{ t.tier }}
70 -
73 -
{{ t.size }}
74 -
{{ t.chunks }} chunks
75 -
76 - }
77 -
63 + @if (!storageLoading() && (storageStats()?.storedByTier?.le
+ngth ?? 0)) {
64 +
67 }
68
⏺ Now remove the dead tiers() method and TIER_COLORS from the statistics tab.
⏺ Update(src/Arius.Web/src/app/features/repo/statistics/statistics-tab.component
.ts)
⎿ Removed 16 lines
135 ];
136 }
137
138 - // Warmer → cooler colours so the access-tier story reads at
- a glance (Archive = coldest = slowest to restore).
139 - private static readonly TIER_COLORS: Record
-= {
140 - Hot: '#ef4444', Cool: '#3b82f6', Cold: '#0ea5e9', Archive:
- '#64748b',
141 - };
142 -
143 - protected tiers() {
144 - const rows = this.storageStats()?.storedByTier ?? [];
145 - const max = Math.max(1, ...rows.map(t => t.storedSize));
146 - return rows.map(t => ({
147 - tier: t.tier,
148 - size: formatBytes(t.storedSize),
149 - chunks: formatCount(t.uniqueChunks),
150 - pct: (t.storedSize / max) * 100,
151 - color: StatisticsTabComponent.TIER_COLORS[t.tier] ?? '#a
-1a1aa',
152 - }));
153 - }
138 }
⏺ Task #6 done. Now Task #5 — Group 1 accounts UI. Let me read app.component.ts
and properties-drawer.component.ts to mirror the drawer mounting/structure
precisely.
Read 2 files (ctrl+o to expand)
⏺ Now I have all the patterns. Let me build Group 1's frontend. First, extend
the drawer store with an account type.
⏺ Update(src/Arius.Web/src/app/core/state/drawer.store.ts)
⎿ Added 1 line, removed 1 line
2 import { RealtimeService } from '../api/realtime.service';
3 import { CostEstimateMsg, LogLine } from '../api/api-models';
4
5 -export type DrawerType = 'archive' | 'restore' | 'properties' |
-null;
5 +export type DrawerType = 'archive' | 'restore' | 'properties' |
+'account' | null;
6 export type StreamState = 'idle' | 'running' | 'cost' | 'done';
7
8 /**
⏺ Update(src/Arius.Web/src/app/core/state/drawer.store.ts)
⎿ Added 1 line
16
17 readonly type = signal(null);
18 readonly repoId = signal(0);
19 + readonly accountId = signal(0);
20 readonly version = signal(null);
21 readonly collectedPaths = signal([]);
22
⏺ Update(src/Arius.Web/src/app/core/state/drawer.store.ts)
⎿ Added 6 lines
49 this.repoId.set(repoId);
50 }
51
52 + openAccount(accountId: number): void {
53 + this.resetStream();
54 + this.type.set('account');
55 + this.accountId.set(accountId);
56 + }
57 +
58 openArchive(repoId: number, tier: string): void {
59 this.resetStream();
60 this.type.set('archive');
⏺ Now the shared folder-picker component (server-driven directory browser).
⏺ Write(src/Arius.Web/src/app/shared/folder-picker/folder-picker.component.ts)
⎿ Wrote 102 lines to
src/Arius.Web/src/app/shared/folder-picker/folder-picker.component.ts
1 import { ChangeDetectionStrategy, Component, inject, input, mode
2 import { FormsModule } from '@angular/forms';
3 import { ApiService } from '../../core/api/api.service';
4 import { FsEntryDto } from '../../core/api/api-models';
6 /**
7 * Local-path input with a [...] button that browses directories
8 * (mounted volumes under Docker; real folders in dev) — the pat
h must resolve on the API side, not the
9 * browser's, so the picker is server-driven via /api/fs/list. T
10 */
… +125 lines (ctrl+o to expand)
⏺ Now extend the drawer store with an accounts-changed signal (so the overview
list refreshes after edits), then build the account drawer.
⏺ Update(src/Arius.Web/src/app/core/state/drawer.store.ts)
⎿ Added 4 lines
20 readonly version = signal(null);
21 readonly collectedPaths = signal([]);
22
23 + /** Bumped whenever an account is created/edited/deleted, so
+account lists (e.g. Overview) can re-fetch. */
24 + readonly accountsRevision = signal(0);
25 + bumpAccounts(): void { this.accountsRevision.update(n => n +
+1); }
26 +
27 readonly streamState = signal('idle');
28 readonly lines = signal([]);
29 readonly progress = signal(0);
⏺ Write(src/Arius.Web/src/app/features/drawer/account-drawer.component.ts)
⎿ Wrote 143 lines to
src/Arius.Web/src/app/features/drawer/account-drawer.component.ts
1 import { ChangeDetectionStrategy, Component, computed, effect, i
nject, signal } from '@angular/core';
2 import { FormsModule } from '@angular/forms';
4 import { DrawerStore } from '../../core/state/drawer.store';
5 import { ApiService } from '../../core/api/api.service';
6 import { AccountDto } from '../../core/api/api-models';
7
8 /** Right slide-over to edit a storage account: rotate the accou
9 @Component({
10 selector: 'arius-account-drawer',
… +187 lines (ctrl+o to expand)
⏺ Now mount the account drawer in app.component.ts.
⏺ Update(src/Arius.Web/src/app/app.component.ts)
⎿ Added 1 line
4 import { MetronicInitService } from './core/services/metronic-i
nit.service';
5 import { ArchiveRestoreDrawerComponent } from './features/drawe
r/archive-restore-drawer.component';
6 import { PropertiesDrawerComponent } from './features/drawer/pr
operties-drawer.component';
7 +import { AccountDrawerComponent } from './features/drawer/accou
+nt-drawer.component';
8 import { GlobalSearchOverlayComponent } from './features/search
/global-search-overlay.component';
9 import { SearchStore } from './core/state/search.store';
10 import { toSignal } from '@angular/core/rxjs-interop';
⏺ Update(src/Arius.Web/src/app/app.component.ts)
⎿ Added 1 line, removed 1 line
20 selector: 'body[app-root]',
21 standalone: true,
22 encapsulation: ViewEncapsulation.None,
23 - imports: [RouterOutlet, RouterLink, RouterLinkActive, Archive
-RestoreDrawerComponent, PropertiesDrawerComponent, GlobalSearch
-OverlayComponent],
23 + imports: [RouterOutlet, RouterLink, RouterLinkActive, Archive
+RestoreDrawerComponent, PropertiesDrawerComponent, AccountDrawe
+rComponent, GlobalSearchOverlayComponent],
24 template: `
25
26
85
86 -
86 +
87
88
89 +
90
91 `,
92 })
⏺ Now the Overview "Storage Accounts" section. Update imports/class first.
⏺ Update(src/Arius.Web/src/app/features/overview/overview.component.ts)
⎿ Added 3 lines, removed 1 line
1 import { ChangeDetectionStrategy, Component, computed, inject }
from '@angular/core';
2 -import { toSignal } from '@angular/core/rxjs-interop';
2 +import { toObservable, toSignal } from '@angular/core/rxjs-inter
+op';
3 import { Router } from '@angular/router';
4 +import { switchMap } from 'rxjs/operators';
5 import { ApiService } from '../../core/api/api.service';
6 +import { DrawerStore } from '../../core/state/drawer.store';
7
8 /** Overview: KPI cards + the repositories table. Cross-repo siz
e/dedup totals arrive with the jobs DB. */
9 @Component({
⏺ Update(src/Arius.Web/src/app/features/overview/overview.component.ts)
⎿ Added 5 lines
85 export class OverviewComponent {
86 private readonly api = inject(ApiService);
87 private readonly router = inject(Router);
88 + private readonly drawer = inject(DrawerStore);
89
90 protected readonly repos = toSignal(this.api.listRepositories
());
91 protected readonly repoCount = computed(() => this.repos()?.l
ength ?? 0);
92
93 + // Re-fetch whenever an account is added/edited/deleted via t
+he wizard or the edit flyout.
94 + protected readonly accounts = toSignal(
95 + toObservable(this.drawer.accountsRevision).pipe(switchMap((
+) => this.api.listAccounts())));
96 +
97 protected readonly kpis = computed(() => [
98 { label: 'Repositories', value: String(this.repoCount()), i
con: 'ki-folder', chipBg: '#eff6ff', chipFg: '#3b82f6' },
99 { label: 'Total archived', value: '—', icon: 'ki-cloud', ch
ipBg: '#f0fdf4', chipFg: '#15803d' },
⏺ Update(src/Arius.Web/src/app/features/overview/overview.component.ts)
⎿ Added 40 lines
80 Loading…
81 }
82
83 +
84 +
85 +
86 +
87 +
88 +
Storage accounts
89 +
Azure st
+orage accounts under management
90 +
91 +
+ Add account
92 +
93 +
94 +
95 +
Account
Region
Repositories
+div>
96 +
97 +
98 + @if (accounts(); as list) {
99 + @for (account of list; track account.id) {
100 +
102 +
103 +
104 +
105 +
106 +
107 +
{{ account.name }}
108 +
{{ a
+ccount.hasKey ? 'Key configured' : 'No key' }}
109 +
110 +
111 +
{{ account.location ?? 'Unknown' }}
112 +
{{ acc
+ount.repositories }}
113 +
114 + } @empty {
115 +
116 + No storage accounts yet — add one to create a repo
+sitory.
117 +
118 + }
119 + } @else {
120 +
Loading…
121 + }
122 +
123 `,
124 })
125 export class OverviewComponent {
⏺ Now add the editAccount method to the overview component.
⏺ Update(src/Arius.Web/src/app/features/overview/overview.component.ts)
⎿ Added 1 line
151 protected refresh(): void { location.reload(); }
152 protected go(path: string): void { this.router.navigateByUrl
(path); }
153 protected openRepo(id: number): void { this.router.navigate(
['/repos', id, 'files']); }
154 + protected editAccount(id: number): void { this.drawer.openAc
+count(id); }
155 }
⏺ Now the properties-tab cleanup (Group 1 #2): remove the account-key field,
rename the passphrase label, and swap the local-path input for the folder
picker.
⏺ Update(src/Arius.Web/src/app/features/repo/properties/properties-tab.component
.ts) ⎿ Added 3 lines, removed 2 lines
4 import { Router } from '@angular/router';
5 import { ApiService } from '../../../core/api/api.service';
6 import { RepositoryDto, ScheduleDto } from '../../../core/api/a
pi-models';
7 +import { FolderPickerComponent } from '../../../shared/folder-p
+icker/folder-picker.component';
8
8 -/** Properties tab: friendly alias, read-only account/container
-, account key, encryption passphrase (rotate), local folder, sc
-hedules, and repository delete. */
9 +/** Properties tab: friendly alias, read-only account/container
+, passphrase (rotate), local folder, schedules, and repository
+delete. The account key belongs to the storage account (edit it
+ from the Overview), not the repository. */
10 @Component({
11 selector: 'arius-properties-tab',
12 standalone: true,
13 changeDetection: ChangeDetectionStrategy.OnPush,
13 - imports: [FormsModule, DatePipe],
14 + imports: [FormsModule, DatePipe, FolderPickerComponent],
15 template: `
16 @if (repo(); as r) {
17
⏺ Update(src/Arius.Web/src/app/features/repo/properties/properties-tab.component
.ts)
⎿ Added 1 line, removed 6 lines
24 Container
25
26
27 - Account key
28 -
29 - Stored encrypted in the Arius.Api SQLite. Repl
-ace to rotate the key.
30 -
31 -
32 - Encryption passphrase
27 + Passphrase
28
29 Encrypts your data in the archive. Replace to
rotate; existing snapshots stay readable with the old passphras
e.
30
⏺ Update(src/Arius.Web/src/app/features/repo/properties/properties-tab.component
.ts)
⎿ Added 3 lines, removed 3 lines
35 @if (passphraseMismatch()) {
Passphrases don't match. }
36
37 }
38 -
38 +
39
Local folder
40 -
40 +
41
Folder the Files view overlays against the arc
hive, and the default source for archive runs.
42 -
42 +
43
44
Dis
card
45
Save changes
⏺ Now remove the accountKey field and its reset() assignment from the
properties-tab class.
⏺ Update(src/Arius.Web/src/app/features/repo/properties/properties-tab.component
.ts)
⎿ Removed 1 line
105
106 protected readonly repo = signal
(null)
;
107 protected alias = '';
108 - protected accountKey = '';
108 protected passphrase = '';
109 protected passphraseConfirm = '';
110 protected localPath = '';
⏺ Update(src/Arius.Web/src/app/features/repo/properties/properties-tab.component
.ts)
⎿ Removed 1 line
155 protected reset(r: RepositoryDto): void {
156 this.alias = r.alias;
157 this.localPath = r.localPath ?? '';
158 - this.accountKey = '';
158 this.passphrase = '';
159 this.passphraseConfirm = '';
160 this.saved.set(false);
⏺ Now the wizards. Let me update the add-repo-wizard (Group 1 #3): region
dropdown, mandatory-red markers, optional alias, folder picker.
⏺ Update(src/Arius.Web/src/app/features/wizards/add/add-repo-wizard.component.ts
)
⎿ Added 2 lines, removed 1 line
6 import { toArray } from 'rxjs/operators';
7 import { ApiService } from '../../../core/api/api.service';
8 import { RealtimeService } from '../../../core/api/realtime.ser
vice';
9 +import { FolderPickerComponent } from '../../../shared/folder-p
+icker/folder-picker.component';
10
11 /** Add-existing repository: 2-step wizard (storage account → c
ontainer + details). */
12 @Component({
13 selector: 'arius-add-wizard',
14 standalone: true,
15 changeDetection: ChangeDetectionStrategy.OnPush,
15 - imports: [FormsModule],
16 + imports: [FormsModule, FolderPickerComponent],
17 template: `
18
19
Step {{ step
() }} of 2 · {{ step() === 1 ? 'Storage account' : 'Reposit
ory' }}
⏺ Update(src/Arius.Web/src/app/features/wizards/add/add-repo-wizard.component.ts
)
⎿ Added 8 lines, removed 2 lines
40
No config
ured accounts — add a new one.
41 }
42 } @else {
43 -
Account name
44 -
Account key
43 +
Account name *
44 +
Account key *
+
45 +
Region
46 +
47 + Unknown / Not in list
48 + @for (r of regions(); track r) { {{ r }} }
49 +
50 +
51 }
52
53
Arius reads
the existing manifest and snapshots — no files are uploaded.
div>
⏺ Now the add-wizard step 2: container-required marker, optional alias with
placeholder, folder picker, and the create-button guard.
⏺ Update(src/Arius.Web/src/app/features/wizards/add/add-repo-wizard.component.ts
)
⎿ Added 4 lines, removed 4 lines
60
61 } @else {
62
Repository
63 -
Select container
63 +
Select container *
64 @for (c of containers(); track c) {
65
66 {{ c }}
...
69 No contai
ners found in this account.
70 }
71
72 -
Friendly alias
72 +
Friendly alias
73
Passphrase
74 -
Local path Folder
- the Files view overlays against the archive.
74 +
Local path Folder the Files view
+ overlays against the archive.
75 @if (error()) {
{{ error() }}
}
76
77
Back
78 - Add repository
78 + Add repository
79
80 }
81
⏺ Now add the .ar-req style and update the add-wizard class (region signal,
regions list, discover/add).
⏺ Update(src/Arius.Web/src/app/features/wizards/add/add-repo-wizard.component.ts
)
⎿ Added 1 line
93 .ar-field > small { display:block;font-size:11.5px;color:#a
1a1aa;margin-top:5px }
94 .ar-input { width:100%;height:40px;border:1px solid #e4e4e7
;border-radius:9px;padding:0 12px;font-size:13.5px;outline:none
}
95 .ar-input:focus { border-color:#3b82f6 }
96 + .ar-req { color:#dc2626 }
97 .ar-note { font-size:12px;color:#71717a;background:#f7f9ff;
border:1px solid #dbeafe;border-radius:9px;padding:10px 12px }
98 `],
99 })
⏺ Update(src/Arius.Web/src/app/features/wizards/add/add-repo-wizard.component.ts
)
⎿ Added 2 lines
103 private readonly router = inject(Router);
104
105 protected readonly accounts = toSignal(this.api.listAccounts
(), { initialValue: [] });
106 + protected readonly regions = toSignal(this.api.getRegions(),
+ { initialValue: [] as string[] });
107 protected readonly step = signal<1 | 2>(1);
108 protected readonly mode = signal<'select' | 'new'>('select')
;
109 protected readonly selectedAccountId = signal(0);
110 protected newName = '';
111 protected newKey = '';
112 + protected readonly newRegion = signal('');
113 protected readonly containers = signal
([]);
114 protected readonly selectedContainer = signal('');
115 protected alias = '';
⏺ Update(src/Arius.Web/src/app/features/wizards/add/add-repo-wizard.component.ts
)
⎿ Added 1 line, removed 1 line
124 try {
125 let accountId = this.selectedAccountId();
126 if (this.mode() === 'new') {
127 - const created = await firstValueFrom(this.api.createAc
-count(this.newName, this.newKey));
127 + const created = await firstValueFrom(this.api.createAc
+count(this.newName, this.newKey, this.newRegion() || null));
128 accountId = created.id;
129 this.selectedAccountId.set(accountId);
130 }
⏺ Update(src/Arius.Web/src/app/features/wizards/add/add-repo-wizard.component.ts
)
⎿ Added 2 lines, removed 1 line
144 this.api.createRepository({
145 accountId: this.selectedAccountId(),
146 container: this.selectedContainer(),
147 - alias: this.alias,
147 + // Friendly alias is optional — fall back to the contain
+er name when left blank.
148 + alias: this.alias || this.selectedContainer(),
149 passphrase: this.passphrase || null,
150 localPath: this.localPath || null,
151 defaultTier: null,
⏺ Now the create-repo-wizard: same treatment (region dropdown, mandatory red,
folder picker, region in createAccount).
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts)
⎿ Added 2 lines, removed 1 line
5 import { Router } from '@angular/router';
6 import { firstValueFrom } from 'rxjs';
7 import { ApiService } from '../../../core/api/api.service';
8 +import { FolderPickerComponent } from '../../../shared/folder-p
+icker/folder-picker.component';
9
10 /** New repository: 2-step wizard (storage account → new contai
ner with tier + passphrase). */
11 @Component({
12 selector: 'arius-create-wizard',
13 standalone: true,
14 changeDetection: ChangeDetectionStrategy.OnPush,
14 - imports: [FormsModule, TitleCasePipe],
15 + imports: [FormsModule, TitleCasePipe, FolderPickerComponent],
16 template: `
17
18
Step {{ step
() }} of 2 · {{ step() === 1 ? 'Storage account' : 'New con
tainer' }}
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts)
⎿ Added 8 lines, removed 2 lines
35
36 } @empty {
No configured accounts — add a new one.
}
37 } @else {
38 -
Account name
39 -
Account key
38 +
Account name *
39 +
Account key *
+
40 +
Region
41 +
42 + Unknown / Not in list
43 + @for (r of regions(); track r) { {{ r }} }
44 +
45 +
46 }
47 @if (error()) {
{{ error() }}
}
48
⏺ Now the create-wizard step 2: mandatory-red markers and the folder picker for
local path.
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts)
⎿ Added 4 lines, removed 4 lines
51
52 } @else {
53
New container
54 -
Friendly alias
54 +
Friendly alias *
55
Container name (auto-gene
rated)
56 -
Local path
56 +
57
Default tier
58
59 @for (t of tiers; track t) { {{ t | titlecase }}
}
60
61
62 -
Passphrase
63 -
Confirm passphrase
-
62 +
Passphrase *
63 + Confirm passphrase *
64
65 The passph
rase encrypts every chunk and cannot be recovered if los
t.
66
⏺ Now the create-wizard styles + class (region signal, regions list, region in
createAccount).
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts) ⎿ Added 1 line
85 .ar-input { width:100%;height:40px;border:1px solid #e4e4e7
;border-radius:9px;padding:0 12px;font-size:13.5px;outline:none
}
86 .ar-input:focus { border-color:#3b82f6 }
87 .ar-input[readonly] { background:#f7f7f8;color:#71717a }
88 + .ar-req { color:#dc2626 }
89 `],
90 })
91 export class CreateRepoWizardComponent {
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts)
⎿ Added 2 lines
93 private readonly router = inject(Router);
94
95 protected readonly accounts = toSignal(this.api.listAccounts
(), { initialValue: [] });
96 + protected readonly regions = toSignal(this.api.getRegions(),
+ { initialValue: [] as string[] });
97 protected readonly step = signal<1 | 2>(1);
98 protected readonly mode = signal<'select' | 'new'>('select')
;
99 protected readonly selectedAccountId = signal(0);
100 protected newName = '';
101 protected newKey = '';
102 + protected readonly newRegion = signal('');
103 protected alias = '';
104 protected readonly container = signal('');
105 protected localPath = '';
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts)
⎿ Added 1 line, removed 1 line
125 this.error.set(null);
126 try {
127 if (this.mode() === 'new') {
128 - const created = await firstValueFrom(this.api.createAc
-count(this.newName, this.newKey));
128 + const created = await firstValueFrom(this.api.createAc
+count(this.newName, this.newKey, this.newRegion() || null));
129 this.selectedAccountId.set(created.id);
130 }
131 if (!this.selectedAccountId()) { this.error.set('Select
or create an account.'); return; }
⏺ All Group 1 frontend edits are done. Let me build the Angular app to catch
type/template errors. First check for node_modules.
Ran 2 shell commands (ctrl+o to expand)
⏺ Dependencies installed (working dir persisted). Now build the Angular app.
Ran 1 shell command (ctrl+o to expand)
⏺ Frontend builds clean — Task #5 done. Now Task #7: check the e2e specs for
references to changed markup/flows so I don't break them.
Ran 1 shell command (ctrl+o to expand)
⏺ The key selectors (tier-breakdown, tier-row, prop-passphrase, create-alias,
btn-create) are preserved. Let me read the affected spec sections to confirm
assertions still hold (especially row content and the tier breakdown).
Read 2 files (ctrl+o to expand)
⏺ Both statistics specs assert each row contains the word "chunks", which the
redesign moved to the column header. I need to update those assertions (the
plan anticipated this). Let me read the create/add/properties specs too before
editing.
Read 2 files (ctrl+o to expand)
⏺ Good — create-wizard.spec (uses select mode) and properties.spec (uses
testids, never touched account-key) remain valid. Only the two statistics
specs assert the per-row word "chunks", which the redesign moved to the
header. Let me update those.
⏺ Update(src/Arius.Web/e2e/specs/statistics.spec.ts)
⎿ Added 5 lines, removed 1 line
85 await expect(rows.first()).toBeVisible();
86 expect(await rows.count()).toBeGreaterThan(0);
87 await expect(rows.first()).toContainText(/Hot|Cool|Cold|Archi
ve/);
88 - await expect(rows.first()).toContainText(/chunks/);
88 +
89 + // The redesigned breakdown carries an estimated monthly cost
+ per tier plus a grand total.
90 + await expect(breakdown).toContainText('Est. cost/mo');
91 + await expect(rows.first().getByTestId('tier-cost')).toBeVisib
+le();
92 + await expect(breakdown.getByTestId('total-cost')).toBeVisible
+();
93 });
⏺ Update(src/Arius.Web/e2e/specs/statistics-tiers.spec.ts)
⎿ Added 1 line, removed 1 line
71 await expect(rows).toHaveCount(storedByTier.length);
72 for (let i = 0; i < storedByTier.length; i++) {
73 await expect(rows.nth(i)).toContainText(storedByTier[i].t
ier);
74 - await expect(rows.nth(i)).toContainText('chunks');
74 + await expect(rows.nth(i).getByTestId('tier-cost')).toBeVi
+sible();
75 }
76 } finally {
77 await request.delete(`/api/repos/${created.id}`);
⏺ Now let me do a comprehensive backend build (all projects) to ensure nothing
else broke. Let me locate the solution.
Ran 2 shell commands (ctrl+o to expand)
⏺ Full solution builds with 0 errors. Let me run the Architecture tests (they
enforce structural/namespace/dependency rules that my new files could
violate).
Ran 1 shell command (ctrl+o to expand)
⏺ An architecture rule fired: internal types must stay within their namespace
subtree. My internal RegionPricing (in Shared.Pricing) is used by
RestoreCostCalculator (in Features.RestoreCommand). Let me read the rule to
choose the right fix.
Searched for 2 patterns (ctrl+o to expand)
⏺ There's a purpose-built [SharedWithinAssembly] attribute for exactly this
(internal types shared across namespaces). Let me find it and how existing
code applies it.
Ran 1 shell command (ctrl+o to expand)
⏺ The [SharedWithinAssembly] attribute (resolved via the enclosing Arius.Core
namespace) is the intended fix. Let me mark the three internal pricing
records.
⏺ Update(src/Arius.Core/Shared/Pricing/PricingCatalog.cs)
⎿ Added 1 line
72 }
73
74 /// Per-region Azure Blob Storage rates loaded from pricing.json .
75 +[SharedWithinAssembly] // consumed by RestoreCostCalculator (Fe
+atures.RestoreCommand) + StorageCostCalculator
76 internal sealed record RegionPricing
77 {
78 /// ISO currency code the rates are expressed in (
e.g. EUR ).
⏺ Update(src/Arius.Core/Shared/Pricing/PricingCatalog.cs)
⎿ Added 1 line
103 }
104
105 /// Pricing rates for the archive tier (retrieval + r
ead operations, plus storage for the cost view).
106 +[SharedWithinAssembly] // accessed by RestoreCostCalculator (F
+eatures.RestoreCommand)
107 internal sealed record ArchivePricingTier
108 {
109 /// Currency per GB of data retrieved from Archiv
e at Standard priority.
⏺ Update(src/Arius.Core/Shared/Pricing/PricingCatalog.cs)
⎿ Added 1 line
128 }
129
130 /// Pricing rates for writing and storing in a (non-a
rchive) tier.
131 +[SharedWithinAssembly] // accessed by RestoreCostCalculator (F
+eatures.RestoreCommand)
132 internal sealed record TierPricingConfig
133 {
134 /// Currency per 10,000 write operations.
Ran 1 shell command (ctrl+o to expand)
⏺ All 11 architecture tests pass. Let me do a final review of the full change
scope and confirm the backend test suite is green end-to-end.
Ran 1 shell command (ctrl+o to expand)
⏺ Everything is green. Let me mark verification complete.
⏺ Implementation complete. Here's what was built and verified.
Summary
Group 1 — Storage Accounts, Repos, Properties
- Region-keyed pricing as single source of truth — moved pricing.json to
src/Arius.Core/Shared/Pricing/ (region-keyed, per-region currency + archive
storage rate), new PricingCatalog (RegionNames, region resolution with default
fallback). RestoreCostCalculator refactored onto it (behavior preserved).
- Account location end-to-end — storage_accounts.location column + additive
EnsureColumn migration; AccountRecord/AccountDto/CreateAccountRequest
extended; new UpdateAccount/DeleteAccount/ListRepositoryIdsForAccount;
GET/PATCH/DELETE /accounts/{id} (key rotation evicts providers, region change
clears stats cache); GET /pricing/regions.
- Server-side folder picker — GET /fs/list (browses the API host/container) +
a shared FolderPickerComponent ([...] button) wired into both wizards'
local-path and the properties tab.
- Overview "Storage Accounts" section listing accounts (name / region / repo
count); clicking a row opens a new edit-account flyout (account-drawer,
mirrors the properties drawer) to rotate key + set region, with a guarded
delete.
- Wizards — region (sourced from /pricing/regions + "Unknown / Not in
list") in the "Add new account" step of both wizards; mandatory fields marked
with red *; friendly alias optional in the add wizard (defaults to the
container name).
- Properties page — removed the vestigial "Account key" field; renamed
"Encryption passphrase" → "Passphrase".
Group 2 — Cost Calculator
- StatisticsQuery switched to IQuery and extended with a Region param; the
handler calls the new StorageCostCalculator (region + per-tier size → per-tier
cost + grand total + currency, GiB-consistent with RestoreCostCalculator).
All math is in Core; the API threads the account's region in and the DTO
carries the finished numbers.
- New shared CostCalculatorComponent (hero stacked bar + detail table with
Est. cost/mo + grand-total footer per the design handoff) replaces the inline
"Stored size by tier" section. The frontend does no arithmetic.
Verification
- Backend: full solution builds (0 errors). Tests green — Core 622, Api 9, Cli
150, Architecture 11 (the last required marking the cross-namespace internal
pricing records [SharedWithinAssembly]).
- Frontend: production AOT build succeeds (all templates type-check).
- e2e: updated statistics/statistics-tiers specs for the redesigned markup
(the per-row "chunks" word moved to the column header); added data-testids
(account-row, account-drawer, account-region, tier-cost, total-cost,
folder-browse, …) for future specs.
Note: the Playwright e2e and Arius.Integration/E2E suites need a live full
stack + real Azure storage, so they weren't run here — but they compile as
part of the solution build.
One thing worth flagging: stale rows in a pre-existing dev .appstate stats
cache will show €0 cost until the next archive/region change recomputes them
(they predate the cost fields); fresh DBs and any region edit are unaffected.
✻ Cooked for 48m 7s
❯ remove the 'add account' button in the storage account overview. switch the
storage account and repositories: storage accounts should be higher. move the
'add existing' and '+ new repository' inside the Repositories window. in the
repos/create the container name should be required user input. friendly name
is optional (default to container name). first ask th econtainer name
⏺ Clear set of refinements. Let me read the current overview component to make
precise edits.
Read 1 file (ctrl+o to expand)
⏺ Now let me restructure the overview: trim the page header, swap the section
order (Storage Accounts first), drop the "Add account" button, and move the
repo buttons into the Repositories header.
⏺ Update(src/Arius.Web/src/app/features/overview/overview.component.ts)
⎿ Removed 2 lines
21
22
23 Refresh
24 - Add existing
25 - New repository
24
25
26
⏺ Update(src/Arius.Web/src/app/features/overview/overview.component.ts)
⎿ Added 32 lines, removed 29 lines
40 }
41
42
43 -
43 +
44
45
46
47 -
Repositories
48 -
Blob cont
-ainers under management
47 +
Storage accounts
48 +
Azure sto
+rage accounts under management
49
50
51
52
53 -
Repository
Tier
Account
53 +
Account
Region
Repositories
54
55
56 - @if (repos(); as list) {
57 - @for (repo of list; track repo.id) {
58 -
60
61 -
62 -
61 +
62 +
63
64
65 -
{{ repo.alias }}
66 -
{{ repo.container }}
65 +
{{ account.name }}
66 +
{{ ac
+count.hasKey ? 'Key configured' : 'No key' }}
67
68
69 -
{{ repo.defaultTier }}
70 -
{{ repo.account }}
69 +
{{ account.location ?? 'Unknown' }}
70 +
{{ acco
+unt.repositories }}
71
72 } @empty {
73
74 - No repositories yet — add an existing one or create
- a new repository.
74 + No storage accounts yet — add one when creating a r
+epository.
75
76 }
77 } @else {
...
79 }
80
81
82 -
82 +
83
84
85
86 -
Storage accounts
87 -
Azure st
-orage accounts under management
86 +
Repositories
87 +
Blob con
+tainers under management
88
89 -
- Add account
89 +
90 +
+ Add existing
91 + New repository
92 +
93
94
95
93 -
Account
Region
Repositories
-div>
96 +
Repository
Tier
Account
97
98
96 - @if (accounts(); as list) {
97 - @for (account of list; track account.id) {
98 -
103
101 -
102 -
104 +
105 +
106
107
105 -
{{ account.name }}
106 -
{{ a
-ccount.hasKey ? 'Key configured' : 'No key' }}
108 +
{{ repo.alias }}
109 +
{{ repo.container }}
110
111
109 -
{{ account.location ?? 'Unknown' }}
110 -
{{ acc
-ount.repositories }}
112 +
{{ repo.defaultTier }}
113 +
{{ repo.account }}
114
115 } @empty {
116
114 - No storage accounts yet — add one to create a repo
-sitory.
117 + No repositories yet — add an existing one or creat
+e a new repository.
118
119 }
120 } @else {
⏺ Now the create-wizard: container name first as required input, alias optional
defaulting to it. Let me read the current create-wizard.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts)
⎿ Added 2 lines, removed 2 lines
51
52 } @else {
53
New container
54 -
Friendly alias *
55 -
Container name (auto-gene
-rated)
54 +
Container name *
+label>
55 + Friendly alias
56
57 Default tier
58
⏺ Now update the create-wizard class logic — read it to edit canCreate,
syncContainer, next, create.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Web/src/app/features/wizards/create/create-repo-wizard.compon
ent.ts)
⎿ Added 4 lines, removed 10 lines
110 protected readonly tiers = ['hot', 'cool', 'cold', 'archive'
];
111
112 // A method (not a computed) because alias/passphrase are pl
ain ngModel properties, not signals —
113 - // OnPush re-evaluates this on each ngModel change.
113 + // OnPush re-evaluates this on each ngModel change. Containe
+r is required; the friendly alias is optional.
114 protected canCreate(): boolean {
115 - return !!this.alias && !!this.container() && !!this.passph
-rase && this.passphrase === this.passphrase2;
115 + return !!this.container() && !!this.passphrase && this.pas
+sphrase === this.passphrase2;
116 }
117
118 - protected syncContainer(): void {
119 - const slug = (this.alias || 'repo').toLowerCase().replace(
-/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'rep
-o';
120 - const suffix = Math.floor(Math.random() * 0xffff).toString
-(16).padStart(4, '0');
121 - this.container.set(`arius-${slug}-${suffix}`);
122 - }
123 -
118 protected async next(): Promise
{
119 this.error.set(null);
120 try {
...
123 this.selectedAccountId.set(created.id);
124 }
125 if (!this.selectedAccountId()) { this.error.set('Select
or create an account.'); return; }
132 - if (!this.container()) this.syncContainer();
126 this.step.set(2);
127 } catch (e: unknown) {
128 this.error.set(e instanceof Error ? e.message : String(e
));
...
134 this.api.createRepository({
135 accountId: this.selectedAccountId(),
136 container: this.container(),
144 - alias: this.alias,
137 + // Friendly alias is optional — fall back to the contain
+er name when left blank.
138 + alias: this.alias || this.container(),
139 passphrase: this.passphrase || null,
140 localPath: this.localPath || null,
141 defaultTier: this.tier(),
⏺ Now update the create-wizard.spec.ts e2e test for the new flow (container is
required input, not auto-generated from alias).
⏺ Update(src/Arius.Web/e2e/specs/create-wizard.spec.ts)
⎿ Added 5 lines, removed 5 lines
1 import { test, expect } from '../support/fixtures';
2
3 -test('create wizard: account step → new-container form auto-fil
-ls + gates on passphrase match', async ({ page }) => {
3 +test('create wizard: container step requires a container name +
+ gates on passphrase match', async ({ page }) => {
4 await page.goto('/repos/create');
5
6 await page.getByTestId('account-radio').first().click();
7 await page.getByTestId('btn-continue').click();
8
9 - // step 2 — alias auto-generates a mono container name
10 - await page.getByTestId('create-alias').fill('My New Repo');
11 - await expect(page.getByTestId('create-container')).toHaveValu
-e(/^arius-my-new-repo-[0-9a-f]{4}$/);
9 + // step 2 — the container name is required user input; the fr
+iendly alias is optional.
10 + await expect(page.getByTestId('btn-create')).toBeDisabled();
11 + await page.getByTestId('create-container').fill('arius-my-new
+-repo');
12
13 - // Create is disabled until passphrase == confirm
13 + // Still disabled until passphrase == confirm
14 await expect(page.getByTestId('btn-create')).toBeDisabled();
15 await page.getByTestId('passphrase').fill('correct horse');
16 await page.getByTestId('passphrase-confirm').fill('mismatch')
;
⏺ Let me check the e2e specs for anything that relied on the moved/removed
buttons (overview header navigation, "Add account", etc.).
Ran 1 shell command (ctrl+o to expand)
⏺ The specs navigate directly to /repos/create and /repos/add (not via the
overview buttons), so the move is safe. Let me quickly check overview.spec.ts
for any header/section assertions.
Read 1 file (ctrl+o to expand)
⏺ overview.spec only checks KPI cards + a repo row + navigation — unaffected.
Let me rebuild the frontend to confirm everything compiles.
Ran 1 shell command (ctrl+o to expand)
⏺ All refinements are done and the frontend builds clean. No backend changes
were needed (all frontend), so the earlier green test runs still hold.
Changes
Overview (overview.component.ts)
- Removed the "Add account" button from the Storage Accounts section.
- Storage Accounts now sits above Repositories (sections swapped).
- Moved "Add existing" and "+ New repository" out of the page header and into
the Repositories section header (the page header now keeps just "Refresh").
Create-repo wizard (create-repo-wizard.component.ts)
- Container name is now a required, user-typed field, asked first (red *, mono
input, e.g. arius-photos placeholder).
- Friendly alias is optional, shown below the container, with placeholder
"defaults to the container name" — and on submit it falls back to the
container name when blank.
- Removed the old auto-generation (syncContainer); canCreate() now gates on
container + matching passphrase (no longer on alias).
- Updated create-wizard.spec.ts to the new flow (fills the container name;
alias optional).
The other e2e specs are unaffected — they navigate to /repos/create and
/repos/add directly rather than through the overview buttons, and
overview.spec only checks the KPI cards + a repo row.
✻ Churned for 2m 57s
※ recap: Building Arius7 storage-accounts management and a region-aware cost calculator; backend and frontend are implemented, tested, and building clean, including your latest overview and create-wizard tweaks. Next: optionally run the live app or Playwright e2e to verify end-to-end. (disable recaps in /config)
❯ commit
⏺ I'm already on the pricing-view branch (not master), so I'll commit directly
here. Let me stage everything and review what's staged before committing.
Ran 1 shell command (ctrl+o to expand)
⏺ No node_modules/dist staged. Let me commit.
Ran 1 shell command (ctrl+o to expand)
⏺ Committed cleanly to the pricing-view branch — working tree is now clean.
fb272bce feat: storage-account management + region-aware cost calculator
The commit bundles both groups (storage-account management + region-aware cost
calculator) across 37 files (+1087/−232), with all backend test suites and
the production builds green. Nothing was pushed — let me know if you'd like me
to open a PR or push.
✻ Cogitated for 30s
❯ actually, i think our cost calculations for the monthly storage and the
restore are wrong; both the formula and the numbers. double check from
https://azure.microsoft.com/en-us/pricing/details/storage/blobs/ and query
https://learn.microsoft.com/en-us/rest/api/cost-management/retail-prices/azur
e-retail-prices to get the necessary numbers. eg. to retrieve data from
cool/cold we are not storing that value in the pricing json ULTRATHINK query
the correct numbers for northeurope, westeurope and belgiumcentral
⏺ The user is right to scrutinize this — the cost model is incomplete (cool/cold
have data-retrieval-per-GB fees we never store) and the numbers are
approximate. Let me get authoritative numbers straight from the Azure Retail
Prices API. First, connectivity + the data shape.
Ran 1 shell command (ctrl+o to expand)
⏺ Connectivity works (EUR supported). Note the first hit is "Hierarchical
Namespace" (ADLS Gen2) — Arius uses flat block blobs, so I must pick the right
product. Let me discover the distinct product/SKU names.
Ran 1 shell command (ctrl+o to expand)
⏺ The right product is "General Block Blob v2" (flat GPv2 block blobs) with LRS
redundancy. Let me list every meter for Hot/Cool/Cold/Archive LRS.
Ran 1 shell command (ctrl+o to expand)
⏺ Now I can see the real meters — and confirm the gaps: Cool/Cold "Data
Retrieval" (0.0086 / 0.0258 €/GB) and per-tier Read Operations are entirely
missing from our json, and several stored numbers are off (e.g. archive
storage real 0.001548, not 0.00086; archive read ops 5.16/55.9, not
6.61/71.6). Let me extract the exact numbers for all three regions into a
clean structure.
Ran 1 shell command (ctrl+o to expand)
⏺ belgiumcentral returned no Archive meters under GPv2 — newer regions sometimes
lack the Archive tier. Let me confirm whether belgiumcentral offers Archive
at all (any product/SKU).
Ran 1 shell command (ctrl+o to expand)
⏺ Confirmed: belgiumcentral has no Archive tier for flat block blobs (only
Hot/Cool/Cold, LRS+ZRS) — so its pricing must omit archive. Now I need two
things before redesigning: (1) the authoritative Azure billing GB definition
(10⁹ vs 2³⁰) and the per-tier charge model, and (2) how the restore handler
classifies chunks (does it know per-tier bytes?). Let me get both in parallel.
Read 1 file (ctrl+o to expand)
⏺ Agent(Verify Azure blob pricing model)
⎿ Done (11 tool uses · 56.3k tokens · 3m 38s)
(ctrl+o to expand)
⏺ Authoritative facts confirmed — critically: Azure bills "GB" as binary GiB
(2³⁰), so our 1024³ divisor is already correct. The real defects are: (1)
wrong/missing numbers, (2) the restore model only prices archive rehydration
and ignores cool/cold data-retrieval + per-tier read ops, and (3) the cost is
only shown when archive rehydration is needed — so cool/cold restore costs are
invisible.
The restore handler classifies by hydration status, not tier. Let me read the
rest of the restore flow (approval gate, forwarders, frontend cost modal) to
design the fix without destabilizing the approval handshake.
Read 1 file, ran 1 shell command (ctrl+o to expand)
⏺ The restore flow is clear now: ShardEntry.StorageTierHint (a BlobTier) is
available per chunk in the classify loop, so I can break downloadBytes down by
tier. The cost estimate is computed at line 201 and flows through
opts.ConfirmRehydration. Let me read the approval/UX consumer side (JobRunner,
RestoreOptions, the SignalR cost message, and the frontend cost modal) to fix
the model without breaking the handshake.
Read 2 files, ran 1 shell command (ctrl+o to expand)
⏺ I now have the full restore flow. Authoritative model confirmed;
StorageTierHint lets me split downloads by tier. Let me read ClassifyChunk and
the restore test files to gauge the rewrite, then implement.
Read 2 files, ran 1 shell command (ctrl+o to expand)
⏺ The minimal-churn path is clear: keep RestoreCostEstimate's existing fields +
Compute's existing params, add optional per-tier download params (default 0)
and new download-cost fields, and unify the tier-rate record. Existing archive
tests stay valid; I add new ones for cool/cold retrieval. Let me read the two
cost-asserting test sections before implementing.
Read 2 files (ctrl+o to expand)
⏺ The test surface confirms the minimal-churn path is safe: integration/handler
tests assert structure (>0, TotalHigh > TotalStandard), not specific values —
so new numbers won't break them. Only
RestoreCostCalculatorTests/PricingConfigTests need the rate-record literal
updated. Let me implement.
Let me set up tasks and start with the corrected, complete pricing.json.
⏺ Write(src/Arius.Core/Shared/Pricing/pricing.json)
⎿ Error writing file
⏺ The file changed since I last read it (the user's earlier edit). Let me
re-read then overwrite.
Read 1 file (ctrl+o to expand)
⏺ Write(src/Arius.Core/Shared/Pricing/pricing.json)
⎿ Added 67 lines, removed 13 lines
1 {
2 - "_comment": "Azure Blob Storage pricing, keyed by programmati
-c region name. Source: https://azure.microsoft.com/en-us/pricin
-g/details/storage/blobs/ — add a region here to make it selecta
-ble in the UI and priced.",
2 + "_comment": "Azure Blob Storage retail pricing — EUR, Standar
+d general-purpose v2 block blobs, LRS redundancy, base volume t
+ier (0-50 TB). Keyed by programmatic region name. Source: Azure
+ Retail Prices API (prices.azure.com), product 'General Block B
+lob v2'. Last updated 2026-06. Add a region here to make it sel
+ectable and priced. Azure bills per-GB values as binary GiB (2^
+30 bytes). Hot has no data-retrieval charge; cool/cold/archive
+do.",
3 "regions": {
4 "westeurope": {
5 "currency": "EUR",
6 - "_comment": "West Europe. Last updated: 2026-03.",
6 "hot": {
8 - "writeOpsPer10000": 0.0560,
9 - "storagePerGBPerMonth": 0.0190
7 + "storagePerGBPerMonth": 0.016858,
8 + "writeOpsPer10000": 0.046446,
9 + "readOpsPer10000": 0.003698
10 },
11 "cool": {
12 - "writeOpsPer10000": 0.1119,
13 - "storagePerGBPerMonth": 0.00861
12 + "storagePerGBPerMonth": 0.008601,
13 + "writeOpsPer10000": 0.086010,
14 + "readOpsPer10000": 0.008601,
15 + "dataRetrievalPerGB": 0.008601
16 },
17 "cold": {
16 - "writeOpsPer10000": 0.2013,
17 - "storagePerGBPerMonth": 0.00310
18 + "storagePerGBPerMonth": 0.003870,
19 + "writeOpsPer10000": 0.154819,
20 + "readOpsPer10000": 0.086010,
21 + "dataRetrievalPerGB": 0.025803
22 },
23 "archive": {
20 - "retrievalPerGB": 0.0204,
21 - "retrievalHighPerGB": 0.1102,
22 - "readOpsPer10000": 6.6094,
23 - "readOpsHighPer10000": 71.6011,
24 - "storagePerGBPerMonth": 0.00086
24 + "storagePerGBPerMonth": 0.001548,
25 + "writeOpsPer10000": 0.103212,
26 + "readOpsPer10000": 5.160624,
27 + "readOpsHighPer10000": 55.906765,
28 + "dataRetrievalPerGB": 0.020642,
29 + "dataRetrievalHighPerGB": 0.111814
30 }
31 + },
32 + "northeurope": {
33 + "currency": "EUR",
34 + "hot": {
35 + "storagePerGBPerMonth": 0.018922,
36 + "writeOpsPer10000": 0.043005,
37 + "readOpsPer10000": 0.003440
38 + },
39 + "cool": {
40 + "storagePerGBPerMonth": 0.008601,
41 + "writeOpsPer10000": 0.086010,
42 + "readOpsPer10000": 0.008601,
43 + "dataRetrievalPerGB": 0.008601
44 + },
45 + "cold": {
46 + "storagePerGBPerMonth": 0.003096,
47 + "writeOpsPer10000": 0.154819,
48 + "readOpsPer10000": 0.086010,
49 + "dataRetrievalPerGB": 0.025803
50 + },
51 + "archive": {
52 + "storagePerGBPerMonth": 0.000852,
53 + "writeOpsPer10000": 0.094611,
54 + "readOpsPer10000": 4.730572,
55 + "readOpsHighPer10000": 51.606244,
56 + "dataRetrievalPerGB": 0.018922,
57 + "dataRetrievalHighPerGB": 0.103212
58 + }
59 + },
60 + "belgiumcentral": {
61 + "currency": "EUR",
62 + "_comment": "Belgium Central does not offer the Archive a
+ccess tier for standard block blobs.",
63 + "hot": {
64 + "storagePerGBPerMonth": 0.016858,
65 + "writeOpsPer10000": 0.046446,
66 + "readOpsPer10000": 0.003698
67 + },
68 + "cool": {
69 + "storagePerGBPerMonth": 0.008601,
70 + "writeOpsPer10000": 0.086010,
71 + "readOpsPer10000": 0.008601,
72 + "dataRetrievalPerGB": 0.008601
73 + },
74 + "cold": {
75 + "storagePerGBPerMonth": 0.003870,
76 + "writeOpsPer10000": 0.154819,
77 + "readOpsPer10000": 0.086010,
78 + "dataRetrievalPerGB": 0.025803
79 + }
80 }
81 }
82 }
❯ you can churn/rename for clarity
⏺ Good — clarity-driven renames are fine. Let me rewrite PricingCatalog.cs with
a clean unified TierRates model + accessors. Let me re-read it first.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/Shared/Pricing/PricingCatalog.cs)
⎿ Added 49 lines, removed 45 lines
71 }
72 }
73
74 -/// Per-region Azure Blob Storage rates loaded from pricing.json .
74 +///
75 +/// Per-region Azure Blob Storage rates loaded from pricing.
+json . A tier is null when the
76 +/// region does not offer it (e.g. Belgium Central has no Archi
+ve tier for standard block blobs).
77 +///
78 [SharedWithinAssembly] // consumed by RestoreCostCalculator (Fe
atures.RestoreCommand) + StorageCostCalculator
79 internal sealed record RegionPricing
80 {
...
82 [JsonPropertyName("currency")]
83 public string Currency { get; init; } = "EUR";
84
82 - [JsonPropertyName("archive")]
83 - public ArchivePricingTier Archive { get; init; } = new();
85 + [JsonPropertyName("hot")] public TierRates? Hot {
+get; init; }
86 + [JsonPropertyName("cool")] public TierRates? Cool {
+get; init; }
87 + [JsonPropertyName("cold")] public TierRates? Cold {
+get; init; }
88 + [JsonPropertyName("archive")] public TierRates? Archive {
+get; init; }
89
85 - [JsonPropertyName("hot")]
86 - public TierPricingConfig Hot { get; init; } = new();
90 + /// The rate set for a tier, or null if th
+e region doesn't offer it.
91 + public TierRates? For(BlobTier tier) => tier switch
92 + {
93 + BlobTier.Hot => Hot,
94 + BlobTier.Cool => Cool,
95 + BlobTier.Cold => Cold,
96 + BlobTier.Archive => Archive,
97 + _ => null,
98 + };
99
88 - [JsonPropertyName("cool")]
89 - public TierPricingConfig Cool { get; init; } = new();
100 + /// Storage rate (currency per GiB per month) for
+ a tier; 0 when the tier is unavailable.
101 + public double StorageRateFor(BlobTier tier) => For(tier)?.
+StoragePerGbMonth ?? 0.0;
102
91 - [JsonPropertyName("cold")]
92 - public TierPricingConfig Cold { get; init; } = new();
103 + /// Write-operations rate (currency per 10,000) f
+or a tier; 0 when unavailable.
104 + public double WriteOpsRateFor(BlobTier tier) => For(tier)?
+.WriteOpsPer10k ?? 0.0;
105
94 - /// Storage rate (currency per GiB per month) for
- a given blob tier; 0 for unknown tiers.
95 - public double StorageRateFor(BlobTier tier) => tier switch
96 - {
97 - BlobTier.Hot => Hot.StoragePerGBPerMonth,
98 - BlobTier.Cool => Cool.StoragePerGBPerMonth,
99 - BlobTier.Cold => Cold.StoragePerGBPerMonth,
100 - BlobTier.Archive => Archive.StoragePerGBPerMonth,
101 - _ => 0.0,
102 - };
106 + /// Read-operations rate (currency per 10,000) fo
+r a tier; the high-priority rate applies only to Archive. 0 wh
+en unavailable.
107 + public double ReadOpsRateFor(BlobTier tier, bool highPrior
+ity = false)
108 + => For(tier) is { } r ? (highPriority ? r.ReadOpsHighP
+er10k : r.ReadOpsPer10k) : 0.0;
109 +
110 + /// Data-retrieval rate (currency per GiB) charge
+d when reading from a tier; 0 for Hot and unavailable tiers. H
+igh priority applies only to Archive.
111 + public double DataRetrievalRateFor(BlobTier tier, bool hig
+hPriority = false)
112 + => For(tier) is { } r ? (highPriority ? r.DataRetrieva
+lHighPerGb : r.DataRetrievalPerGb) : 0.0;
113 }
114
105 -/// Pricing rates for the archive tier (retrieval + r
-ead operations, plus storage for the cost view).
115 +///
116 +/// Rates for one access tier. Operation rates are per 10,000
+operations; per-GB values are per binary GiB.
117 +/// is 0 for Hot; the *Hig
+h* fields are populated only for Archive.
118 +///
119 [SharedWithinAssembly] // accessed by RestoreCostCalculator (F
eatures.RestoreCommand)
107 -internal sealed record ArchivePricingTier
120 +internal sealed record TierRates
121 {
109 - /// Currency per GB of data retrieved from Archiv
-e at Standard priority.
110 - [JsonPropertyName("retrievalPerGB")]
111 - public double RetrievalPerGB { get; init; }
122 + /// Currency per GiB stored per month.
123 + [JsonPropertyName("storagePerGBPerMonth")]
124 + public double StoragePerGbMonth { get; init; }
125
113 - /// Currency per GB of data retrieved from Archiv
-e at High priority.
114 - [JsonPropertyName("retrievalHighPerGB")]
115 - public double RetrievalHighPerGB { get; init; }
126 + /// Currency per 10,000 write operations.
127 + [JsonPropertyName("writeOpsPer10000")]
128 + public double WriteOpsPer10k { get; init; }
129
117 - /// Currency per 10,000 read operations at Standa
-rd priority.
130 + /// Currency per 10,000 read operations (Standard
+ priority for Archive).
131 [JsonPropertyName("readOpsPer10000")]
119 - public double ReadOpsPer10000 { get; init; }
132 + public double ReadOpsPer10k { get; init; }
133
121 - /// Currency per 10,000 read operations at High p
-riority.
134 + /// Currency per 10,000 read operations at High p
+riority (Archive only).
135 [JsonPropertyName("readOpsHighPer10000")]
123 - public double ReadOpsHighPer10000 { get; init; }
136 + public double ReadOpsHighPer10k { get; init; }
137
125 - /// Currency per GB stored per month in the Archi
-ve tier.
126 - [JsonPropertyName("storagePerGBPerMonth")]
127 - public double StoragePerGBPerMonth { get; init; }
128 -}
138 + /// Currency per GiB of data retrieved when readi
+ng (Cool/Cold/Archive; Standard priority for Archive). 0 for H
+ot.
139 + [JsonPropertyName("dataRetrievalPerGB")]
140 + public double DataRetrievalPerGb { get; init; }
141
130 -/// Pricing rates for writing and storing in a (non-a
-rchive) tier.
131 -[SharedWithinAssembly] // accessed by RestoreCostCalculator (F
-eatures.RestoreCommand)
132 -internal sealed record TierPricingConfig
133 -{
134 - /// Currency per 10,000 write operations.
135 - [JsonPropertyName("writeOpsPer10000")]
136 - public double WriteOpsPer10000 { get; init; }
137 -
138 - /// Currency per GB stored per month.
139 - [JsonPropertyName("storagePerGBPerMonth")]
140 - public double StoragePerGBPerMonth { get; init; }
142 + /// Currency per GiB of data retrieved at High pr
+iority (Archive only).
143 + [JsonPropertyName("dataRetrievalHighPerGB")]
144 + public double DataRetrievalHighPerGb { get; init; }
145 }
⏺ StorageCostCalculator already uses StorageRateFor (kept), so the
monthly-storage path now just gets correct numbers. Now the restore calculator
— let me read its current state and rewrite it.
Read 1 file (ctrl+o to expand)
⏺ Now rewrite RestoreCostCalculator.cs — add per-tier download (read ops +
cool/cold retrieval) on top of the archive-rehydration model, using the new
accessors.
⏺ Write(src/Arius.Core/Features/RestoreCommand/RestoreCostCalculator.cs)
⎿ Added 57 lines, removed 28 lines
1 using Arius.Core.Shared.Pricing;
2 +using Arius.Core.Shared.Storage;
3
4 namespace Arius.Core.Features.RestoreCommand;
5
6 ///
6 -/// Computes the restore cost estimate shown before downloads o
-r rehydration begin.
7 -/// Restore-specific; storage-by-tier cost lives in . Both read
8 -/// their rates from the shared .
7 +/// Computes the restore cost estimate shown before downloads o
+r rehydration begin. Restore-specific;
8 +/// storage-by-tier monthly cost lives in . Both read their rates from
9 +/// the shared region-aware .
10 ///
11 +///
12 +/// Two cost groups:
13 +///
14 +/// Archive rehydration — chunks in the offline
+archive tier must be rehydrated first: per-GiB
15 +/// data retrieval + read operations (Standard or High priori
+ty), then Arius copies them into the Hot tier
16 +/// (chunks-rehydrated/), adding Hot write operations + Hot s
+torage for the copies.
17 +/// Online download — chunks already in an onlin
+e tier (Hot/Cool/Cold, plus archive copies that
18 +/// are already rehydrated) are read directly: read operation
+s on every chunk + a per-GiB data-retrieval
19 +/// charge on Cool and Cold (Hot has none).
20 +///
21 +///
22 internal sealed class RestoreCostCalculator(RegionPricing? pric
ing)
23 {
24 + // Azure bills per-GB storage and retrieval as binary GiB (
+2^30 bytes).
25 + private const double BytesPerGiB = 1024.0 * 1024.0 * 1024.0
+;
26 +
27 private readonly RegionPricing _pricing = pricing ?? Pricin
gCatalog.LoadEmbedded().Resolve(null).Pricing;
28
29 ///
15 - /// Computes a from class
-ified chunk counts and byte totals.
30 + /// Computes a from class
+ified chunk counts and byte totals. The
31 + /// per-tier *Download* parameters describe the chun
+ks read directly from online tiers (an
32 + /// already-rehydrated archive copy counts as Hot); omittin
+g them yields no download cost.
33 ///
17 - /// Chunk count ready for imm
-ediate download.
18 - /// Archive-tier chun
-k count with ready rehydrated copies.
19 - /// Archive-tier chu
-nk count that needs rehydration.
20 - /// Archive-tier chu
-nk count with rehydration already pending.
21 - /// Chunk bytes that
-require a new rehydration request.
22 - /// Chunk bytes alrea
-dy pending rehydration.
23 - /// Chunk bytes available for i
-mmediate download.
24 - /// Storage duration assumed for
- rehydrated chunk copies.
34 public RestoreCostEstimate Compute(
35 int chunksAvailable,
36 int chunksAlreadyRehydrated,
...
39 long bytesNeedingRehydration,
40 long bytesPendingRehydration,
41 long downloadBytes,
33 - double monthsStored = 1.0)
42 + double monthsStored = 1.0,
43 + int hotDownloadChunks = 0, long hotDownloa
+dBytes = 0,
44 + int coolDownloadChunks = 0, long coolDownlo
+adBytes = 0,
45 + int coldDownloadChunks = 0, long coldDownlo
+adBytes = 0)
46 {
35 - var numberOfBlobs = chunksNeedingRehydration;
36 - var totalGB = bytesNeedingRehydration / (1024.0 *
- 1024.0 * 1024.0);
37 - var opsUnits = numberOfBlobs / 10_000.0;
47 + // ── Archive rehydration (offline → online) ──
48 + var rehydGiB = bytesNeedingRehydration / BytesPerGiB;
49 + var rehydOps = chunksNeedingRehydration / 10_000.0;
50
51 + // ── Online download: read ops on every chunk + per-Gi
+B retrieval on Cool/Cold (Hot is free) ──
52 + var downloadReadOps =
53 + hotDownloadChunks / 10_000.0 * _pricing.ReadOpsRat
+eFor(BlobTier.Hot) +
54 + coolDownloadChunks / 10_000.0 * _pricing.ReadOpsRat
+eFor(BlobTier.Cool) +
55 + coldDownloadChunks / 10_000.0 * _pricing.ReadOpsRat
+eFor(BlobTier.Cold);
56 + var downloadRetrieval =
57 + coolDownloadBytes / BytesPerGiB * _pricing.DataRetr
+ievalRateFor(BlobTier.Cool) +
58 + coldDownloadBytes / BytesPerGiB * _pricing.DataRetr
+ievalRateFor(BlobTier.Cold);
59 +
60 return new RestoreCostEstimate
61 {
62 ChunksAvailable = chunksAvailable,
...
67 BytesPendingRehydration = bytesPendingRehydration,
68 DownloadBytes = downloadBytes,
69
49 - // Retrieval cost: per GB from archive
50 - RetrievalCostStandard = totalGB * _pricing.Archive.
-RetrievalPerGB,
51 - RetrievalCostHigh = totalGB * _pricing.Archive.
-RetrievalHighPerGB,
70 + // Archive rehydration — per-GiB data retrieval at
+Standard / High priority.
71 + RetrievalCostStandard = rehydGiB * _pricing.DataRet
+rievalRateFor(BlobTier.Archive),
72 + RetrievalCostHigh = rehydGiB * _pricing.DataRet
+rievalRateFor(BlobTier.Archive, highPriority: true),
73
53 - // Read ops: (N/10000) * rate — Azure charges per o
-peration, not per batch
54 - ReadOpsCostStandard = opsUnits * _pricing.Archive
-.ReadOpsPer10000,
55 - ReadOpsCostHigh = opsUnits * _pricing.Archive
-.ReadOpsHighPer10000,
74 + // Archive read ops — (N/10000) * rate at Standard
+/ High priority.
75 + ReadOpsCostStandard = rehydOps * _pricing.ReadOps
+RateFor(BlobTier.Archive),
76 + ReadOpsCostHigh = rehydOps * _pricing.ReadOps
+RateFor(BlobTier.Archive, highPriority: true),
77
57 - // Write ops: (N/10000) * rate to Hot tier
58 - WriteOpsCost = opsUnits * _pricing.Hot.Wri
-teOpsPer10000,
78 + // Arius copies each rehydrated chunk into the Hot
+tier (chunks-rehydrated/): write ops + storage.
79 + WriteOpsCost = rehydOps * _pricing.WriteOp
+sRateFor(BlobTier.Hot),
80 + StorageCost = rehydGiB * _pricing.Storage
+RateFor(BlobTier.Hot) * monthsStored,
81
60 - // Storage: N months in Hot tier (rehydrated copies
- in chunks-rehydrated/)
61 - StorageCost = totalGB * _pricing.Hot.Stor
-agePerGBPerMonth * monthsStored,
82 + // Direct download from online tiers.
83 + DownloadReadOpsCost = downloadReadOps,
84 + DownloadRetrievalCost = downloadRetrieval,
85 };
86 }
87 }
...
138 /// Storage cost for rehydrated chunk copies.
139 public required double StorageCost { get; init; }
140
141 + /// Read operation cost for chunks downloaded dir
+ectly from online tiers (Hot/Cool/Cold + rehydrated copies).
+summary>
142 + public double DownloadReadOpsCost { get; init; }
143 +
144 + /// Per-GiB data-retrieval cost for chunks downlo
+aded from the Cool and Cold tiers.
145 + public double DownloadRetrievalCost { get; init; }
146 +
147 // ── Computed totals ────────────────────────────────────
───────────────────
148
149 /// Total estimated cost at Standard priority.
121 - public double TotalStandard => RetrievalCostStandard + Rea
-dOpsCostStandard + WriteOpsCost + StorageCost;
150 + public double TotalStandard => RetrievalCostStandard + Rea
+dOpsCostStandard + WriteOpsCost + StorageCost + DownloadReadOp
+sCost + DownloadRetrievalCost;
151
152 /// Total estimated cost at High priority.
124 - public double TotalHigh => RetrievalCostHigh + ReadOpsCost
-High + WriteOpsCost + StorageCost;
153 + public double TotalHigh => RetrievalCostHigh + ReadOpsCost
+High + WriteOpsCost + StorageCost + DownloadReadOpsCost + Down
+loadRetrievalCost;
154 }
⏺ Now add Region to RestoreOptions and wire per-tier download accumulators +
region into the handler.
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommand.cs)
⎿ Added 6 lines
36 public bool NoPointers { get; init; } = false;
37
38 ///
39 + /// Programmatic Azure region of the account (e.g. weste
+urope ), used to price the restore cost
40 + /// estimate. null falls back to the pricing catalog
+'s default region.
41 + ///
42 + public string? Region { get; init; }
43 +
44 + ///
45 /// Optional callback invoked with the cost estimate before
downloads begin when archive-tier chunks need rehydration.
46 /// Return the desired to c
ontinue, or null to cancel before files are written.
47 /// When this callback is null , archive-tier chunks
are rehydrated using Standard priority without confirmation.
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 4 lines
126 var skipped = new StrongBox(0);
127 int fileCount = 0, availableCount
= 0, rehydratedCount = 0, needsRehydrationCount =
0, pendingRehydrationCount = 0, largeChunks = 0, totalChunks =
0;
128 long totalOriginalBytes = 0, totalChunkBytes
= 0, downloadBytes = 0, bytesNeedingRehydration = 0,
bytesPendingRehydration = 0;
129 + // Per-tier breakdown of the online chunks that wi
+ll be downloaded (cost differs by tier).
130 + // A rehydrated archive copy is read from the Hot
+tier where Arius placed it.
131 + int hotDownloadChunks = 0, coolDownloadChunks =
+0, coldDownloadChunks = 0;
132 + long hotDownloadBytes = 0, coolDownloadBytes =
+0, coldDownloadBytes = 0;
133 var chunksNeedingRehydration = new Dictionary();
134 var seenChunks = new HashSet();
135
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 12 lines
175 case ChunkHydrationStatus.Available:
176 // NOTE: we _may_ undercount available
if the StorageTierHint is not in sync with actual blob storag
e
177 downloadBytes += entry.ChunkSize;
178 + // Split downloads by source tier so r
+etrieval (Cool/Cold) and read-op rates are priced
179 + // correctly. A rehydrated archive cop
+y lives in Hot regardless of its index tier hint.
180 + if (rehydratedState.ContainsKey(chunkH
+ash))
181 + {
182 + hotDownloadChunks++; hotDownloadB
+ytes += entry.ChunkSize;
183 + }
184 + else switch (entry.StorageTierHint)
185 + {
186 + case BlobTier.Cool: coolDownloadCh
+unks++; coolDownloadBytes += entry.ChunkSize; break;
187 + case BlobTier.Cold: coldDownloadCh
+unks++; coldDownloadBytes += entry.ChunkSize; break;
188 + default: hotDownloadChu
+nks++; hotDownloadBytes += entry.ChunkSize; break; // Hot or
+ unknown — no retrieval charge
189 + }
190 break;
191 case ChunkHydrationStatus.NeedsRehydration
:
192 bytesNeedingRehydration += entry.Chunk
Size;
⏺ Now update the Compute call to pass region pricing + the per-tier download
breakdown.
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 6 lines, removed 2 lines
214 if (needsRehydrationCount > 0 && opts.ConfirmRehyd
ration is not null)
215 {
216 logger.LogInformation("[phase] confirm-rehydra
tion");
217 - var costEstimate = new RestoreCostCalculator(p
-ricing: null).Compute(
217 + var regionPricing = PricingCatalog.LoadEmbedde
+d().Resolve(opts.Region).Pricing;
218 + var costEstimate = new RestoreCostCalculator(r
+egionPricing).Compute(
219 chunksAvailable: availableCount,
220 chunksAlreadyRehydrated: rehydratedCount,
221 chunksNeedingRehydration: needsRehydration
Count,
222 chunksPendingRehydration: pendingRehydrati
onCount,
223 bytesNeedingRehydration: bytesNeedingRehy
dration,
224 bytesPendingRehydration: bytesPendingRehy
dration,
224 - downloadBytes: downloadBytes);
225 + downloadBytes: downloadBytes,
226 + hotDownloadChunks: hotDownloadChunk
+s, hotDownloadBytes: hotDownloadBytes,
227 + coolDownloadChunks: coolDownloadChun
+ks, coolDownloadBytes: coolDownloadBytes,
228 + coldDownloadChunks: coldDownloadChun
+ks, coldDownloadBytes: coldDownloadBytes);
229
230 var chosenPriority = await opts.ConfirmRehydra
tion(costEstimate, cancellationToken);
231 if (chosenPriority is null)
⏺ Now add the using to the handler and wire the region from the account in
JobRunner. Let me check the handler's usings and the JobRunner restore
method's access to the account.
Ran 1 shell command (ctrl+o to expand)
⏺ RunRestoreAsync has repo (with AccountId) and database. Let me add the using
to the handler and the region lookup in JobRunner.
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 1 line
6 using Arius.Core.Shared.ChunkStorage;
7 using Arius.Core.Shared.Encryption;
8 using Arius.Core.Shared.FileTree;
9 +using Arius.Core.Shared.Pricing;
10 using Arius.Core.Shared.Snapshot;
11 using Arius.Core.Shared.Storage;
12 using Humanizer;
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Api/Jobs/JobRunner.cs)
⎿ Added 1 line
94 var sink = new JobSink(jobId, hub);
95 var repo = database.GetRepository(repositoryId);
96 if (repo is null) { sink.Done("failed", "Repository no
t found."); return; }
97 + var region = database.GetAccount(repo.AccountId)?.Loca
+tion; // prices the restore cost estimate
98 database.InsertJob(jobId, repositoryId, "restore", "on
e-off", "running");
99
100 var destination = string.IsNullOrWhiteSpace(repo.Local
Path)
⏺ Update(src/Arius.Api/Jobs/JobRunner.cs)
⎿ Added 1 line
124 TargetPath = target is null ? null : Re
lativePath.Parse(target),
125 Overwrite = overwrite,
126 NoPointers = noPointers,
127 + Region = region,
128 ConfirmRehydration = async (estimate, ct)
=>
129 {
130 sink.Log("⚠ archive-tier chunks need r
ehydration — awaiting approval", "warn");
⏺ Now update the two pricing tests for the new TierRates shape. Let me read the
current PricingConfigTests.cs.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/PricingConfigTests.cs) ⎿ Added 6 lines, removed 6 lines
17 hotWriteOpsPer10000: 0.1,
18 hotStoragePerGbPerMonth: 0.02);
19
20 - config.Archive.RetrievalPerGB.ShouldBe(1.0);
21 - config.Archive.RetrievalHighPerGB.ShouldBe(5.0);
22 - config.Archive.ReadOpsPer10000.ShouldBe(2.0);
23 - config.Archive.ReadOpsHighPer10000.ShouldBe(10.0);
24 - config.Hot.WriteOpsPer10000.ShouldBe(0.1);
25 - config.Hot.StoragePerGBPerMonth.ShouldBe(0.02);
20 + config.Archive!.DataRetrievalPerGb.ShouldBe(1.0);
21 + config.Archive.DataRetrievalHighPerGb.ShouldBe(5.0);
22 + config.Archive.ReadOpsPer10k.ShouldBe(2.0);
23 + config.Archive.ReadOpsHighPer10k.ShouldBe(10.0);
24 + config.Hot!.WriteOpsPer10k.ShouldBe(0.1);
25 + config.Hot.StoragePerGbMonth.ShouldBe(0.02);
26 }
27
28 [Test]
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/PricingConfigTests.cs)
⎿ Added 14 lines, removed 14 lines
94 {
95 return new RegionPricing
96 {
97 - Archive = new ArchivePricingTier
97 + Archive = new TierRates
98 {
99 - RetrievalPerGB = retrievalPerGb,
100 - RetrievalHighPerGB = retrievalHighPerGb,
101 - ReadOpsPer10000 = readOpsPer10000,
102 - ReadOpsHighPer10000 = readOpsHighPer10000,
99 + DataRetrievalPerGb = retrievalPerGb,
100 + DataRetrievalHighPerGb = retrievalHighPerGb,
101 + ReadOpsPer10k = readOpsPer10000,
102 + ReadOpsHighPer10k = readOpsHighPer10000,
103 },
104 - Hot = new TierPricingConfig
104 + Hot = new TierRates
105 {
106 - WriteOpsPer10000 = hotWriteOpsPer10000,
107 - StoragePerGBPerMonth = hotStoragePerGbPerMonth
-,
106 + WriteOpsPer10k = hotWriteOpsPer10000,
107 + StoragePerGbMonth = hotStoragePerGbPerMonth,
108 },
109 - Cool = new TierPricingConfig
109 + Cool = new TierRates
110 {
111 - WriteOpsPer10000 = 0.2,
112 - StoragePerGBPerMonth = 0.01,
111 + WriteOpsPer10k = 0.2,
112 + StoragePerGbMonth = 0.01,
113 },
114 - Cold = new TierPricingConfig
114 + Cold = new TierRates
115 {
116 - WriteOpsPer10000 = 0.3,
117 - StoragePerGBPerMonth = 0.005,
116 + WriteOpsPer10k = 0.3,
117 + StoragePerGbMonth = 0.005,
118 },
119 };
120 }
⏺ Now the RestoreCostCalculatorTests _pricing literal.
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCostCalculatorTests
.cs)
⎿ Added 8 lines, removed 8 lines
8 // Deterministic pricing config for all tests
9 private static readonly RegionPricing _pricing = new()
10 {
11 - Archive = new ArchivePricingTier
11 + Archive = new TierRates
12 {
13 - RetrievalPerGB = 1.0,
14 - RetrievalHighPerGB = 5.0,
15 - ReadOpsPer10000 = 2.0,
16 - ReadOpsHighPer10000 = 10.0,
13 + DataRetrievalPerGb = 1.0,
14 + DataRetrievalHighPerGb = 5.0,
15 + ReadOpsPer10k = 2.0,
16 + ReadOpsHighPer10k = 10.0,
17 },
18 - Hot = new TierPricingConfig { WriteOpsPer10000 = 0.1,
-StoragePerGBPerMonth = 0.5 },
19 - Cool = new TierPricingConfig { WriteOpsPer10000 = 0.2,
-StoragePerGBPerMonth = 0.3 },
20 - Cold = new TierPricingConfig { WriteOpsPer10000 = 0.3,
-StoragePerGBPerMonth = 0.1 },
18 + Hot = new TierRates { WriteOpsPer10k = 0.1, StoragePer
+GbMonth = 0.5, ReadOpsPer10k = 0.04 },
19 + Cool = new TierRates { WriteOpsPer10k = 0.2, StoragePer
+GbMonth = 0.3, ReadOpsPer10k = 0.05, DataRetrievalPerGb = 0.6 }
+,
20 + Cold = new TierRates { WriteOpsPer10k = 0.3, StoragePer
+GbMonth = 0.1, ReadOpsPer10k = 0.07, DataRetrievalPerGb = 0.9 }
+,
21 };
22
23 // 1 GB expressed in bytes
⏺ Now add new tests covering the per-tier download read-ops + cool/cold
retrieval (the previously-missing behavior).
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCostCalculatorTests
.cs)
⎿ Added 39 lines
217
218 estimate.TotalHigh.ShouldBeGreaterThanOrEqualTo(estima
te.TotalStandard);
219 }
220 +
221 + // ── Online download cost (read ops per tier + Cool/Cold
+data retrieval) ─────
222 +
223 + [Test]
224 + public void DownloadCost_ReadOpsPerTier_PlusCoolColdRetrie
+val()
225 + {
226 + var estimate = new RestoreCostCalculator(_pricing).Com
+pute(
227 + chunksAvailable: 30_000, chunksAlreadyRehydrated:
+0,
228 + chunksNeedingRehydration: 0, chunksPendingRehydrat
+ion: 0,
229 + bytesNeedingRehydration: 0, bytesPendingRehydratio
+n: 0, downloadBytes: 3 * OneGBBytes,
230 + hotDownloadChunks: 10_000, hotDownloadBytes: One
+GBBytes,
231 + coolDownloadChunks: 10_000, coolDownloadBytes: One
+GBBytes,
232 + coldDownloadChunks: 10_000, coldDownloadBytes: One
+GBBytes);
233 +
234 + // Read ops: 1*0.04 (hot) + 1*0.05 (cool) + 1*0.07 (co
+ld) = 0.16
235 + estimate.DownloadReadOpsCost.ShouldBe(0.16, tolerance:
+ 1e-9);
236 + // Retrieval: hot none + 1 GiB*0.6 (cool) + 1 GiB*0.9
+(cold) = 1.5
237 + estimate.DownloadRetrievalCost.ShouldBe(1.5, tolerance
+: 1e-9);
238 + // No archive rehydration → those components are zero.
239 + estimate.RetrievalCostStandard.ShouldBe(0.0);
240 + estimate.WriteOpsCost.ShouldBe(0.0);
241 + estimate.StorageCost.ShouldBe(0.0);
242 + // Download cost is independent of rehydration priorit
+y.
243 + estimate.TotalStandard.ShouldBe(1.66, tolerance: 1e-9)
+;
244 + estimate.TotalHigh.ShouldBe(1.66, tolerance: 1e-9);
245 + }
246 +
247 + [Test]
248 + public void DownloadCost_HotTier_HasNoRetrievalCharge()
249 + {
250 + var estimate = new RestoreCostCalculator(_pricing).Com
+pute(
251 + chunksAvailable: 10_000, chunksAlreadyRehydrated:
+0,
252 + chunksNeedingRehydration: 0, chunksPendingRehydrat
+ion: 0,
253 + bytesNeedingRehydration: 0, bytesPendingRehydratio
+n: 0, downloadBytes: OneGBBytes,
254 + hotDownloadChunks: 10_000, hotDownloadBytes: OneGB
+Bytes);
255 +
256 + estimate.DownloadRetrievalCost.ShouldBe(0.0); // Hot h
+as no per-GiB retrieval charge
257 + estimate.DownloadReadOpsCost.ShouldBe(0.04, tolerance:
+ 1e-9);
258 + }
259 }
⏺ Now let me build the solution and run the affected tests.
Ran 2 shell commands (ctrl+o to expand)
⏺ Core (624, +2 new) and Architecture (11) pass. Let me confirm Api + Cli tests
(DTOs/handler/JobRunner changed).
Ran 1 shell command (ctrl+o to expand)
⏺ All backend tests pass (Core 624, Architecture 11, Api 9, Cli 150); solution
builds clean. The new numbers were cross-checked against the raw meter dump
(e.g. westeurope Cold "Data Retrieval" 0.025803, Archive "Read Operations"
5.160624). No frontend type changes — the DTO shapes are unchanged, only the
numbers/totals.
⏺ You were right on both counts. Here's what I found and fixed, sourced from the
Azure Retail Prices API (prices.azure.com, product General Block Blob v2,
LRS, EUR) and verified against Microsoft docs.
What was wrong
- Numbers were off. e.g. westeurope archive storage was 0.00086 → real
0.001548; archive read-ops 6.61/71.60 → real 5.16/55.91; hot/cool/cold
write+storage all slightly wrong.
- Restore formula was incomplete. It only priced archive rehydration. Reading
from Cool/Cold incurs a per-GiB data-retrieval charge (your example) plus
per-tier read operations — none of which were stored or computed. So a
cool/cold restore showed €0.
- GiB vs GB: I checked — Azure bills "GB" as binary GiB (2³⁰) for both storage
and retrieval, so the existing 1024³ divisor was actually correct. Kept it.
Corrected pricing.json (EUR/GiB, LRS, base tier)
region: westeurope
tier: hot
storage /GB·mo: 0.016858
write /10k: 0.046446
read /10k: 0.003698
retrieval /GB: —
archive hi-read /10k:
archive hi-retr /GB:
────────────────────────────────────────
region:
tier: cool
storage /GB·mo: 0.008601
write /10k: 0.086010
read /10k: 0.008601
retrieval /GB: 0.008601
archive hi-read /10k:
archive hi-retr /GB:
────────────────────────────────────────
region:
tier: cold
storage /GB·mo: 0.003870
write /10k: 0.154819
read /10k: 0.086010
retrieval /GB: 0.025803
archive hi-read /10k:
archive hi-retr /GB:
────────────────────────────────────────
region:
tier: archive
storage /GB·mo: 0.001548
write /10k: 0.103212
read /10k: 5.160624
retrieval /GB: 0.020642
archive hi-read /10k: 55.906765
archive hi-retr /GB: 0.111814
────────────────────────────────────────
region: northeurope
tier: archive
storage /GB·mo: 0.000852
write /10k: 0.094611
read /10k: 4.730572
retrieval /GB: 0.018922
archive hi-read /10k: 51.606244
archive hi-retr /GB: 0.103212
────────────────────────────────────────
region: belgiumcentral
tier: —
storage /GB·mo: (hot/cool/cold only — no Archive tier for block blobs)
write /10k:
read /10k:
retrieval /GB:
archive hi-read /10k:
archive hi-retr /GB:
(northeurope hot/cool/cold and belgiumcentral follow the same shape; full
numbers are in the file.)
Formula fixes
- RestoreCostCalculator now adds, on top of archive rehydration: per-tier read
operations for every downloaded chunk + Cool/Cold data-retrieval per GiB (Hot
has none). The handler splits downloads by StorageTierHint (rehydrated copies
counted as Hot), and the calculator is region-aware (RestoreOptions.Region,
set from the account's region in JobRunner).
- Monthly storage (StorageCostCalculator) formula was already correct — it
just gets the right numbers now.
- Unified the rate model into a clean TierRates (nullable per region, so
belgiumcentral simply has no archive).
Tests: Core 624 (+2 new download-cost tests), Architecture 11, Api 9, Cli 150
— all green; solution builds clean.
One thing to flag (your call)
The restore cost-approval prompt still only triggers when archive rehydration
is needed (its original purpose). For a pure Cool/Cold restore the cost is now
computed correctly but isn't surfaced (no prompt, since nothing needs
"rehydrating"). Showing a cost prompt for cool/cold restores is a UX change I
didn't make unilaterally — want me to (a) prompt whenever cost > 0, (b) just
log the estimate at restore start, or (c) leave as-is?
I haven't committed — say the word and I will.
✻ Crunched for 24m 40s
※ recap: Goal: fix Arius's Azure cost calculations. Done — corrected pricing.json with real Retail-API numbers for 3 regions, added cool/cold retrieval plus per-tier read ops to the restore formula; all backend tests pass. Next: commit, once you confirm. (disable recaps in /config)
❯ did you include egress cost? yes, surface the prompt when cost > 0
⏺ Good — two clear directives. No, I didn't include egress (I'd noted it as a
separate bandwidth meter). For a restore-to-local-machine that's real internet
egress and often the dominant cost, so let me query those numbers and fold
them in, plus flip the gate to prompt whenever cost > 0. First, the egress
pricing.
Ran 1 shell command (ctrl+o to expand)
⏺ Egress is tiered with the first 100 GB/month free (tierMin=0 = €0), then
~€0.069–0.075/GB. The default account routing is Microsoft Global Network
("Rtn Preference: MGN"). Let me pull the per-region MGN egress rate (100 GB–10
TB band) for all three regions.
Ran 1 shell command (ctrl+o to expand)
⏺ Egress is the same across all three Europe regions: first 100 GB/month free,
then €0.07483/GB (default Microsoft-network routing, 100 GB–10 TB band). Let
me add egress to the model and flip the gate. First let me check the frontend
cost modal so the prompt reads sensibly for non-archive restores.
Ran 1 shell command (ctrl+o to expand)
⏺ The cost modal is archive-framed (rehydration priority, "~15h/~1h"). For
non-archive restores I'll make it show a simple total. But first — does any
Core test assert ConfirmRehydration is not invoked for non-archive restores?
That would break with the new gate. Let me check.
Ran 1 shell command (ctrl+o to expand)
⏺ Let me check the two remaining ConfirmRehydration test sites (around lines
880–1000) for any "not invoked" assertion on non-archive restores.
Read 1 file (ctrl+o to expand)
⏺ Let me see the pending-chunk test's final assertions (it's named
"DoesNotPrompt").
Read 1 file (ctrl+o to expand)
⏺ The pending-chunk test asserts the prompt is not shown when cost is 0 — so
I'll compute egress from downloadBytes + bytesNeedingRehydration (excluding
pending), keeping that test valid and the semantics correct. Existing calc
tests all use < 100 GiB so egress stays 0 for them. Let me implement. First,
add egress to pricing.json (re-reading it first).
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/Shared/Pricing/pricing.json)
⎿ Added 3 lines
3 "regions": {
4 "westeurope": {
5 "currency": "EUR",
6 + "egressPerGB": 0.07483,
7 "hot": {
8 "storagePerGBPerMonth": 0.016858,
9 "writeOpsPer10000": 0.046446,
...
32 },
33 "northeurope": {
34 "currency": "EUR",
35 + "egressPerGB": 0.07483,
36 "hot": {
37 "storagePerGBPerMonth": 0.018922,
38 "writeOpsPer10000": 0.043005,
...
61 },
62 "belgiumcentral": {
63 "currency": "EUR",
64 + "egressPerGB": 0.07483,
65 "_comment": "Belgium Central does not offer the Archive a
ccess tier for standard block blobs.",
66 "hot": {
67 "storagePerGBPerMonth": 0.016858,
⏺ Update(src/Arius.Core/Shared/Pricing/pricing.json)
⎿ Added 1 line, removed 1 line
1 {
2 - "_comment": "Azure Blob Storage retail pricing — EUR, Standard
- general-purpose v2 block blobs, LRS redundancy, base volume tie
-r (0-50 TB). Keyed by programmatic region name. Source: Azure Re
-tail Prices API (prices.azure.com), product 'General Block Blob
-v2'. Last updated 2026-06. Add a region here to make it selectab
-le and priced. Azure bills per-GB values as binary GiB (2^30 byt
-es). Hot has no data-retrieval charge; cool/cold/archive do.",
2 + "_comment": "Azure Blob Storage retail pricing — EUR, Standard
+ general-purpose v2 block blobs, LRS redundancy, base volume tie
+r (0-50 TB). Keyed by programmatic region name. Source: Azure Re
+tail Prices API (prices.azure.com), products 'General Block Blob
+ v2' + 'Bandwidth'. Last updated 2026-06. Add a region here to m
+ake it selectable and priced. Azure bills per-GB values as binar
+y GiB (2^30 bytes). Hot has no data-retrieval charge; cool/cold/
+archive do. egressPerGB is internet data-transfer-out (default M
+icrosoft-network routing, 100 GB-10 TB band); the first 100 GiB/
+month is free account-wide.",
3 "regions": {
4 "westeurope": {
5 "currency": "EUR",
⏺ Now add EgressPerGb to RegionPricing.
⏺ Update(src/Arius.Core/Shared/Pricing/PricingCatalog.cs)
⎿ Added 4 lines
82 [JsonPropertyName("currency")]
83 public string Currency { get; init; } = "EUR";
84
85 + /// Internet egress (data transfer out) per GiB be
+yond the monthly free allowance; 0 if not configured.
86 + [JsonPropertyName("egressPerGB")]
87 + public double EgressPerGb { get; init; }
88 +
89 [JsonPropertyName("hot")] public TierRates? Hot { g
et; init; }
90 [JsonPropertyName("cool")] public TierRates? Cool { g
et; init; }
91 [JsonPropertyName("cold")] public TierRates? Cold { g
et; init; }
⏺ Now wire egress into RestoreCostCalculator.
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCostCalculator.cs)
⎿ Added 3 lines
24 // Azure bills per-GB storage and retrieval as binary GiB (
2^30 bytes).
25 private const double BytesPerGiB = 1024.0 * 1024.0 * 1024.0
;
26
27 + // The first 100 GiB of internet egress per month is free a
+ccount-wide.
28 + private const double FreeEgressGiB = 100.0;
29 +
30 private readonly RegionPricing _pricing = pricing ?? Pricin
gCatalog.LoadEmbedded().Resolve(null).Pricing;
31
32 ///
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCostCalculator.cs)
⎿ Added 6 lines
60 coolDownloadBytes / BytesPerGiB * _pricing.DataRetr
ievalRateFor(BlobTier.Cool) +
61 coldDownloadBytes / BytesPerGiB * _pricing.DataRetr
ievalRateFor(BlobTier.Cold);
62
63 + // ── Internet egress: every restored byte leaves Azure
+ (download now + the archive bytes we
64 + // rehydrate then download). The first 100 GiB/month
+ is free account-wide. ──
65 + var egressGiB = (downloadBytes + bytesNeedingRe
+hydration) / BytesPerGiB;
66 + var billableEgressGiB = Math.Max(0.0, egressGiB - FreeE
+gressGiB);
67 + var egressCost = billableEgressGiB * _pricing.Eg
+ressPerGb;
68 +
69 return new RestoreCostEstimate
70 {
71 ChunksAvailable = chunksAvailable,
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCostCalculator.cs)
⎿ Added 3 lines
91 // Direct download from online tiers.
92 DownloadReadOpsCost = downloadReadOps,
93 DownloadRetrievalCost = downloadRetrieval,
94 +
95 + // Internet egress for the restored data (beyond th
+e free monthly allowance).
96 + EgressCost = egressCost,
97 };
98 }
99 }
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCostCalculator.cs)
⎿ Added 5 lines, removed 2 lines
156 /// Per-GiB data-retrieval cost for chunks downlo
aded from the Cool and Cold tiers.
157 public double DownloadRetrievalCost { get; init; }
158
159 + /// Internet egress (data transfer out) cost for
+the restored data, beyond the free monthly allowance.
160 + public double EgressCost { get; init; }
161 +
162 // ── Computed totals ────────────────────────────────────
───────────────────
163
164 /// Total estimated cost at Standard priority.
162 - public double TotalStandard => RetrievalCostStandard + Rea
-dOpsCostStandard + WriteOpsCost + StorageCost + DownloadReadOp
-sCost + DownloadRetrievalCost;
165 + public double TotalStandard => RetrievalCostStandard + Rea
+dOpsCostStandard + WriteOpsCost + StorageCost + DownloadReadOp
+sCost + DownloadRetrievalCost + EgressCost;
166
167 /// Total estimated cost at High priority.
165 - public double TotalHigh => RetrievalCostHigh + ReadOpsCost
-High + WriteOpsCost + StorageCost + DownloadReadOpsCost + Down
-loadRetrievalCost;
168 + public double TotalHigh => RetrievalCostHigh + ReadOpsCost
+High + WriteOpsCost + StorageCost + DownloadReadOpsCost + Down
+loadRetrievalCost + EgressCost;
169 }
⏺ Now restructure the handler's Stage 3 to always compute the estimate and gate
on TotalStandard > 0. Let me read the current block.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 21 lines, removed 18 lines
210 logger.LogInformation("[rehydration] Status: avail
able={Available} rehydrated={Rehydrated} needsRehydration={Nee
dsRehydration} pending={Pending} downloadSize={DownloadSize} r
ehydrateSize={RehydrateSize} pendingSize={PendingSize}", avail
ableCount, rehydratedCount, needsRehydrationCount, pendingRehy
drationCount, downloadBytes.Bytes().Humanize(), bytesNeedingRe
hydration.Bytes().Humanize(), bytesPendingRehydration.Bytes().
Humanize());
211 await mediator.Publish(new RehydrationStatusEvent(
availableCount, rehydratedCount, needsRehydrationCount, pendin
gRehydrationCount), cancellationToken);
212
213 - // ── Stage 3: Cost estimate + confirm rehydration
- ──────────────────────
213 + // ── Stage 3: Cost estimate + confirm ───────────
+───────────────────────
214 + // Estimate the full restore cost (archive rehydra
+tion + online-tier download retrieval/read-ops
215 + // + internet egress) and, when it is non-zero, as
+k the caller to approve before any cost is
216 + // incurred. Archive rehydration also takes hours,
+ so the same prompt carries the priority choice.
217 var rehydratePriority = RehydratePriority.Standard
;
215 - if (needsRehydrationCount > 0 && opts.ConfirmRehyd
-ration is not null)
216 - {
217 - logger.LogInformation("[phase] confirm-rehydra
-tion");
218 - var regionPricing = PricingCatalog.LoadEmbedde
-d().Resolve(opts.Region).Pricing;
219 - var costEstimate = new RestoreCostCalculator(r
-egionPricing).Compute(
220 - chunksAvailable: availableCount,
221 - chunksAlreadyRehydrated: rehydratedCount,
222 - chunksNeedingRehydration: needsRehydration
-Count,
223 - chunksPendingRehydration: pendingRehydrati
-onCount,
224 - bytesNeedingRehydration: bytesNeedingRehy
-dration,
225 - bytesPendingRehydration: bytesPendingRehy
-dration,
226 - downloadBytes: downloadBytes,
227 - hotDownloadChunks: hotDownloadChunk
-s, hotDownloadBytes: hotDownloadBytes,
228 - coolDownloadChunks: coolDownloadChun
-ks, coolDownloadBytes: coolDownloadBytes,
229 - coldDownloadChunks: coldDownloadChun
-ks, coldDownloadBytes: coldDownloadBytes);
218 + var regionPricing = PricingCatalog.LoadEmbedde
+d().Resolve(opts.Region).Pricing;
219 + var costEstimate = new RestoreCostCalculator(
+regionPricing).Compute(
220 + chunksAvailable: availableCount,
221 + chunksAlreadyRehydrated: rehydratedCount,
222 + chunksNeedingRehydration: needsRehydrationCoun
+t,
223 + chunksPendingRehydration: pendingRehydrationCo
+unt,
224 + bytesNeedingRehydration: bytesNeedingRehydrat
+ion,
225 + bytesPendingRehydration: bytesPendingRehydrat
+ion,
226 + downloadBytes: downloadBytes,
227 + hotDownloadChunks: hotDownloadChunks,
+hotDownloadBytes: hotDownloadBytes,
228 + coolDownloadChunks: coolDownloadChunks,
+coolDownloadBytes: coolDownloadBytes,
229 + coldDownloadChunks: coldDownloadChunks,
+coldDownloadBytes: coldDownloadBytes);
230
231 + if (costEstimate.TotalStandard > 0 && opts.Confirm
+Rehydration is not null)
232 + {
233 + logger.LogInformation("[phase] confirm-cost");
234 var chosenPriority = await opts.ConfirmRehydra
tion(costEstimate, cancellationToken);
235 if (chosenPriority is null)
236 {
234 - // User cancelled rehydration — exit witho
-ut downloading or rehydrating.
235 - logger.LogInformation("[rehydration] Confi
-rmation cancelled: pending={Pending} rehydrateSize={RehydrateS
-ize}", needsRehydrationCount + pendingRehydrationCount, bytesN
-eedingRehydration.Bytes().Humanize());
237 + // User declined — exit without downloadin
+g or rehydrating.
238 + logger.LogInformation("[restore] Cost decl
+ined: pending={Pending} rehydrateSize={RehydrateSize}", needsR
+ehydrationCount + pendingRehydrationCount, bytesNeedingRehydra
+tion.Bytes().Humanize());
239 logger.LogInformation("[restore] Done: res
tored=0 skipped={Skipped} pendingRehydration={Pending}", skipp
ed.Value, needsRehydrationCount + pendingRehydrationCount);
240
241 return new RestoreResult
⏺ Now make the JobRunner approval message generic (it fires for cool/cold/egress
too now).
⏺ Update(src/Arius.Api/Jobs/JobRunner.cs)
⎿ Added 3 lines, removed 1 line
127 Region = region,
128 ConfirmRehydration = async (estimate, ct)
=>
129 {
130 - sink.Log("⚠ archive-tier chunks need r
-ehydration — awaiting approval", "warn");
130 + sink.Log(estimate.ChunksNeedingRehydra
+tion > 0
131 + ? "⚠ archive-tier chunks need rehy
+dration — awaiting cost approval"
132 + : "Estimated restore cost — awaiti
+ng approval", "warn");
133 sink.Cost(new
134 {
135 chunksAvailable = estimat
e.ChunksAvailable + estimate.ChunksAlreadyRehydrated,
⏺ Now make the frontend cost modal adapt to non-archive restores (no
rehydration). Let me read the full modal block.
Read 1 file (ctrl+o to expand)
⏺ Now rewrite the cost modal to handle both archive (rehydration priority) and
non-archive (plain cost) cases.
⏺ Update(src/Arius.Web/src/app/features/drawer/archive-restore-drawer.component.
ts)
⎿ Added 27 lines, removed 15 lines
98 @if (store.streamState() === 'cost' && store.cost(); a
s cost) {
99
100
101 -
Rehydration required before download
102 -
103 -
Ready now
{{ cost.chunksAvailable }}
104 -
Rehydrate
{{ cost.chunksNeedingRehydration }}
105 -
From archive
<
-div class="ar-statv">{{ formatBytes(cost.bytesNeedingRehydrati
-on) }}
106 -
107 -
Rehydration priority
108 -
109 -
110 - Standard
<
-div style="font-size:12px;color:#71717a">~15 h · €{{ cost.tota
-lStandard.toFixed(2) }}
111 -
112 -
113 - High
~1 h · €{{ cost.totalHigh
-.toFixed(2) }}
114 -
115 -
101 + @if (cost.chunksNeedingRehydration > 0) {
102 +
103 +
Rehydration required before download<
+/div>
104 +
105 +
Ready now
{{ cost.chunksAvailable }}
106 +
Rehydrate
{{ cost.chunksNeedingRehydration }} <
+/div>
107 +
From archive
{{ formatBytes(cost.bytesNeedingRehydra
+tion) }}
108 +
109 +
Rehydration priority
110 +
111 +
112 + Standard
~15 h · €{{ cost.to
+talStandard.toFixed(2) }}
113 +
114 +
115 + High
~1 h · €{{ cost.totalHi
+gh.toFixed(2) }}
116 +
117 +
118 + } @else {
119 +
120 +
Estimated restore cost
121 +
122 +
Chunks
{{ cost.chunksAvailable }}
123 +
Download
{{ formatBytes(cost.downloadBytes) }}
+
124 +
Est. cost
€{{ cost.totalSta
+ndard.toFixed(2) }}
125 +
126 + }
127 +
Includes data retrieval, operations, and internet egr
+ess (first 100 GB/month free).
128
129
Decline
130
Approve & restore
⏺ Now add EgressPerGb to the test pricing + egress tests.
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCostCalculatorTests
.cs)
⎿ Added 1 line
8 // Deterministic pricing config for all tests
9 private static readonly RegionPricing _pricing = new()
10 {
11 + EgressPerGb = 0.08,
12 Archive = new TierRates
13 {
14 DataRetrievalPerGb = 1.0,
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCostCalculatorTests
.cs)
⎿ Added 43 lines
257 estimate.DownloadRetrievalCost.ShouldBe(0.0); // Hot h
as no per-GiB retrieval charge
258 estimate.DownloadReadOpsCost.ShouldBe(0.04, tolerance:
1e-9);
259 }
260 +
261 + // ── Internet egress (first 100 GiB/month free) ─────────
+─────────────────────
262 +
263 + [Test]
264 + public void EgressCost_BeyondFreeAllowance_ChargedPerGiB()
265 + {
266 + // 200 GiB restored → 100 GiB billable (first 100 free
+) × 0.08 = 8.0
267 + var estimate = new RestoreCostCalculator(_pricing).Com
+pute(
268 + chunksAvailable: 0, chunksAlreadyRehydrated: 0,
269 + chunksNeedingRehydration: 0, chunksPendingRehydrat
+ion: 0,
270 + bytesNeedingRehydration: 0, bytesPendingRehydratio
+n: 0,
271 + downloadBytes: 200L * 1024 * 1024 * 1024);
272 +
273 + estimate.EgressCost.ShouldBe(8.0, tolerance: 1e-9);
274 + estimate.TotalStandard.ShouldBe(8.0, tolerance: 1e-9);
275 + }
276 +
277 + [Test]
278 + public void EgressCost_WithinFreeAllowance_IsZero()
279 + {
280 + var estimate = new RestoreCostCalculator(_pricing).Com
+pute(
281 + chunksAvailable: 0, chunksAlreadyRehydrated: 0,
282 + chunksNeedingRehydration: 0, chunksPendingRehydrat
+ion: 0,
283 + bytesNeedingRehydration: 0, bytesPendingRehydratio
+n: 0,
284 + downloadBytes: 50L * 1024 * 1024 * 1024);
285 +
286 + estimate.EgressCost.ShouldBe(0.0);
287 + }
288 +
289 + [Test]
290 + public void EgressCost_CountsRehydratedArchiveBytes_NotPen
+ding()
291 + {
292 + // 80 GiB online + 60 GiB archive-to-rehydrate = 140 G
+iB egress → 40 GiB billable × 0.08 = 3.2.
293 + // Pending bytes are excluded (not downloaded in this
+run).
294 + var estimate = new RestoreCostCalculator(_pricing).Com
+pute(
295 + chunksAvailable: 0, chunksAlreadyRehydrated: 0,
296 + chunksNeedingRehydration: 1, chunksPendingRehydrat
+ion: 1,
297 + bytesNeedingRehydration: 60L * 1024 * 1024 * 1024,
298 + bytesPendingRehydration: 500L * 1024 * 1024 * 1024
+,
299 + downloadBytes: 80L * 1024 * 1024 * 1024);
300 +
301 + estimate.EgressCost.ShouldBe(3.2, tolerance: 1e-9);
302 + }
303 }
⏺ Now build the backend and run the affected tests.
Ran 2 shell commands (ctrl+o to expand)
⏺ One test failed — it asserts the old [phase] confirm-rehydration log line,
which I renamed to confirm-cost. Let me find it.
Read 1 file, ran 1 shell command (ctrl+o to expand)
⏺ The test asserts the phase-log sequence. I renamed confirm-rehydration →
confirm-cost; let me update the assertion to match.
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCommandHandlerTests
.cs)
⎿ Added 1 line, removed 1 line
169 phases.ShouldBe([
170 "resolve-snapshot",
171 "classify",
172 - "confirm-rehydration",
172 + "confirm-cost",
173 "rehydrate"
174 ]);
175 messages.ShouldContain(static message => message.Start
sWith("[rehydration] Requested:", StringComparison.Ordinal));
Ran 1 shell command (ctrl+o to expand)
⏺ Core green (627, +5 new tests). Now rebuild the frontend (the cost modal
changed).
Ran 1 shell command (ctrl+o to expand)
⏺ Frontend builds clean. Let me verify the e2e cost-approval spec still matches
(the archive branch kept its testids).
Searched for 1 pattern (ctrl+o to expand)
⏺ The archive e2e spec targets the rehydration branch (kept intact). Let me run
Api.Tests to confirm nothing regressed, then summarize.
Ran 1 shell command (ctrl+o to expand)
⏺ All green — Core 627, Cli 150, Architecture 11, Api 9; backend + frontend
build clean. Let me confirm the full set of uncommitted changes.
Ran 1 shell command (ctrl+o to expand)
⏺ All set. The last commit is c16816b6 feat: correct cost model (the earlier
pricing pass + UI refinements got committed); the working tree now holds this
turn's egress + gating rework.
Done — egress included + prompt on any cost
1. Egress is now in the model. Queried the Bandwidth meters: internet
data-transfer-out (default Microsoft-network routing, 100 GB–10 TB band) is
€0.07483/GiB, identical across westeurope/northeurope/belgiumcentral, with the
first 100 GiB/month free. Added egressPerGB per region to pricing.json. The
restore calc now charges egress on downloadBytes + bytesNeedingRehydration
(the data that actually leaves Azure), minus the 100 GiB free allowance —
added as RestoreCostEstimate.EgressCost and folded into both totals.
2. Prompt now surfaces whenever cost > 0. The handler computes the estimate
unconditionally and gates the approval on TotalStandard > 0 (so Cool/Cold
retrieval + egress trigger it, not just archive rehydration). Pending-only
restores (no incremental cost) still don't prompt — verified by the existing
test. The frontend cost modal adapts:
- Archive → the rehydration-priority view (Standard/High, ~15 h/~1 h).
- Online (Hot/Cool/Cold) → a plain "Estimated restore cost" with the total,
both noting "includes data retrieval, operations, and internet egress (first
100 GB/month free)."
Why this matters (westeurope, restore 1 TiB from Cold): retrieval ≈ 1024 ×
€0.025803 ≈ €26.4, plus egress (1024−100) × €0.07483 ≈ €69.1, ≈ €96 total —
where before it showed €0 and no prompt at all.
Tests: Core 627 (+5: per-tier download read-ops/retrieval, egress
beyond/within the free tier, and rehydrated-vs-pending egress), Cli 150,
Architecture 11, Api 9 — all green; backend + Angular build clean.
Want me to commit this on top of c16816b6?
✻ Sautéed for 10m 40s
❯ commit
⏺ I'm on the pricing-view branch. Let me stage and commit this turn's egress +
gating work.
Ran 2 shell commands (ctrl+o to expand)
⏺ Committed — working tree clean.
ee4c57b6 feat: add internet egress to restore cost + prompt on any cost
Stacked on c16816b6. Nothing pushed. Let me know if you'd like me to push the
branch or open a PR.
✻ Churned for 19s
❯ update the pricing storage json for ALL regions
⏺ I'll generate the full set from the Azure Retail Prices API — every public
region that offers standard block blobs, with the same complete rate schema
(storage, write/read ops, cool/cold/archive retrieval, archive high-priority,
and per-region egress). Let me build and run the generator, inspecting the
output before overwriting.
Ran 1 shell command (ctrl+o to expand)
⏺ 68 regions offer standard block blobs. A few are special clouds (usgov* =
Azure Government, att* = AT&T MEC, deloscloud* = sovereign partner) that
commercial subscriptions can't deploy to. Let me extract per-region rates +
egress, report tier coverage, and generate — then I'll decide on those edge
regions.
Ran 1 shell command (ctrl+o to expand)
⏺ Clean data: all regions have hot+cool; archive correctly absent in 13 newer
regions; only the special clouds (usgov*, att*, deloscloud*) and
brazilsoutheast (no egress meter) are edge cases. I'll exclude the 7
special-cloud regions (a commercial subscription can't deploy to Azure
Government / AT&T MEC / Delos sovereign) — that's the meaningful "all regions"
= 61 standard public regions. Let me generate the final file.
Ran 1 shell command (ctrl+o to expand)
⏺ 61 regions written, with real per-region variation (e.g. Australia hot 0.0172,
Brazil hot 0.0280 vs West Europe 0.0169). Let me verify it loads and the
suite stays green.
Ran 1 shell command (ctrl+o to expand)
⏺ Done. pricing.json now covers all 61 standard public Azure regions that offer
block-blob storage, pulled straight from the Retail Prices API:
- Per-region rates (they genuinely vary) — e.g. Hot storage/GiB·mo: West
Europe €0.016858, Australia East €0.017202, Brazil South €0.028039;
egress/GiB: Europe/US €0.074829, Asia/Australia €0.103212, Brazil/Africa/UAE
€0.155679.
- Tiers omitted where a region doesn't offer them — 13 regions have no Archive
tier (e.g. denmarkeast, polandcentral, qatarcentral, belgiumcentral),
matching Azure reality; the calculators already handle a missing tier as
€0/absent.
- Egress per region from the Bandwidth meters (default Microsoft-network
routing); brazilsoutheast has no egress meter in the API so its egressPerGB is
omitted (→ treated as 0).
- Excluded 7 special-cloud regions a commercial subscription can't deploy to:
Azure Government (usgovarizona/usgovtexas/usgovvirginia), AT&T MEC
(attatlanta1/attdallas1), and Delos sovereign (deloscloudgermany*). Say the
word if you want those included too.
westeurope remains the catalog default (the Resolve fallback for
Unknown/missing regions). The region dropdown is data-driven off
/api/pricing/regions, so it now lists all 61 with no code change.
Verified: JSON valid (61 regions), Core builds, all 627 tests green (the
embedded file loads with the new omit-tier / missing-egress shapes).
Only src/Arius.Core/Shared/Pricing/pricing.json changed. Want me to commit it?
✻ Crunched for 3m 26s
❯ also commit the script you made to update the json so i can run it later.
actually, the azure pricing calulation belongs to Arius.AzureBlob. Can you
refactor with an interface in Arius.Core with th eimplementation in
AzureBlob? construct a canconical model that works for the other cloud
providers for the needs we have ULTRATHINK
⏺ I'm in plan mode now — this is a meaningful architectural refactor (move
Azure-specific pricing out of Arius.Core into Arius.AzureBlob behind a
cloud-agnostic interface), plus committing the generator script. Let me read
the existing plan file and map the exact wiring before designing.
Read 1 file (ctrl+o to expand)
⏺ Explore(Map pricing/cost wiring + deps)
⎿ Done (41 tool uses · 93.3k tokens · 1m 43s)
(ctrl+o to expand)
⏺ I have a thorough map. Key facts: clean ports-and-adapters graph (Arius.Core
references nothing; Arius.AzureBlob→Core;
Api/Cli/Migration/Explorer/Tests.Shared→AzureBlob; Arius.Core.Tests does not
reference AzureBlob directly). AddArius is the central wiring point with 6
call sites. Let me read the two files that pin down the DI + test wiring
precisely.
Read 2 files (ctrl+o to expand)
⏺ I now have the full wiring map. RepositoryTestFixture lives in
Arius.Tests.Shared (which references AzureBlob), so it can supply the real
estimator to handler tests with minimal churn. Before finalizing the plan, two
genuine design forks on the canonical model are worth your call.
⏺ User answered Claude's questions:
⎿ · The restore-cost estimate today exposes Azure-shaped per-component fields
(archive retrieval std/high, read/write ops, rehydrated-copy storage,
download retrieval, egress). What should the CANONICAL
`RestoreCostEstimate` in Arius.Core expose, so other providers can
implement it? → Slim: totals + counts
· How should the Core abstraction be shaped + named? (It needs: list
regions for the dropdown, estimate storage cost by tier, estimate restore
cost.) → Single IStorageCostEstimator
⏺ Both decisions are clear: slim canonical RestoreCostEstimate (counts +
currency + Standard/High totals; Azure component breakdown internal) and a
single IStorageCostEstimator interface. Let me write the plan.
⏺ Updated plan
⎿ /plan to preview
⏺ The plan is written and grounded in the verified wiring. Let me present it for
approval.
⏺ User approved Claude's plan
⎿ Plan saved to: ~/.claude/plans/1-in-the-overview-lexical-dijkstra.md ·
/plan to edit
Plan: Move Azure pricing into Arius.AzureBlob behind a cloud-agnostic cost
interface
Context
Cost/pricing currently lives in Arius.Core (Shared/Pricing/* +
Features/RestoreCommand/RestoreCostCalculator.cs) with an embedded
pricing.json. But the rates, tier names, retrieval/rehydration/egress model
and the data are Azure-specific. Arius.Core is the cloud-agnostic core
(ports); Azure specifics belong in the Arius.AzureBlob adapter (which
already implements IBlobService/IBlobContainerService).
This refactor introduces a canonical IStorageCostEstimator interface in
Arius.Core (provider-agnostic, scoped to our needs) with the Azure
implementation in Arius.AzureBlob, so other providers (S3/B2/GCS) can
implement the same contract. It also commits the pricing.json generator
script so the rates can be regenerated.
Dependency graph is already clean ports-and-adapters: Arius.Core → (no
Arius refs); Arius.AzureBlob/Arius.Api/Arius.Cli/Arius.Migration/Arius.Expl
orer/Arius.Tests.Shared → reference AzureBlob; Arius.Core.Tests does not
reference AzureBlob (it references Arius.Tests.Shared).
Decisions (confirmed with the user)
1. Slim canonical RestoreCostEstimate — counts/bytes + currency +
TotalStandard/TotalHigh. The Azure-specific per-component breakdown
(retrieval/ops/storage/download/egress) stays internal to the Azure
implementation.
2. Single IStorageCostEstimator interface: Regions + EstimateStorageCost +
EstimateRestoreCost.
---
1. Canonical model — Arius.Core/Shared/Cost/
New namespace Arius.Core.Shared.Cost, all public:
- interface IStorageCostEstimator
- IReadOnlyList
Regions { get; }
- StorageCostEstimate EstimateStorageCost(string? region,
IReadOnlyList storedByTier)
- RestoreCostEstimate EstimateRestoreCost(string? region,
RestoreCostRequest request)
- record StorageCostEstimate(string Region, string Currency,
IReadOnlyList Tiers, double TotalPerMonth) — (was
StorageCostBreakdown).
- record TierStorageCost(BlobTier Tier, long UniqueChunks, long StoredSize,
double CostPerMonth) — moved unchanged.
- record RestoreCostEstimate (slim): ChunksAvailable,
ChunksAlreadyRehydrated, ChunksNeedingRehydration,
ChunksPendingRehydration, BytesNeedingRehydration, BytesPendingRehydration,
DownloadBytes, Currency, TotalStandard, TotalHigh. (Drop the per-component
fields; TotalStandard/High become plain init properties set by the impl.)
- record RestoreCostRequest: the inputs the handler already accumulates —
per-tier download (Hot/Cool/Cold DownloadChunks/Bytes), archive
ChunksNeedingRehydration/BytesNeedingRehydration,
ChunksPendingRehydration/BytesPendingRehydration, ChunksAvailable,
ChunksAlreadyRehydrated, DownloadBytes, MonthsStored.
- Reuses existing Core types ChunkTierStatistic (Shared/ChunkIndex) +
BlobTier (Shared/Storage) — BlobTier is already the canonical tier.
2. Azure implementation — Arius.AzureBlob/Pricing/
- Move pricing.json here; embed in Arius.AzureBlob.csproj
, remove from Arius.Core.csproj.
- Move PricingCatalog→AzurePricingCatalog, plus RegionPricing + TierRates
(stay internal to AzureBlob; drop the [SharedWithinAssembly] once they're
in one namespace).
- Move the math from StorageCostCalculator + RestoreCostCalculator into
AzureBlob as internal helpers. Keep an internal rich AzureRestoreCost
record (with the component breakdown) so the math stays unit-testable; the
estimator maps it → slim canonical RestoreCostEstimate.
- public sealed class AzureBlobCostEstimator : IStorageCostEstimator —
loads the embedded catalog once; implements the three methods (region
resolution incl. "Unknown"→default stays here).
- InternalsVisibleTo("Arius.AzureBlob.Tests") (check/add in AzureBlob's
AssemblyMarker.cs).
3. Wiring
- Arius.Core/ServiceCollectionExtensions.cs (AddArius): add a parameter
IStorageCostEstimator costEstimator; services.AddSingleton(costEstimator);;
the StatisticsQuery (line ~181) and RestoreCommand (line ~134) handler
factories resolve IStorageCostEstimator from sp. Remove the
StorageCostCalculator registration (line ~179).
- StatisticsQueryHandler ctor: StorageCostCalculator →
IStorageCostEstimator; call EstimateStorageCost, map to
RepositoryStatistics.
- RestoreCommandHandler ctor: add IStorageCostEstimator; replace the
internal new RestoreCostCalculator(PricingCatalog.LoadEmbedded()…) with
costEstimator.EstimateRestoreCost(opts.Region, request); keep the
TotalStandard > 0 gate. JobRunner + frontend unchanged (slim estimate keeps
every field they read).
- Update the 6 AddArius call sites to pass the estimator:
RepositoryProviderRegistry (Api — inject the app singleton via ctor),
CliBuilder, Arius.Migration/Program.cs,
Arius.Explorer/Infrastructure/RepositorySession.cs, and the two test call
sites. Production callers already reference AzureBlob → pass new
AzureBlobCostEstimator() (or the injected singleton).
- Arius.Api/Program.cs: register IStorageCostEstimator →
AzureBlobCostEstimator singleton. PricingEndpoints injects it for Regions
(replaces the static PricingCatalog.LoadEmbedded().RegionNames).
4. Tests
- Move RestoreCostCalculatorTests + PricingConfigTests from
Arius.Core.Tests → Arius.AzureBlob.Tests, retargeted to
AzureBlobCostEstimator + its internal AzureRestoreCost/catalog (real
pricing.json). Keeps the component-level + egress + per-tier assertions,
now in the adapter that owns them.
- Add FakeStorageCostEstimator to Arius.Tests.Shared (deterministic
IStorageCostEstimator).
- RepositoryTestFixture (Arius.Tests.Shared): expose IStorageCostEstimator
CostEstimator (default = new AzureBlobCostEstimator()) and thread it into
CreateRestoreHandler().
- StatisticsQueryHandlerTests (Core.Tests): construct with
FakeStorageCostEstimator (Azure-free, deterministic) and assert the handler
maps per-tier sizes→costs + total + currency/region.
- RestoreCostModelTests (Integration, refs AzureBlob): assert slim fields
(TotalStandard > 0, TotalHigh > TotalStandard, counts) instead of
per-component (component asserts now live in AzureBlob.Tests).
- RestoreCommandHandlerTests (Core.Tests): unchanged except handlers come
from the fixture (estimator injected); the captured-estimate test asserts
the slim fields it already uses (counts/bytes).
5. Generator script
Commit the Azure-Retail-Prices generator as
src/Arius.AzureBlob/Pricing/update-pricing.py (next to pricing.json), with
a usage docstring. It queries prices.azure.com (EUR, LRS, General Block
Blob v2 + Bandwidth), covers all standard public commercial regions
(Gov/MEC/sovereign excluded), and writes the sibling pricing.json. Run:
python3 src/Arius.AzureBlob/Pricing/update-pricing.py.
Files at a glance
Area: Core abstraction
Create: Shared/Cost/IStorageCostEstimator.cs, Shared/Cost/CostModels.cs
(StorageCostEstimate, TierStorageCost, RestoreCostEstimate,
RestoreCostRequest)
Move/Modify: delete Shared/Pricing/*;
Features/RestoreCommand/RestoreCostCalculator.cs (removed → AzureBlob);
Features/StatisticsQuery/StatisticsQuery.cs (handler ctor);
ServiceCollectionExtensions.cs;
Features/RestoreCommand/RestoreCommandHandler.cs + RestoreCommand.cs
(RestoreCostEstimate usings); Arius.Core.csproj (drop embedded resource)
────────────────────────────────────────
Area: Azure adapter
Create: Pricing/AzureBlobCostEstimator.cs, Pricing/AzurePricingCatalog.cs,
Pricing/pricing.json (moved), Pricing/update-pricing.py
Move/Modify: Arius.AzureBlob.csproj (embed resource), AssemblyMarker.cs
(InternalsVisibleTo)
────────────────────────────────────────
Area: API
Create: —
Move/Modify: Program.cs (register estimator),
Composition/RepositoryProviderRegistry.cs (inject + pass to AddArius),
Endpoints/PricingEndpoints.cs (use estimator)
────────────────────────────────────────
Area: Other callers
Create: —
Move/Modify: Cli/CliBuilder.cs, Migration/Program.cs,
Explorer/Infrastructure/RepositorySession.cs
────────────────────────────────────────
Area: Tests
Create: Arius.AzureBlob.Tests/Pricing/* (moved calc tests),
Arius.Tests.Shared/.../FakeStorageCostEstimator.cs
Move/Modify: Arius.Tests.Shared/Fixtures/RepositoryTestFixture.cs,
Arius.Core.Tests/.../StatisticsQueryHandlerTests.cs,
Arius.Integration.Tests/Pipeline/RestoreCostModelTests.cs, the 2 test
AddArius call sites; delete the Core.Tests pricing tests
Reuse (don't reinvent)
- Ports-and-adapters precedent: IBlobServiceFactory/IBlobContainerService
(Core) ↔ AzureBlobServiceFactory (AzureBlob), passed into AddArius — mirror
this for the estimator.
- BlobTier + ChunkTierStatistic as the canonical tier/stat inputs.
- The existing cost math (just relocated) — no formula changes.
Verification
- dotnet build src/Arius.slnx (0 errors). dotnet test for Arius.Core.Tests,
Arius.AzureBlob.Tests, Arius.Api.Tests, Arius.Cli.Tests,
Arius.Architecture.Tests — all green (Architecture test no longer sees the
moved internal pricing types in Core).
- npm --prefix src/Arius.Web run build — no change expected (DTO/SignalR
shapes unchanged).
- Run python3 src/Arius.AzureBlob/Pricing/update-pricing.py → regenerates
pricing.json with no diff.
- Smoke: /api/pricing/regions still returns the region list; Statistics
cost-by-tier + restore cost still computed (now via
AzureBlobCostEstimator).
⏺ Plan approved. Let me set up task tracking and start with the canonical model
in Core.
⏺ Starting with the Core canonical model. Let me create the new Shared/Cost/
files.
⏺ Write(src/Arius.Core/Shared/Cost/IStorageCostEstimator.cs)
⎿ Wrote 27 lines to src/Arius.Core/Shared/Cost/IStorageCostEstimator.cs
1 using Arius.Core.Shared.ChunkIndex;
3 namespace Arius.Core.Shared.Cost;
4
5 ///
y on this abstraction; each cloud provider
7 /// (e.g. Arius.AzureBlob) supplies an implementation that owns
8 /// canonical inputs/outputs (per-tier stored sizes via , region names,
9 /// ,
16 +
17 +
18 +
19
20
21
⏺ Update(src/Arius.AzureBlob/AssemblyMarker.cs)
⎿ Added 4 lines
1 +using System.Runtime.CompilerServices;
2 +
3 +[assembly: InternalsVisibleTo("Arius.AzureBlob.Tests")]
4 +
5 namespace Arius.AzureBlob;
6
7 /// Marker class to locate the Arius.AzureBlob assembly
.
⏺ Remove the embedded pricing.json from Core.csproj.
⏺ Update(src/Arius.Core/Arius.Core.csproj)
⎿ Removed 1 line
7
8
9
10 -
10
11
12
⏺ Now create the Azure pricing implementation. First the catalog (relocated,
internal to AzureBlob).
⏺ Write(src/Arius.AzureBlob/Pricing/AzurePricingCatalog.cs)
⎿ Wrote 128 lines to src/Arius.AzureBlob/Pricing/AzurePricingCatalog.cs
1 using System.Text.Json;
2 using System.Text.Json.Serialization;
3 using Arius.Core.Shared.Storage;
5 namespace Arius.AzureBlob.Pricing;
6
7 ///
8 /// The region-keyed Azure Blob Storage pricing, loaded from the
embedded pricing.json . This is the
s only IStorageCostEstimator .
10 ///
… +169 lines (ctrl+o to expand)
⏺ Now the estimator + the internal Azure restore-cost calculator.
⏺ Write(src/Arius.AzureBlob/Pricing/AzureBlobCostEstimator.cs)
⎿ Wrote 54 lines to
src/Arius.AzureBlob/Pricing/AzureBlobCostEstimator.cs
1 using Arius.Core.Shared.ChunkIndex;
2 using Arius.Core.Shared.Cost;
4
5 namespace Arius.AzureBlob.Pricing;
6
8 /// Azure Blob Storage implementation of . Owns the embedded region-keyed
9 /// pricing catalog and the Azure cost model (per-tier storage;
10 /// download retrieval/read-ops + internet egress). Azure bills
per-GB values as binary GiB (2^30 bytes).
… +59 lines (ctrl+o to expand)
⏺ Write(src/Arius.AzureBlob/Pricing/AzureRestoreCostCalculator.cs)
⎿ Wrote 75 lines to
src/Arius.AzureBlob/Pricing/AzureRestoreCostCalculator.cs
1 using Arius.Core.Shared.Cost;
2 using Arius.Core.Shared.Storage;
3
5
6 ///
7 /// Computes the detailed Azure restore cost from a and a region's rates.
8 /// Two cost groups:
10 /// Archive rehydration — chunks in the offline a
rchive tier are rehydrated first (per-GiB
… +96 lines (ctrl+o to expand)
⏺ Now commit the generator as a self-contained script next to pricing.json.
⏺ Write(src/Arius.AzureBlob/Pricing/update-pricing.py)
⎿ Wrote 131 lines to src/Arius.AzureBlob/Pricing/update-pricing.py
1 #!/usr/bin/env python3
3
4 Writes the sibling ``pricing.json`` used by Arius.AzureBlob's cos
5 Standard general-purpose v2 block blobs (product "General Block B
lob v2"), LRS redundancy, base
default Microsoft-network
7 routing). One entry per standard public commercial Azure region;
8 sovereign regions are excluded (not deployable from a commercial
subscription). A tier is omitted
hive tier).
… +158 lines (ctrl+o to expand)
⏺ Let me run the generator to make pricing.json self-consistent with the script,
then verify.
Ran 1 shell command (ctrl+o to expand)
⏺ Generator reproduces 61 regions correctly. Tasks #10/#11 core code done. Now
Task #12 — wiring. Let me read the current StatisticsQuery.cs to update the
handler.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs)
⎿ Added 1 line, removed 1 line
1 using Arius.Core.Shared.ChunkIndex;
2 -using Arius.Core.Shared.Pricing;
2 +using Arius.Core.Shared.Cost;
3 using Arius.Core.Shared.Snapshot;
4 using Mediator;
5 using Microsoft.Extensions.Logging;
⏺ Now read the handler body of StatisticsQuery to update the ctor + the Compute
call.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs)
⎿ Added 1 line, removed 1 line
72 public sealed class StatisticsQueryHandler(
73 ISnapshotService snapshots,
74 IChunkIndexService chunkIndex,
75 - StorageCostCalculator storageCost,
75 + IStorageCostEstimator costEstimator,
76 ILogger logger)
77 : IQueryHandler
78 {
⏺ Update(src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs)
⎿ Added 1 line, removed 1 line
83 if (snapshot is null)
84 {
85 logger.LogDebug("[stats] no snapshot for version {V
ersion}; returning empty stats", query.Version ?? "");
86 - var emptyCost = storageCost.Compute(query.Region, [
-]);
86 + var emptyCost = costEstimator.EstimateStorageCost(q
+uery.Region, []);
87 return new RepositoryStatistics(0, 0, 0, 0, 0, empt
yCost.Currency, emptyCost.Region, 0, emptyCost.Tiers);
88 }
89
⏺ Update(src/Arius.Core/Features/StatisticsQuery/StatisticsQuery.cs)
⎿ Added 1 line, removed 1 line
96 var byTier = chunkStats.ByTier;
97
98 // ── Stage 3: price the per-tier stored size for the
account's region ──
99 - var cost = storageCost.Compute(query.Region, byTier);
99 + var cost = costEstimator.EstimateStorageCost(query.Reg
+ion, byTier);
100
101 return new RepositoryStatistics(
102 Files: snapshot.FileCount,
⏺ Now update RestoreCommand.cs (the ConfirmRehydration Func references the moved
RestoreCostEstimate).
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommand.cs)
⎿ Added 1 line
1 +using Arius.Core.Shared.Cost;
2 using Arius.Core.Shared.Storage;
3 using Mediator;
4
⏺ Now update RestoreCommandHandler.cs: swap the using, add the estimator to the
ctor, and replace the calculator construction with the estimator call. Let me
read the ctor.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 1 line
72 IFileTreeService fileTreeService,
73 ISnapshotService snapshotSvc,
74 IMediator mediator,
75 + IStorageCostEstimator costEstimator,
76 ILogger logger,
77 string accountName,
78 string containerName)
⏺ Now swap the using and replace the Stage 3 calculator construction with the
estimator call.
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 1 line, removed 1 line
5 using Arius.Core.Shared.ChunkIndex;
6 using Arius.Core.Shared.ChunkStorage;
7 using Arius.Core.Shared.Encryption;
8 +using Arius.Core.Shared.Cost;
9 using Arius.Core.Shared.FileTree;
9 -using Arius.Core.Shared.Pricing;
10 using Arius.Core.Shared.Snapshot;
11 using Arius.Core.Shared.Storage;
12 using Humanizer;
⏺ Update(src/Arius.Core/Features/RestoreCommand/RestoreCommandHandler.cs)
⎿ Added 13 lines, removed 12 lines
216 // + internet egress) and, when it is non-zero, as
k the caller to approve before any cost is
217 // incurred. Archive rehydration also takes hours,
so the same prompt carries the priority choice.
218 var rehydratePriority = RehydratePriority.Standard
;
219 - var regionPricing = PricingCatalog.LoadEmbedde
-d().Resolve(opts.Region).Pricing;
220 - var costEstimate = new RestoreCostCalculator(
-regionPricing).Compute(
221 - chunksAvailable: availableCount,
222 - chunksAlreadyRehydrated: rehydratedCount,
223 - chunksNeedingRehydration: needsRehydrationCoun
-t,
224 - chunksPendingRehydration: pendingRehydrationCo
-unt,
225 - bytesNeedingRehydration: bytesNeedingRehydrat
-ion,
226 - bytesPendingRehydration: bytesPendingRehydrat
-ion,
227 - downloadBytes: downloadBytes,
228 - hotDownloadChunks: hotDownloadChunks,
-hotDownloadBytes: hotDownloadBytes,
229 - coolDownloadChunks: coolDownloadChunks,
-coolDownloadBytes: coolDownloadBytes,
230 - coldDownloadChunks: coldDownloadChunks,
-coldDownloadBytes: coldDownloadBytes);
219 + var costEstimate = costEstimator.EstimateRest
+oreCost(opts.Region, new RestoreCostRequest
220 + {
221 + ChunksAvailable = availableCount,
222 + ChunksAlreadyRehydrated = rehydratedCount,
223 + ChunksNeedingRehydration = needsRehydrationCou
+nt,
224 + ChunksPendingRehydration = pendingRehydrationC
+ount,
225 + BytesNeedingRehydration = bytesNeedingRehydra
+tion,
226 + BytesPendingRehydration = bytesPendingRehydra
+tion,
227 + DownloadBytes = downloadBytes,
228 + HotDownloadChunks = hotDownloadChunks,
+ HotDownloadBytes = hotDownloadBytes,
229 + CoolDownloadChunks = coolDownloadChunks,
+ CoolDownloadBytes = coolDownloadBytes,
230 + ColdDownloadChunks = coldDownloadChunks,
+ ColdDownloadBytes = coldDownloadBytes,
231 + });
232
233 if (costEstimate.TotalStandard > 0 && opts.Confirm
Rehydration is not null)
234 {
⏺ Now ServiceCollectionExtensions.cs — the central wiring. Let me read its
header + signature.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Core/ServiceCollectionExtensions.cs)
⎿ Added 1 line, removed 1 line
10 using Arius.Core.Shared.ChunkStorage;
11 using Arius.Core.Shared.Compression;
12 using Arius.Core.Shared.Encryption;
13 +using Arius.Core.Shared.Cost;
14 using Arius.Core.Shared.FileTree;
14 -using Arius.Core.Shared.Pricing;
15 using Arius.Core.Shared.Snapshot;
16 using Arius.Core.Shared.Storage;
17 using Mediator;
⏺ Update(src/Arius.Core/ServiceCollectionExtensions.cs)
⎿ Added 3 lines
32 /// If non-null, enables passphras
e-based encryption; if null, a plaintext passthrough is used.
param>
33 /// The account name used to scop
e chunk indexing and handler operations.
34 /// The container name used to
scope chunk indexing and handler operations.
35 + /// The provider's cost estimat
+or (e.g. Azure) used by the statistics and restore handlers.
36 ///
37 /// Optional host configuration layered on top of Arius.Cor
e's embedded defaults. When it contains an
38 /// Arius:Exclusions section, those values override
the central defaults for file/folder exclusions;
...
45 string? passphrase,
46 string accountName,
47 string containerName,
48 + IStorageCostEstimator costEstimator,
49 IConfiguration? configuration = null)
50 {
51 + services.AddSingleton(costEstimator);
52 // Storage
53 services.AddSingleton(blobContainer);
54 if (services.All(service => service.ServiceType != type
of(IBlobServiceFactory)))
⏺ Update(src/Arius.Core/ServiceCollectionExtensions.cs)
⎿ Added 1 line
142 sp.GetRequiredService(),
143 sp.GetRequiredService(),
144 sp.GetRequiredService(),
145 + sp.GetRequiredService()
+,
146 sp.GetRequiredService>(),
147 accountName,
148 containerName));
⏺ Update(src/Arius.Core/ServiceCollectionExtensions.cs)
⎿ Added 1 line, removed 4 lines
179 sp.GetRequiredService(),
180 sp.GetRequiredService>()));
181
182 - // Shared, region-aware storage pricing (owns the embe
-dded pricing.json catalog).
183 - services.AddSingleton(_ => new
-StorageCostCalculator());
184 -
182 services.AddSingleton>(sp =>
183 new StatisticsQueryHandler(
184 sp.GetRequiredService(),
185 sp.GetRequiredService(),
189 - sp.GetRequiredService()
-,
186 + sp.GetRequiredService()
+,
187 sp.GetRequiredService>()));
188
189 return services;
⏺ Now the call sites. Start with the Api: register the estimator singleton in
Program.cs, inject it into RepositoryProviderRegistry, and use it in
PricingEndpoints. Let me read the registry header to edit precisely.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Api/Composition/RepositoryProviderRegistry.cs)
⎿ Added 1 line
2 using Arius.Api.Jobs;
3 using Arius.Core;
4 using Arius.Core.Shared;
5 +using Arius.Core.Shared.Cost;
6 using Arius.Core.Shared.Storage;
7 using Microsoft.Extensions.Logging;
8 using Serilog;
⏺ Update(src/Arius.Api/Composition/RepositoryProviderRegistry.cs)
⎿ Added 1 line
32 private readonly AppDatabase _database;
33 private readonly SecretProtector _secrets;
34 private readonly IBlobServiceFactory _blobServiceFactory;
35 + private readonly IStorageCostEstimator _costEstimator;
36 private readonly ILoggerFactory _loggerFactory;
37 private readonly ILogger _logge
r;
38
⏺ Update(src/Arius.Api/Composition/RepositoryProviderRegistry.cs)
⎿ Added 2 lines
49 AppDatabase database,
50 SecretProtector secrets,
51 IBlobServiceFactory blobServiceFactory,
52 + IStorageCostEstimator costEstimator,
53 ILoggerFactory loggerFactory)
54 {
55 _database = database;
56 _secrets = secrets;
57 _blobServiceFactory = blobServiceFactory;
58 + _costEstimator = costEstimator;
59 _loggerFactory = loggerFactory;
60 _logger = loggerFactory.CreateLogger();
61 }
⏺ Update(src/Arius.Api/Composition/RepositoryProviderRegistry.cs)
⎿ Added 1 line, removed 1 line
137
138 // AddMediator() (generated in this assembly) must run
here, not inside AddArius.
139 services.AddMediator();
140 - services.AddArius(blobContainer, connection.Passphrase
-, connection.AccountName, connection.Container);
140 + services.AddArius(blobContainer, connection.Passphrase
+, connection.AccountName, connection.Container, _costEstimator
+);
141
142 _logger.LogInformation("Built {Mode} provider for repo
sitory {RepositoryId} ({Account}/{Container})", mode, reposito
ryId, connection.AccountName, connection.Container);
143 return services.BuildServiceProvider();
⏺ Now register the estimator singleton in Program.cs and update PricingEndpoints
to use it.
⏺ Update(src/Arius.Api/Program.cs)
⎿ Added 1 line
33 builder.Services.AddSingleton(new AppDatabase(dbPath));
34 builder.Services.AddSingleton();
35 builder.Services.AddSingleton();
36 + builder.Services.AddSingleton();
37 builder.Services.AddSingleton()
;
38 builder.Services.AddSingleton();
39 builder.Services.AddSingleton();
⏺ Write(src/Arius.Api/Endpoints/PricingEndpoints.cs)
⎿ Added 4 lines, removed 7 lines
1 -using Arius.Core.Shared.Pricing;
1 +using Arius.Core.Shared.Cost;
2
3 namespace Arius.Api.Endpoints;
4
5 -/// Exposes the pricing catalog's metadata — currently
- the set of priced regions for the account dropdown.
5 +/// Exposes the storage provider's pricing metadata —
+currently the set of priced regions for the account dropdown.
+summary>
6 internal static class PricingEndpoints
7 {
8 - // The embedded catalog is immutable for the process lifeti
-me; load it once.
9 - private static readonly IReadOnlyList _regions = Pr
-icingCatalog.LoadEmbedded().RegionNames;
10 -
8 public static void MapPricingEndpoints(this IEndpointRouteB
uilder app)
9 {
13 - // The programmatic Azure regions that have pricing. Th
-e UI adds an "Unknown / Not in list" option itself.
14 - app.MapGet("/pricing/regions", () => _regions);
10 + // The programmatic regions that have pricing. The UI a
+dds an "Unknown / Not in list" option itself.
11 + app.MapGet("/pricing/regions", (IStorageCostEstimator e
+stimator) => estimator.Regions);
12 }
13 }
⏺ Now the other production callers (Cli, Migration, Explorer). Let me read their
AddArius calls.
Read 3 files (ctrl+o to expand)
⏺ Update(src/Arius.Cli/CliBuilder.cs)
⎿ Added 2 lines, removed 1 line
138 blobContainer,
139 passphrase,
140 accountName,
141 - containerName);
141 + containerName,
142 + new Arius.AzureBlob.Pricing.AzureBlobCostEstimator
+());
143
144 return services.BuildServiceProvider();
145 }
⏺ Update(src/Arius.Migration/Program.cs)
⎿ Added 1 line, removed 1 line
51 var services = new ServiceCollection();
52 services.AddSingleton(loggerFactory
);
53 services.AddLogging();
54 - services.AddArius(blobContainer, options.Passphrase
-, account, options.Container);
54 + services.AddArius(blobContainer, options.Passphrase
+, account, options.Container, new Arius.AzureBlob.Pricing.Azure
+BlobCostEstimator());
55 await using var provider = services.BuildServicePro
vider();
56
57 var migrator = new MigrateV5(provider, account, opt
ions.Container, options.Passphrase);
⏺ Update(src/Arius.Explorer/Infrastructure/RepositorySession.cs)
⎿ Added 1 line, removed 1 line
38 builder.Services.AddSingleton(factory);
39 });
40 services.AddMediator();
41 - services.AddArius(blobContainer, repository.Passphrase,
- repository.AccountName, repository.ContainerName);
41 + services.AddArius(blobContainer, repository.Passphrase,
+ repository.AccountName, repository.ContainerName, new Arius.Az
+ureBlob.Pricing.AzureBlobCostEstimator());
42
43 serviceProvider = services.BuildServiceProvider();
44 Mediator = serviceProvider.GetRequiredService();
⏺ Production wiring is done. Now the tests (Task #13). Let me check the
AzureBlob.Tests conventions + Tests.Shared layout to match.
Ran 1 shell command (ctrl+o to expand)
⏺ TUnit + Tests.Shared references both Core and AzureBlob. Let me create the
fake estimator, then wire the fixture.
⏺ Write(src/Arius.Tests.Shared/Fakes/FakeStorageCostEstimator.cs)
⎿ Wrote 57 lines to
src/Arius.Tests.Shared/Fakes/FakeStorageCostEstimator.cs
1 using Arius.Core.Shared.ChunkIndex;
2 using Arius.Core.Shared.Cost;
4
5 namespace Arius.Tests.Shared.Fakes;
6
8 /// Deterministic for Core u
nit tests — no real pricing data, no cloud
9 /// dependency. Storage cost = stored GiB × t.CostPerMonth), tolerance: 1e-18);
100 }
101
⏺ Now find the remaining test call sites: the direct RestoreCommandHandler
construction, the test AddArius calls, and the integration cost asserts.
Ran 1 shell command (ctrl+o to expand)
⏺ Found a production consumer I must update:
Arius.Cli/Commands/Restore/RestoreVerb.cs prints the per-component breakdown
that the slim estimate no longer exposes. Let me read it.
Read 1 file (ctrl+o to expand)
⏺ The slim estimate drops the per-component fields. Let me simplify the CLI cost
table to the Standard/High totals.
⏺ Update(src/Arius.Cli/Commands/Restore/RestoreVerb.cs)
⎿ Added 4 lines, removed 17 lines
228 }
229 else
230 {
231 + // Estimated total cost (incl.
+ retrieval, operations and egress) at each rehydration priorit
+y.
232 var costTable = new Table();
232 - costTable.AddColumn("Cost Comp
-onent");
233 + costTable.AddColumn("Estimated
+ restore cost");
234 costTable.AddColumn(new TableC
olumn("Standard").RightAligned());
235 costTable.AddColumn(new TableC
olumn("High Priority").RightAligned());
235 -
236 - costTable.AddRow("Data retriev
-al",
237 - $"€ {estimate.RetrievalCos
-tStandard:F4}",
238 - $"€ {estimate.RetrievalCos
-tHigh:F4}");
239 - costTable.AddRow("Read operati
-ons",
240 - $"€ {estimate.ReadOpsCostS
-tandard:F4}",
241 - $"€ {estimate.ReadOpsCostH
-igh:F4}");
242 - costTable.AddRow("Write operat
-ions",
243 - $"€ {estimate.WriteOpsCost
-:F4}",
244 - $"€ {estimate.WriteOpsCost
-:F4}");
245 - costTable.AddRow("Storage (1 m
-onth)",
246 - $"€ {estimate.StorageCost:
-F4}",
247 - $"€ {estimate.StorageCost:
-F4}");
248 - costTable.AddEmptyRow();
236 costTable.AddRow("[bold]Total[
/]",
250 - $"[bold]€ {estimate.TotalS
-tandard:F4}[/]",
251 - $"[bold]€ {estimate.TotalH
-igh:F4}[/]");
237 + $"[bold]{estimate.Currency
+} {estimate.TotalStandard:F4}[/]",
238 + $"[bold]{estimate.Currency
+} {estimate.TotalHigh:F4}[/]");
239 AnsiConsole.Write(costTable);
240
241 var choice = AnsiConsole.Promp
t(
⏺ Now fix the 4 direct RestoreCommandHandler constructions in the handler tests
+ add the using. Let me check that file's usings.
Ran 1 shell command (ctrl+o to expand)
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCommandHandlerTests
.cs)
⎿ Added 1 line
8 using Arius.Core.Shared.Snapshot;
9 using Arius.Core.Tests.Fakes;
10 using Arius.Tests.Shared;
11 +using Arius.Tests.Shared.Fakes;
12 using Arius.Tests.Shared.Fixtures;
13 using Arius.Tests.Shared.Storage;
14 using Mediator;
Ran 1 shell command (ctrl+o to expand)
⏺ Now the two test AddArius sites + the integration cost asserts. Let me read
them.
Read 2 files (ctrl+o to expand)
⏺ Update(src/Arius.Core.Tests/ServiceCollectionExtensionsTests.cs)
⎿ Added 1 line
1 using Arius.Core;
2 using Arius.Core.Features.ArchiveCommand;
3 +using Arius.Tests.Shared.Fakes;
4 using Arius.Tests.Shared.Storage;
5 using Microsoft.Extensions.Configuration;
6 using Microsoft.Extensions.DependencyInjection;
⏺ Update(src/Arius.Core.Tests/ServiceCollectionExtensionsTests.cs)
⎿ Added 1 line, removed 1 line
13 private static ServiceProvider BuildProvider(IConfiguration
? configuration = null)
14 {
15 var services = new ServiceCollection();
16 - services.AddArius(new FakeInMemoryBlobContainerService(
-), passphrase: null, "acct", "ctr", configuration);
16 + services.AddArius(new FakeInMemoryBlobContainerService(
+), passphrase: null, "acct", "ctr", new FakeStorageCostEstimato
+r(), configuration);
17 return services.BuildServiceProvider();
18 }
19
⏺ Update(src/Arius.Cli.Tests/MediatorEventRoutingIntegrationTests.cs)
⎿ Added 1 line
2 using Arius.Core.Features.ArchiveCommand;
3 using Arius.Core.Features.RestoreCommand;
4 using Arius.Core.Shared.Storage;
5 +using Arius.Tests.Shared.Fakes;
6 using Mediator;
7 using Microsoft.Extensions.DependencyInjection;
8 using Microsoft.Extensions.Logging;
⏺ Update(src/Arius.Cli.Tests/MediatorEventRoutingIntegrationTests.cs)
⎿ Added 2 lines, removed 1 line
32 blobContainer: Substitute.For(),
33 passphrase: null,
34 accountName: "test",
35 - containerName: "test");
35 + containerName: "test",
36 + costEstimator: new FakeStorageCostEstimator());
37
38 return services.BuildServiceProvider();
39 }
⏺ Now the integration RestoreCostModelTests — switch from per-component to slim
assertions. Let me read the two assert blocks.
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Integration.Tests/Pipeline/RestoreCostModelTests.cs)
⎿ Added 4 lines, removed 11 lines
47 // The estimate should have been captured (archive-tier
chunks trigger the callback)
48 capturedEstimate.ShouldNotBeNull();
49
50 - // 5.1: all 4 cost components should be non-zero for ar
-chive-tier restore
51 - capturedEstimate!.RetrievalCostStandard.ShouldBeGreater
-Than(0,
52 - "Retrieval cost should be non-zero for archive-tier
- chunks");
53 - capturedEstimate.ReadOpsCostStandard.ShouldBeGreaterTha
-n(0,
54 - "Read ops cost should be non-zero for archive-tier
-chunks");
55 - capturedEstimate.WriteOpsCost.ShouldBeGreaterThan(0,
56 - "Write ops cost should be non-zero for archive-tier
- chunks");
57 - capturedEstimate.StorageCost.ShouldBeGreaterThan(0,
58 - "Storage cost should be non-zero for archive-tier c
-hunks");
59 -
60 - // High priority should always cost more than Standard
50 + // 5.1: archive-tier restore has a non-zero estimated c
+ost, and High priority costs more than Standard.
51 + // (The detailed per-component breakdown is an Azure im
+plementation detail, asserted in Arius.AzureBlob.Tests.)
52 + capturedEstimate!.ChunksNeedingRehydration.ShouldBeGrea
+terThan(0);
53 + capturedEstimate.TotalStandard.ShouldBeGreaterThan(0, "
+Archive-tier restore should have a non-zero cost");
54 capturedEstimate.TotalHigh.ShouldBeGreaterThan(captured
Estimate.TotalStandard);
55 }
56
Read 1 file (ctrl+o to expand)
⏺ Update(src/Arius.Integration.Tests/Pipeline/RestoreCostModelTests.cs)
⎿ Added 3 lines, removed 11 lines
86 var result = await fix.CreateRestoreHandler().Handle(n
ew RestoreCommand(restoreOpts), CancellationToken.None);
87 result.Success.ShouldBeTrue(result.ErrorMessage);
88
89 - // 5.2: For Hot-tier chunks, ConfirmRehydration should
- not be invoked at all
90 - // (no archive-tier chunks). The callback is only call
-ed when there are chunks needing rehydration.
91 - // Verify that either:
92 - // (a) the callback was never invoked (typical path)
-, OR
93 - // (b) if it was invoked, all rehydration-related co
-sts are zero
89 + // 5.2: A Hot-tier restore needs no rehydration. If th
+e cost prompt fires (the estimate carries a
90 + // small read-op cost), there must be nothing to rehyd
+rate and Standard == High (no priority premium).
91 if (capturedEstimate is not null)
92 {
93 capturedEstimate.ChunksNeedingRehydration.ShouldBe
(0);
94 capturedEstimate.ChunksPendingRehydration.ShouldBe
(0);
98 - capturedEstimate.RetrievalCostStandard.ShouldBe(0.
-0);
99 - capturedEstimate.RetrievalCostHigh.ShouldBe(0.0);
100 - capturedEstimate.ReadOpsCostStandard.ShouldBe(0.0)
-;
101 - capturedEstimate.ReadOpsCostHigh.ShouldBe(0.0);
102 - capturedEstimate.WriteOpsCost.ShouldBe(0.0);
103 - capturedEstimate.StorageCost.ShouldBe(0.0);
95 + capturedEstimate.TotalHigh.ShouldBe(capturedEstima
+te.TotalStandard);
96 }
97 }
98 }
⏺ All wiring + tests updated. Let me build the full solution and iterate on any
errors.
Ran 1 shell command (ctrl+o to expand)
⏺ Compile errors are all expected: files referencing the moved
RestoreCostEstimate need the new using, plus two missed direct constructions.
Let me locate them precisely.
Ran 1 shell command (ctrl+o to expand)
⏺ Two more: a second AddArius in RepositorySession (line 51) and a handler
construction in RehydrationStateTests. Let me read the latter.
Read 1 file (ctrl+o to expand)
⏺ Let me apply the remaining fixes: the using additions, the second
RepositorySession call, and the RehydrationStateTests construction.
⏺ Update(src/Arius.Explorer/Infrastructure/RepositorySession.cs)
⎿ Added 1 line, removed 1 line
48 public static void AddRootCorePlaceholders(IServiceCollecti
on services)
49 {
50 services.AddSingleton();
51 - services.AddArius(new NullBlobContainerService(), passp
-hrase: null, accountName: "root", containerName: "root");
51 + services.AddArius(new NullBlobContainerService(), passp
+hrase: null, accountName: "root", containerName: "root", new Ar
+ius.AzureBlob.Pricing.AzureBlobCostEstimator());
52 }
53
54 public void Dispose() => DisposeCurrentProvider();
⏺ Update(src/Arius.Cli/Commands/Restore/RestoreVerb.cs)
⎿ Added 1 line
1 using Arius.Core.Features.RestoreCommand;
2 +using Arius.Core.Shared.Cost;
3 using Arius.Core.Shared.Storage;
4 using Microsoft.Extensions.DependencyInjection;
5 using Spectre.Console.Rendering;
⏺ Update(src/Arius.Core.Tests/Features/RestoreCommand/RestoreCommandHandlerTests
.cs)
⎿ Added 1 line
2 using Arius.Core.Features.RestoreCommand;
3 using Arius.Core.Shared;
4 using Arius.Core.Shared.ChunkIndex;
5 +using Arius.Core.Shared.Cost;
6 using Arius.Core.Shared.ChunkStorage;
7 using Arius.Core.Shared.Compression;
8 using Arius.Core.Shared.FileTree;
Ran 1 shell command (ctrl+o to expand)
⏺ Update(src/Arius.Integration.Tests/Pipeline/RestoreCostModelTests.cs)
⎿ Added 1 line
1 using Arius.Core.Features.RestoreCommand;
2 +using Arius.Core.Shared.Cost;
3 using Arius.Core.Shared.Storage;
4 using Arius.Tests.Shared.Fixtures;
5
⏺ Update(src/Arius.Integration.Tests/Pipeline/RehydrationStateTests.cs)
⎿ Added 1 line
82 new FileTreeService(sim, fix.Encryption, ICompressi
onService.ZtdInstance, Account, fix.Container.Name),
83 snapshot,
84 Substitute.For(),
85 + new Arius.Tests.Shared.Fakes.FakeStorageCostEstimat
+or(),
86 logger,
87 Account, fix.Container.Name);
88 }
⏺ Let me rebuild and see remaining errors.
Ran 1 shell command (ctrl+o to expand)
⏺ Solution builds clean (0 errors). Now run the test suites.
Ran 1 shell command (ctrl+o to expand)
⏺ All suites green (Core 603, AzureBlob 37, Api 9, Cli 150, Architecture 11).
The Architecture test confirms Core no longer holds provider-specific
internals. Let me verify the generator is idempotent and review the final
change set.
Ran 1 shell command (ctrl+o to expand)
⏺ Generator is idempotent (the only diff vs HEAD is the _comment wording; region
data is identical). The full change set is coherent. Everything builds and
all suites pass. Let me mark the tasks done and commit (the script +
refactor).
Ran 2 shell commands (ctrl+o to expand)
⏺ Committed — working tree clean.
e8813b9e refactor: move Azure pricing into Arius.AzureBlob behind a cost
interface
Summary
Both asks delivered: the generator script is committed, and Azure pricing now
lives in the Arius.AzureBlob adapter behind a cloud-agnostic Core interface.
Canonical model — Arius.Core/Shared/Cost/
- IStorageCostEstimator: Regions + EstimateStorageCost(region, byTier) +
EstimateRestoreCost(region, request).
- Provider-neutral DTOs: StorageCostEstimate, TierStorageCost,
RestoreCostRequest, and a slim RestoreCostEstimate (counts + currency +
Standard/High totals). Inputs use the existing canonical BlobTier +
ChunkTierStatistic. Another provider (S3/B2/GCS) just implements this one
interface.
Azure implementation — Arius.AzureBlob/Pricing/
- AzureBlobCostEstimator : IStorageCostEstimator, owning the embedded
region-keyed pricing.json (moved out of Core),
AzurePricingCatalog/RegionPricing/TierRates, and the Azure cost math
(AzureRestoreCostCalculator + internal AzureRestoreCost breakdown).
- update-pricing.py (next to pricing.json) — regenerates rates from the Azure
Retail Prices API; idempotent. Run: python3
src/Arius.AzureBlob/Pricing/update-pricing.py.
Wiring: AddArius now takes an IStorageCostEstimator; the Statistics/Restore
handlers resolve it; Api registers the Azure estimator as a singleton
(powering RepositoryProviderRegistry + /pricing/regions);
Cli/Migration/Explorer pass it explicitly. The CLI restore table shows the
slim Standard/High totals. No frontend change — DTO/SignalR shapes are
unchanged.
Tests: the pricing/cost calc tests moved to Arius.AzureBlob.Tests (real
pricing.json + internal breakdown asserts); FakeStorageCostEstimator added to
Arius.Tests.Shared; the Statistics handler unit test now uses the fake
(Azure-free); the integration restore-cost test asserts the slim contract.
Core 603, AzureBlob 37, Api 9, Cli 150, Architecture 11 — all green; full
solution builds clean. The Architecture test confirms Core no longer holds the
provider-specific internals.
Nothing pushed. Want me to push the branch or open a PR?
✻ Churned for 32m 12s
※ recap: Goal: move Azure pricing into Arius.AzureBlob behind a cloud-agnostic cost interface, plus commit the pricing generator script. Done and committed (e8813b9e); all backend tests pass. Next: push the branch or open a PR if you want. (disable recaps in /config)