From 92ff6b6172ee9daaba56ff23aed058169c28ea61 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 23:45:54 +0900 Subject: [PATCH 1/2] Keep a budget's threshold on edit and scope account states to the account Editing a budget reset its alert threshold to 80, because the status carried no threshold to prefill from. It does now, and only an account with no budget gets the default. The rule form says what the chosen account's budget is, or that the rule won't fire until it has one. The Accounts state badge matched anomalies and untagged spend by provider, so one account's problem marked its siblings. Both now read the account, and an unscoped rule still matches at provider level. An anomaly or low balance badge opens Alerts. Also move the remaining English strings on Accounts and Rules into the locale file, and label the row actions, account links and rule switches for assistive technology. Co-Authored-By: Claude Opus 5.5 --- locales/ui.yml | 52 +++++++++++++++++- src/db.rs | 1 + src/ledger/query.rs | 46 ++++++++++++++++ src/model.rs | 2 + src/ui/accounts.rs | 117 +++++++++++++++++++++------------------- src/ui/data.rs | 36 ++++++------- src/ui/rules.rs | 43 +++++++++++---- src/web/db.rs | 1 + src/web/ledger/query.rs | 17 ++++++ 9 files changed, 230 insertions(+), 85 deletions(-) diff --git a/locales/ui.yml b/locales/ui.yml index e94aad3..fb4c8f8 100644 --- a/locales/ui.yml +++ b/locales/ui.yml @@ -960,6 +960,45 @@ attribution: zh-CN: "本期有费用后,这里会显示 数据源 → 服务 → 业务线 的流向。可在“账户”页获取或导入账单。" accounts: + hint_aws: + en: "An IAM user's access key with a policy allowing ce:GetCostAndUsage. Cost Explorer charges $0.01 per request." + zh-CN: "IAM 用户的访问密钥,其策略需允许 ce:GetCostAndUsage。Cost Explorer 每次请求收费 $0.01。" + hint_aliyun: + en: "A RAM user's AccessKey with AliyunBSSReadOnlyAccess. Don't use the primary account's key." + zh-CN: "具有 AliyunBSSReadOnlyAccess 权限的 RAM 用户 AccessKey。请勿使用主账号的密钥。" + hint_azure: + en: "An app registration's client ID and secret, with the Cost Management Reader role on the subscription. The subscription's tenant is looked up from it." + zh-CN: "应用注册的客户端 ID 和密钥,并在订阅上授予 Cost Management Reader 角色。订阅所属租户会自动查询。" + hint_gcp: + en: "The path of a service account's JSON key, and the table the billing account's BigQuery export writes to. The account needs BigQuery Job User and Data Viewer there." + zh-CN: "服务账号 JSON 密钥的路径,以及结算账号 BigQuery 导出写入的表。该账号需要在那里拥有 BigQuery Job User 和 Data Viewer 权限。" + hint_cloudflare: + en: "An API token with Account → Billing → Read — and Account → Account Analytics → Read to split the bill by bucket, Worker, D1 database and Durable Object — and the account ID. Pay-as-you-go accounts only." + zh-CN: "具有 Account → Billing → Read 权限的 API 令牌(如需按存储桶、Worker、D1 数据库和 Durable Object 拆分账单,还需 Account → Account Analytics → Read),以及账户 ID。仅支持按量付费账户。" + hint_deepseek: + en: "A platform key reads the balance only; import the cost export for spend detail. The key can also spend, so keep it private." + zh-CN: "平台密钥只能读取余额;如需支出明细,请导入费用导出文件。该密钥也可用于消费,请妥善保管。" + no_credentials_needed: + en: "No credentials needed: this source is read from its %{format}. Save the account, then use Import. (%{origin})" + zh-CN: "无需凭据:此数据源从其 %{format} 读取。保存账户后使用“导入”。(%{origin})" + export_uri_hint: + en: "Optional. A FOCUS 1.2 export from Billing and Cost Management → Data Exports. When set, the bill is read from S3 and Cost Explorer is not called." + zh-CN: "可选。来自 Billing and Cost Management → Data Exports 的 FOCUS 1.2 导出。设置后将从 S3 读取账单,不再调用 Cost Explorer。" + fill_tooltip: + en: "Read the credentials this machine already has, from the environment or the provider's credentials file" + zh-CN: "读取本机已有的凭据,来源为环境变量或服务商的凭据文件" + state_tooltip: + en: "See the alert" + zh-CN: "查看告警" + a11y_validate: + en: "Validate %{name}" + zh-CN: "验证 %{name}" + a11y_import: + en: "Import a bill for %{name}" + zh-CN: "为 %{name} 导入账单" + a11y_delete: + en: "Delete %{name}" + zh-CN: "删除 %{name}" tag_hint: en: "Tag these resources with '%{tag}' at your provider so their spend lands on a business line." zh-CN: "请在服务商处为这些资源添加“%{tag}”标签,其支出才会归入业务线。" @@ -973,8 +1012,8 @@ accounts: en: "Setup guide" zh-CN: "配置指南" subtitle: - en: "%{n} accounts. Credentials stay in the OS keyring, never in the database" - zh-CN: "%{n} 个账户。凭据保存在系统密钥环中,不会写入数据库" + en: "%{n} accounts. Credentials stay in the OS keyring, never in the database. Month figures are net of credits, so they can be negative; balances stay in the account's own currency." + zh-CN: "%{n} 个账户。凭据保存在系统密钥环中,不会写入数据库。本月金额已扣除抵扣,因此可能为负;余额保留账户自身的币种。" col_account: en: "ACCOUNT" zh-CN: "账户" @@ -1495,6 +1534,9 @@ insights: zh-CN: "包括" rules: + subtitle: + en: "Every rule runs on this computer after each refresh or import. Nothing leaves the machine." + zh-CN: "每次刷新或导入后,所有规则都在本机运行,数据不会离开本机。" kind_cost_anomaly: en: "Model cost growth anomaly" zh-CN: "模型成本增长异常" @@ -1624,6 +1666,12 @@ rules: kind: en: "Kind" zh-CN: "类型" + rule_budget_is: + en: "This account's budget is %{budget}." + zh-CN: "此账户的预算为 %{budget}。" + rule_needs_budget: + en: "This account has no budget, so this rule won't fire until you set one under Monthly budgets." + zh-CN: "此账户还没有预算,在“每月预算”中设置之前,此规则不会触发。" enabled_on_creation: en: "The rule is enabled on creation." zh-CN: "规则创建后即启用。" diff --git a/src/db.rs b/src/db.rs index 4074cc1..7e25330 100644 --- a/src/db.rs +++ b/src/db.rs @@ -936,6 +936,7 @@ fn budget_status_of( account_id: budget.account_id.clone(), account_name: account.name.clone(), monthly_budget: budget.monthly_budget, + alert_threshold: budget.alert_threshold, current_cost, currency: budget.currency.clone(), percentage_used, diff --git a/src/ledger/query.rs b/src/ledger/query.rs index d47fa3e..1e2f9a4 100644 --- a/src/ledger/query.rs +++ b/src/ledger/query.rs @@ -246,6 +246,36 @@ pub fn untagged_totals_by_provider( with_connection_ref(|conn| untagged_totals_by_provider_of(conn, billing_period, tag_key)) } +/// Untagged usage of a period per account, for a share of that account's own +/// spend. +pub fn untagged_totals_by_account( + billing_period: &str, + tag_key: &str, +) -> Result> { + with_connection_ref(|conn| untagged_totals_by_account_of(conn, billing_period, tag_key)) +} + +fn untagged_totals_by_account_of( + conn: &Connection, + billing_period: &str, + tag_key: &str, +) -> Result> { + let mut stmt = conn.prepare(&format!( + "SELECT account_id, sum(billed_cost_base) AS amount + FROM {NORMALIZED_VIEW} + WHERE billing_period = ? + AND coalesce(nullif(json_extract_string(tags, ?), ''), '') = '' + AND billed_cost_base > 0 + GROUP BY account_id" + ))?; + let rows = stmt + .query_map(params![billing_period, tag_key], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + })? + .collect::, _>>()?; + Ok(rows) +} + /// A caller that wants "every row" passes `usize::MAX`, which wraps to -1 /// as an i64 — and DuckDB refuses a negative LIMIT outright. fn bounded_limit(limit: usize) -> i64 { @@ -4477,6 +4507,22 @@ mod tests { ); } + #[test] + fn untagged_totals_split_a_provider_by_account() { + let mut conn = conn("USD"); + let other = PeriodKey::new("AWS", "other-account", "2026-08"); + write(&mut conn, &aws(), &[charge("EC2", 10.0, "USD", 1)]); + write(&mut conn, &other, &[charge("EC2", 90.0, "USD", 1)]); + + let totals: std::collections::HashMap = + untagged_totals_by_account_of(&conn, "2026-08", "business_line") + .unwrap() + .into_iter() + .collect(); + assert_eq!(totals.get("acct-1"), Some(&10.0)); + assert_eq!(totals.get("other-account"), Some(&90.0)); + } + #[test] fn account_findings_read_only_that_accounts_charges() { let mut conn = conn("USD"); diff --git a/src/model.rs b/src/model.rs index ea7aab7..291d813 100644 --- a/src/model.rs +++ b/src/model.rs @@ -416,6 +416,8 @@ pub struct BudgetStatus { pub account_name: String, /// Monthly budget pub monthly_budget: f64, + /// The percent of the budget that alerts, as saved. + pub alert_threshold: f64, /// Current month actual cost pub current_cost: f64, /// Currency diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 640cd1d..d8b3597 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -42,41 +42,14 @@ const POLICIES_URL: &str = "https://cloudbridge.jetsquirrel.cloud/policies.html" /// What a source's API credential is and what it must be allowed to do, /// with the anchor of its section on the permissions page. `None` for the /// sources read from a bill file, whose form explains that instead. -fn setup_hint(source_id: &str) -> Option<(&'static str, &'static str)> { +fn setup_hint(source_id: &str) -> Option<(SharedString, &'static str)> { match source_id { - "AWS" => Some(( - "An IAM user's access key with a policy allowing ce:GetCostAndUsage. \ - Cost Explorer charges $0.01 per request.", - "aws-cost-explorer", - )), - "Aliyun" => Some(( - "A RAM user's AccessKey with AliyunBSSReadOnlyAccess. Don't use the \ - primary account's key.", - "alibaba-cloud", - )), - "Azure" => Some(( - "An app registration's client ID and secret, with the Cost Management \ - Reader role on the subscription. The subscription's tenant is looked up \ - from it.", - "azure", - )), - "GCP" => Some(( - "The path of a service account's JSON key, and the table the billing \ - account's BigQuery export writes to. The account needs BigQuery Job User \ - and Data Viewer there.", - "google-cloud", - )), - "Cloudflare" => Some(( - "An API token with Account → Billing → Read — and Account → Account \ - Analytics → Read to split the bill by bucket, Worker, D1 database and Durable \ - Object — and the account ID. Pay-as-you-go accounts only.", - "cloudflare", - )), - "DeepSeek" => Some(( - "A platform key reads the balance only; import the cost export for \ - spend detail. The key can also spend, so keep it private.", - "deepseek", - )), + "AWS" => Some((tr!("accounts.hint_aws"), "aws-cost-explorer")), + "Aliyun" => Some((tr!("accounts.hint_aliyun"), "alibaba-cloud")), + "Azure" => Some((tr!("accounts.hint_azure"), "azure")), + "GCP" => Some((tr!("accounts.hint_gcp"), "google-cloud")), + "Cloudflare" => Some((tr!("accounts.hint_cloudflare"), "cloudflare")), + "DeepSeek" => Some((tr!("accounts.hint_deepseek"), "deepseek")), _ => None, } } @@ -1365,6 +1338,17 @@ impl AccountsView { ) }, ) + .role(Role::Link) + .aria_label(row.name.clone()) + .tab_index(0) + .on_key_down({ + let detail_id = detail_id.clone(); + move |event, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + crate::app::navigate_to_account(detail_id.clone(), cx); + } + } + }) .on_click(move |_, _, cx| { crate::app::navigate_to_account(detail_id.clone(), cx) }), @@ -1427,7 +1411,7 @@ impl AccountsView { // A flex parent, so the pill hugs its label instead of // stretching across the lane. .flex() - .child(render_state(row.state, cx)), + .child(render_state_link(&row.id, row.state, cx)), ) .child( div() @@ -1448,6 +1432,10 @@ impl AccountsView { validate_id ))) .label(tr!("accounts.validate")) + .accessibility_label(tr!( + "accounts.a11y_validate", + name = row.name + )) .ghost() .small() .disabled(self.validating_ids.contains(&row.id)) @@ -1467,6 +1455,10 @@ impl AccountsView { import_id ))) .label(tr!("accounts.import")) + .accessibility_label(tr!( + "accounts.a11y_import", + name = row.name + )) .ghost() .small() .disabled(self.importing_ids.contains(&row.id)) @@ -1480,6 +1472,7 @@ impl AccountsView { .child( Button::new(SharedString::from(format!("delete-{}", delete_id))) .label(tr!("common.delete")) + .accessibility_label(tr!("accounts.a11y_delete", name = row.name)) .danger() .ghost() .small() @@ -1812,12 +1805,10 @@ impl AccountsView { .child(div().text_sm().child(tr!("accounts.export_uri"))) .child(Input::new(&self.export_uri_input)) .child( - div().text_xs().text_color(theme::text_muted(cx)).child( - "Optional. A FOCUS 1.2 export from \ - Billing and Cost Management → Data \ - Exports. When set, the bill is read \ - from S3 and Cost Explorer is not called.", - ), + div() + .text_xs() + .text_color(theme::text_muted(cx)) + .child(tr!("accounts.export_uri_hint")), ), ) }) @@ -1827,14 +1818,11 @@ impl AccountsView { .bill_file .filter(|_| !self.selected_source.fetches_from_api()), |el, format| { - el.child(div().text_sm().text_color(theme::text_muted(cx)).child( - format!( - "No credentials needed: this source is read \ - from its {}. Save the account, then use \ - Import. ({})", - format.display_name, format.origin_hint - ), - )) + el.child(div().text_sm().text_color(theme::text_muted(cx)).child(tr!( + "accounts.no_credentials_needed", + format = format.display_name, + origin = format.origin_hint + ))) }, ) // Offered whether or not a key was seen when the @@ -1859,11 +1847,7 @@ impl AccountsView { Button::new("fill-from-system") .label(tr!("accounts.fill_from_system")) .ghost() - .tooltip( - "Read the credentials this machine \ - already has, from the environment or \ - the provider's credentials file", - ) + .tooltip(tr!("accounts.fill_tooltip")) .on_click(cx.listener(|this, _, window, cx| { this.fill_from_system(window, cx); })), @@ -2135,6 +2119,31 @@ fn format_bytes(bytes: u64) -> String { /// The STATE column: problems get a pill in their severity's colours, /// untagged spend an outline pill, healthy plain muted text. +/// The state badge, which leads to the alert behind it when one does. +fn render_state_link(account_id: &str, state: data::AccountState, cx: &App) -> AnyElement { + let badge = render_state(state, cx); + if !matches!( + state, + data::AccountState::Anomaly | data::AccountState::LowBalance + ) { + return badge.into_any_element(); + } + badge + .id(SharedString::from(format!("state-{account_id}"))) + .role(Role::Link) + .aria_label(state.label()) + .tab_index(0) + .cursor_pointer() + .tooltip(|window, cx| Tooltip::new(tr!("accounts.state_tooltip")).build(window, cx)) + .on_key_down(|event, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + crate::app::navigate_to(crate::app::CurrentView::Alerts, cx); + } + }) + .on_click(|_, _, cx| crate::app::navigate_to(crate::app::CurrentView::Alerts, cx)) + .into_any_element() +} + fn render_state(state: data::AccountState, cx: &App) -> Div { match state { data::AccountState::Anomaly => { diff --git a/src/ui/data.rs b/src/ui/data.rs index b2288bf..aca9120 100644 --- a/src/ui/data.rs +++ b/src/ui/data.rs @@ -1192,7 +1192,7 @@ pub fn load_accounts() -> Result { // Untagged usage per provider of the current period, for the badge // check: one period-wide query, built on first use and shared by every // account instead of being re-queried per account. - let mut untagged_by_provider: Option> = None; + let mut untagged_by_account: Option> = None; let mut rows = Vec::new(); for account in accounts { @@ -1218,7 +1218,7 @@ pub fn load_accounts() -> Result { mtd, &open, ¤t, - &mut untagged_by_provider, + &mut untagged_by_account, )?; rows.push(AccountRowData { @@ -1312,7 +1312,7 @@ fn account_state( mtd: f64, open: &[AlertView], current: &BillingPeriod, - untagged_by_provider: &mut Option>, + untagged_by_account: &mut Option>, ) -> Result { // The worst applicable badge wins, in the mock's order of severity. let low_balance = is_snapshot @@ -1324,9 +1324,14 @@ fn account_state( return Ok(AccountState::LowBalance); } + // A rule scoped to an account names it. An unscoped rule watches the whole + // provider, so it can only be matched that far. let anomaly = open.iter().any(|alert| { alert.kind == AlertKind::CostAnomaly - && alert.context.get("provider").and_then(|v| v.as_str()) == Some(provider) + && match alert.context.get("account_id").and_then(|v| v.as_str()) { + Some(account_id) => account_id == account.id, + None => alert.context.get("provider").and_then(|v| v.as_str()) == Some(provider), + } }); if anomaly { return Ok(AccountState::Anomaly); @@ -1335,13 +1340,17 @@ fn account_state( // The month-to-date total comes from the caller, which already read it // for the row's mtd column. if mtd > 0.0 { - if untagged_by_provider.is_none() { - *untagged_by_provider = Some(untagged_usage_by_provider(¤t.label())?); + if untagged_by_account.is_none() { + *untagged_by_account = Some( + query::untagged_totals_by_account(¤t.label(), BUSINESS_LINE_TAG)? + .into_iter() + .collect(), + ); } - let untagged = untagged_by_provider + let untagged = untagged_by_account .as_ref() .expect("just populated") - .get(provider) + .get(&account.id) .copied() .unwrap_or(0.0); if untagged / mtd > alerts::DEFAULT_UNTAGGED_THRESHOLD { @@ -1352,17 +1361,6 @@ fn account_state( Ok(AccountState::Healthy) } -/// Untagged usage of a period summed per provider — one period-wide -/// aggregate query, grouped in SQL so the per-account badge check neither -/// re-queries for every account nor materializes the per-charge list. -fn untagged_usage_by_provider(billing_period: &str) -> Result> { - Ok( - query::untagged_totals_by_provider(billing_period, BUSINESS_LINE_TAG)? - .into_iter() - .collect(), - ) -} - // ==================== Attribution ==================== /// One step of the attribution path (Source → Service → Business line), diff --git a/src/ui/rules.rs b/src/ui/rules.rs index bd5e5bd..b561ace 100644 --- a/src/ui/rules.rs +++ b/src/ui/rules.rs @@ -426,27 +426,33 @@ impl RulesView { // ==================== Budget editor ==================== /// Select the account the budget editor edits, prefilling the fields - /// from its current budget when one exists. (The status carries no - /// alert threshold, so the percent field falls back to the default.) + /// from its current budget when one exists. Only an account with no + /// budget gets the default threshold; saving an edited amount must not + /// quietly reset the one it has. fn select_budget_account( &mut self, account_id: String, window: &mut Window, cx: &mut Context, ) { - let amount = self + let existing = self .budgets .iter() - .find(|status| status.account_id == account_id) + .find(|status| status.account_id == account_id); + let amount = existing .map(|status| format!("{}", status.monthly_budget)) .unwrap_or_default(); + let threshold = existing.map_or_else( + || "80".to_string(), + |status| format!("{}", status.alert_threshold), + ); self.budget_account = Some(account_id); self.budget_error = None; self.budget_amount_input.update(cx, |state, cx| { state.set_value(amount, window, cx); }); self.budget_threshold_input.update(cx, |state, cx| { - state.set_value("80", window, cx); + state.set_value(threshold, window, cx); }); cx.notify(); } @@ -652,6 +658,7 @@ impl RulesView { .gap_2() .child( Switch::new(switch_id) + .accessibility_label(rule.name.clone()) .checked(rule.enabled) .on_click(cx.listener(move |this, checked: &bool, _, cx| { this.set_enabled(rule_id.clone(), *checked, cx); @@ -953,6 +960,26 @@ impl RulesView { .v_flex() .gap_4() .child(account_picker) + .when_some(self.rule_account.as_deref(), |el, account| { + let budget = self + .budgets + .iter() + .find(|status| status.account_id == account); + el.child(match budget { + Some(status) => theme::caption( + cx, + tr!( + "rules.rule_budget_is", + budget = + fmt::amount(status.monthly_budget, &status.currency) + ), + ), + None => div() + .text_sm() + .text_color(theme::warning_text(cx)) + .child(tr!("rules.rule_needs_budget")), + }) + }) .child( div() .h_flex() @@ -1108,11 +1135,7 @@ impl Render for RulesView { .v_flex() .gap_1() .child(theme::page_title(cx, tr!("nav.rules"))) - .child(theme::caption( - cx, - "Every rule runs on the local ledger after each ingest. \ - Nothing leaves the machine.", - )), + .child(theme::caption(cx, tr!("rules.subtitle"))), ) .child( Button::new("new-rule") diff --git a/src/web/db.rs b/src/web/db.rs index 28383b5..f43da19 100644 --- a/src/web/db.rs +++ b/src/web/db.rs @@ -297,6 +297,7 @@ pub fn get_budget_status(account_id: &str) -> Result> { account_id: account_id.to_string(), account_name: account.name.clone(), monthly_budget: budget.monthly_budget, + alert_threshold: budget.alert_threshold, current_cost, currency: budget.currency, percentage_used, diff --git a/src/web/ledger/query.rs b/src/web/ledger/query.rs index 54d56f1..bf47c0d 100644 --- a/src/web/ledger/query.rs +++ b/src/web/ledger/query.rs @@ -930,6 +930,23 @@ pub fn tag_usage_breakdown_by_service( // ==================== Untagged usage ==================== /// Untagged usage of a period summed per provider, largest first. +/// Untagged usage of a period per account, for a share of that account's own +/// spend. +pub fn untagged_totals_by_account( + billing_period: &str, + tag_key: &str, +) -> Result> { + read(|all| { + let rows: Vec<&NormalizedRow> = all + .iter() + .filter(|row| row.billing_period == billing_period && untagged(row, tag_key)) + .filter(|row| row.billed_cost_base.is_some_and(|amount| amount > 0.0)) + .collect(); + + totals_by_bucket(&rows, |row| row.account_id.clone()) + }) +} + pub fn untagged_totals_by_provider( billing_period: &str, tag_key: &str, From 104a9d1e875bbb715aed0674ecb413719f7e7543 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 4 Nov 2026 00:04:56 +0900 Subject: [PATCH 2/2] Note the budget and account fixes, and put two doc comments back Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ src/ui/accounts.rs | 4 ++-- src/web/ledger/query.rs | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9e03b..4240e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 names in the accessibility tree. ### Fixed +- **Editing a budget reset its alert threshold.** Changing the amount put the + threshold back to 80%. The editor now starts from the saved threshold, and + only an account with no budget gets the default. The rule form also shows + the chosen account's budget, or warns that the rule won't fire without one. +- **One account's problem marked its siblings on Accounts.** Anomaly and + untagged spend badges matched by provider. They now read the account, except + for an anomaly from a rule that watches a whole provider. An anomaly or low + balance badge opens Alerts. +- **English left on Accounts and Rules in 简体中文.** The setup hints, form + notes and the Rules subtitle are translated, and row actions, account links + and rule switches name their target for assistive technology. - **Account detail listed other accounts' data-health findings.** It now reads only that account's charges, with shares of its usage, and Dismiss there covers only that account. diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index d8b3597..f33c064 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -2117,8 +2117,6 @@ fn format_bytes(bytes: u64) -> String { } } -/// The STATE column: problems get a pill in their severity's colours, -/// untagged spend an outline pill, healthy plain muted text. /// The state badge, which leads to the alert behind it when one does. fn render_state_link(account_id: &str, state: data::AccountState, cx: &App) -> AnyElement { let badge = render_state(state, cx); @@ -2144,6 +2142,8 @@ fn render_state_link(account_id: &str, state: data::AccountState, cx: &App) -> A .into_any_element() } +/// The STATE column: problems get a pill in their severity's colours, +/// untagged spend an outline pill, healthy plain muted text. fn render_state(state: data::AccountState, cx: &App) -> Div { match state { data::AccountState::Anomaly => { diff --git a/src/web/ledger/query.rs b/src/web/ledger/query.rs index f0cc9fc..07e1360 100644 --- a/src/web/ledger/query.rs +++ b/src/web/ledger/query.rs @@ -945,7 +945,6 @@ pub fn tag_usage_breakdown_by_service( // ==================== Untagged usage ==================== -/// Untagged usage of a period summed per provider, largest first. /// Untagged usage of a period per account, for a share of that account's own /// spend. pub fn untagged_totals_by_account( @@ -963,6 +962,7 @@ pub fn untagged_totals_by_account( }) } +/// Untagged usage of a period summed per provider, largest first. pub fn untagged_totals_by_provider( billing_period: &str, tag_key: &str,