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/locales/ui.yml b/locales/ui.yml index 27d1f6e..f5bcd8b 100644 --- a/locales/ui.yml +++ b/locales/ui.yml @@ -1369,6 +1369,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}”标签,其支出才会归入业务线。" @@ -1382,8 +1421,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: "账户" @@ -1904,6 +1943,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: "模型成本增长异常" @@ -2033,6 +2075,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 c1b5ff4..11c2316 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 { @@ -4492,6 +4522,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 cf0f9c6..6def450 100644 --- a/src/model.rs +++ b/src/model.rs @@ -578,6 +578,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..f33c064 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); })), @@ -2133,6 +2117,31 @@ fn format_bytes(bytes: u64) -> String { } } +/// 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() +} + /// 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 { 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 703cf5f..07e1360 100644 --- a/src/web/ledger/query.rs +++ b/src/web/ledger/query.rs @@ -945,6 +945,23 @@ pub fn tag_usage_breakdown_by_service( // ==================== Untagged usage ==================== +/// 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()) + }) +} + /// Untagged usage of a period summed per provider, largest first. pub fn untagged_totals_by_provider( billing_period: &str,