From 37972c96dec6b3ef9269fe25b0eb9f775bd959cf Mon Sep 17 00:00:00 2001 From: Yashodhan Joshi Date: Tue, 20 Oct 2026 06:56:23 +0000 Subject: [PATCH] feat: new usage page design (#14937) Signed-off-by: Yashodhan Joshi Co-authored-by: Yashodhan Joshi Co-authored-by: sai nikhil kethe Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 271 ++++- src/api/common/src/auth/jwt.rs | 14 +- .../management/src/request/cloud/billings.rs | 8 +- .../management/src/request/cloud/org_usage.rs | 58 +- .../src/request/organization/settings.rs | 77 +- src/common/src/meta/organization.rs | 20 + src/jobs/Cargo.toml | 1 + src/jobs/src/job/cloud.rs | 358 ++++++- web/src/components/DateTime.spec.ts | 17 + web/src/components/DateTime.vue | 7 +- .../components/billings/Billing.spec.ts | 322 +----- .../components/billings/Billing.vue | 217 +--- .../components/billings/BillingGroup.spec.ts | 14 +- .../components/billings/BillingGroup.vue | 3 - .../billings/UsageMemberList.spec.ts | 122 --- .../components/billings/UsageMemberList.vue | 225 ----- .../components/billings/usage.spec.ts | 923 ----------------- .../enterprise/components/billings/usage.vue | 879 ----------------- .../billings/usage/UsageBillStrip.vue | 200 ++++ .../billings/usage/UsageBudget.spec.ts | 280 ++++++ .../components/billings/usage/UsageBudget.vue | 474 +++++++++ .../billings/usage/UsageCachedChart.spec.ts | 69 ++ .../billings/usage/UsageCachedChart.vue | 71 ++ .../billings/usage/UsageChartSkeleton.vue | 47 + .../billings/usage/UsageEstimator.spec.ts | 220 +++++ .../billings/usage/UsageEstimator.vue | 614 ++++++++++++ .../billings/usage/UsageKpiCard.vue | 93 ++ .../usage/UsageMeterBreakdown.spec.ts | 161 +++ .../billings/usage/UsageMeterBreakdown.vue | 561 +++++++++++ .../billings/usage/UsageMeterTable.spec.ts | 74 ++ .../billings/usage/UsageMeterTable.vue | 224 +++++ .../billings/usage/UsageOverview.spec.ts | 396 ++++++++ .../billings/usage/UsageOverview.vue | 931 ++++++++++++++++++ .../billings/usage/UsageOverviewSkeleton.vue | 67 ++ .../billings/usage/UsagePaneHeader.vue | 41 + .../billings/usage/UsageSuperBudget.vue | 653 ++++++++++++ .../billings/usage/UsageTrends.spec.ts | 334 +++++++ .../components/billings/usage/UsageTrends.vue | 757 ++++++++++++++ .../billings/usage/UsageView.spec.ts | 439 +++++++++ .../components/billings/usage/UsageView.vue | 424 ++++++++ .../components/billings/usage/UsageWarnAt.vue | 92 ++ .../billings/usage/budgetModel.spec.ts | 142 +++ .../components/billings/usage/budgetModel.ts | 147 +++ .../billings/usage/meteringMock.spec.ts | 40 + .../components/billings/usage/meteringMock.ts | 117 +++ .../billings/usage/meteringModel.spec.ts | 504 ++++++++++ .../billings/usage/meteringModel.ts | 624 ++++++++++++ .../billings/usage/totalsBar.spec.ts | 29 + .../components/billings/usage/totalsBar.ts | 37 + .../billings/usage/trendsModel.spec.ts | 174 ++++ .../components/billings/usage/trendsModel.ts | 221 +++++ .../billings/usage/usageCharts.spec.ts | 117 +++ .../components/billings/usage/usageCharts.ts | 444 +++++++++ .../billings/usage/usageQueries.spec.ts | 256 +++++ .../components/billings/usage/usageQueries.ts | 334 +++++++ .../billings/usage/useOrgCosts.spec.ts | 99 ++ .../components/billings/usage/useOrgCosts.ts | 132 +++ .../billings/usage/useUsageReporting.ts | 67 ++ .../billings/usage/useUsageSearch.spec.ts | 171 ++++ .../billings/usage/useUsageSearch.ts | 128 +++ .../billings/usageDailyPanelSchema.spec.ts | 88 -- .../billings/usageDailyPanelSchema.ts | 180 ---- web/src/enterprise/composables/router.spec.ts | 4 +- web/src/enterprise/composables/router.ts | 2 +- .../core/ToggleGroup/OToggleGroupItem.spec.ts | 18 + .../lib/core/ToggleGroup/OToggleGroupItem.vue | 4 +- .../lib/data/ProgressBar/OProgressBar.spec.ts | 16 + .../data/ProgressBar/OProgressBar.types.ts | 1 + web/src/lib/data/ProgressBar/OProgressBar.vue | 10 +- web/src/locales/languages/ar-SA.json | 262 ++++- web/src/locales/languages/de-DE.json | 262 ++++- web/src/locales/languages/en-US.json | 262 ++++- web/src/locales/languages/es-ES.json | 262 ++++- web/src/locales/languages/fr-FR.json | 262 ++++- web/src/locales/languages/it-IT.json | 262 ++++- web/src/locales/languages/ja-JP.json | 262 ++++- web/src/locales/languages/ko-KR.json | 262 ++++- web/src/locales/languages/nl-NL.json | 262 ++++- web/src/locales/languages/pl-PL.json | 262 ++++- web/src/locales/languages/pt-PT.json | 262 ++++- web/src/locales/languages/ru-RU.json | 262 ++++- web/src/locales/languages/tr-TR.json | 262 ++++- web/src/locales/languages/vi-VN.json | 262 ++++- web/src/locales/languages/zh-CN.json | 262 ++++- web/src/locales/languages/zh-TW.json | 262 ++++- web/src/services/billings.spec.ts | 75 +- web/src/services/billings.ts | 15 +- 87 files changed, 15761 insertions(+), 3385 deletions(-) delete mode 100644 web/src/enterprise/components/billings/UsageMemberList.spec.ts delete mode 100644 web/src/enterprise/components/billings/UsageMemberList.vue delete mode 100644 web/src/enterprise/components/billings/usage.spec.ts delete mode 100644 web/src/enterprise/components/billings/usage.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageBillStrip.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageBudget.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageBudget.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageCachedChart.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageCachedChart.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageChartSkeleton.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageEstimator.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageEstimator.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageKpiCard.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageMeterBreakdown.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageMeterBreakdown.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageMeterTable.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageMeterTable.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageOverview.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageOverview.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageOverviewSkeleton.vue create mode 100644 web/src/enterprise/components/billings/usage/UsagePaneHeader.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageSuperBudget.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageTrends.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageTrends.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageView.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/UsageView.vue create mode 100644 web/src/enterprise/components/billings/usage/UsageWarnAt.vue create mode 100644 web/src/enterprise/components/billings/usage/budgetModel.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/budgetModel.ts create mode 100644 web/src/enterprise/components/billings/usage/meteringMock.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/meteringMock.ts create mode 100644 web/src/enterprise/components/billings/usage/meteringModel.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/meteringModel.ts create mode 100644 web/src/enterprise/components/billings/usage/totalsBar.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/totalsBar.ts create mode 100644 web/src/enterprise/components/billings/usage/trendsModel.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/trendsModel.ts create mode 100644 web/src/enterprise/components/billings/usage/usageCharts.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/usageCharts.ts create mode 100644 web/src/enterprise/components/billings/usage/usageQueries.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/usageQueries.ts create mode 100644 web/src/enterprise/components/billings/usage/useOrgCosts.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/useOrgCosts.ts create mode 100644 web/src/enterprise/components/billings/usage/useUsageReporting.ts create mode 100644 web/src/enterprise/components/billings/usage/useUsageSearch.spec.ts create mode 100644 web/src/enterprise/components/billings/usage/useUsageSearch.ts delete mode 100644 web/src/enterprise/components/billings/usageDailyPanelSchema.spec.ts delete mode 100644 web/src/enterprise/components/billings/usageDailyPanelSchema.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 774524162f..d4d6ff2f6e 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -2753,16 +2753,22 @@ "alert_library": { "addSelected": "bd467a2684768a3d", "alertCount": "42c0f3db6ef90f50", + "alertType": "ab30d609b7e2e4ae", + "alertTypeAll": "a52ace420f2175d0", + "alertTypeAnomaly": "79622828d2d8d988", + "alertTypeThreshold": "0da627ada476c179", "categories": "292c06f0045a45d0", "clearFilters": "7179ea0035fca783", "clearGroupSelection": "65a102085dc79c05", "clearSelection": "cea4d2e010b8fd52", "contribute": "73c910aa328725a7", "drawer": { + "anomalyTrainingNote": "65b35e5e34c9f417", "customize": "cbd25697f502caf2", "customizeFailed": "3853332997c114a5", "customizeHint": "520afc305b886bc5", "dataAvailable": "60d0189cfc855989", + "detection": "e3818c7886164dae", "detects": "ccf5fa44743c685f", "install": "6f914957def92ea3", "lastData": "51d523bffcc5d222", @@ -2786,6 +2792,8 @@ }, "emptyLibraryDescription": "b211ca78f7e7bbfb", "emptyLibraryTitle": "770a0a4fc4064de4", + "enterprise": "3fbe5ed156f14993", + "enterpriseHint": "3674924ee487b996", "error": { "http": "70af237dd4ace2a2", "malformed": "905a8a1240203c0c", @@ -2797,6 +2805,8 @@ "header": "6bbc0c5ddade1739", "install": { "allInstalled": "d7e5c2ac9661c291", + "anomalyTrainingNote": "9abf6d92cafbdfb2", + "anomalyUnavailable": "6e0d80036514c77d", "back": "76900f1bfd16c8d4", "cancel": "19766ed6ccb2f4a3", "clear": "83b12c2216efb4fd", @@ -2829,6 +2839,7 @@ "summaryTuningOverride": "4618a77d0da6425f", "title": "4cc111be1713bc4e", "tuneHint": "7ba506dae70e89de", + "tuneThresholdOnly": "cafa4f6f953b75d5", "tuneToggle": "4ab88542cfe87ce9", "unknownError": "65d61bbf27a965c6", "unreadableConditions": "22f17accaf7ae132" @@ -6481,7 +6492,6 @@ "billing": { "action": "64cff1319d2fd2cb", "activationTimeoutContactSupport": "123268f58ca67e8d", - "aiBillableCredits": "3d97c071ace6161a", "aiContractExhaustedMessage": "2593eb81c67bd4c0", "aiCredits": "75813321dba8304f", "aiExhaustedMessage": "7f591df6aab4cd05", @@ -6535,7 +6545,6 @@ "chipLinkOrgs": "4bd3d1b2b459fa07", "chipNoInvoices": "de1d81af9b584842", "chipPlanManaged": "651639f188ea8f99", - "currentOrgTitle": "4df764f20fe6e62c", "dateColumn": "99c40ab405926cb5", "emptyTitle": "7c904b33bd8274f2", "filterAll": "a52ace420f2175d0", @@ -6555,9 +6564,7 @@ "leaveBody": "cd9be00dbf0bcdb7", "leaveTitle": "bfa575af3332121f", "left": "928a1fe7e8d41fae", - "memberOrgsTitle": "7ff9b97b8359655b", "memberSince": "f4d3b821c4ccee2a", - "noMemberMatch": "71c29c9182ccd7de", "noMembers": "05407d8a3a9b5565", "notEnabledDesc": "3dfb25856c21892b", "notEnabledTitle": "a6270380ebb5e7a6", @@ -6570,7 +6577,6 @@ "removeBody": "7dc4fe895a0e766f", "removeTitle": "7a5903f234f2a439", "removed": "17907ce6407aa2e9", - "searchMemberOrg": "83ebd2f5fe9fb746", "sendInvite": "c1ad5dcf2e0576ad", "statActive": "92340695899bd2d8", "statPending": "74d26f0e2e44514f", @@ -6586,16 +6592,13 @@ "contactLabel": "484aa053905e2a3f", "contactSupport": "f8d47b82e285ba95", "contractManagedMessage": "5918e12db3a4db55", - "credits": "2a6b24ad28722034", "creditsUsedLabel": "ffc467350dc0cac7", - "currentCycle": "0df2d497917d57cd", "dataRetention": "6c5a4be839b4874b", "discountTag": "0c37dff0eded1e5c", "downloadInvoice": "d11d091a61cfffae", "enterpriseLabel": "1ac28bc3333eacab", "enterpriseNote": "1da58a40d41d9896", "enterpriseSubtitle": "116e1ceb28f61b16", - "errorTracking": "dc1a57203ee7a27b", "event": "4e1f49a9c8ae8a15", "failedAlerts": "5b4730e5cfe08780", "failedToCreateOrganization": "f2204373452ba70e", @@ -6618,26 +6621,19 @@ "thankYou": "1d5165cafdbb6e84", "title": "b900e2e8a36e1dd7" }, - "messageDataNotFound": "635f814c1f34ee53", "numberOfPipelines": "aa062d627b32b1a8", "oneDayUntilLicenseExpires": "93e2b3b764e95d38", "paymentNote": "8bcf324b1c715fb6", - "period30Days": "4ae21a9bc8175e70", - "period3Months": "1248c71c5c56ce0a", - "period60Days": "87de16c18df24680", - "period6Months": "782827a68b521231", "pipelineStatusFailed": "031a8f0f659df890", "pipelineStatusHealthy": "7f1e323b6272c620", "pipelineStatusWarning": "e981ddae45d8f4ca", "pipelines": "d1a8fffeb935847e", "plansLabel": "dfe8b2f0de26bb87", "pleaseWaitProcessingRequest": "d7f1fc8b9c731796", - "previousCycle": "ec96f68960aa13d4", "pricingErrorMessage": "c705b111cc0c0037", "proPlanLabel": "0ee0cdef83e013b7", "proPlanSubtitle": "e3bae0b2364cfce0", "remotePipelines": "b3b7f5179a96739b", - "rumSession": "c909b3d07603b076", "search": "49c266baaaa70981", "status": "920e413c7d411b61", "subscribe": "cc0e38da9c41790b", @@ -6663,15 +6659,256 @@ "usageReportingFailedForDays": "59274cf648180aba", "usageReportingFailingForHours": "35449e9e26321fd6", "usageTrends": { - "dailyUsage": "4e830b730cb922d0", "enableButton": "20ec67e51b6ac756", "enableConfirmMessage": "ab13603d97b75ab9", "enableConfirmTitle": "9e3caf7dd520abdf", "enablePending": "3c3204ac8196b16f", - "enableSubtitle": "0a4969f1716d78ab", + "enableSubtitle": "0935e6ccc02f084d", "enableTitle": "2e395a99aae92282", "waitingForData": "e80dc0b28952adc8", "waitingTitle": "a50738ffa966b144" + }, + "usageV2": { + "acrossDays": "ab8173ef76c0f964", + "aiCreditsPerMonth": "8953e7b58cf02533", + "alertsFor": "d373270b15fcd74f", + "alertsOnHint": "df4e9a41c328986a", + "alertsPausedHint": "9c9a12f4d8949993", + "allChangesSaved": "fc2f5a4752973d72", + "allMeters": "51b43dae4883bff8", + "allOrgs": "f145f7997210b599", + "apiChecks": "0fa56aca8318660f", + "apiRunsNote": "cfb781d4c7920581", + "appliesToGroup": "b045b3931f5b6196", + "assumptions": "69ef1ef347b53ec3", + "assumptionsFrom": "d62fa9fb0b47dfc6", + "atCurrentPace": "c244715ad3bb22ea", + "atPace": "2f8417504cc6d134", + "atPacePerDay": "8cac7a68de007409", + "atPaceProjected": "ff3d36e517b4c7fc", + "attentionHint": "7348e9947a776c21", + "avgStreamSize": "83574ed4274e76f7", + "billedAi": "64476a548e1869b1", + "billedDay": "e62aa12a58719b96", + "billedIngestion": "0b62a862e5bc3e7d", + "billedPipeline": "d2c54da1c9328295", + "billedQuery": "9ab48a22c1289d7f", + "billedRemotePipeline": "49755b9ddd764eac", + "billedRetention": "9940ab31d1c7fbca", + "billedSynthetics": "ace33920bd04d97a", + "billedThrough": "a1794bd74906c61a", + "breakdownSummary": "02fc1917ef8af94e", + "browserChecks": "7cb27db9b0a5bd0c", + "browserRunsNote": "2b88d65127044dee", + "budgetAmount": "f8354841f29d2fa3", + "budgetAmountHelp": "af85586420dcd3c1", + "budgetFor": "e83e838c0603c4ea", + "budgetKpi": "1c6225ec7092ad2f", + "budgetLoadFailed": "afa49087d84f3642", + "budgetLoadFailedTitle": "63e72d16a64e3e04", + "budgetNotSet": "4895f73177ab5d67", + "budgetReached": "357ff5eccf35d616", + "budgetRemoved": "140f72fd286a3d67", + "budgetSaveFailed": "e1d05f9e332d2161", + "budgetSaved": "75af170c8f7ebf14", + "budgetSettings": "75d9f19c77f64bbb", + "budgetTab": "1c6225ec7092ad2f", + "budgetThisCycle": "092f03497ecf0e48", + "byAlert": "c674c748a68e748c", + "byCheck": "d46df2461e330a4a", + "byDashboard": "e26275540d594fcd", + "byDestination": "98e3a6d977a06511", + "byIncident": "5ed72fe7a1518652", + "byOrg": "e025376580b8d813", + "byPipeline": "4414b0370a5c7d23", + "byScheduledPipeline": "0cedf114d506f7ff", + "byStream": "36214222e1f6c4ce", + "byUser": "6e6f567b097733c0", + "checkDevices": "4ba5121d4d1be174", + "checkInterval": "49ee81099058e6d2", + "checkRegions": "610c65d84510bef5", + "clickForBreakdown": "3f228898b06da70f", + "clickForOrg": "0d3d2225754650c1", + "colAlert": "44a57b22e03d9a66", + "colAlerts": "f62e9267d196c761", + "colBilled": "b3a9f29f68ac9c39", + "colBudget": "1c6225ec7092ad2f", + "colCheck": "9d60841e0a7891df", + "colCost": "204a5eb2cd28bcfd", + "colCredits": "2a6b24ad28722034", + "colCurrentSize": "09f55a69739cde81", + "colDashboard": "67b696468610b879", + "colDestination": "293d404a500f5f9a", + "colEstCost": "5d745880f8adf972", + "colGross": "0589b626717ccca8", + "colIncident": "36a606d4886552e8", + "colMeter": "2293b30e539a6842", + "colOrganization": "d764d42592fc9687", + "colPipeline": "37e1c775f452d695", + "colRetentionPeriod": "111b9efb81de4f10", + "colScheduledPipeline": "20575921fae17d04", + "colSpendThisCycle": "badebeb0b7ec4f52", + "colSteps": "1de3df70ddf4faab", + "colStorageShare": "9c54eb1940daa774", + "colStream": "1eec3071dd26e224", + "colTotal": "c9b3c38247f744e1", + "colUser": "b512d97e7cbf97c2", + "colVolume": "b10fb966d72063f5", + "costBreakdown": "5379bb5081695f9e", + "costByMeter": "06c040efd736b2db", + "costByOrg": "a6ea9521b978f67a", + "costOverTime": "9e9644ba94eda046", + "costSoFar": "e78c704d4c4e440f", + "cycleCloses": "1c876c29f80675a9", + "cycleDay": "ce2f194dc47ad38d", + "cycleRange": "eee639fcacc65e2c", + "dailyAverage": "f34b2d6e895229f6", + "dailyCost": "7de5f8facf96834a", + "dailyVolume": "8a70f4c9ed1a0f4d", + "dayTotal": "38278f0d99968925", + "discard": "eb1a70e39274bb76", + "discount": "cb72ec985b5f3d75", + "discountBadge": "672c9b4458644fe5", + "drilldownFailed": "86feb3a393b7a7a1", + "emailAlerts": "9d025f2187911d93", + "enableVolumeSubtitle": "489f2b416f78bc05", + "estimateDisclaimer": "bfc1191ccbd03961", + "estimatedAtCyclePrice": "fd58d23161f79891", + "estimatedCostSoFar": "72fba79bbf7f67ba", + "estimatedTotal": "8019400caf0913fa", + "estimationTab": "21f1ca880b5562e3", + "everythingElse": "cd6c5f59d15c09f4", + "expectedUsage": "c84f4a1a8ded236d", + "expectedUsageHint": "753702523261ecf7", + "forecast": "75205de8b342f506", + "formerMemberIngested": "67cfa043b435d5d4", + "grossLabel": "0589b626717ccca8", + "groupAlertsOnHint": "75b42702c264efc0", + "groupBy": "956a51f6b098a41b", + "groupNotRecorded": "9b75c43146730e44", + "historyStartsOn": "f00dac71312a1f88", + "ingestPerMonth": "43fba1a1f7915166", + "ingestedVolume": "c7a96ce7cdd80209", + "lastDaysRange": "27d4a69dd11db2ba", + "leftInBudget": "3152898f21ae93dc", + "loadFailedNetwork": "c1a5ed4d16d13a92", + "loadFailedServer": "d08c2d06bb442d26", + "loadFailedTitle": "8c813d334145b5d7", + "loadingHistory": "9dac0447a9cf1b29", + "measureCost": "204a5eb2cd28bcfd", + "measureVolume": "b10fb966d72063f5", + "meterByGroup": "c1664caefa349499", + "meterShare": "e37e968daf5d91c4", + "missingMeters": "443343353b89cc0d", + "needsAttention": "63cdbf0ebd1d2d8e", + "noBreakdownYet": "86a8d5870b6f7b0e", + "noDailyRetention": "81a9d1f3ee0d9c77", + "noPricingSubtitle": "f6eb428062939cab", + "noPricingTitle": "bc32abf4a9a35a88", + "noUsageInRange": "e9366364ec9c0178", + "notSplitByOrg": "6ab290f02db0ade7", + "notThisCycle": "600f8b9372218208", + "ofBudget": "7f57c2fdfd0db2cf", + "orgBudgetsOver": "0c16a5cdcae0986b", + "orgBudgetsTotal": "a57f8540d2a78705", + "orgCostEstimated": "bc6492634ac907d3", + "orgCostsFailed": "0044838bfbfbfd65", + "orgCountThisCycle": "44f12da2b3abdcd4", + "orgDefaultRetention": "1ae863d46d95e4d5", + "orgFilter": "d764d42592fc9687", + "orgSummary": "ef0fa3638bd4a7c6", + "organizationsTitle": "2730183d6b776b40", + "otherBand": "f97e9da0e3b879f0", + "overBudgetBy": "471805f34e045af0", + "overBy": "cd5b4fd3ea616c79", + "overallBudget": "d7139fef19fbdff5", + "overallBudgetLabel": "504ff19711198f1b", + "overviewTab": "d4b1ea5708dd5329", + "peakDay": "5cb37d3bcf2c6755", + "perDay": "c42973cdbfdb3ae9", + "perMonth": "88b1d1a67e34a269", + "percentInParens": "e05971578f600009", + "pipelineCount": "77008dfe4baa1031", + "projectedTotal": "46da41fb795ae393", + "qtyCredits": "b90f97b62546c42e", + "qtyKeptFor": "3ea352433a333822", + "qtyProcessed": "1fea4972338139aa", + "qtyRuns": "627a5f9e7cc0ba0f", + "qtyScanned": "80d20946e75458c3", + "qtyStepRuns": "778cde71523e6184", + "rangeCurrentCycle": "0df2d497917d57cd", + "rangeDates": "c72e51fdcb01a621", + "rangeLast90Days": "d4e3c09b24bdb153", + "rangeLastCycle": "e3f320b69c34199f", + "rateIncluded": "2bcc686adabe9137", + "rateIncludedTooltip": "605cde3d607b388f", + "ratePerUnit": "e60af0b3cae44636", + "rateTooltip": "436698ff53d3ddae", + "reachedOnDay": "562e5b929d75143b", + "refreshFailed": "078798b7bafd549a", + "resetEstimate": "daee7606b339f3c3", + "retentionDays": "deeec593154313a0", + "retentionDaysValue": "a2246fbc2542d1dd", + "retry": "942087cc2d41e013", + "runRate": "ff4d1be854e24292", + "runRateWindow": "b902e3177b30cd13", + "saveBudget": "ec92e1dc9bb3bf7b", + "saveChanges": "35322b5bb5a218a1", + "scanNote": "a7c1918174261648", + "scanPerSearch": "e273681c58bb5415", + "scenarioCurrent": "76c99aec92791529", + "scenarioFiveTimes": "f9e1bfb759637dc1", + "scenarioFrom": "a318b22bd7196d90", + "scenarioTwice": "2f5f39c77dd43099", + "searchesPerMonth": "4e3c385e3e26f750", + "segmentTitle": "af5be75eaf5ee952", + "segmentTitleDiscounted": "eca5bee8881f90f5", + "setBudgetFirst": "c17a427f7866487a", + "shareOfBudget": "b2ccfd00f6760909", + "shareOfMeter": "d8801d0b5934dac0", + "shareOfOrg": "bd469aab37c1c733", + "showAllMeters": "b98e6dbce4ecddc6", + "showOnlyMeter": "14fc8f8b3b43ea3e", + "spendAgainstBudget": "017c3f31141ef2bf", + "spendToDate": "eda6716ac5f0ff50", + "spentOfBudget": "d6ae35730f8e922a", + "statusNone": "dbd81113c19c4c09", + "statusOk": "eb54d22db02bf083", + "statusOver": "82d5e1d37ac48471", + "statusWarn": "cbfdd68a43f29db9", + "stepsPerCheck": "9b2b3df2a1f17dea", + "syntheticsBrowser": "4ab967ae8305bc7f", + "syntheticsProtocol": "e7d5797fe8e38457", + "thisOrg": "c9a9701aeea0aaf6", + "toSpare": "ac0fe29c52ffe635", + "tooEarlyToProject": "147896a9bb3c2c93", + "topMeter": "4cafd8e58d825606", + "topMeterOfCost": "10088e0dddeda766", + "topMeterShare": "6bbe03d1f4d8a9b9", + "totalCost": "dfa5cc738da25bec", + "trendsLoadFailed": "63d0c6088d60a060", + "trendsTab": "e856c6c6a48b24f2", + "unitCredit": "fb1875d0a5e85f29", + "unitGb": "b4043b0b8297e379", + "unitGbHour": "7c564f41c3ad0a72", + "unitRun": "00d60e31a4e6b834", + "unknownCheck": "a39a3d9d1113b2f3", + "unknownPipeline": "b764cdc0eab71374", + "unsavedChanges": "11bf417d585b7af6", + "unsavedOrgs": "288dc631cd852583", + "unsavedOverall": "572221600db6cbd3", + "usageByMeter": "3bdc865077a32133", + "volumeAtRate": "8d48684c0395f46a", + "volumeAtRateBilled": "ed81550e2abfe990", + "volumeOverTime": "8bd51667d6991387", + "volumeSubtitle": "42fa3199fb7249a6", + "warnAmount": "0f1cfa42fe34949d", + "warnAt": "7fab59e7fbc44d86", + "warnAtPercent": "3d9e12cf9dc09e9f", + "warnHelp": "afc32c66ee216616", + "warnPercent": "39b04dea5258fedc", + "warnPercentFor": "8d917cd7e57f1b78", + "yourEstimate": "5877075f4617e541" } }, "chatHistory": { diff --git a/src/api/common/src/auth/jwt.rs b/src/api/common/src/auth/jwt.rs index 4386b7b632..a82786d2e3 100644 --- a/src/api/common/src/auth/jwt.rs +++ b/src/api/common/src/auth/jwt.rs @@ -938,13 +938,13 @@ pub async fn process_domain_org_mapping( .await; } - if !add_tuples.is_empty() || !remove_tuples.is_empty() { - if let Err(e) = update_tuples(add_tuples, remove_tuples).await { - log::error!( - "error updating claim based role tuples for user {user_email} org {} : {e}", - mapped.org_id - ); - } + if (!add_tuples.is_empty() || !remove_tuples.is_empty()) + && let Err(e) = update_tuples(add_tuples, remove_tuples).await + { + log::error!( + "error updating claim based role tuples for user {user_email} org {} : {e}", + mapped.org_id + ); } } diff --git a/src/api/management/src/request/cloud/billings.rs b/src/api/management/src/request/cloud/billings.rs index d4d9fe6fb3..de0a9ded94 100644 --- a/src/api/management/src/request/cloud/billings.rs +++ b/src/api/management/src/request/cloud/billings.rs @@ -317,15 +317,11 @@ pub async fn unsubscribe( ("x-o2-mcp" = json!({"enabled": false})) ), )] -pub async fn list_invoices( - Path(org_id): Path, - Headers(user_email): Headers, -) -> Response { - let email = user_email.user_id.as_str(); +pub async fn list_invoices(Path(org_id): Path) -> Response { if organization::get_org(&org_id).await.is_none() { return o2_cloud_billings::BillingError::OrgNotFound.into_http_response(); } - match o2_cloud_billings::list_invoice(&org_id, email).await { + match o2_cloud_billings::list_invoice(&org_id).await { Ok(invoices) => { let body = ListInvoicesResponseBody { invoices: invoices.unwrap_or_default(), diff --git a/src/api/management/src/request/cloud/org_usage.rs b/src/api/management/src/request/cloud/org_usage.rs index 1a61f7c0fa..6640712cc4 100644 --- a/src/api/management/src/request/cloud/org_usage.rs +++ b/src/api/management/src/request/cloud/org_usage.rs @@ -18,7 +18,10 @@ use axum::{ response::Response, }; use hashbrown::HashMap; -use o2_enterprise::enterprise::cloud::{billing_group, billings}; +use o2_enterprise::enterprise::cloud::{ + billing_group, + billings::{self, MeteringProvider}, +}; use super::IntoHttpResponse; use crate::{ @@ -52,35 +55,32 @@ pub async fn get_org_usage( Path((org_id, query_range)): Path<(String, String)>, Query(query): Query>, ) -> Response { - let usage_range = match query_range.parse::() { - Ok(usage_range) => usage_range, - Err(e) => return e.into_http_response(), - }; - let unit = query.get("data_type").map(|h| h.as_str()).unwrap_or("mb"); - - let member_query = query.get("member").map(|h| h.as_str()); - - // if member is present in the query, check if it part of the member group, and if so use that - // else error out. If member is not present in the query, use the normal org_id - let query_org = if let Some(member) = member_query { - let members = match billing_group::list_billing_group_members_of(&org_id).await { - Ok(v) => v, - Err(e) => { - log::error!("error listing billing group members of {org_id} : {e}"); - return MetaHttpResponse::internal_error(format!("error listing members : {e}")); + if let Ok(billings) = + o2_enterprise::enterprise::cloud::customer_billings::get_by_org_id(&org_id).await + { + // if subscription is present, and stripe is provider , and range is cycle and subscription + // id is present, we will try to get the cycle based usage + if let Some(b) = billings.first() + && b.provider == MeteringProvider::Stripe + && let Some(id) = &b.customer_id + { + match o2_enterprise::enterprise::cloud::billings::get_metering_details(&org_id, id) + .await + { + Ok(v) => { + return MetaHttpResponse::json(serde_json::json!({ + "price_details":v + })); + } + Err(e) => { + return MetaHttpResponse::internal_error(format!( + "error getting metering details : {e}" + )); + } } - }; - - if members.iter().find(|m| m.member_org_id == member).is_none() { - return MetaHttpResponse::bad_request(format!("no member with org id {member} found")); } - member - } else { - &org_id - }; - - match org_usage::get_org_usage(&org_id, query_org, &usage_range, unit).await { - Err(e) => e.into_http_response(), - Ok(body) => MetaHttpResponse::json(body), } + MetaHttpResponse::json(serde_json::json!({ + "price_details": serde_json::Value::Null, + })) } diff --git a/src/api/management/src/request/organization/settings.rs b/src/api/management/src/request/organization/settings.rs index c0e04b7a47..85855c6059 100644 --- a/src/api/management/src/request/organization/settings.rs +++ b/src/api/management/src/request/organization/settings.rs @@ -19,8 +19,6 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -#[cfg(feature = "cloud")] -use config::META_ORG_ID; use db::organization::{get_org_setting, set_org_setting}; use infra::errors::{DbError, Error}; #[cfg(feature = "enterprise")] @@ -28,6 +26,14 @@ use { axum::extract::{Multipart, Query}, o2_enterprise::enterprise::common::settings, }; +#[cfg(feature = "cloud")] +use { + config::META_ORG_ID, + o2_enterprise::enterprise::cloud::{ + billing_group::list_billing_group_members_of, billings::MeteringProvider, customer_billings, + }, + std::collections::HashSet, +}; use crate::common::meta::{ http::HttpResponse as MetaHttpResponse, @@ -74,6 +80,10 @@ pub async fn create( Ok(settings) => settings, Err(msg) => return MetaHttpResponse::bad_request(msg), }; + #[cfg(feature = "cloud")] + if let Err(response) = check_budget_config(&org_id, &settings).await { + return response; + } match db::organization::update_org_setting(&org_id, |data| { apply_settings_payload(data, settings) @@ -296,6 +306,64 @@ async fn check_domain_org_mappings( Ok(settings) } +// Budgets are for Stripe-billed orgs only, and only for this org, its group members and the +// group total; checked before the settings lock, since it reads billing and group tables. +#[cfg(feature = "cloud")] +async fn check_budget_config( + org_id: &str, + settings: &OrganizationSettingPayload, +) -> Result<(), Response> { + let Some(configs) = settings.budget_config.as_ref() else { + return Ok(()); + }; + let billings = match customer_billings::get_by_org_id(org_id).await { + Ok(v) => v, + Err(e) => { + log::error!("error getting customer billings for org_id {org_id} : {e}"); + return Err(MetaHttpResponse::internal_error(e)); + } + }; + if !billings + .first() + .is_some_and(|v| v.provider == MeteringProvider::Stripe) + { + return Err(MetaHttpResponse::bad_request( + "this org is not configured with stripe billing, so budget cannot be set".to_string(), + )); + } + let child_orgs = match list_billing_group_members_of(org_id).await { + Ok(v) => v, + Err(e) => { + log::error!("error in getting child orgs of {org_id} for budget config update : {e}"); + return Err(MetaHttpResponse::internal_error(e)); + } + }; + let mut allowed_orgs: HashSet<_> = child_orgs.into_iter().map(|v| v.member_org_id).collect(); + allowed_orgs.insert(org_id.to_string()); + allowed_orgs.insert("total".to_string()); // special indicator for total of super org + if let Some(config) = configs.iter().find(|c| !allowed_orgs.contains(&c.org_id)) { + return Err(MetaHttpResponse::bad_request(format!( + "cannot set budget notification configuration for org {}", + config.org_id + ))); + } + for config in configs { + if config.total_budget_amount <= 0.0 { + return Err(MetaHttpResponse::bad_request(format!( + "cannot set budget amount less than 0 for org {}", + config.org_id + ))); + } + if config.warn_at_amount <= 0.0 || config.warn_at_amount > config.total_budget_amount { + return Err(MetaHttpResponse::bad_request(format!( + "invalid warning amount for org {}", + config.org_id + ))); + } + } + Ok(()) +} + // Runs under the settings lock on a fresh read, so it must not await or fail. fn apply_settings_payload( data: &mut OrganizationSetting, @@ -355,6 +423,11 @@ fn apply_settings_payload( field_found = true; data.domain_org_mappings = mappings; } + #[cfg(feature = "cloud")] + if let Some(configs) = settings.budget_config { + field_found = true; + data.budget_config = configs; + } // this will always be taken from orgs table, never from settings data.free_trial_expiry = None; field_found diff --git a/src/common/src/meta/organization.rs b/src/common/src/meta/organization.rs index 7a18040cfb..ea4d164762 100644 --- a/src/common/src/meta/organization.rs +++ b/src/common/src/meta/organization.rs @@ -490,6 +490,14 @@ pub struct DomainOrgMapping { pub create_missing_roles: bool, } +#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] +pub struct BudgetNotificationConfiguration { + pub org_id: String, + pub total_budget_amount: f64, + pub warn_at_amount: f64, + pub paused: bool, +} + #[derive(Serialize, ToSchema, Deserialize, Debug, Clone)] pub struct OrganizationSettingPayload { /// Ideally this should be the same as prometheus-scrape-interval (in @@ -525,6 +533,8 @@ pub struct OrganizationSettingPayload { pub cross_links: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub domain_org_mappings: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub budget_config: Option>, } #[derive(Serialize, ToSchema, Deserialize, Debug, Clone)] @@ -569,6 +579,9 @@ pub struct OrganizationSetting { #[cfg(feature = "cloud")] #[serde(default)] pub domain_org_mappings: Vec, + #[cfg(feature = "cloud")] + #[serde(default)] + pub budget_config: Vec, } impl Default for OrganizationSetting { @@ -605,6 +618,8 @@ impl Default for OrganizationSetting { org_storage_enabled: false, #[cfg(feature = "cloud")] domain_org_mappings: Vec::new(), + #[cfg(feature = "cloud")] + budget_config: Vec::new(), } } } @@ -1444,6 +1459,7 @@ mod tests { claim_parser_function: None, cross_links: None, domain_org_mappings: None, + budget_config: None, }; let json = serde_json::to_value(&payload).unwrap(); let obj = json.as_object().unwrap(); @@ -1524,6 +1540,8 @@ mod tests { org_storage_enabled: false, #[cfg(feature = "cloud")] domain_org_mappings: vec![], + #[cfg(feature = "cloud")] + budget_config: vec![], }; let json = serde_json::to_value(&setting).unwrap(); let obj = json.as_object().unwrap(); @@ -1555,6 +1573,8 @@ mod tests { org_storage_enabled: false, #[cfg(feature = "cloud")] domain_org_mappings: vec![], + #[cfg(feature = "cloud")] + budget_config: vec![], }; let json = serde_json::to_value(&setting).unwrap(); let obj = json.as_object().unwrap(); diff --git a/src/jobs/Cargo.toml b/src/jobs/Cargo.toml index 30e0f8e98c..c9d4f66a90 100644 --- a/src/jobs/Cargo.toml +++ b/src/jobs/Cargo.toml @@ -85,6 +85,7 @@ proto.workspace = true regex.workspace = true report_server.workspace = true reqwest.workspace = true +serde.workspace = true serde_json.workspace = true tokio.workspace = true tonic.workspace = true diff --git a/src/jobs/src/job/cloud.rs b/src/jobs/src/job/cloud.rs index 65537964e5..16fe889d01 100644 --- a/src/jobs/src/job/cloud.rs +++ b/src/jobs/src/job/cloud.rs @@ -15,15 +15,25 @@ use std::collections::HashMap; +use common::meta::organization::BudgetNotificationConfiguration; use config::{ - meta::{destinations::Email, self_reporting::usage::USAGE_STREAM}, + meta::{ + destinations::Email, + self_reporting::usage::{DATA_RETENTION_USAGE_STREAM, USAGE_STREAM}, + user::UserRole, + }, utils::{ json, time::{hour_micros, now_micros}, }, }; +use db::{ + org_users::list_users_by_org, + organization::{self, get_org_setting}, +}; use hashbrown::HashSet; use infra::table::org_users::get_admin; +use o2_enterprise::enterprise::cloud::billings::{MeteringDetail, MeteringProvider}; use stream::get_streams; use crate::{ @@ -52,12 +62,44 @@ const EXPIRY_WARNING_STAGES: &[( &[(1, OneDay), (7, SevenDay), (30, ThirtyDay)] }; +const BUDGET_WARNING_INTERVAL: u64 = 3600; + +macro_rules! calculate_subcost { + ($total:ident, $events:ident, $name:literal, $field:expr) => { + if let Some(amt) = $events.get($name) { + let mut base = (*amt / $field.unit_divisor) * $field.base_cost; + if let Some(discount) = $field.percent_discount + && discount > 0.0 + { + base = base * (100.0 - discount) / 100.; + } + $total += base; + } + }; + ($total:ident, $amt:expr, $field:expr) => { + let mut base = ($amt / $field.unit_divisor) * $field.base_cost; + if let Some(discount) = $field.percent_discount + && discount > 0.0 + { + base = base * (100.0 - discount) / 100.; + } + $total += base; + }; +} + +#[derive(serde::Deserialize)] +struct UsageRecords { + event: String, + size: f64, +} + pub fn start() { tokio::spawn(async move { run_no_ingestion_period().await }); tokio::spawn(async move { run_no_ingestion_daily().await }); tokio::spawn(async move { run_org_expiry_daily().await }); tokio::spawn(async move { run_ai_quota_check().await }); tokio::spawn(async move { run_external_contract_expiry_check().await }); + tokio::spawn(async move { run_budget_warning_emails().await }); } /// Start trial quota background jobs (flush + cluster sync). @@ -612,6 +654,320 @@ fn find_pending_expiry_stage( .map(|(_, stage)| *stage) } +async fn run_budget_warning_emails() { + let mut interval = + tokio::time::interval(tokio::time::Duration::from_secs(BUDGET_WARNING_INTERVAL)); + loop { + // we will skip first tick, which is ok, and we anyways want this at top, so we can continue + // from the loop + interval.tick().await; + let now = now_micros(); + log::info!("starting budget warning emails checks at {now}"); + let billed_orgs = + match o2_enterprise::enterprise::cloud::customer_billings::list_customer_billings() + .await + { + Ok(v) => v, + Err(e) => { + log::error!( + "error getting customer billings list : {e}, skipping {now} iteration" + ); + continue; + } + }; + let stripe_orgs: Vec<_> = billed_orgs + .into_iter() + .filter(|v| v.provider == MeteringProvider::Stripe) + .collect(); + for org in stripe_orgs { + let settings = match get_org_setting(&org.org_id).await { + Ok(v) => v, + Err(e) => { + log::error!( + "error getting settings for org {} for budget warning, skipping : {e}", + org.org_id + ); + continue; + } + }; + if !settings.budget_config.is_empty() + && let Some(cid) = &org.customer_id + && let Err(e) = + send_budget_warning_emails(&org.org_id, cid, settings.budget_config).await + { + log::error!( + "error in sending warning emails for budget config for org {} : {e}", + org.org_id + ); + } + } + } +} + +async fn send_budget_warning_emails( + org_id: &str, + customer_id: &str, + config: Vec, +) -> Result<(), anyhow::Error> { + let member_orgs = + o2_enterprise::enterprise::cloud::billing_group::list_billing_group_members_of(org_id) + .await?; + + let upcoming_invoice = + o2_enterprise::enterprise::cloud::billings::get_upcoming_invoice_details(customer_id) + .await?; + + let cycle_start = upcoming_invoice.cycle_start * 1_000_000; + let cycle_end = upcoming_invoice.cycle_end * 1_000_000; + + let member_orgs: HashSet<_> = member_orgs.into_iter().map(|v| v.member_org_id).collect(); + let allowed_configs: Vec<_> = config + .into_iter() + .filter(|v| { + let allowed = + v.org_id == org_id || member_orgs.contains(&v.org_id) || v.org_id == "total"; + if !allowed { + log::warn!( + "skipping org {} for org {}, as not a member nor the same org", + v.org_id, + org_id + ); + } + allowed + }) + .collect(); + + let total_config = allowed_configs.iter().find(|v| v.org_id == "total"); + let child_configs: Vec<_> = allowed_configs + .iter() + .filter(|v| v.org_id != "total") + .collect(); + + let start_str = chrono::DateTime::from_timestamp_micros(cycle_start) + .unwrap() + .to_utc() + .to_rfc2822(); + let end_str = chrono::DateTime::from_timestamp_micros(cycle_end) + .unwrap() + .to_utc() + .to_rfc2822(); + + if let Some(total) = total_config + && !total.paused + && total.warn_at_amount <= upcoming_invoice.total_cost + { + send_warning_email( + org_id, + None, + &start_str, + &end_str, + total.total_budget_amount, + total.warn_at_amount, + upcoming_invoice.total_cost, + true, + ) + .await?; + } + + for child_config in child_configs { + if child_config.paused { + continue; + } + + let res = get_usage( + format!("select sum(size) as size, event from \"{USAGE_STREAM}\" where org_id='{}' group by event",child_config.org_id), + cycle_start, + cycle_end, + false, + ) + .await?; + + let res: Vec<_> = res + .into_iter() + .map(serde_json::from_value::) + .collect::, _>>()?; + let event_map: HashMap<_, _> = res.into_iter().map(|v| (v.event, v.size)).collect(); + + let retention = get_usage( + format!( + "select sum(mb_hours) as size, 'retention' as event from \"{DATA_RETENTION_USAGE_STREAM}\" where org_id='{}'", + child_config.org_id + ), + cycle_start, + cycle_end, + false + ).await.inspect_err(|e|{ + log::error!("error in getting retention for org {org_id} for budget warning check : {e}"); + }).unwrap_or_default(); + + let retention: Vec<_> = retention + .into_iter() + .map(serde_json::from_value::) + .collect::, _>>()?; + + let retention_amt = match retention.first() { + Some(v) => v.size, + None => { + log::warn!( + "no retention record found for org {} child of org {} for budget notification, marking as 0", + child_config.org_id, + org_id + ); + 0.0 + } + }; + + let total_cost = calculate_total_cost(event_map, &upcoming_invoice, retention_amt); + + if child_config.warn_at_amount <= total_cost { + send_warning_email( + org_id, + Some(&child_config.org_id), + &start_str, + &end_str, + child_config.total_budget_amount, + child_config.warn_at_amount, + total_cost, + false, + ) + .await?; + } + } + + Ok(()) +} + +fn calculate_total_cost( + events: HashMap, + cost_details: &MeteringDetail, + retention_amt: f64, +) -> f64 { + let mut total_cost = 0.0; + + calculate_subcost!(total_cost, events, "Ingestion", cost_details.ingestion); + calculate_subcost!(total_cost, events, "Search", cost_details.query); + calculate_subcost!(total_cost, events, "Pipeline", cost_details.pipeline); + calculate_subcost!( + total_cost, + events, + "RemotePipeline", + cost_details.remote_pipeline + ); + calculate_subcost!(total_cost, retention_amt, cost_details.retention); + calculate_subcost!(total_cost, events, "AiCredits", cost_details.ai); + calculate_subcost!( + total_cost, + events, + "SyntheticsBrowserSteps", + cost_details.synthetics_browser + ); + calculate_subcost!( + total_cost, + events, + "SyntheticsProtocolSteps", + cost_details.synthetics_protocol + ); + + total_cost +} + +#[allow(clippy::too_many_arguments)] +async fn send_warning_email( + org_id: &str, + child_org: Option<&str>, + cycle_start: &str, + cycle_end: &str, + total: f64, + warn: f64, + actual: f64, + is_total_config: bool, +) -> Result<(), anyhow::Error> { + let admins = list_users_by_org(org_id) + .await? + .into_iter() + .filter(|v| matches!(v.role, UserRole::Admin)) + .map(|v| v.email) + .collect(); + + let email = Email { recipients: admins }; + + let considered_org_id = child_org.unwrap_or(org_id); + + let parent_name_id_pair = match organization::get_org(org_id).await { + Ok(v) => format!("{} ({org_id})", v.name), + Err(e) => { + log::error!("error in getting org record for {org_id} for budget email : {e}"); + org_id.to_string() + } + }; + + let name_id_pair = match organization::get_org(considered_org_id).await { + Ok(v) => format!("{} ({considered_org_id})", v.name), + Err(e) => { + log::error!( + "error in getting org record for {considered_org_id} for budget email : {e}" + ); + considered_org_id.to_string() + } + }; + + let subject = format!("OpenObserve Cloud: Budget Warning notification for {name_id_pair} org",); + + let info_line = if is_total_config { + format!( + "This is a notification email based on configured total cost budget for org {parent_name_id_pair}" + ) + } else { + format!( + "This is a notification email based on configured cost budget for org {name_id_pair}." + ) + }; + + let body = format!( + r#"Hello, + {info_line}
+ You have configured a total budget of ${}, with warning starting at ${} or {:.2}%.
+ The org has reached estimated cost of ${:.2} or {:.2}% of the total budget, exceeding the warning level.
+ These amounts are for the current billing cycle ranging from {} (UTC) to {} (UTC).
+
+ These notifications will now continue until the end of the cycle, and you can pause them from the Budget tab of the Usage section in OpenObserve Cloud.
+
+ Regards,
+ The OpenObserve Team"#, + total, + warn, + (warn / total) * 100.0, + actual, + (actual / total) * 100.0, + cycle_start, + cycle_end + ); + + match openobserve_core::alerts::alert::send_email_notification( + &subject, + &email, + body.clone(), + body, + ) + .await + { + Ok(_) => { + log::info!( + "budget warning notification successfully sent for {} (via org {org_id})", + child_org.unwrap_or(org_id) + ); + } + Err(e) => { + log::error!( + "error sending budget notification for {} (via org {org_id}) : {e}", + child_org.unwrap_or(org_id) + ); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use o2_enterprise::enterprise::cloud::billings::ExpiryNotificationStage; diff --git a/web/src/components/DateTime.spec.ts b/web/src/components/DateTime.spec.ts index f46fec1e13..9c2152a38d 100644 --- a/web/src/components/DateTime.spec.ts +++ b/web/src/components/DateTime.spec.ts @@ -781,6 +781,23 @@ describe("DateTime Component", () => { }); }); + describe("disablePrevShift", () => { + const prev = (w: any) => + w + .findAllComponents({ name: "OButton" }) + .find((b: any) => b.attributes("data-test") === "date-time-prev-btn"); + + it("leaves ‹ on by default, as every existing picker has it", () => { + wrapper = createWrapper(); + expect(prev(wrapper).props("disabled")).toBeFalsy(); + }); + + it("turns ‹ off when the parent asks", () => { + wrapper = createWrapper({ disablePrevShift: true }); + expect(prev(wrapper).props("disabled")).toBe(true); + }); + }); + describe("Relative preset search", () => { it("shows every period with no search term", () => { wrapper = createWrapper(); diff --git a/web/src/components/DateTime.vue b/web/src/components/DateTime.vue index a929a770f2..e7ce2b061a 100644 --- a/web/src/components/DateTime.vue +++ b/web/src/components/DateTime.vue @@ -30,7 +30,7 @@ along with this program. If not, see . class="border-button-outline-border h-auto! rounded-e-none! border-e!" icon-left="chevron-left" :aria-label="t('common.previous')" - :disabled="disable" + :disabled="disable || disablePrevShift" @click.prevent.stop="shiftTimeRange('prev')" /> @@ -460,6 +460,11 @@ export default defineComponent({ type: Boolean, default: false, }, + /** Turns off the ‹ shift, for a parent that knows there is nothing earlier to show. */ + disablePrevShift: { + type: Boolean, + default: false, + }, minDate: { type: String, default: null, diff --git a/web/src/enterprise/components/billings/Billing.spec.ts b/web/src/enterprise/components/billings/Billing.spec.ts index b35a5ba8f6..920e6fd7c2 100644 --- a/web/src/enterprise/components/billings/Billing.spec.ts +++ b/web/src/enterprise/components/billings/Billing.spec.ts @@ -40,10 +40,7 @@ const mockRouter = { currentRoute: { value: { name: "billings", - query: { - data_type: "gb", - usage_date: "30days", - }, + query: {}, }, }, push: vi.fn(), @@ -82,10 +79,7 @@ describe("Billing Component", () => { // Reset router state mockRouter.currentRoute.value.name = "billings"; - mockRouter.currentRoute.value.query = { - data_type: "gb", - usage_date: "30days", - }; + mockRouter.currentRoute.value.query = {}; wrapper = mount(Billing, { global: { @@ -126,13 +120,6 @@ describe("Billing Component", () => { expect(wrapper.vm.$options.name).toBe("PageIngestion"); }); - it("should initialize with correct data", () => { - expect(wrapper.vm.billingtab).toBe("plans"); // Should be set to "plans" in onMounted after fetchBillingInfo - expect(wrapper.vm.usageDataType).toBe("gb"); - expect(wrapper.vm.splitterModel).toBe(220); - expect(wrapper.vm.usageDate).toBe("30days"); - }); - it("should expose all required properties from setup", () => { expect(wrapper.vm.t).toBeDefined(); expect(wrapper.vm.store).toBeDefined(); @@ -141,26 +128,7 @@ describe("Billing Component", () => { expect(wrapper.vm.billingtab).toBeDefined(); expect(wrapper.vm.getImageURL).toBeDefined(); expect(wrapper.vm.headerBasedOnRoute).toBeDefined(); - expect(wrapper.vm.selectUsageDate).toBeDefined(); - expect(wrapper.vm.updateActiveTab).toBeDefined(); - }); - - it("should have correct tabs configuration", () => { - expect(wrapper.vm.tabs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ label: "GB", value: "gb" }), - expect.objectContaining({ label: "MB", value: "mb" }), - ]), - ); - }); - - it("should have correct options configuration", () => { - expect(wrapper.vm.options).toEqual([ - { label: "30 Days", value: "30days" }, - { label: "60 Days", value: "60days" }, - { label: "3 Months", value: "3months" }, - { label: "6 Months", value: "6months" }, - ]); + expect(wrapper.vm.isUsageRoute).toBeDefined(); }); }); @@ -177,10 +145,7 @@ describe("Billing Component", () => { it("should redirect to plans when route is plans", async () => { // Reset router state and clear mocks before mounting mockRouter.currentRoute.value.name = "plans"; - mockRouter.currentRoute.value.query = { - data_type: "gb", - usage_date: "30days", - }; + mockRouter.currentRoute.value.query = {}; // Mock billing service response for this test (BillingService.list_subscription as any).mockResolvedValue({ @@ -438,106 +403,6 @@ describe("Billing Component", () => { }); }); - describe("selectUsageDate Function", () => { - it("should navigate to usage route with correct parameters", () => { - wrapper.vm.usageDate = "60days"; - wrapper.vm.usageDataType = "mb"; - - wrapper.vm.selectUsageDate(); - - expect(mockRouter.push).toHaveBeenCalledWith({ - path: "/billings/usage", - query: { - org_identifier: store.state.selectedOrganization.identifier, - usage_date: "60days", - data_type: "mb", - }, - }); - }); - - it("should use default values when properties are not set", () => { - wrapper.vm.usageDate = undefined; - wrapper.vm.usageDataType = undefined; - - wrapper.vm.selectUsageDate(); - - expect(mockRouter.push).toHaveBeenCalledWith({ - path: "/billings/usage", - query: { - org_identifier: store.state.selectedOrganization.identifier, - usage_date: undefined, - data_type: undefined, - }, - }); - }); - - it("should handle empty string values", () => { - wrapper.vm.usageDate = ""; - wrapper.vm.usageDataType = ""; - - wrapper.vm.selectUsageDate(); - - expect(mockRouter.push).toHaveBeenCalledWith({ - path: "/billings/usage", - query: { - org_identifier: store.state.selectedOrganization.identifier, - usage_date: "", - data_type: "", - }, - }); - }); - }); - - describe("updateActiveTab Function", () => { - it("should update usageDataType and call router push", () => { - mockRouter.push.mockClear(); - - wrapper.vm.updateActiveTab("mb"); - - expect(wrapper.vm.usageDataType).toBe("mb"); - expect(mockRouter.push).toHaveBeenCalledWith({ - path: "/billings/usage", - query: { - org_identifier: store.state.selectedOrganization.identifier, - usage_date: wrapper.vm.usageDate, - data_type: "mb", - }, - }); - }); - - it("should handle different tab values", () => { - mockRouter.push.mockClear(); - - wrapper.vm.updateActiveTab("gb"); - - expect(wrapper.vm.usageDataType).toBe("gb"); - expect(mockRouter.push).toHaveBeenCalledWith({ - path: "/billings/usage", - query: { - org_identifier: store.state.selectedOrganization.identifier, - usage_date: wrapper.vm.usageDate, - data_type: "gb", - }, - }); - }); - - it("should handle null value", () => { - mockRouter.push.mockClear(); - - wrapper.vm.updateActiveTab(null); - - expect(wrapper.vm.usageDataType).toBe(null); - expect(mockRouter.push).toHaveBeenCalledWith({ - path: "/billings/usage", - query: { - org_identifier: store.state.selectedOrganization.identifier, - usage_date: wrapper.vm.usageDate, - data_type: null, - }, - }); - }); - }); - describe("Reactive Data Properties", () => { it("should have reactive billingtab", async () => { wrapper.vm.billingtab = "usage"; @@ -546,20 +411,6 @@ describe("Billing Component", () => { expect(wrapper.vm.billingtab).toBe("usage"); }); - it("should have reactive usageDataType", async () => { - wrapper.vm.usageDataType = "mb"; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.usageDataType).toBe("mb"); - }); - - it("should have reactive usageDate", async () => { - wrapper.vm.usageDate = "60days"; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.usageDate).toBe("60days"); - }); - it("should have reactive splitterModel", async () => { wrapper.vm.splitterModel = 300; await wrapper.vm.$nextTick(); @@ -573,18 +424,6 @@ describe("Billing Component", () => { expect(wrapper.vm.store.state.selectedOrganization).toBeDefined(); expect(wrapper.vm.store.state.selectedOrganization.identifier).toBeDefined(); }); - - it("should use store state in router navigation", () => { - wrapper.vm.selectUsageDate(); - - expect(mockRouter.push).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - org_identifier: store.state.selectedOrganization.identifier, - }), - }), - ); - }); }); describe("Configuration and Utils", () => { @@ -603,134 +442,6 @@ describe("Billing Component", () => { }); }); - describe("Query Parameter Initialization", () => { - it("should initialize usageDataType from router query", async () => { - mockRouter.currentRoute.value.query.data_type = "mb"; - - const testWrapper = mount(Billing, { - global: { - plugins: [i18n], - provide: { store }, - stubs: { - "router-view": true, - OIcon: true, - ConfirmDialog: true, - Usage: true, - AppTabs: { - template: "
", - props: ["tabs", "activeTab"], - emits: ["update:activeTab"], - }, - }, - }, - }); - - await testWrapper.vm.$nextTick(); - - expect(testWrapper.vm.usageDataType).toBe("mb"); - testWrapper.unmount(); - }); - - it("should initialize usageDate from router query", async () => { - mockRouter.currentRoute.value.query.usage_date = "3months"; - - const testWrapper = mount(Billing, { - global: { - plugins: [i18n], - provide: { store }, - stubs: { - "router-view": true, - OIcon: true, - ConfirmDialog: true, - Usage: true, - AppTabs: { - template: "
", - props: ["tabs", "activeTab"], - emits: ["update:activeTab"], - }, - }, - }, - }); - - await testWrapper.vm.$nextTick(); - - expect(testWrapper.vm.usageDate).toBe("3months"); - testWrapper.unmount(); - }); - - it("should use default values when query parameters are missing", async () => { - mockRouter.currentRoute.value.query = {}; - - const testWrapper = mount(Billing, { - global: { - plugins: [i18n], - provide: { store }, - stubs: { - "router-view": true, - OIcon: true, - ConfirmDialog: true, - Usage: true, - AppTabs: { - template: "
", - props: ["tabs", "activeTab"], - emits: ["update:activeTab"], - }, - }, - }, - }); - - await testWrapper.vm.$nextTick(); - - expect(testWrapper.vm.usageDataType).toBe("gb"); - expect(testWrapper.vm.usageDate).toBe("30days"); - testWrapper.unmount(); - }); - }); - - describe("Return Object from Setup", () => { - it("should return all required properties", () => { - const expectedProps = [ - "t", - "store", - "router", - "config", - "billingtab", - "getImageURL", - "splitterModel", - "headerBasedOnRoute", - "options", - "usageDate", - "selectUsageDate", - "isUsageRoute", - "tabs", - "usageDataType", - "updateActiveTab", - ]; - - expectedProps.forEach((prop) => { - expect(wrapper.vm).toHaveProperty(prop); - }); - }); - - it("should have correct types for returned properties", () => { - expect(typeof wrapper.vm.t).toBe("function"); - expect(typeof wrapper.vm.store).toBe("object"); - expect(typeof wrapper.vm.router).toBe("object"); - expect(typeof wrapper.vm.config).toBe("object"); - expect(typeof wrapper.vm.billingtab).toBe("string"); - expect(typeof wrapper.vm.getImageURL).toBe("function"); - expect(typeof wrapper.vm.splitterModel).toBe("number"); - expect(typeof wrapper.vm.headerBasedOnRoute).toBe("function"); - expect(Array.isArray(wrapper.vm.options)).toBe(true); - expect(typeof wrapper.vm.usageDate).toBe("string"); - expect(typeof wrapper.vm.selectUsageDate).toBe("function"); - expect(typeof wrapper.vm.isUsageRoute).toBe("boolean"); - expect(Array.isArray(wrapper.vm.tabs)).toBe(true); - expect(typeof wrapper.vm.usageDataType).toBe("string"); - expect(typeof wrapper.vm.updateActiveTab).toBe("function"); - }); - }); - describe("Edge Cases and Error Handling", () => { it("should handle missing router gracefully", () => { expect(wrapper.vm.router).toBeDefined(); @@ -741,10 +452,6 @@ describe("Billing Component", () => { expect(wrapper.vm.store).toBeDefined(); expect(wrapper.vm.store.state).toBeDefined(); }); - - it("should handle function calls with undefined parameters", () => { - expect(() => wrapper.vm.updateActiveTab(undefined)).not.toThrow(); - }); }); describe("fetchBillingInfo — billing info TypeError/401 regression", () => { @@ -919,25 +626,4 @@ describe("Billing Component", () => { testWrapper.unmount(); }); }); - - describe("Daily-view date range", () => { - it("exposes usageStreamEnabled from org settings", () => { - store.state.organizationData.organizationSettings.usage_stream_enabled = true; - expect(wrapper.vm.usageStreamEnabled).toBe(true); - store.state.organizationData.organizationSettings.usage_stream_enabled = false; - }); - - it("resolves a relative range into micros and bumps the key", () => { - const before = wrapper.vm.usageStreamEnabled; - wrapper.vm.dateRange = { - valueType: "relative", - relativeTimePeriod: "7d", - }; - wrapper.vm.onRangeChange(); - // getConsumableRelativeTime returns start.