From dfeb6ea731552d3fae41372ca00ae1aa67705b42 Mon Sep 17 00:00:00 2001 From: Loakesh Indiran <66478092+Loaki07@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:15:09 +0000 Subject: [PATCH] feat(synthetics): bill status page checks against a monthly protocol grant (#14271) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 40 ++++ .../src/request/organization/org.rs | 4 +- src/common/src/meta/organization.rs | 24 +++ src/config/src/utils/time.rs | 17 ++ src/core/src/trial_quota.rs | 76 ++++++-- .../src/table/entity/trial_quota_usage.rs | 4 + src/infra/src/table/status_pages.rs | 108 +++++++++++ src/infra/src/table/trial_quota_usage.rs | 171 +++++++++++++++++- src/synthetics/src/pool.rs | 4 +- src/synthetics/src/scheduler.rs | 155 +++++++++++++++- .../settings/OrganizationManagement.spec.ts | 50 ++++- .../settings/OrganizationManagement.vue | 102 ++++++++--- web/src/locales/languages/ar-SA.json | 44 ++++- web/src/locales/languages/de-DE.json | 44 ++++- web/src/locales/languages/en-US.json | 6 + web/src/locales/languages/es-ES.json | 44 ++++- web/src/locales/languages/fr-FR.json | 44 ++++- web/src/locales/languages/it-IT.json | 44 ++++- web/src/locales/languages/ja-JP.json | 44 ++++- web/src/locales/languages/ko-KR.json | 44 ++++- web/src/locales/languages/nl-NL.json | 44 ++++- web/src/locales/languages/pl-PL.json | 44 ++++- web/src/locales/languages/pt-PT.json | 44 ++++- web/src/locales/languages/ru-RU.json | 44 ++++- web/src/locales/languages/tr-TR.json | 44 ++++- web/src/locales/languages/vi-VN.json | 44 ++++- web/src/locales/languages/zh-CN.json | 6 + web/src/locales/languages/zh-TW.json | 44 ++++- 28 files changed, 1301 insertions(+), 82 deletions(-) diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index fde268c984..fd639f29e8 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -8435,6 +8435,7 @@ "metrics": "ee6d251a11211d7e", "metricsDesc": "f400ccd9a75ae417", "or": "304d31e4fd1575bd", + "profiles": "535e52e4a2616ebe", "shippers": "2fb41e96ccbf1cc4", "title": "0b5176a5e1844d67", "traces": "7cb4b2b4b59736f4", @@ -9298,6 +9299,7 @@ "otlpGrpc": "6e039a65134b7f7b", "otlpHttp": "c7fcbcefe82c2d5a", "parametersToEnterInAwsWizard": "3559f49af02df001", + "profilesLabel": "535e52e4a2616ebe", "prometheusDocLinkText": "a454c921984f8a3b", "python": "18885f27b5af9012", "recommendedLabel": "d70604e843046137", @@ -10006,6 +10008,7 @@ "labelLogs": "ea2100dc89ae9fe2", "labelMetadata": "9eddf573cb509f1f", "labelMetrics": "a58da793c7250f1b", + "labelProfiles": "535e52e4a2616ebe", "labelTraces": "998741a58ed2b411", "lastIngested": "0d820e9fa2cfde94", "mapping": "1ff9a5f8b4bd530e", @@ -10387,6 +10390,7 @@ "navigationLink": "4f7a4f883a01a04c", "onCall": "3004e1251d8890eb", "pipeline": "d1a8fffeb935847e", + "profiles": "535e52e4a2616ebe", "reliability": "71d0acd465564246", "report": "dacca3cba3f346a4", "rum": "28aff5e3489eacae", @@ -13762,6 +13766,35 @@ "type": "baaddf70fb5d432b", "viewError": "dcfb19244b8b0c98" }, + "profiles": { + "addFilter": "e520df698000f85f", + "collapse": "be6eb1fc3b05bf9d", + "cpuDuration": "5ccaa4b9c47e65d0", + "dataSource": "548c5b4d99727c76", + "errors": { + "loadFailed": "8dfd389c73dc729f" + }, + "expand": "07548c2c6511d4d1", + "filters": "546ebb8eb993ea56", + "noResults": "8d78bfc2a318212b", + "noSeries": "416188111c924f67", + "noStreams": "80fa05a3381de8ca", + "percent": "bbf3f11cb5b43e70", + "profileType": "3a2cfc3fe434e0e4", + "resultTitle": "b160af80dd83d1c9", + "runQuery": "1837e00c45e8663d", + "self": "85bc0f95858d7572", + "service": "d677190e0a9990e7", + "stream": "1eec3071dd26e224", + "tagKey": "034df31a6ad168fb", + "tagValue": "16f64f1f7c7fa66b", + "timeSeriesTitle": "38d465cbff87aea3", + "title": "535e52e4a2616ebe", + "total": "c9b3c38247f744e1", + "viewFlame": "d776d0b395c67943", + "viewTop": "1fa45d080800e023", + "viewTree": "9d25dd2b5d417e58" + }, "promql": { "operations": { "absDoc": "7da10ff035493bc8", @@ -14956,6 +14989,7 @@ "filterBy": "0eadca7d4f93d753", "foundInLogs": "a98652173f2161ff", "foundInMetrics": "19b03af9aa422d83", + "foundInProfiles": "a15836cbacbc2c1a", "foundInTraces": "392289106cf51dfb", "goToConfiguration": "f39be26febb2b95a", "identityConfigNoSets": "e2c8a7137249c794", @@ -15298,17 +15332,22 @@ "saveCredits": "ebccf6ab3710613a", "saveSteps": "e69e897def1b7bbb", "setAiCreditsSubtitle": "53b695a8425d08ef", + "setSyntheticsStatusStepsSubtitle": "f8c40d09ceeafcce", "setSyntheticsStepsSubtitle": "ea912a7589fe8a3b", "setUsageLimits": "f9d770698a90d178", + "statusStepsTotal": "88038e69338bf088", + "statusStepsUsed": "ffdc93dff61f0d25", "steps": "b7595e2a863957fc", "storageEnabled": "b9130eb4a95b39b0", "storageEnabledSuccess": "1dafd453e23e36b2", "subtitle": "6c2be37323274fe3", "syntheticsBrowserStepsTab": "ef03697f28c551c5", "syntheticsProtocolStepsTab": "8b956ee50aee0481", + "syntheticsStatusStepsTab": "26cd6200f7f8fea6", "totalAiCredits": "24aadffb67e37032", "totalBrowserSteps": "fac6a5f9d6a54a20", "totalProtocolSteps": "b6bfad0b639f2000", + "totalStatusSteps": "2ceaf7fd4b1f6e06", "trialExtendedSuccess": "13ac584aa5dc3c9b", "weeks": "cf64435fbb0dc732" }, @@ -15410,6 +15449,7 @@ "setAiCreditsFor": "a03b8ef357afa817", "setSyntheticsBrowserStepsFor": "ed88883a8eaa70ee", "setSyntheticsProtocolStepsFor": "c7d4305532b08a89", + "setSyntheticsStatusStepsFor": "fb446d02353eec8c", "somethingWentWrong": "ab827e3fe17da220", "spanIdFieldName": "c173041159652841", "ssoDomainRestrictions": "346a58682faef602", diff --git a/src/api/management/src/request/organization/org.rs b/src/api/management/src/request/organization/org.rs index 8a1ab9e094..0ee7e709cb 100644 --- a/src/api/management/src/request/organization/org.rs +++ b/src/api/management/src/request/organization/org.rs @@ -252,6 +252,8 @@ pub async fn all_organizations( browser_steps_limit: synthetics.browser_limit, protocol_steps_used: synthetics.protocol_used, protocol_steps_limit: synthetics.protocol_limit, + status_steps_used: synthetics.status_used, + status_steps_limit: synthetics.status_limit, created_at: org.created_at, updated_at: org.updated_at, trial_expires_at: Some(org.trial_ends_at), @@ -673,7 +675,7 @@ async fn set_pool_limit( security(("Authorization" = [])), params( ("org_id" = String, Path, description = "Must be _meta"), - ("pool" = String, Path, description = "ai_credits | synthetics_browser_steps | synthetics_protocol_steps (the pre-split key `synthetics_steps` is accepted as an alias for the protocol pool)"), + ("pool" = String, Path, description = "ai_credits | synthetics_browser_steps | synthetics_protocol_steps | synthetics_status_protocol (the pre-split key `synthetics_steps` is accepted as an alias for the protocol pool)"), ), request_body(content = inline(SetQuotaUsageLimitRequest), content_type = "application/json"), responses( diff --git a/src/common/src/meta/organization.rs b/src/common/src/meta/organization.rs index 2050350e34..426df05bc5 100644 --- a/src/common/src/meta/organization.rs +++ b/src/common/src/meta/organization.rs @@ -193,7 +193,11 @@ pub struct AllOrgListDetails { #[cfg(feature = "cloud")] pub protocol_steps_used: u64, #[cfg(feature = "cloud")] + pub status_steps_used: u64, + #[cfg(feature = "cloud")] pub protocol_steps_limit: u64, + #[cfg(feature = "cloud")] + pub status_steps_limit: u64, pub trial_expires_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub contract_end_date: Option, @@ -846,7 +850,11 @@ mod tests { #[cfg(feature = "cloud")] protocol_steps_used: 0, #[cfg(feature = "cloud")] + status_steps_used: 0, + #[cfg(feature = "cloud")] protocol_steps_limit: 0, + #[cfg(feature = "cloud")] + status_steps_limit: 0, trial_expires_at: None, contract_end_date: None, billing_provider: String::new(), @@ -910,7 +918,11 @@ mod tests { #[cfg(feature = "cloud")] protocol_steps_used: 0, #[cfg(feature = "cloud")] + status_steps_used: 0, + #[cfg(feature = "cloud")] protocol_steps_limit: 0, + #[cfg(feature = "cloud")] + status_steps_limit: 0, trial_expires_at: None, contract_end_date: None, billing_provider: String::new(), @@ -939,7 +951,11 @@ mod tests { #[cfg(feature = "cloud")] protocol_steps_used: 0, #[cfg(feature = "cloud")] + status_steps_used: 0, + #[cfg(feature = "cloud")] protocol_steps_limit: 0, + #[cfg(feature = "cloud")] + status_steps_limit: 0, trial_expires_at: Some(1641081600), contract_end_date: None, billing_provider: String::new(), @@ -1023,7 +1039,11 @@ mod tests { #[cfg(feature = "cloud")] protocol_steps_used: 0, #[cfg(feature = "cloud")] + status_steps_used: 0, + #[cfg(feature = "cloud")] protocol_steps_limit: 0, + #[cfg(feature = "cloud")] + status_steps_limit: 0, trial_expires_at: Some(1641081600), contract_end_date: Some(1893456000000000), billing_provider: "no_op".to_string(), @@ -1062,7 +1082,11 @@ mod tests { #[cfg(feature = "cloud")] protocol_steps_used: 0, #[cfg(feature = "cloud")] + status_steps_used: 0, + #[cfg(feature = "cloud")] protocol_steps_limit: 0, + #[cfg(feature = "cloud")] + status_steps_limit: 0, trial_expires_at: Some(1641081600), contract_end_date: None, billing_provider: String::new(), diff --git a/src/config/src/utils/time.rs b/src/config/src/utils/time.rs index b0f587d3d0..96a7613c3a 100644 --- a/src/config/src/utils/time.rs +++ b/src/config/src/utils/time.rs @@ -73,6 +73,14 @@ pub fn second_micros(n: i64) -> i64 { .unwrap() } +/// The `YYYYMM` of a microsecond timestamp, UTC. One encoding, or a period compares against +/// something that was written with another. +#[inline(always)] +pub fn month_of(micros: i64) -> i32 { + let at = DateTime::from_timestamp_micros(micros).unwrap_or_default(); + at.year() * 100 + at.month() as i32 +} + #[inline(always)] pub fn get_ymdh_from_micros(n: i64, hour_format: HourFormat) -> String { let n = if n > 0 { @@ -355,6 +363,15 @@ pub fn parse_interval_to_minutes(s: &str) -> i64 { #[cfg(test)] mod tests { + #[test] + fn test_month_of_reads_the_utc_month() { + assert_eq!(month_of(1_788_800_853_300_917), 202609); + assert_eq!(month_of(0), 197001); + // A month boundary is the one place an off-by-one lands on the wrong grant. + assert_eq!(month_of(1_767_225_599_000_000), 202512); + assert_eq!(month_of(1_767_225_600_000_000), 202601); + } + use super::*; #[test] diff --git a/src/core/src/trial_quota.rs b/src/core/src/trial_quota.rs index 9f7fa47594..8c87f2a814 100644 --- a/src/core/src/trial_quota.rs +++ b/src/core/src/trial_quota.rs @@ -22,7 +22,7 @@ //! //! ## Pools //! -//! [`TrialQuotaPool::AiCredits`] and the two synthetics step pools are +//! [`TrialQuotaPool::AiCredits`] and the three synthetics step pools are //! independent: every counter, limit, DB read and HA message is keyed //! `(org, pool)`, so spending one grant cannot drain the other, and //! [`TrialQuotaFeature::pool`] is the only feature-to-pool mapping. No @@ -107,8 +107,9 @@ struct FlushRecord { cost: i64, } -/// A lifetime free grant, and the unit of isolation between features. Every counter, limit and DB -/// read is keyed per `(org, pool)` via [`scope`]: an org that spends its AI credits must not +/// A free grant per org, and the unit of isolation between features. Every pool is lifetime except +/// [`TrialQuotaPool::SyntheticsStatusProtocol`], which resets each month. Every counter, limit and +/// DB read is keyed per `(org, pool)` via [`scope`]: an org that spends its AI credits must not /// thereby lose its synthetics budget. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum TrialQuotaPool { @@ -120,6 +121,9 @@ pub enum TrialQuotaPool { /// from the browser grant because a browser step costs ~52x a protocol one, /// so a shared pool let the org's mix decide our free-tier cost. SyntheticsProtocolSteps, + /// Protocol steps of checks attached to a status page. The only pool that + /// resets: its row carries the `YYYYMM` its count belongs to. + SyntheticsStatusProtocol, } impl TrialQuotaPool { @@ -128,6 +132,7 @@ impl TrialQuotaPool { TrialQuotaPool::AiCredits, TrialQuotaPool::SyntheticsBrowserSteps, TrialQuotaPool::SyntheticsProtocolSteps, + TrialQuotaPool::SyntheticsStatusProtocol, ]; /// Stable identifier used in the [`scope`] key and on the HA wire. @@ -136,6 +141,7 @@ impl TrialQuotaPool { TrialQuotaPool::AiCredits => "ai_credits", TrialQuotaPool::SyntheticsBrowserSteps => "synthetics_browser_steps", TrialQuotaPool::SyntheticsProtocolSteps => "synthetics_protocol_steps", + TrialQuotaPool::SyntheticsStatusProtocol => "synthetics_status_protocol", } } @@ -148,6 +154,7 @@ impl TrialQuotaPool { "synthetics_steps" | "synthetics_protocol_steps" => { Some(TrialQuotaPool::SyntheticsProtocolSteps) } + "synthetics_status_protocol" => Some(TrialQuotaPool::SyntheticsStatusProtocol), _ => None, } } @@ -164,10 +171,17 @@ impl TrialQuotaPool { pub fn is_synthetics(self) -> bool { matches!( self, - TrialQuotaPool::SyntheticsBrowserSteps | TrialQuotaPool::SyntheticsProtocolSteps + TrialQuotaPool::SyntheticsBrowserSteps + | TrialQuotaPool::SyntheticsProtocolSteps + | TrialQuotaPool::SyntheticsStatusProtocol ) } + /// The one pool whose count belongs to a month rather than to the lifetime of the org. + pub fn is_monthly(self) -> bool { + matches!(self, TrialQuotaPool::SyntheticsStatusProtocol) + } + /// Every `trial_quota_usage.feature` value that spends from this pool; each /// per-pool query uses this set as an `IN` filter. MUST stay in sync with /// [`TrialQuotaFeature::pool`] (`every_feature_is_listed_by_its_own_pool`). @@ -180,6 +194,7 @@ impl TrialQuotaPool { TrialQuotaPool::SyntheticsProtocolSteps => { &["synthetics_protocol_steps", "synthetics_steps"] } + TrialQuotaPool::SyntheticsStatusProtocol => &["synthetics_status_protocol"], } } @@ -202,6 +217,7 @@ impl TrialQuotaPool { TrialQuotaPool::AiCredits => cfg.cloud.ai_free_credit_pool, TrialQuotaPool::SyntheticsBrowserSteps => cfg.cloud.synthetics_free_browser_step_pool, TrialQuotaPool::SyntheticsProtocolSteps => cfg.cloud.synthetics_free_protocol_step_pool, + TrialQuotaPool::SyntheticsStatusProtocol => cfg.cloud.synthetics_free_status_step_pool, } } } @@ -1234,6 +1250,8 @@ pub struct SyntheticsQuota { pub browser_limit: u64, pub protocol_used: u64, pub protocol_limit: u64, + pub status_used: u64, + pub status_limit: u64, } /// Every `trial_quota_usage.feature` value that spends from a synthetics pool. @@ -1257,7 +1275,12 @@ pub async fn synthetics_remaining_for_orgs(org_ids: Vec) -> HashMap i32 { + config::utils::time::month_of(config::utils::time::now_micros()) } /// Each requested org's synthetics spend and grant, for the `_meta` org listing — SPEC §11 #5. @@ -1267,7 +1290,7 @@ pub async fn synthetics_quota_for_orgs(org_ids: Vec) -> HashMap HashMap { - fold_synthetics_quota(org_ids, rows) + fold_synthetics_quota(org_ids, rows, month) .into_iter() .map(|(org_id, quota)| { ( @@ -1304,6 +1328,7 @@ pub(crate) fn fold_synthetics_remaining( StepRemaining { browser: quota.browser_limit.saturating_sub(quota.browser_used), protocol: quota.protocol_limit.saturating_sub(quota.protocol_used), + status: quota.status_limit.saturating_sub(quota.status_used), }, ) }) @@ -1315,6 +1340,7 @@ pub(crate) fn fold_synthetics_remaining( pub(crate) fn fold_synthetics_quota( org_ids: &[String], rows: &[infra::table::entity::trial_quota_usage::Model], + month: i32, ) -> HashMap { let mut used: HashMap<(&str, TrialQuotaPool), u64> = HashMap::new(); let mut limits: HashMap<(&str, TrialQuotaPool), u64> = HashMap::new(); @@ -1329,8 +1355,14 @@ pub(crate) fn fold_synthetics_quota( .and_modify(|current| *current = (*current).max(limit)) .or_insert(limit); } + // A monthly row from a closed month has already been forgiven, so its spend reads as 0. + let spent = if pool.is_monthly() && row.period != month { + 0 + } else { + u64::try_from(row.usage_count).unwrap_or(0) + }; let entry = used.entry((row.org_id.as_str(), pool)).or_default(); - *entry = entry.saturating_add(u64::try_from(row.usage_count).unwrap_or(0)); + *entry = entry.saturating_add(spent); } org_ids @@ -1349,6 +1381,7 @@ pub(crate) fn fold_synthetics_quota( }; let (browser_used, browser_limit) = spent(TrialQuotaPool::SyntheticsBrowserSteps); let (protocol_used, protocol_limit) = spent(TrialQuotaPool::SyntheticsProtocolSteps); + let (status_used, status_limit) = spent(TrialQuotaPool::SyntheticsStatusProtocol); ( org_id.clone(), SyntheticsQuota { @@ -1356,6 +1389,8 @@ pub(crate) fn fold_synthetics_quota( browser_limit, protocol_used, protocol_limit, + status_used, + status_limit, }, ) }) @@ -1383,6 +1418,7 @@ mod tests { TrialQuotaPool::AiCredits => 0, TrialQuotaPool::SyntheticsBrowserSteps => 1, TrialQuotaPool::SyntheticsProtocolSteps => 2, + TrialQuotaPool::SyntheticsStatusProtocol => 3, } } @@ -1757,6 +1793,10 @@ mod tests { get_limit_for_pool(org_id, TrialQuotaPool::SyntheticsProtocolSteps), cfg.cloud.synthetics_free_protocol_step_pool, ); + assert_eq!( + get_limit_for_pool(org_id, TrialQuotaPool::SyntheticsStatusProtocol), + cfg.cloud.synthetics_free_status_step_pool, + ); assert_eq!( get_limit_for_pool(org_id, TrialQuotaPool::AiCredits), cfg.cloud.ai_free_credit_pool, @@ -1764,6 +1804,8 @@ mod tests { // §6.1: 10,000 browser steps and 20,000 protocol; the AI pool is 1,000 credits. assert_eq!(cfg.cloud.synthetics_free_browser_step_pool, 10_000); assert_eq!(cfg.cloud.synthetics_free_protocol_step_pool, 20_000); + // 43,200 protocol steps a month for status-page checks, and nothing for browser ones. + assert_eq!(cfg.cloud.synthetics_free_status_step_pool, 43_200); // The three grants must not collapse onto one knob. assert_ne!( cfg.cloud.synthetics_free_browser_step_pool, @@ -1785,6 +1827,7 @@ mod tests { usage_count: i64, ) -> infra::table::entity::trial_quota_usage::Model { infra::table::entity::trial_quota_usage::Model { + period: 0, org_id: org_id.to_string(), feature: feature.to_string(), usage_count, @@ -1963,6 +2006,7 @@ mod tests { .feature_keys() .iter() .chain(TrialQuotaPool::SyntheticsProtocolSteps.feature_keys()) + .chain(TrialQuotaPool::SyntheticsStatusProtocol.feature_keys()) .copied() .collect(); features.sort_unstable(); @@ -1985,7 +2029,7 @@ mod tests { let org_id = steps_org("fold-empty", 700); set_cached_limit(&org_id, TrialQuotaPool::SyntheticsProtocolSteps, 900); - let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &[]); + let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &[], 0); let r = remaining .get(&org_id) .expect("an org with no rows has not used the feature — it is not absent"); @@ -2003,7 +2047,7 @@ mod tests { db_row(&org_id, "synthetics_protocol_steps", 11), ]; - let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &rows); + let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &rows, 0); let r = remaining.get(&org_id).expect("the org was requested"); assert_eq!( r.browser, 0, @@ -2023,7 +2067,7 @@ mod tests { db_row(&org_id, "synthetics_browser_steps", 100), ]; - let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &rows); + let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &rows, 0); let r = remaining.get(&org_id).expect("the org was requested"); assert_eq!( r.protocol, 500, @@ -2051,6 +2095,7 @@ mod tests { let remaining = fold_synthetics_remaining( std::slice::from_ref(&org_id), &[browser, protocol, pre_split], + 0, ); let r = remaining.get(&org_id).expect("the org was requested"); assert_eq!( @@ -2081,7 +2126,7 @@ mod tests { protocol, ]; - let quota = fold_synthetics_quota(std::slice::from_ref(&org_id), &rows); + let quota = fold_synthetics_quota(std::slice::from_ref(&org_id), &rows, 0); let q = quota.get(&org_id).expect("the org was requested"); assert_eq!( (q.browser_used, q.browser_limit), @@ -2110,7 +2155,7 @@ mod tests { db_row(&org_id, "synthetics_protocol_steps", 11), ]; - let quota = fold_synthetics_quota(std::slice::from_ref(&org_id), &rows); + let quota = fold_synthetics_quota(std::slice::from_ref(&org_id), &rows, 0); let q = quota.get(&org_id).expect("the org was requested"); assert_eq!( (q.browser_used, q.browser_limit), @@ -2132,6 +2177,7 @@ mod tests { let quota = fold_synthetics_quota( std::slice::from_ref(&org_id), &[db_row(&other, "synthetics_browser_steps", 4)], + 0, ); assert_eq!( @@ -2163,7 +2209,7 @@ mod tests { pre_split.usage_limit = Some(5_000); let rows = vec![browser, protocol, pre_split]; - let quota = fold_synthetics_quota(std::slice::from_ref(&org_id), &rows); + let quota = fold_synthetics_quota(std::slice::from_ref(&org_id), &rows, 0); let q = quota.get(&org_id).expect("the org was requested"); assert_eq!( q.browser_used, 900, @@ -2171,7 +2217,7 @@ mod tests { they agree on", ); - let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &rows); + let remaining = fold_synthetics_remaining(std::slice::from_ref(&org_id), &rows, 0); let r = remaining.get(&org_id).expect("the org was requested"); // Every pool here is under its grant, so the two halves must add back up to it. assert_eq!( diff --git a/src/infra/src/table/entity/trial_quota_usage.rs b/src/infra/src/table/entity/trial_quota_usage.rs index 5bf7479436..de66044a43 100644 --- a/src/infra/src/table/entity/trial_quota_usage.rs +++ b/src/infra/src/table/entity/trial_quota_usage.rs @@ -11,6 +11,8 @@ pub struct Model { pub usage_limit: Option, pub updated_at: i64, pub notified_checkpoint: i16, + /// `0` is a lifetime row. A monthly pool carries the `YYYYMM` its count belongs to. + pub period: i32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -31,10 +33,12 @@ mod tests { usage_limit: Some(1_000), updated_at: 1000, notified_checkpoint: 0, + period: 0, }; assert_eq!(m.org_id, "org"); assert_eq!(m.usage_count, 100); assert_eq!(m.usage_limit, Some(1_000)); assert_eq!(m.notified_checkpoint, 0); + assert_eq!(m.period, 0); } } diff --git a/src/infra/src/table/status_pages.rs b/src/infra/src/table/status_pages.rs index d8727cc372..6f6f8a8f13 100644 --- a/src/infra/src/table/status_pages.rs +++ b/src/infra/src/table/status_pages.rs @@ -20,6 +20,8 @@ //! request-time callers. The public serving path reads only //! [`get_page_by_slug`] and [`get_snapshot`]. +use std::collections::HashSet; + use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, prelude::Expr, @@ -960,3 +962,109 @@ pub async fn get_domain_claim_by_host( .one(conn) .await?) } + +/// The check ids attached to a status page, narrowed to the ids the caller asks about. +/// +/// `Some(&[])` answers with nothing rather than falling back to an unfiltered read: reading the +/// whole join table would hand every check in the cluster the status allowance. +pub async fn mapped_check_ids( + conn: &C, + filter: Option<&[String]>, +) -> Result, errors::Error> { + if filter.is_some_and(<[String]>::is_empty) { + return Ok(HashSet::new()); + } + let mut query = status_page_component_checks::Entity::find() + .select_only() + .column(status_page_component_checks::Column::SyntheticsId) + .distinct(); + if let Some(ids) = filter { + query = query.filter( + status_page_component_checks::Column::SyntheticsId + .is_in(ids.iter().map(String::as_str)), + ); + } + let ids: Vec = query.into_tuple().all(conn).await?; + Ok(ids.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use sea_orm::{ActiveValue, ConnectOptions, Database, DatabaseConnection, Schema}; + + use super::*; + + /// One connection, not a pool: two connections to `sqlite::memory:` are two databases. + async fn db() -> DatabaseConnection { + let mut opts = ConnectOptions::new("sqlite::memory:".to_string()); + opts.max_connections(1); + let db = Database::connect(opts).await.unwrap(); + let backend = db.get_database_backend(); + let schema = Schema::new(backend); + db.execute( + backend.build(&schema.create_table_from_entity(status_page_component_checks::Entity)), + ) + .await + .unwrap(); + db + } + + async fn map_check(db: &DatabaseConnection, id: &str, component_id: &str, check_id: &str) { + status_page_component_checks::ActiveModel { + id: ActiveValue::Set(id.to_string()), + component_id: ActiveValue::Set(component_id.to_string()), + synthetics_id: ActiveValue::Set(check_id.to_string()), + org_id: ActiveValue::Set("acme".to_string()), + } + .insert(db) + .await + .unwrap(); + } + + #[tokio::test] + async fn mapped_check_ids_filters_to_the_given_ids() { + let db = db().await; + map_check(&db, "m1", "c1", "chk_1").await; + map_check(&db, "m2", "c1", "chk_2").await; + + let asked = ["chk_1".to_string(), "chk_9".to_string()]; + let found = mapped_check_ids(&db, Some(&asked)).await.unwrap(); + + assert_eq!(found, HashSet::from(["chk_1".to_string()])); + } + + #[tokio::test] + async fn mapped_check_ids_deduplicates_a_check_on_two_components() { + let db = db().await; + map_check(&db, "m1", "c1", "chk_1").await; + map_check(&db, "m2", "c2", "chk_1").await; + + let asked = ["chk_1".to_string()]; + assert_eq!( + mapped_check_ids(&db, Some(&asked)).await.unwrap(), + HashSet::from(["chk_1".to_string()]), + ); + } + + #[tokio::test] + async fn mapped_check_ids_of_an_empty_filter_returns_nothing() { + let db = db().await; + map_check(&db, "m1", "c1", "chk_1").await; + + assert!( + mapped_check_ids(&db, Some(&[])).await.unwrap().is_empty(), + "an empty ask must not degrade into reading the whole table", + ); + } + + #[tokio::test] + async fn mapped_check_ids_reads_every_row_when_nothing_is_asked_for() { + let db = db().await; + map_check(&db, "m1", "c1", "chk_1").await; + map_check(&db, "m2", "c1", "chk_2").await; + + let found = mapped_check_ids(&db, None).await.unwrap(); + + assert_eq!(found.len(), 2); + } +} diff --git a/src/infra/src/table/trial_quota_usage.rs b/src/infra/src/table/trial_quota_usage.rs index fd348b568b..99187917bd 100644 --- a/src/infra/src/table/trial_quota_usage.rs +++ b/src/infra/src/table/trial_quota_usage.rs @@ -15,7 +15,7 @@ use sea_orm::{ ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QuerySelect, TransactionTrait, - sea_query::{Expr, Func, OnConflict}, + sea_query::{CaseStatement, Expr, Func, OnConflict}, }; use crate::{ @@ -25,11 +25,17 @@ use crate::{ pub const SYNTHETICS_BROWSER_FEATURE: &str = "synthetics_browser_steps"; pub const SYNTHETICS_PROTOCOL_FEATURE: &str = "synthetics_protocol_steps"; +pub const SYNTHETICS_STATUS_FEATURE: &str = "synthetics_status_protocol"; -/// One settled window's free steps per pool. +/// The `YYYYMM` a lifetime row carries, meaning the count belongs to no month. +pub const LIFETIME_PERIOD: i32 = 0; + +/// One settled window's free steps per pool. `month` scopes the status pool alone. pub struct SyntheticsDeltas { pub browser: i64, pub protocol: i64, + pub status: i64, + pub month: i32, } /// Additively upsert one `(org_id, feature, delta)` triple per record. @@ -172,6 +178,8 @@ async fn set_usage_limit_for_org_in( usage_limit: sea_orm::ActiveValue::Set(Some(usage_limit)), updated_at: sea_orm::ActiveValue::Set(now), notified_checkpoint: sea_orm::ActiveValue::Set(0), + // A raised limit belongs to no month; the monthly row keeps its own period. + period: sea_orm::ActiveValue::Set(LIFETIME_PERIOD), }; trial_quota_usage::Entity::insert(active_model) @@ -383,6 +391,58 @@ pub async fn apply_synthetics_deltas_in( increment_lifetime_row(txn, org_id, feature, delta, now).await?; } } + if deltas.status != 0 { + increment_monthly_row(txn, org_id, deltas.status, deltas.month, now).await?; + } + Ok(()) +} + +/// The reset rides the increment, so no pass adds to a month and a later one zeroes it. +/// +/// A row from an older month starts the new month at this window's draw. A row from a NEWER month +/// only accumulates: a late replay of an old window must never rewind the period it settled into. +async fn increment_monthly_row( + conn: &C, + org_id: &str, + delta: i64, + month: i32, + now: i64, +) -> Result<(), sea_orm::DbErr> { + let period = Expr::col((trial_quota_usage::Entity, trial_quota_usage::Column::Period)); + let stale = period.clone().lt(month); + let count = CaseStatement::new().case(stale.clone(), delta).finally( + Expr::col(( + trial_quota_usage::Entity, + trial_quota_usage::Column::UsageCount, + )) + .add(delta), + ); + // Stands in for `GREATEST`, which SQLite lacks and Postgres spells as an aggregate. + let advance = CaseStatement::new().case(stale, month).finally(period); + + let active_model = trial_quota_usage::ActiveModel { + org_id: sea_orm::ActiveValue::Set(org_id.to_string()), + feature: sea_orm::ActiveValue::Set(SYNTHETICS_STATUS_FEATURE.to_string()), + usage_count: sea_orm::ActiveValue::Set(delta), + usage_limit: sea_orm::ActiveValue::NotSet, + updated_at: sea_orm::ActiveValue::Set(now), + notified_checkpoint: sea_orm::ActiveValue::Set(0), + period: sea_orm::ActiveValue::Set(month), + }; + + trial_quota_usage::Entity::insert(active_model) + .on_conflict( + OnConflict::columns([ + trial_quota_usage::Column::OrgId, + trial_quota_usage::Column::Feature, + ]) + .value(trial_quota_usage::Column::UsageCount, count) + .value(trial_quota_usage::Column::Period, advance) + .value(trial_quota_usage::Column::UpdatedAt, Expr::value(now)) + .to_owned(), + ) + .exec(conn) + .await?; Ok(()) } @@ -400,6 +460,7 @@ async fn increment_lifetime_row( usage_limit: sea_orm::ActiveValue::NotSet, updated_at: sea_orm::ActiveValue::Set(now), notified_checkpoint: sea_orm::ActiveValue::Set(0), + period: sea_orm::ActiveValue::Set(LIFETIME_PERIOD), }; trial_quota_usage::Entity::insert(active_model) @@ -433,6 +494,8 @@ mod tests { use super::*; + const ORG: &str = "acme"; + const STATUS: &str = SYNTHETICS_STATUS_FEATURE; const AI: &str = "ai_chat"; const BROWSER: &str = "synthetics_browser_steps"; const PROTOCOL: &str = "synthetics_protocol_steps"; @@ -473,6 +536,7 @@ mod tests { usage_limit: ActiveValue::Set(usage_limit), updated_at: ActiveValue::Set(0), notified_checkpoint: ActiveValue::Set(notified_checkpoint), + period: ActiveValue::Set(LIFETIME_PERIOD), } } @@ -502,7 +566,21 @@ mod tests { } fn deltas(browser: i64, protocol: i64) -> SyntheticsDeltas { - SyntheticsDeltas { browser, protocol } + SyntheticsDeltas { + browser, + protocol, + status: 0, + month: LIFETIME_PERIOD, + } + } + + fn status_deltas(status: i64, month: i32) -> SyntheticsDeltas { + SyntheticsDeltas { + browser: 0, + protocol: 0, + status, + month, + } } async fn apply(db: &DatabaseConnection, org_id: &str, d: SyntheticsDeltas, now: i64) { @@ -511,6 +589,12 @@ mod tests { .unwrap(); } + async fn apply_status(db: &DatabaseConnection, org_id: &str, status: i64, month: i32) { + apply_synthetics_deltas_in(db, org_id, &status_deltas(status, month), 0) + .await + .unwrap(); + } + async fn row_of( db: &DatabaseConnection, org_id: &str, @@ -867,4 +951,85 @@ mod tests { assert!(has_no_row(&db, "acme", BROWSER).await, "browser"); assert!(has_no_row(&db, "acme", PROTOCOL).await, "protocol"); } + + #[tokio::test] + async fn status_upsert_inserts_with_the_month() { + let db = db().await; + apply_status(&db, ORG, 40, 202610).await; + + let row = row_of(&db, ORG, STATUS).await; + assert_eq!((row.usage_count, row.period), (40, 202610)); + } + + #[tokio::test] + async fn status_upsert_adds_within_month() { + let db = db().await; + apply_status(&db, ORG, 12_480, 202609).await; + apply_status(&db, ORG, 410, 202609).await; + + let row = row_of(&db, ORG, STATUS).await; + assert_eq!((row.usage_count, row.period), (12_890, 202609)); + } + + #[tokio::test] + async fn status_upsert_resets_on_a_newer_month() { + let db = db().await; + apply_status(&db, ORG, 12_890, 202609).await; + apply_status(&db, ORG, 90, 202610).await; + + let row = row_of(&db, ORG, STATUS).await; + assert_eq!( + (row.usage_count, row.period), + (90, 202610), + "a new month starts the count again instead of adding to the old one", + ); + } + + #[tokio::test] + async fn status_upsert_adds_a_late_older_month_to_the_current_month() { + let db = db().await; + apply_status(&db, ORG, 90, 202610).await; + apply_status(&db, ORG, 5, 202609).await; + + let row = row_of(&db, ORG, STATUS).await; + assert_eq!( + (row.usage_count, row.period), + (95, 202610), + "a late replay counts against the current month and never rewinds the period", + ); + } + + #[tokio::test] + async fn status_upsert_never_touches_usage_limit() { + let db = db().await; + seed_row(&db, ORG, STATUS, 0, Some(1_000)).await; + + for (draw, month) in [(40, 202609), (10, 202609), (7, 202610), (1, 202608)] { + apply_status(&db, ORG, draw, month).await; + assert_eq!( + row_of(&db, ORG, STATUS).await.usage_limit, + Some(1_000), + "an administrator override outlives every reset", + ); + } + } + + #[tokio::test] + async fn lifetime_rows_keep_period_at_zero() { + let db = db().await; + apply(&db, ORG, deltas(10, 20), 0).await; + apply(&db, ORG, deltas(5, 5), 1).await; + + for feature in [BROWSER, PROTOCOL] { + assert_eq!(row_of(&db, ORG, feature).await.period, LIFETIME_PERIOD); + } + } + + #[tokio::test] + async fn a_zero_status_delta_writes_no_row() { + let db = db().await; + apply_status(&db, ORG, 0, 202609).await; + + assert!(has_no_row(&db, ORG, STATUS).await); + } } diff --git a/src/synthetics/src/pool.rs b/src/synthetics/src/pool.rs index 35a6815e21..031a542137 100644 --- a/src/synthetics/src/pool.rs +++ b/src/synthetics/src/pool.rs @@ -32,7 +32,7 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::OnceLock}; /// Set once, by [`crate::init`], before any worker is spawned. static HOOKS: OnceLock = OnceLock::new(); -/// Steps left in one org's two synthetics grants — SPEC §6.1, §6.6. +/// Steps left in one org's three synthetics grants — SPEC §6.1, §6.6. /// /// Deliberately NOT `Default`: a zeroed value for an org missing from the batch /// read means "grant spent" and blacks that org's monitoring out, where absent @@ -41,6 +41,8 @@ static HOOKS: OnceLock = OnceLock::new(); pub struct StepRemaining { pub browser: u64, pub protocol: u64, + /// The monthly allowance for status-page checks, already scoped to this month. + pub status: u64, } /// The batch read's result, boxed because a `fn` pointer cannot name an diff --git a/src/synthetics/src/scheduler.rs b/src/synthetics/src/scheduler.rs index 87812cfbd8..6a8eeca0ea 100644 --- a/src/synthetics/src/scheduler.rs +++ b/src/synthetics/src/scheduler.rs @@ -167,6 +167,8 @@ pub enum PoolGate { pub(crate) struct GateContext { pub remaining: HashMap, pub policies: HashMap, + /// The claimed checks that feed a status page, read once per tick. + pub status_attached: std::collections::HashSet, } /// The per-run values every slot of one fan-out shares. @@ -607,15 +609,18 @@ pub(crate) fn distinct_org_ids(checks: &[synthetics_checks::DueCheck]) -> Vec, is_browser: bool, + is_status_attached: bool, ) -> PoolGate { let Some((policy, remaining)) = gate else { return PoolGate::Run; }; + // A status check reaches the monthly allowance only after the shared one-time pool is spent, + // so either pool having room is enough to run it. Browser has no monthly allowance at all. let has_room = if is_browser { remaining.browser > 0 } else { - remaining.protocol > 0 + remaining.protocol > 0 || (is_status_attached && remaining.status > 0) }; match policy { @@ -639,6 +644,7 @@ pub(crate) fn slot_verdict( if is_private { return PoolGate::Run; } + let is_status_attached = ctx.is_some_and(|c| c.status_attached.contains(&check.id)); gate_decision( ctx.and_then(|c| { Some(( @@ -647,6 +653,7 @@ pub(crate) fn slot_verdict( )) }), check.check_type == SyntheticType::Browser, + is_status_attached, ) } @@ -779,9 +786,29 @@ async fn resolve_gate_context(checks: &[synthetics_checks::DueCheck]) -> Option< let remaining = (hooks.remaining_for_orgs)(org_ids).await; + // One read for the whole tick: inside the fan-out it would run once per claimed check. + let claimed: Vec = checks.iter().map(|check| check.id.clone()).collect(); + let status_attached = infra::table::status_pages::mapped_check_ids( + infra::db::get_orm_client_ro().await, + Some(&claimed), + ) + .await + .unwrap_or_else(|e| { + // A check billed as regular is the safe answer: it draws no allowance it did not earn. + tracing::error!("[synthetics scheduler] status page mapping unreadable: {e}"); + std::collections::HashSet::new() + }); + + tracing::debug!( + claimed = claimed.len(), + status_attached = status_attached.len(), + "[synthetics scheduler] status page membership for this tick" + ); + Some(GateContext { remaining, policies, + status_attached, }) } @@ -2154,7 +2181,11 @@ mod pool_gate_tests { const A_LOCATION: &str = "us-east-1"; fn remaining(browser: u64, protocol: u64) -> StepRemaining { - StepRemaining { browser, protocol } + StepRemaining { + browser, + protocol, + status: 0, + } } fn due_check() -> DueCheck { @@ -2191,6 +2222,7 @@ mod pool_gate_tests { rows: &[(&str, StepRemaining)], ) -> GateContext { GateContext { + status_attached: std::collections::HashSet::new(), remaining: rows .iter() .map(|(org, r)| ((*org).to_string(), *r)) @@ -2228,7 +2260,7 @@ mod pool_gate_tests { for (is_browser, r, has_room) in rows { let case = format!("browser={is_browser} remaining={r:?}"); assert_eq!( - gate_decision(Some((SubscriptionRequired, *r)), *is_browser), + gate_decision(Some((SubscriptionRequired, *r)), *is_browser, false), if *has_room { PoolGate::Run } else { @@ -2237,7 +2269,7 @@ mod pool_gate_tests { "T30/E15, a Free org's slot is skipped only when the grant is spent: {case}", ); assert_eq!( - gate_decision(Some((MeteredOverage, *r)), *is_browser), + gate_decision(Some((MeteredOverage, *r)), *is_browser, false), if *has_room { PoolGate::Run } else { @@ -2246,7 +2278,7 @@ mod pool_gate_tests { "T31/E16, a Rate or Enterprise org is never skipped: {case}", ); assert_eq!( - gate_decision(Some((AdditionalCreditsRequired, *r)), *is_browser), + gate_decision(Some((AdditionalCreditsRequired, *r)), *is_browser, false), PoolGate::RunAndNotify, "T36/E18, a contract org is never pool-gated: {case}", ); @@ -2268,6 +2300,119 @@ mod pool_gate_tests { } } + /// The reason the gate learned about the status pool at all: a free org whose one-time pool is + /// spent must keep the checks its status page is built on. + #[test] + fn a_status_check_runs_on_the_monthly_pool_when_the_one_time_pool_is_empty() { + let spent = StepRemaining { + browser: 0, + protocol: 0, + status: 43_200, + }; + + assert_eq!( + gate_decision( + Some((PoolExhaustionPolicy::SubscriptionRequired, spent)), + false, + true + ), + PoolGate::Run, + ); + } + + #[test] + fn a_regular_check_ignores_the_monthly_pool() { + let spent = StepRemaining { + browser: 0, + protocol: 0, + status: 43_200, + }; + + assert_eq!( + gate_decision( + Some((PoolExhaustionPolicy::SubscriptionRequired, spent)), + false, + false + ), + PoolGate::Skip, + ); + } + + /// There is no monthly browser allowance, so attachment must not rescue a browser check. + #[test] + fn a_browser_check_on_a_status_page_reads_only_the_browser_grant() { + let spent = StepRemaining { + browser: 0, + protocol: 500, + status: 43_200, + }; + + assert_eq!( + gate_decision( + Some((PoolExhaustionPolicy::SubscriptionRequired, spent)), + true, + true + ), + PoolGate::Skip, + ); + } + + #[test] + fn an_exhausted_monthly_pool_skips_a_status_check_again() { + let spent = StepRemaining { + browser: 0, + protocol: 0, + status: 0, + }; + + assert_eq!( + gate_decision( + Some((PoolExhaustionPolicy::SubscriptionRequired, spent)), + false, + true + ), + PoolGate::Skip, + ); + } + + /// A metered org is never blocked, whichever pool is empty. + #[test] + fn a_metered_org_runs_a_status_check_as_overage() { + let spent = StepRemaining { + browser: 0, + protocol: 0, + status: 0, + }; + + assert_eq!( + gate_decision( + Some((PoolExhaustionPolicy::MeteredOverage, spent)), + false, + true + ), + PoolGate::RunAsOverage, + ); + } + + #[test] + fn slot_verdict_reads_status_membership_off_the_check_id() { + let check = protocol_check(); + let with_monthly_room = StepRemaining { + browser: 0, + protocol: 0, + status: 10, + }; + let mut ctx = ctx( + &[(&check.org_id, PoolExhaustionPolicy::SubscriptionRequired)], + &[(&check.org_id, with_monthly_room)], + ); + + assert_eq!(slot_verdict(Some(&ctx), &check, false), PoolGate::Skip); + + ctx.status_attached.insert(check.id.clone()); + assert_eq!(slot_verdict(Some(&ctx), &check, false), PoolGate::Run); + } + /// T17/E13 — the customer's own hardware ran it, so we never paid and must never stop it. #[test] fn a_private_venue_is_never_gated() { diff --git a/web/src/components/settings/OrganizationManagement.spec.ts b/web/src/components/settings/OrganizationManagement.spec.ts index 0596599ac4..c6daa90412 100644 --- a/web/src/components/settings/OrganizationManagement.spec.ts +++ b/web/src/components/settings/OrganizationManagement.spec.ts @@ -303,7 +303,7 @@ describe("OrganizationManagement.vue", () => { it("should have correct column configuration", () => { wrapper = createWrapper(); const columns = wrapper.vm.columns; - expect(columns).toHaveLength(15); + expect(columns).toHaveLength(17); expect(columns[0].id).toBe("name"); expect(columns[1].id).toBe("identifier"); expect(columns[2].id).toBe("subscription_status"); @@ -315,10 +315,12 @@ describe("OrganizationManagement.vue", () => { expect(columns[8].id).toBe("browser_steps_total"); expect(columns[9].id).toBe("protocol_steps_used"); expect(columns[10].id).toBe("protocol_steps_total"); - expect(columns[11].id).toBe("created_on"); - expect(columns[12].id).toBe("trial_expiry"); - expect(columns[13].id).toBe("contract_end_date"); - expect(columns[14].id).toBe("actions"); + expect(columns[11].id).toBe("status_steps_used"); + expect(columns[12].id).toBe("status_steps_total"); + expect(columns[13].id).toBe("created_on"); + expect(columns[14].id).toBe("trial_expiry"); + expect(columns[15].id).toBe("contract_end_date"); + expect(columns[16].id).toBe("actions"); }); it("should have subscription plans mapping", () => { @@ -468,6 +470,8 @@ describe("OrganizationManagement.vue", () => { browser_steps_limit: 0, protocol_steps_used: 0, protocol_steps_limit: 0, + status_steps_used: 0, + status_steps_limit: 0, created_at: "2023-12-01", trial_expires_at: "2023-12-01", contract_end_date: 0, @@ -479,6 +483,42 @@ describe("OrganizationManagement.vue", () => { }); }); + it("should carry every synthetics pool from the response into the row", async () => { + mockGetAdminOrg.mockResolvedValue({ + data: { + data: [ + { + id: 1, + name: "Test Org", + identifier: "test-org", + plan: "1", + created_at: 1701388800000000, + trial_expires_at: 1701388800000000, + browser_steps_used: 400, + browser_steps_limit: 10000, + protocol_steps_used: 900, + protocol_steps_limit: 20000, + status_steps_used: 12000, + status_steps_limit: 43200, + }, + ], + }, + }); + + wrapper = createWrapper(); + await wrapper.vm.getData(); + + // A dropped field opens the limit dialog at 0, and saving that wipes the grant. + expect(wrapper.vm.tabledata[0]).toMatchObject({ + browser_steps_used: 400, + browser_steps_limit: 10000, + protocol_steps_used: 900, + protocol_steps_limit: 20000, + status_steps_used: 12000, + status_steps_limit: 43200, + }); + }); + it("should set resultTotal correctly", async () => { const mockResponse = { data: { diff --git a/web/src/components/settings/OrganizationManagement.vue b/web/src/components/settings/OrganizationManagement.vue index 04ebf16c50..ab099a779d 100644 --- a/web/src/components/settings/OrganizationManagement.vue +++ b/web/src/components/settings/OrganizationManagement.vue @@ -94,6 +94,12 @@ along with this program. If not, see . + +