Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 50 additions & 2 deletions locales/ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}”标签,其支出才会归入业务线。"
Expand All @@ -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: "账户"
Expand Down Expand Up @@ -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: "模型成本增长异常"
Expand Down Expand Up @@ -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: "规则创建后即启用。"
Expand Down
1 change: 1 addition & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions src/ledger/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<(String, f64)>> {
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<Vec<(String, f64)>> {
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::<Result<Vec<_>, _>>()?;
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 {
Expand Down Expand Up @@ -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<String, f64> =
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");
Expand Down
2 changes: 2 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 63 additions & 54 deletions src/ui/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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)
}),
Expand Down Expand Up @@ -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()
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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()
Expand Down Expand Up @@ -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")),
),
)
})
Expand All @@ -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
Expand All @@ -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);
})),
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading