mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
feat: new usage page design (#14937)
Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com> Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com> Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Yashodhan Joshi
sai nikhil kethe
github-actions[bot]
parent
7529707b86
commit
37972c96de
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -317,15 +317,11 @@ pub async fn unsubscribe(
|
||||
("x-o2-mcp" = json!({"enabled": false}))
|
||||
),
|
||||
)]
|
||||
pub async fn list_invoices(
|
||||
Path(org_id): Path<String>,
|
||||
Headers(user_email): Headers<UserEmail>,
|
||||
) -> Response {
|
||||
let email = user_email.user_id.as_str();
|
||||
pub async fn list_invoices(Path(org_id): Path<String>) -> 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(),
|
||||
|
||||
@@ -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<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let usage_range = match query_range.parse::<billings::org_usage::UsageRange>() {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Vec<config::meta::stream::CrossLink>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_org_mappings: Option<Vec<DomainOrgMapping>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub budget_config: Option<Vec<BudgetNotificationConfiguration>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Deserialize, Debug, Clone)]
|
||||
@@ -569,6 +579,9 @@ pub struct OrganizationSetting {
|
||||
#[cfg(feature = "cloud")]
|
||||
#[serde(default)]
|
||||
pub domain_org_mappings: Vec<DomainOrgMapping>,
|
||||
#[cfg(feature = "cloud")]
|
||||
#[serde(default)]
|
||||
pub budget_config: Vec<BudgetNotificationConfiguration>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+357
-1
@@ -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<BudgetNotificationConfiguration>,
|
||||
) -> 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::<UsageRecords>)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
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::<UsageRecords>)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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<String, f64>,
|
||||
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 <b>{parent_name_id_pair}</b>"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"This is a notification email based on configured cost budget for org <b>{name_id_pair}</b>."
|
||||
)
|
||||
};
|
||||
|
||||
let body = format!(
|
||||
r#"Hello,
|
||||
{info_line}<br />
|
||||
You have configured a total budget of <b>${}</b>, with warning starting at <b>${}</b> or <b>{:.2}%</b>.<br/>
|
||||
The org has reached estimated cost of <b>${:.2}</b> or <b>{:.2}%</b> of the total budget, exceeding the warning level.<br/>
|
||||
These amounts are for the current billing cycle ranging from <b>{}</b> (UTC) to <b>{}</b> (UTC).<br/>
|
||||
<br/>
|
||||
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.<br/>
|
||||
<br/>
|
||||
Regards,<br/>
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -30,7 +30,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
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')"
|
||||
/>
|
||||
</OTooltip>
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "<div></div>",
|
||||
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: "<div></div>",
|
||||
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: "<div></div>",
|
||||
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<end micros
|
||||
// (usageRange is provided to the child; here we just assert the handler ran)
|
||||
expect(typeof wrapper.vm.onRangeChange).toBe("function");
|
||||
expect(before).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- eslint-disable vue/x-invalid-end-tag -->
|
||||
<template>
|
||||
<OPageLayout :title="raw(headerBasedOnRoute())" icon="paid" bleed>
|
||||
<template v-if="isUsageRoute" #title>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ raw(headerBasedOnRoute()) }}
|
||||
<BetaBadge />
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="isOrgGroupRoute" class="flex items-center gap-2">
|
||||
<OButton
|
||||
@@ -56,12 +62,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
name="usage"
|
||||
data-test="billing-tab-usage"
|
||||
:to="
|
||||
'/billings/usage?org_identifier=' +
|
||||
store.state.selectedOrganization.identifier +
|
||||
'&usage_date=' +
|
||||
usageDate +
|
||||
'&data_type=' +
|
||||
usageDataType
|
||||
'/billings/usage?org_identifier=' + store.state.selectedOrganization.identifier
|
||||
"
|
||||
:icon="'img:' + getImageURL('images/common/usage_icon.svg')"
|
||||
:label="t('billing.usageLabel')"
|
||||
@@ -107,63 +108,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<template v-slot:after>
|
||||
<div class="flex h-full w-full flex-col pt-2">
|
||||
<div
|
||||
v-if="isUsageRoute"
|
||||
class="ms-2 me-3 mb-2 flex shrink-0 items-center justify-end gap-2 overflow-y-auto px-3 py-2"
|
||||
>
|
||||
<!-- The billing-cycle range dropdown only applies to the totals
|
||||
view. When self-usage reporting is on, the daily view renders
|
||||
its own date-range picker, so hide this to avoid two selectors. -->
|
||||
<div v-if="!usageStreamEnabled" class="custom-usage-date-select">
|
||||
<OSelect
|
||||
v-model="usageDate"
|
||||
:options="options"
|
||||
labelKey="label"
|
||||
valueKey="value"
|
||||
@update:model-value="selectUsageDate"
|
||||
class="mx-0 mt-1 h-10 p-0"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<OIcon name="schedule" size="xs" class="me-2 mt-1" @click.stop.prevent />
|
||||
</template>
|
||||
</OSelect>
|
||||
</div>
|
||||
<!-- Daily view's date-range picker — same toolbar line as GB/MB. -->
|
||||
<DateTimePickerDashboard
|
||||
v-if="usageStreamEnabled"
|
||||
ref="dateTimePicker"
|
||||
v-model="dateRange"
|
||||
size="sm"
|
||||
data-test="usage-daily-date-picker"
|
||||
@hide="onRangeChange"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<div class="app-tabs-container h-9">
|
||||
<AppTabs
|
||||
class="tabs-selection-container"
|
||||
:tabs="tabs"
|
||||
:activeTab="usageDataType"
|
||||
@update:activeTab="(value: any) => updateActiveTab(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 gap-2.5 pe-2.5 pb-2.5">
|
||||
<div
|
||||
v-if="isUsageRoute && billingMembers.length > 0"
|
||||
class="h-full w-65 shrink-0"
|
||||
data-test="usage-member-list"
|
||||
class="h-full min-w-0 flex-1 overflow-y-auto"
|
||||
:class="{ 'flex flex-col': isUsageRoute }"
|
||||
>
|
||||
<UsageMemberList v-model="usageMember.selected" :members="billingMembers" />
|
||||
</div>
|
||||
<div class="h-full min-w-0 flex-1 overflow-y-auto pb-3">
|
||||
<TrialPeriod
|
||||
v-if="isUsageRoute && billingInfoLoaded"
|
||||
current-page="usage"
|
||||
:provider="billingProvider"
|
||||
class="ms-2 me-3 mb-3"
|
||||
class="ms-2 me-3 mb-3 shrink-0"
|
||||
/>
|
||||
<router-view title=""> </router-view>
|
||||
<router-view title="" :class="{ 'min-h-0 flex-1': isUsageRoute }"> </router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,7 +131,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<script lang="ts">
|
||||
import ORouteTab from "@/lib/navigation/Tabs/ORouteTab.vue";
|
||||
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
// @ts-ignore
|
||||
import { defineComponent, ref, computed, onMounted, provide, reactive, watch } from "vue";
|
||||
@@ -186,16 +141,12 @@ import { useRouter } from "vue-router";
|
||||
import config from "@/aws-exports";
|
||||
import { getImageURL } from "@/utils/zincutils";
|
||||
import { resolveTab } from "@/utils/routeTabMaps";
|
||||
import AppTabs from "@/components/common/AppTabs.vue";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
|
||||
import BillingService from "@/services/billings";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSplitter from "@/lib/core/Splitter/OSplitter.vue";
|
||||
import UsageMemberList from "./UsageMemberList.vue";
|
||||
import DateTimePickerDashboard from "@/components/DateTimePickerDashboard.vue";
|
||||
import { getConsumableRelativeTime } from "@/utils/date";
|
||||
import TrialPeriod from "./TrialPeriod.vue";
|
||||
import BetaBadge from "@/components/common/BetaBadge.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "PageIngestion",
|
||||
@@ -203,14 +154,10 @@ export default defineComponent({
|
||||
OPageLayout,
|
||||
OTabs,
|
||||
ORouteTab,
|
||||
AppTabs,
|
||||
OSelect,
|
||||
OIcon,
|
||||
OSplitter,
|
||||
OButton,
|
||||
UsageMemberList,
|
||||
DateTimePickerDashboard,
|
||||
TrialPeriod,
|
||||
BetaBadge,
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18nTyped();
|
||||
@@ -222,7 +169,6 @@ export default defineComponent({
|
||||
const billingtab = ref(
|
||||
resolveTab("billings", router.currentRoute.value.name as string, "plans"),
|
||||
);
|
||||
const usageDataType = ref(router.currentRoute.value.query.data_type || "gb");
|
||||
const showSidebar = ref(true);
|
||||
const lastSplitterPosition = ref(200);
|
||||
const splitterModel = ref(220);
|
||||
@@ -237,25 +183,6 @@ export default defineComponent({
|
||||
);
|
||||
});
|
||||
|
||||
// Billing-group members for the Usage member selector (rendered as a
|
||||
// sidebar beside the usage view). Shared with usage.vue via provide/inject.
|
||||
const billingMembers = ref<{ id: string; name: string }[]>([]);
|
||||
const usageMember = reactive({ selected: "" });
|
||||
provide("usageMember", usageMember);
|
||||
const fetchBillingMembers = () => {
|
||||
if (config.isCloud !== "true") return;
|
||||
BillingService.list_billing_group_members(store.state.selectedOrganization.identifier)
|
||||
.then((res: any) => {
|
||||
billingMembers.value = (res.data ?? []).map((m: any) => ({
|
||||
id: m.member_org_id,
|
||||
name: m.member_org_name,
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
billingMembers.value = [];
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch billing info to determine provider
|
||||
const fetchBillingInfo = async () => {
|
||||
try {
|
||||
@@ -279,21 +206,6 @@ export default defineComponent({
|
||||
const showInvoiceTab = computed(() => {
|
||||
return billingInfoLoaded.value && billingProvider.value === "stripe";
|
||||
});
|
||||
const options = computed(() => {
|
||||
const periods = [
|
||||
{ label: t("billing.period30Days"), value: "30days" },
|
||||
{ label: t("billing.period60Days"), value: "60days" },
|
||||
{ label: t("billing.period3Months"), value: "3months" },
|
||||
{ label: t("billing.period6Months"), value: "6months" },
|
||||
];
|
||||
return billingInfoLoaded.value && billingProvider.value === "stripe" && isPaidUser.value
|
||||
? [
|
||||
{ label: t("billing.previousCycle"), value: "-1cycle" },
|
||||
{ label: t("billing.currentCycle"), value: "1cycle" },
|
||||
...periods,
|
||||
]
|
||||
: periods;
|
||||
});
|
||||
const collapseSidebar = () => {
|
||||
showSidebar.value = !showSidebar.value;
|
||||
if (showSidebar.value) {
|
||||
@@ -316,22 +228,13 @@ export default defineComponent({
|
||||
onMounted(async () => {
|
||||
// Fetch billing info to determine provider type
|
||||
await fetchBillingInfo();
|
||||
fetchBillingMembers();
|
||||
|
||||
// Default to current cycle for paid Stripe users (only on the usage tab)
|
||||
if (
|
||||
router.currentRoute.value.name == "usage" &&
|
||||
!router.currentRoute.value.query.usage_date &&
|
||||
billingProvider.value === "stripe" &&
|
||||
isPaidUser.value
|
||||
) {
|
||||
usageDate.value = "1cycle";
|
||||
selectUsageDate();
|
||||
}
|
||||
|
||||
if (router.currentRoute.value.name == "billings" && isTrialOrg.value) {
|
||||
billingtab.value = "usage";
|
||||
selectUsageDate();
|
||||
router.push({
|
||||
path: "/billings/usage",
|
||||
query: { org_identifier: store.state.selectedOrganization.identifier },
|
||||
});
|
||||
} else if (
|
||||
router.currentRoute.value.name == "billings" ||
|
||||
router.currentRoute.value.name == "plans"
|
||||
@@ -356,55 +259,9 @@ export default defineComponent({
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const usageDate = ref(router.currentRoute.value.query.usage_date || "30days");
|
||||
|
||||
const isUsageRoute = computed(() => {
|
||||
return router.currentRoute.value.name == "usage";
|
||||
});
|
||||
// Self-usage reporting on → the Usage tab renders the daily view (which has
|
||||
// its own date-range picker), so the billing-cycle range dropdown is hidden.
|
||||
const usageStreamEnabled = computed(
|
||||
() => !!store.state?.organizationData?.organizationSettings?.usage_stream_enabled,
|
||||
);
|
||||
|
||||
// Daily-view date range: the picker lives here (toolbar, next to GB/MB) and
|
||||
// shares the RESOLVED window (microseconds) with the child usage.vue via
|
||||
// provide/inject. `key` bumps on every change so the charts re-render.
|
||||
const dateTimePicker = ref<any>(null);
|
||||
const dateRange = ref<any>({
|
||||
valueType: "relative",
|
||||
relativeTimePeriod: "7d",
|
||||
});
|
||||
const usageRange = reactive({
|
||||
start: (new Date().getTime() - 7 * 24 * 60 * 60 * 1000) * 1000,
|
||||
end: new Date().getTime() * 1000,
|
||||
key: 0,
|
||||
});
|
||||
provide("usageRange", usageRange);
|
||||
const onRangeChange = () => {
|
||||
const v: any = dateRange.value;
|
||||
if (v?.valueType === "relative" && v.relativeTimePeriod) {
|
||||
const r = getConsumableRelativeTime(v.relativeTimePeriod);
|
||||
if (r) {
|
||||
usageRange.start = r.startTime;
|
||||
usageRange.end = r.endTime;
|
||||
}
|
||||
} else if (v?.valueType === "absolute" && v.startTime && v.endTime) {
|
||||
usageRange.start = v.startTime;
|
||||
usageRange.end = v.endTime;
|
||||
} else if (dateTimePicker.value?.getConsumableDateTime) {
|
||||
// Fallback: ask the picker to resolve its current selection.
|
||||
const d = dateTimePicker.value.getConsumableDateTime();
|
||||
if (d?.startTime && d?.endTime) {
|
||||
usageRange.start = d.startTime;
|
||||
usageRange.end = d.endTime;
|
||||
}
|
||||
}
|
||||
usageRange.key++;
|
||||
};
|
||||
// Apply on any value change (relative or absolute), not just on @hide, so a
|
||||
// calendar selection immediately re-renders the charts below.
|
||||
watch(dateRange, onRangeChange, { deep: true });
|
||||
const isOrgGroupRoute = computed(() => {
|
||||
return router.currentRoute.value.name == "billing_group";
|
||||
});
|
||||
@@ -413,36 +270,8 @@ export default defineComponent({
|
||||
const orgGroupInvite = reactive({
|
||||
trigger: 0,
|
||||
canInvite: false,
|
||||
refreshMembers: fetchBillingMembers,
|
||||
});
|
||||
provide("orgGroupInvite", orgGroupInvite);
|
||||
const selectUsageDate = () => {
|
||||
router.push({
|
||||
path: "/billings/usage",
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
usage_date: usageDate.value,
|
||||
data_type: usageDataType.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
const updateActiveTab = (value: any) => {
|
||||
usageDataType.value = value;
|
||||
selectUsageDate();
|
||||
};
|
||||
const tabs = [
|
||||
{
|
||||
label: raw("GB"),
|
||||
value: "gb",
|
||||
icon: "storage",
|
||||
},
|
||||
{
|
||||
label: raw("MB"),
|
||||
value: "mb",
|
||||
icon: "database",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
raw,
|
||||
t,
|
||||
@@ -453,19 +282,9 @@ export default defineComponent({
|
||||
getImageURL,
|
||||
splitterModel,
|
||||
headerBasedOnRoute,
|
||||
options,
|
||||
usageStreamEnabled,
|
||||
dateTimePicker,
|
||||
dateRange,
|
||||
onRangeChange,
|
||||
usageDate,
|
||||
selectUsageDate,
|
||||
isUsageRoute,
|
||||
isOrgGroupRoute,
|
||||
orgGroupInvite,
|
||||
tabs,
|
||||
usageDataType,
|
||||
updateActiveTab,
|
||||
collapseSidebar,
|
||||
showSidebar,
|
||||
lastSplitterPosition,
|
||||
@@ -473,8 +292,6 @@ export default defineComponent({
|
||||
billingInfoLoaded,
|
||||
billingProvider,
|
||||
isPaidUser,
|
||||
billingMembers,
|
||||
usageMember,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@ async function mountBillingGroup(opts: MountOptions = {}) {
|
||||
billing_group_allowed_orgs: allowedOrgs,
|
||||
};
|
||||
|
||||
const orgGroupInvite = reactive({ trigger: 0, canInvite: false, refreshMembers: vi.fn() });
|
||||
const orgGroupInvite = reactive({ trigger: 0, canInvite: false });
|
||||
|
||||
const wrapper = mount(BillingGroup, {
|
||||
global: {
|
||||
@@ -561,10 +561,9 @@ describe("BillingGroup.vue", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the member with the right ids after the dialog is accepted, then reloads and refreshes the payer member list", async () => {
|
||||
it("removes the member with the right ids after the dialog is accepted, then reloads", async () => {
|
||||
billing.remove_billing_group_member.mockResolvedValue({ data: "ok" });
|
||||
let orgGroupInvite: any;
|
||||
({ wrapper, orgGroupInvite } = await mountBillingGroup({
|
||||
({ wrapper } = await mountBillingGroup({
|
||||
members: [member],
|
||||
renderTable: true,
|
||||
}));
|
||||
@@ -580,7 +579,6 @@ describe("BillingGroup.vue", () => {
|
||||
expect(billing.remove_billing_group_member).toHaveBeenCalledWith("default", "child-1");
|
||||
expect(billing.get_billing_group_membership).toHaveBeenCalledTimes(2);
|
||||
expect(dialog(wrapper, "org-group-remove-dialog").props("open")).toBe(false);
|
||||
expect(orgGroupInvite.refreshMembers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows Leave billing group on the member view only", async () => {
|
||||
@@ -592,10 +590,9 @@ describe("BillingGroup.vue", () => {
|
||||
expect(button(wrapper, "org-group-leave")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the group after the dialog is accepted, then reloads and refreshes the payer member list", async () => {
|
||||
it("leaves the group after the dialog is accepted, then reloads", async () => {
|
||||
billing.leave_billing_group.mockResolvedValue({ data: "ok" });
|
||||
let orgGroupInvite: any;
|
||||
({ wrapper, orgGroupInvite } = await mountBillingGroup({ membership }));
|
||||
({ wrapper } = await mountBillingGroup({ membership }));
|
||||
|
||||
button(wrapper, "org-group-leave")!.vm.$emit("click");
|
||||
await flushPromises();
|
||||
@@ -607,7 +604,6 @@ describe("BillingGroup.vue", () => {
|
||||
|
||||
expect(billing.leave_billing_group).toHaveBeenCalledWith("default");
|
||||
expect(billing.get_billing_group_membership).toHaveBeenCalledTimes(2);
|
||||
expect(orgGroupInvite.refreshMembers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls nothing when either dialog is cancelled", async () => {
|
||||
|
||||
@@ -596,7 +596,6 @@ export default defineComponent({
|
||||
const headerInvite = inject<{
|
||||
trigger: number;
|
||||
canInvite: boolean;
|
||||
refreshMembers?: () => void;
|
||||
}>("orgGroupInvite", undefined as any);
|
||||
watch(
|
||||
() => headerInvite?.trigger,
|
||||
@@ -913,7 +912,6 @@ export default defineComponent({
|
||||
removeTarget.value = null;
|
||||
}
|
||||
await loadAll();
|
||||
headerInvite?.refreshMembers?.();
|
||||
};
|
||||
|
||||
const leaveGroup = async () => {
|
||||
@@ -937,7 +935,6 @@ export default defineComponent({
|
||||
showLeaveDialog.value = false;
|
||||
}
|
||||
await loadAll();
|
||||
headerInvite?.refreshMembers?.();
|
||||
};
|
||||
|
||||
onMounted(loadAll);
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mount, VueWrapper } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import i18n from "@/locales";
|
||||
import UsageMemberList from "./UsageMemberList.vue";
|
||||
|
||||
function mountList(members: any[] = [], modelValue = "") {
|
||||
return mount(UsageMemberList, {
|
||||
props: { members, modelValue },
|
||||
global: {
|
||||
plugins: [store, i18n],
|
||||
stubs: { OTabs: true, OTab: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("UsageMemberList.vue", () => {
|
||||
let wrapper: VueWrapper<any>;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
});
|
||||
|
||||
describe("current org display", () => {
|
||||
it("shows current org separately when it's a super org (not in members list)", () => {
|
||||
wrapper = mountList([{ id: "child-1", name: "Child One" }]);
|
||||
// Current org from store is "default", which is not in members
|
||||
expect(wrapper.vm.currentOrgToShow).toBeTruthy();
|
||||
expect(wrapper.vm.currentOrgToShow.primary).toBe("default Organization");
|
||||
});
|
||||
|
||||
it("hides current org section when it's a member org (in members list)", () => {
|
||||
wrapper = mountList([{ id: "default", name: "Default Org" }]);
|
||||
// Current org is in members, so it shouldn't show separately
|
||||
expect(wrapper.vm.currentOrgToShow).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("member options", () => {
|
||||
it("lists only member organizations (no all-members entry)", () => {
|
||||
wrapper = mountList([{ id: "child-1", name: "Child One" }]);
|
||||
expect(wrapper.vm.filteredOptions).toHaveLength(1);
|
||||
expect(wrapper.vm.filteredOptions[0].value).toBe("child-1");
|
||||
});
|
||||
|
||||
it("shows member name as primary and identifier as secondary", () => {
|
||||
wrapper = mountList([{ id: "child-1", name: "Child One" }]);
|
||||
const opt = wrapper.vm.filteredOptions[0];
|
||||
expect(opt.value).toBe("child-1");
|
||||
expect(opt.primary).toBe("Child One");
|
||||
expect(opt.secondary).toBe("child-1");
|
||||
expect(opt.title).toBe("Child One | child-1");
|
||||
});
|
||||
|
||||
it("falls back to the identifier when no name is present", () => {
|
||||
wrapper = mountList([{ id: "child-2", name: "" }]);
|
||||
const opt = wrapper.vm.filteredOptions[0];
|
||||
expect(opt.primary).toBe("child-2");
|
||||
expect(opt.secondary).toBe("");
|
||||
expect(opt.title).toBe("child-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
beforeEach(() => {
|
||||
wrapper = mountList([
|
||||
{ id: "acme-global", name: "Acme Global" },
|
||||
{ id: "globex-corp", name: "Globex Corp" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by organization name (case-insensitive)", async () => {
|
||||
wrapper.vm.searchQuery = "acme";
|
||||
await nextTick();
|
||||
const values = wrapper.vm.filteredOptions.map((o: any) => o.value);
|
||||
expect(values).toEqual(["acme-global"]);
|
||||
});
|
||||
|
||||
it("filters by organization identifier", async () => {
|
||||
wrapper.vm.searchQuery = "globex-corp";
|
||||
await nextTick();
|
||||
const values = wrapper.vm.filteredOptions.map((o: any) => o.value);
|
||||
expect(values).toEqual(["globex-corp"]);
|
||||
});
|
||||
|
||||
it("returns no options when nothing matches", async () => {
|
||||
wrapper.vm.searchQuery = "no-such-org";
|
||||
await nextTick();
|
||||
expect(wrapper.vm.filteredOptions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v-model", () => {
|
||||
it("emits update:modelValue when the active member changes", () => {
|
||||
wrapper = mountList([{ id: "child-1", name: "Child One" }], "");
|
||||
wrapper.vm.activeMember = "child-1";
|
||||
expect(wrapper.emitted("update:modelValue")).toBeTruthy();
|
||||
expect(wrapper.emitted("update:modelValue")![0]).toEqual(["child-1"]);
|
||||
});
|
||||
|
||||
it("reflects the modelValue prop as the active member", () => {
|
||||
wrapper = mountList([{ id: "child-1", name: "Child One" }], "child-1");
|
||||
expect(wrapper.vm.activeMember).toBe("child-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="bg-card-glass-bg flex h-full flex-col pb-[0.3rem]">
|
||||
<!-- Current org section (if super org, not a member) -->
|
||||
<div v-if="currentOrgToShow" class="mb-3">
|
||||
<div class="rounded-default bg-card-glass-bg dark:bg-surface-base">
|
||||
<div class="px-2 py-2 font-semibold">
|
||||
{{ t("billing.billingGroup.currentOrgTitle") }}
|
||||
</div>
|
||||
<OSeparator class="mt-0.75 mb-1" />
|
||||
|
||||
<OTabs orientation="vertical" v-model="activeMember" data-test="usage-member-tab-current">
|
||||
<OTab name="" :data-test="`usage-member-tab-current-item`">
|
||||
<div class="member-item flex w-full min-w-0 flex-col items-start">
|
||||
<div
|
||||
class="member-name max-w-full truncate font-semibold normal-case"
|
||||
:title="currentOrgToShow.title"
|
||||
>
|
||||
{{ currentOrgToShow.primary }}
|
||||
</div>
|
||||
<OTruncatedText
|
||||
v-if="currentOrgToShow.secondary"
|
||||
as="div"
|
||||
class="member-id max-w-full text-xs normal-case opacity-60"
|
||||
>
|
||||
{{ currentOrgToShow.secondary }}
|
||||
</OTruncatedText>
|
||||
</div>
|
||||
</OTab>
|
||||
</OTabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member organizations section -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="rounded-default bg-card-glass-bg dark:bg-surface-base">
|
||||
<div class="px-2 py-2 font-semibold">
|
||||
{{ t("billing.billingGroup.memberOrgsTitle") }}
|
||||
</div>
|
||||
<OSeparator class="mt-0.75 mb-1" />
|
||||
|
||||
<div class="flex w-full items-center py-1">
|
||||
<OInput
|
||||
v-model="searchQuery"
|
||||
data-test="usage-member-search"
|
||||
:placeholder="t('billing.billingGroup.searchMemberOrg')"
|
||||
class="mx-2 w-full"
|
||||
>
|
||||
<template #icon-left>
|
||||
<OIcon name="search" size="sm" />
|
||||
</template>
|
||||
</OInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="members-tabs flex-1 overflow-y-auto px-1.5">
|
||||
<OTabs orientation="vertical" v-model="activeMember" data-test="usage-member-tabs">
|
||||
<OTab
|
||||
v-for="opt in filteredOptions"
|
||||
:key="opt.value"
|
||||
:name="opt.value"
|
||||
:data-test="`usage-member-tab-${opt.value}`"
|
||||
>
|
||||
<div class="member-item flex w-full min-w-0 flex-col items-start">
|
||||
<div
|
||||
class="member-name max-w-full truncate font-semibold normal-case"
|
||||
:title="opt.title"
|
||||
>
|
||||
{{ opt.primary }}
|
||||
</div>
|
||||
<OTruncatedText
|
||||
v-if="opt.secondary"
|
||||
as="div"
|
||||
class="member-id max-w-full text-xs normal-case opacity-60"
|
||||
>
|
||||
{{ opt.secondary }}
|
||||
</OTruncatedText>
|
||||
</div>
|
||||
</OTab>
|
||||
</OTabs>
|
||||
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="o2-page-subtitle px-2 py-4 text-center"
|
||||
data-test="usage-member-no-results"
|
||||
>
|
||||
{{ t("billing.billingGroup.noMemberMatch") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
|
||||
import OTab from "@/lib/navigation/Tabs/OTab.vue";
|
||||
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
|
||||
export interface MemberOrg {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: "UsageMemberList",
|
||||
components: { OTabs, OTab, OSeparator, OInput, OIcon, OTruncatedText },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
members: {
|
||||
type: Array as () => MemberOrg[],
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const store = useStore();
|
||||
const { t } = useI18nTyped();
|
||||
const searchQuery = ref("");
|
||||
|
||||
const activeMember = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: string) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
const currentOrgId = computed(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
const currentOrgInMembers = computed(() => {
|
||||
return props.members.some((m) => m.id === currentOrgId.value);
|
||||
});
|
||||
|
||||
const currentOrgToShow = computed(() => {
|
||||
if (currentOrgInMembers.value) {
|
||||
return null;
|
||||
}
|
||||
const currentOrg = store.state.selectedOrganization;
|
||||
const label = currentOrg.label || currentOrg.identifier;
|
||||
return {
|
||||
value: "current",
|
||||
primary: label,
|
||||
secondary: currentOrg.label ? currentOrg.identifier : "",
|
||||
title: currentOrg.label
|
||||
? `${currentOrg.label} | ${currentOrg.identifier}`
|
||||
: currentOrg.identifier,
|
||||
};
|
||||
});
|
||||
|
||||
const options = computed(() =>
|
||||
props.members.map((m) => ({
|
||||
value: m.id,
|
||||
primary: m.name || m.id,
|
||||
secondary: m.name ? m.id : "",
|
||||
title: m.name ? `${m.name} | ${m.id}` : m.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = searchQuery.value?.toLowerCase().trim();
|
||||
if (!q) return options.value;
|
||||
return options.value.filter(
|
||||
(o) => o.primary.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
t,
|
||||
searchQuery,
|
||||
activeMember,
|
||||
filteredOptions,
|
||||
currentOrgToShow,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* keep(lib-override:o2-tabs): OTabs/OTab internals (.o-tabs--vertical, .o-tab,
|
||||
.o-tabs) are child-component DOM this component can only reach through :deep();
|
||||
the active-tab → .member-id opacity is a descendant state chain. */
|
||||
.members-tabs :deep(.o-tabs--vertical) {
|
||||
margin: 0.3125rem;
|
||||
}
|
||||
|
||||
/* Vertical padding only — the horizontal inset comes from OTab's vertical
|
||||
variant (--spacing-page-edge) so this rail aligns with the page header. */
|
||||
.members-tabs :deep(.o-tabs--vertical .o-tab) {
|
||||
justify-content: flex-start;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 1.5rem;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.members-tabs :deep(.o-tabs--vertical .o-tab[data-state="active"] .member-id) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.members-tabs :deep(.o-tabs) {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,923 +0,0 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import Usage from "@/enterprise/components/billings/usage.vue";
|
||||
import BillingService from "@/services/billings";
|
||||
import organizations from "@/services/organizations";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import router from "@/test/unit/helpers/router";
|
||||
import i18n from "@/locales";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
// Mock the billings service
|
||||
vi.mock("@/services/billings", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
get_data_usage: vi.fn(),
|
||||
get_ai_usage: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/services/organizations", async (importOriginal) => {
|
||||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||||
return overlayServiceMock(await importOriginal(), {
|
||||
default: {
|
||||
post_organization_settings: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the router
|
||||
const mockRouter = {
|
||||
currentRoute: {
|
||||
value: {
|
||||
query: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/router", () => ({
|
||||
default: mockRouter,
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("vue-router")>();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => mockRouter,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock zincutils — partial so the store (pulled in transitively via the
|
||||
// dashboards renderer) still resolves useLocalOrganization etc.
|
||||
vi.mock("@/utils/zincutils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/zincutils")>();
|
||||
return {
|
||||
...actual,
|
||||
getImageURL: vi.fn((path) => `mocked-image-url-${path}`),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock convertBillingData
|
||||
vi.mock("@/utils/billing/convertBillingData", () => ({
|
||||
convertBillingData: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock CustomChartRenderer
|
||||
vi.mock("@/components/dashboards/panels/CustomChartRenderer.vue", () => ({
|
||||
default: {
|
||||
name: "CustomChartRenderer",
|
||||
template: "<div class='custom-chart-renderer'></div>",
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub the heavy dashboards renderer + date picker the daily view uses.
|
||||
vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", () => ({
|
||||
default: {
|
||||
name: "PanelSchemaRenderer",
|
||||
template: "<div class='panel-schema-renderer'></div>",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/DateTimePickerDashboard.vue", () => ({
|
||||
default: {
|
||||
name: "DateTimePickerDashboard",
|
||||
template: "<div class='date-time-picker'></div>",
|
||||
methods: {
|
||||
getConsumableDateTime() {
|
||||
return { startTime: 1000, endTime: 2000 };
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const node = document.createElement("div");
|
||||
node.setAttribute("id", "app");
|
||||
document.body.appendChild(node);
|
||||
|
||||
describe("Usage Component", () => {
|
||||
let wrapper: any = null;
|
||||
const mockBillingService = vi.mocked(BillingService);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock successful API response for mounted hook
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [
|
||||
{ event: "INGESTION", value: "10.50" },
|
||||
{ event: "SEARCH", value: "5.25" },
|
||||
{ event: "FUNCTIONS", value: "2.75" },
|
||||
],
|
||||
},
|
||||
};
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
mockBillingService.get_ai_usage.mockResolvedValue({
|
||||
data: {
|
||||
mode: "free",
|
||||
credits_used: 0,
|
||||
credits_limit: 1000,
|
||||
credits_remaining: 1000,
|
||||
requires_additional_credits: false,
|
||||
},
|
||||
});
|
||||
|
||||
wrapper = mount(Usage, {
|
||||
attachTo: "#app",
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: {
|
||||
store,
|
||||
$router: mockRouter,
|
||||
// The daily view injects the resolved range from the Billing shell.
|
||||
usageRange: { start: 1000, end: 2000, key: 1 },
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key,
|
||||
},
|
||||
stubs: {
|
||||
ChartRenderer: {
|
||||
name: "ChartRenderer",
|
||||
template: "<div class='chart-renderer'></div>",
|
||||
},
|
||||
CustomChartRenderer: {
|
||||
name: "CustomChartRenderer",
|
||||
template: "<div class='custom-chart-renderer'></div>",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount();
|
||||
}
|
||||
vi.clearAllTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Test 1: Component Mounting
|
||||
it("should mount Usage component successfully", () => {
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
// Test 2: Component name
|
||||
it("should have correct component name", () => {
|
||||
expect(wrapper.vm.$options.name).toBe("Usage");
|
||||
});
|
||||
|
||||
// Test 3: Initial reactive data
|
||||
it("should initialize reactive data correctly", async () => {
|
||||
expect(wrapper.vm.lastUsageUpdated).toBe(0);
|
||||
expect(wrapper.vm.pipelinesPanelDataKey).toBe(0);
|
||||
expect(wrapper.vm.elapsedText).toBe("Just now");
|
||||
// After mounted hook completes, dataLoading should be false
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.dataLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Test 4: Usage data initial state
|
||||
it("should initialize usage data with default values", async () => {
|
||||
// After mounted hook completes, data should be populated from API
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.usageData).toEqual({
|
||||
ingestion: "10.50",
|
||||
search: "5.25",
|
||||
dataretention: "0.00",
|
||||
functions: "2.75",
|
||||
pipeline: "0.00",
|
||||
remotepipeline: "0.00",
|
||||
ai_credits: "0.00",
|
||||
});
|
||||
});
|
||||
|
||||
it("should direct exhausted contract organizations to add AI credits", async () => {
|
||||
wrapper.vm.aiUsage = {
|
||||
mode: "exhausted",
|
||||
requires_additional_credits: true,
|
||||
};
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="contract-ai-credits-exhausted"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Contact your account manager to add more AI credits");
|
||||
});
|
||||
|
||||
// Test 5: Chart data initial state
|
||||
it("should initialize chart data as empty object", () => {
|
||||
expect(wrapper.vm.chartData).toEqual({});
|
||||
});
|
||||
|
||||
// Test 6: Options array
|
||||
it("should have correct options array", () => {
|
||||
expect(wrapper.vm.options).toEqual(["30days", "60days", "3months", "6months"]);
|
||||
});
|
||||
|
||||
// Test 7: Store access
|
||||
it("should have access to store", () => {
|
||||
expect(wrapper.vm.store).toBeDefined();
|
||||
expect(wrapper.vm.store.state).toBeDefined();
|
||||
});
|
||||
|
||||
// Test 8: Translation function
|
||||
it("should have access to translation function", () => {
|
||||
expect(wrapper.vm.t).toBeDefined();
|
||||
expect(typeof wrapper.vm.t).toBe("function");
|
||||
});
|
||||
|
||||
// Test 9: Icon URLs generation
|
||||
it("should generate correct icon URLs", () => {
|
||||
expect(wrapper.vm.errorTrackingIcon).toBe("mocked-image-url-images/usage/error_tracking.svg");
|
||||
expect(wrapper.vm.rumSessionIcon).toBe("mocked-image-url-images/usage/rum_session.svg");
|
||||
expect(wrapper.vm.ingestionIcon).toBe("mocked-image-url-images/usage/ingestion.svg");
|
||||
expect(wrapper.vm.searchIcon).toBe("mocked-image-url-images/usage/search.svg");
|
||||
expect(wrapper.vm.functionsIcon).toBe("mocked-image-url-images/usage/function.svg");
|
||||
});
|
||||
|
||||
// Test 10: Data model structure
|
||||
it("should have correct data model structure", () => {
|
||||
expect(wrapper.vm.dataModel).toBeDefined();
|
||||
expect(wrapper.vm.dataModel.chartType).toBe("bar");
|
||||
expect(wrapper.vm.dataModel.options).toBeDefined();
|
||||
expect(wrapper.vm.dataModel.extras).toBeDefined();
|
||||
});
|
||||
|
||||
// Test 11: Usage date computed property default
|
||||
it("should return default usage date when no query param", () => {
|
||||
expect(wrapper.vm.usageDate).toBe("30days");
|
||||
});
|
||||
|
||||
// Test 12: Usage data type computed property default
|
||||
it("should return default usage data type when no query param", () => {
|
||||
expect(wrapper.vm.usageDataType).toBe("gb");
|
||||
});
|
||||
|
||||
// Test 13: Display no data message when usage data is empty
|
||||
it("should display no data message when usage data is empty", async () => {
|
||||
wrapper.vm.usageData = {};
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain(wrapper.vm.t("billing.messageDataNotFound"));
|
||||
});
|
||||
|
||||
// Test 14: Loading state display
|
||||
it("should display loading message when data is loading", async () => {
|
||||
wrapper.vm.dataLoading = true;
|
||||
await nextTick();
|
||||
// Component shows an OSpinner (aria-label="Loading") when dataLoading is true,
|
||||
// not a "Loading..." text string.
|
||||
expect(wrapper.find('[aria-label="Loading"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
// Test 15: Usage tiles display when data is available
|
||||
it("should display usage tiles when data is available", async () => {
|
||||
wrapper.vm.usageData = {
|
||||
ingestion: "10.50",
|
||||
search: "5.25",
|
||||
functions: "2.75",
|
||||
};
|
||||
wrapper.vm.dataLoading = false;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain("Ingestion");
|
||||
expect(wrapper.text()).toContain("Search");
|
||||
});
|
||||
|
||||
// Test 16: Usage data formatting in tiles
|
||||
it("should format usage data correctly in tiles", async () => {
|
||||
wrapper.vm.usageData = {
|
||||
ingestion: "10.50",
|
||||
search: "5.25",
|
||||
functions: "2.75",
|
||||
};
|
||||
wrapper.vm.dataLoading = false;
|
||||
await nextTick();
|
||||
|
||||
// Value and unit render in separate spans (no literal space in .text()).
|
||||
expect(wrapper.text()).toContain("10.50");
|
||||
expect(wrapper.text()).toContain("5.25");
|
||||
expect(wrapper.text()).toContain("GB");
|
||||
});
|
||||
|
||||
it("auto-scales large byte values up from the base unit", async () => {
|
||||
// base GB: 10930.96 GB → 10.67 TB; 1095652 GB → ~1.05 PB
|
||||
wrapper.vm.usageData = {
|
||||
ingestion: "10930.96",
|
||||
dataretention: "1095652",
|
||||
search: "5.25",
|
||||
ai_credits: "42",
|
||||
};
|
||||
wrapper.vm.dataLoading = false;
|
||||
await nextTick();
|
||||
|
||||
const tiles = wrapper.vm.usageTiles;
|
||||
const ing = tiles.find((t: any) => t.key === "ingestion");
|
||||
expect(ing.unit).toBe("TB");
|
||||
expect(parseFloat(ing.value)).toBeCloseTo(10.67, 1);
|
||||
|
||||
const ret = tiles.find((t: any) => t.key === "dataretention");
|
||||
expect(ret.unit).toBe("PB");
|
||||
|
||||
// small value keeps the base unit
|
||||
const srch = tiles.find((t: any) => t.key === "search");
|
||||
expect(srch.unit).toBe("GB");
|
||||
expect(srch.value).toBe("5.25");
|
||||
|
||||
// AI credits is a plain count, never scaled
|
||||
const ai = tiles.find((t: any) => t.key === "ai_credits");
|
||||
expect(ai.unit).toBe("Credits");
|
||||
});
|
||||
|
||||
// Test 19: GetUsage function call with notification
|
||||
it("should call getUsage and show notification", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [
|
||||
{ event: "INGESTION", value: "15.50" },
|
||||
{ event: "SEARCH", value: "7.25" },
|
||||
{ event: "FUNCTIONS", value: "3.75" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// The notification should have been called during component mounting
|
||||
// Let's test that the function completes successfully instead
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
|
||||
// Verify that the function ran successfully
|
||||
expect(wrapper.vm.dataLoading).toBe(false);
|
||||
expect(mockBillingService.get_data_usage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 20: GetUsage function updates usage data correctly
|
||||
it("should update usage data correctly after successful API call", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [
|
||||
{ event: "INGESTION", value: "10.50" },
|
||||
{ event: "SEARCH", value: "5.25" },
|
||||
{ event: "FUNCTIONS", value: "2.75" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.usageData).toEqual({
|
||||
ingestion: "10.50",
|
||||
search: "5.25",
|
||||
functions: "2.75",
|
||||
pipeline: "0.00",
|
||||
remotepipeline: "0.00",
|
||||
dataretention: "0.00",
|
||||
ai_credits: "0.00",
|
||||
});
|
||||
});
|
||||
|
||||
// Test 21: GetUsage function handles API errors
|
||||
it("should handle API errors gracefully", async () => {
|
||||
const errorMessage = "API Error";
|
||||
|
||||
// Test that error handling works correctly
|
||||
mockBillingService.get_data_usage.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
try {
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
} catch (error) {
|
||||
// Error should be handled gracefully by the component
|
||||
}
|
||||
|
||||
// Verify that loading state is reset after error
|
||||
expect(wrapper.vm.dataLoading).toBe(false);
|
||||
expect(mockBillingService.get_data_usage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 22: SelectUsageDate function resets chart data
|
||||
it("should reset chart data when selectUsageDate is called", async () => {
|
||||
// Mock the API response first
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [{ event: "INGESTION", value: "10.50" }],
|
||||
},
|
||||
};
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
|
||||
wrapper.vm.chartData = { some: "data" };
|
||||
wrapper.vm.selectUsageDate();
|
||||
expect(wrapper.vm.chartData).toEqual({});
|
||||
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Test 23: Data model chart type
|
||||
it("should have bar chart type in data model", () => {
|
||||
expect(wrapper.vm.dataModel.chartType).toBe("bar");
|
||||
});
|
||||
|
||||
// Test 24: Data model options structure
|
||||
it("should have correct options structure in data model", () => {
|
||||
const options = wrapper.vm.dataModel.options;
|
||||
expect(options.backgroundColor).toBe("transparent");
|
||||
expect(options.legend).toBeDefined();
|
||||
expect(options.grid).toBeDefined();
|
||||
expect(options.tooltip).toBeDefined();
|
||||
expect(options.xAxis).toBeDefined();
|
||||
expect(options.yAxis).toBeDefined();
|
||||
expect(options.toolbox).toBeDefined();
|
||||
expect(options.series).toBeDefined();
|
||||
});
|
||||
|
||||
// Test 25: Data model legend configuration
|
||||
it("should have correct legend configuration", () => {
|
||||
const legend = wrapper.vm.dataModel.options.legend;
|
||||
expect(legend.show).toBe(false);
|
||||
expect(legend.type).toBe("scroll");
|
||||
expect(legend.orient).toBe("horizontal");
|
||||
});
|
||||
|
||||
// Test 26: Data model grid configuration
|
||||
it("should have correct grid configuration", () => {
|
||||
const grid = wrapper.vm.dataModel.options.grid;
|
||||
expect(grid.containLabel).toBe(true);
|
||||
expect(grid.left).toBe(30);
|
||||
expect(grid.right).toBe(20);
|
||||
expect(grid.top).toBe("15");
|
||||
expect(grid.bottom).toBe(35);
|
||||
});
|
||||
|
||||
// Test 27: Data model tooltip configuration
|
||||
it("should have correct tooltip configuration", () => {
|
||||
const tooltip = wrapper.vm.dataModel.options.tooltip;
|
||||
expect(tooltip.trigger).toBe("axis");
|
||||
expect(tooltip.textStyle.color).toBe("#000");
|
||||
expect(tooltip.textStyle.fontSize).toBe(12);
|
||||
expect(tooltip.enterable).toBe(true);
|
||||
expect(tooltip.backgroundColor).toBe("rgba(255,255,255,1)");
|
||||
});
|
||||
|
||||
// Test 28: Data model xAxis configuration
|
||||
it("should have correct xAxis configuration", () => {
|
||||
const xAxis = wrapper.vm.dataModel.options.xAxis[0];
|
||||
expect(xAxis.type).toBe("time");
|
||||
expect(xAxis.position).toBe("bottom");
|
||||
expect(xAxis.inverse).toBe(false);
|
||||
expect(xAxis.name).toBe("Timestamp");
|
||||
});
|
||||
|
||||
// Test 29: Data model yAxis configuration
|
||||
it("should have correct yAxis configuration", () => {
|
||||
const yAxis = wrapper.vm.dataModel.options.yAxis;
|
||||
expect(yAxis.type).toBe("value");
|
||||
expect(yAxis.name).toBe("K8s Container Name");
|
||||
expect(yAxis.nameLocation).toBe("middle");
|
||||
expect(yAxis.nameGap).toBe(61);
|
||||
});
|
||||
|
||||
// Test 30: Data model toolbox configuration
|
||||
it("should have correct toolbox configuration", () => {
|
||||
const toolbox = wrapper.vm.dataModel.options.toolbox;
|
||||
expect(toolbox.orient).toBe("vertical");
|
||||
expect(toolbox.show).toBe(true);
|
||||
expect(toolbox.showTitle).toBe(false);
|
||||
expect(toolbox.itemSize).toBe(0);
|
||||
expect(toolbox.itemGap).toBe(0);
|
||||
});
|
||||
|
||||
// Test 31: Data model series configuration
|
||||
it("should have correct series configuration", () => {
|
||||
const series = wrapper.vm.dataModel.options.series;
|
||||
expect(Array.isArray(series)).toBe(true);
|
||||
expect(series.length).toBe(2);
|
||||
|
||||
const barSeries = series[0];
|
||||
expect(barSeries.type).toBe("bar");
|
||||
expect(barSeries.name).toBe("K8s Container Name");
|
||||
expect(barSeries.color).toBe("#64b5f6");
|
||||
});
|
||||
|
||||
// Test 32: Data model series data array
|
||||
it("should have data array in series", () => {
|
||||
const series = wrapper.vm.dataModel.options.series[0];
|
||||
expect(Array.isArray(series.data)).toBe(true);
|
||||
expect(series.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Test 33: Data model extras configuration
|
||||
it("should have correct extras configuration", () => {
|
||||
const extras = wrapper.vm.dataModel.extras;
|
||||
expect(extras.panelId).toBe("Panel_ID5876310");
|
||||
expect(extras.isTimeSeries).toBe(true);
|
||||
});
|
||||
|
||||
// Test 34: Usage data numeric value parsing
|
||||
it("should parse numeric values correctly", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [
|
||||
{ event: "INGESTION", value: "10.567" },
|
||||
{ event: "SEARCH", value: "5.234" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.usageData.ingestion).toBe("10.57");
|
||||
expect(wrapper.vm.usageData.search).toBe("5.23");
|
||||
});
|
||||
|
||||
// Test 35: Case insensitive event name handling
|
||||
it("should handle case insensitive event names", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [
|
||||
{ event: "ingestion", value: "10.50" },
|
||||
{ event: "SEARCH", value: "5.25" },
|
||||
{ event: "Functions", value: "2.75" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.usageData.ingestion).toBe("10.50");
|
||||
expect(wrapper.vm.usageData.search).toBe("5.25");
|
||||
expect(wrapper.vm.usageData.functions).toBe("2.75");
|
||||
});
|
||||
|
||||
// Test 36: API call parameters
|
||||
it("should call API with correct parameters", async () => {
|
||||
// Mock the API response first
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [{ event: "INGESTION", value: "10.50" }],
|
||||
},
|
||||
};
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
|
||||
const orgIdentifier = "test-org";
|
||||
wrapper.vm.store.state.selectedOrganization.identifier = orgIdentifier;
|
||||
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockBillingService.get_data_usage).toHaveBeenCalledWith(
|
||||
orgIdentifier,
|
||||
"30days",
|
||||
"gb",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
// Test 37: Loading state during API call
|
||||
it("should set loading state during API call", async () => {
|
||||
const mockPromise = new Promise(() => {}); // Never resolves
|
||||
mockBillingService.get_data_usage.mockReturnValue(mockPromise);
|
||||
|
||||
wrapper.vm.getUsage();
|
||||
|
||||
expect(wrapper.vm.dataLoading).toBe(true);
|
||||
});
|
||||
|
||||
// Test 38: Component lifecycle - mounted hook
|
||||
it("should call selectUsageDate on mount", async () => {
|
||||
// Check that the component was mounted and API was called
|
||||
// The mounted hook should have triggered selectUsageDate -> getUsage
|
||||
await flushPromises();
|
||||
expect(mockBillingService.get_data_usage).toHaveBeenCalled();
|
||||
expect(wrapper.vm.dataLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Test 40: Hidden error tracking tiles
|
||||
it("should not display error tracking tiles when v-if is false", () => {
|
||||
const tiles = wrapper.findAll('[data-test="billings-usage-tile-title"]');
|
||||
const errorTrackingExists = tiles.some((tile: any) => tile.text().includes("Error Tracking"));
|
||||
expect(errorTrackingExists).toBe(false);
|
||||
});
|
||||
|
||||
// Test 41: Hidden RUM session tiles
|
||||
it("should not display RUM session tiles when v-if is false", () => {
|
||||
const tiles = wrapper.findAll('[data-test="billings-usage-tile-title"]');
|
||||
const rumSessionExists = tiles.some((tile: any) => tile.text().includes("RUM Session"));
|
||||
expect(rumSessionExists).toBe(false);
|
||||
});
|
||||
|
||||
it("counts only billable AI credits, as whole numbers", async () => {
|
||||
wrapper.vm.usageData = { ai_credits: "2.00" };
|
||||
wrapper.vm.dataLoading = false;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.usageTiles.find((t: any) => t.key === "ai_credits")).toMatchObject({
|
||||
label: "Billable AI credits",
|
||||
value: "2",
|
||||
});
|
||||
});
|
||||
|
||||
// Test 42: Usage tiles visibility logic
|
||||
it("should show usage tiles when data is available and not loading", async () => {
|
||||
wrapper.vm.usageData = { ingestion: "1.00", search: "1.00", functions: "1.00" };
|
||||
wrapper.vm.dataLoading = false;
|
||||
await nextTick();
|
||||
|
||||
const usageTiles = wrapper.findAll('[data-test="billings-usage-tile-title"]');
|
||||
const tileTexts = usageTiles.map((tile: any) => tile.text());
|
||||
|
||||
expect(tileTexts).toContain("Ingestion");
|
||||
expect(tileTexts).toContain("Search");
|
||||
// Functions card was removed (not a billable line item).
|
||||
expect(tileTexts).not.toContain("Functions");
|
||||
});
|
||||
|
||||
// Test 43: Usage tiles hidden when loading
|
||||
it("should hide usage tiles when loading", async () => {
|
||||
wrapper.vm.dataLoading = true;
|
||||
wrapper.vm.usageData = {};
|
||||
await nextTick();
|
||||
|
||||
const tiles = wrapper.findAll('[data-test="billings-usage-tile-title"]');
|
||||
const ingestionExists = tiles.some((tile: any) => tile.text().includes("Ingestion"));
|
||||
expect(ingestionExists).toBe(false);
|
||||
});
|
||||
|
||||
// Test 44: Usage tiles hidden when no data
|
||||
it("should hide usage tiles when no usage data", async () => {
|
||||
wrapper.vm.usageData = {};
|
||||
wrapper.vm.dataLoading = false;
|
||||
await nextTick();
|
||||
|
||||
const tiles = wrapper.findAll('[data-test="billings-usage-tile-title"]');
|
||||
const ingestionExists = tiles.some((tile: any) => tile.text().includes("Ingestion"));
|
||||
expect(ingestionExists).toBe(false);
|
||||
});
|
||||
|
||||
// Test 45: Total usage heading display
|
||||
it("renders the billable usage tiles", () => {
|
||||
// The "Total Usage" heading was removed; tiles carry the usage now.
|
||||
const tiles = wrapper.findAll('[data-test="billings-usage-tile"]');
|
||||
expect(tiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Test 46: Component container styling
|
||||
it("should have correct container styling", () => {
|
||||
const container = wrapper.find('[style="height: calc(100vh - 130px);"]');
|
||||
expect(container.exists()).toBe(true);
|
||||
expect(container.attributes("style")).toContain("height: calc(100vh - 130px)");
|
||||
// width is now applied via the w-full utility class rather than inline style
|
||||
expect(container.classes()).toContain("w-full");
|
||||
});
|
||||
|
||||
// Test 48: No data section visibility
|
||||
it("should show no data section when usage data is empty", async () => {
|
||||
wrapper.vm.usageData = {};
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(wrapper.vm.t("billing.messageDataNotFound"));
|
||||
});
|
||||
|
||||
// Test 49: Data type uppercase display
|
||||
it("should display data type in uppercase in tiles", async () => {
|
||||
wrapper.vm.usageData = { ingestion: "1.00", search: "1.00", functions: "1.00" };
|
||||
wrapper.vm.dataLoading = false;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain("GB"); // usageDataType.toUpperCase()
|
||||
});
|
||||
|
||||
// Test 50: Series line chart configuration
|
||||
it("should have line chart series configuration", () => {
|
||||
const lineSeries = wrapper.vm.dataModel.options.series[1];
|
||||
expect(lineSeries.type).toBe("line");
|
||||
expect(lineSeries.markLine).toBeDefined();
|
||||
expect(lineSeries.markArea).toBeDefined();
|
||||
expect(lineSeries.zlevel).toBe(1);
|
||||
});
|
||||
|
||||
// Test 51: Series bar chart zlevel
|
||||
it("should have correct zlevel for bar series", () => {
|
||||
const barSeries = wrapper.vm.dataModel.options.series[0];
|
||||
expect(barSeries.zlevel).toBe(2);
|
||||
});
|
||||
|
||||
// Test 52: Mark line styling in line series
|
||||
it("should have correct mark line styling", () => {
|
||||
const lineSeries = wrapper.vm.dataModel.options.series[1];
|
||||
expect(lineSeries.markLine.itemStyle.color).toBe("rgba(0, 191, 255, 0.5)");
|
||||
expect(lineSeries.markLine.silent).toBe(false);
|
||||
expect(lineSeries.markLine.animation).toBe(false);
|
||||
});
|
||||
|
||||
// Test 53: Mark area styling in line series
|
||||
it("should have correct mark area styling", () => {
|
||||
const lineSeries = wrapper.vm.dataModel.options.series[1];
|
||||
expect(lineSeries.markArea.itemStyle.color).toBe("rgba(0, 191, 255, 0.15)");
|
||||
});
|
||||
|
||||
// Test 54: Data validation for numeric parsing
|
||||
it("should handle invalid numeric values gracefully", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [
|
||||
{ event: "INGESTION", value: "invalid" },
|
||||
{ event: "SEARCH", value: "5.25" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockBillingService.get_data_usage.mockResolvedValue(mockResponse);
|
||||
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.usageData.ingestion).toBe("NaN"); // parseFloat of "invalid" is NaN
|
||||
expect(wrapper.vm.usageData.search).toBe("5.25");
|
||||
});
|
||||
|
||||
// Test 55: Router access in component
|
||||
it("should have access to router", () => {
|
||||
expect(wrapper.vm.router).toBeDefined();
|
||||
});
|
||||
|
||||
// The member-org selector now lives in Billing.vue (rendered beside this
|
||||
// component) and shares the selection via inject; its formatting/search is
|
||||
// covered in UsageMemberList.spec.ts.
|
||||
|
||||
// --- Daily chart appended below the existing cards (self-usage ON) -----
|
||||
describe("Daily chart", () => {
|
||||
afterEach(() => {
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = false;
|
||||
});
|
||||
|
||||
it("hides the daily chart when self-usage is off", async () => {
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = false;
|
||||
await nextTick();
|
||||
expect(wrapper.vm.usageStreamEnabled).toBe(false);
|
||||
expect(wrapper.find('[data-test="usage-daily-chart"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the daily chart when self-usage is on", async () => {
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = true;
|
||||
await nextTick();
|
||||
expect(wrapper.vm.usageStreamEnabled).toBe(true);
|
||||
expect(wrapper.find('[data-test="usage-daily-chart"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("builds one combined line panel over the org's usage stream", async () => {
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = true;
|
||||
store.state.selectedOrganization.identifier = "org-daily";
|
||||
await nextTick();
|
||||
const schema = wrapper.vm.combinedSchema;
|
||||
expect(schema.type).toBe("line");
|
||||
expect(schema.queries).toHaveLength(1);
|
||||
expect(schema.queries[0].fields.stream).toBe("usage");
|
||||
expect(schema.queries[0].query).toContain("org_id = 'org-daily'");
|
||||
expect(schema.queries[0].query).toContain(
|
||||
"event IN ('Ingestion', 'Search', 'Pipeline', 'RemotePipeline')",
|
||||
);
|
||||
expect(schema.queries[0].query).not.toContain("Functions");
|
||||
expect(schema.queries[0].fields.breakdown).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the injected range for the chart's time window", async () => {
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = true;
|
||||
await nextTick();
|
||||
// injected usageRange = { start: 1000, end: 2000 } (micros)
|
||||
expect(wrapper.vm.dailyTimeObj.start_time.getTime()).toBe(1000);
|
||||
expect(wrapper.vm.dailyTimeObj.end_time.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("fetches the cards with a calendar-derived <N>days range when on", async () => {
|
||||
mockBillingService.get_data_usage.mockClear();
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = true;
|
||||
store.state.selectedOrganization.identifier = "org-cal";
|
||||
await nextTick();
|
||||
await wrapper.vm.getUsage();
|
||||
await flushPromises();
|
||||
// injected sub-day window rounds up to "1days"
|
||||
expect(mockBillingService.get_data_usage).toHaveBeenLastCalledWith(
|
||||
"org-cal",
|
||||
"1days",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Usage enable-reporting CTA", () => {
|
||||
const mountUsage = () =>
|
||||
mount(Usage, {
|
||||
global: {
|
||||
plugins: [store, router, i18n],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(BillingService.get_data_usage as any).mockResolvedValue({
|
||||
data: { data: [], start_time: 0, end_time: 0 },
|
||||
});
|
||||
store.state.selectedOrganization = { identifier: "org-a" } as any;
|
||||
store.state.organizationData = store.state.organizationData || ({} as any);
|
||||
});
|
||||
|
||||
it("shows the enable CTA when usage_stream_enabled is false", async () => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
usage_stream_enabled: false,
|
||||
} as any;
|
||||
const wrapper = mountUsage();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="usage-enable-cta"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="usage-daily-chart"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the CTA when usage_stream_enabled is true", async () => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
usage_stream_enabled: true,
|
||||
} as any;
|
||||
const wrapper = mountUsage();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="usage-enable-cta"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("posts only the opt-in, never the rest of the cached settings, on click", async () => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
usage_stream_enabled: false,
|
||||
scrape_interval: 15,
|
||||
} as any;
|
||||
(organizations.post_organization_settings as any).mockResolvedValue({});
|
||||
const wrapper = mountUsage();
|
||||
await flushPromises();
|
||||
// The CTA now opens a confirm dialog before enabling: fire the empty
|
||||
// state's action, then confirm the dialog to run the enable flow.
|
||||
wrapper.findComponent({ name: "OEmptyState" }).vm.$emit("action");
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: "ConfirmDialog" }).vm.$emit("update:ok");
|
||||
await flushPromises();
|
||||
// The backend applies the fields present, so posting the cached copy back would overwrite newer server values.
|
||||
expect(organizations.post_organization_settings).toHaveBeenCalledWith("org-a", {
|
||||
usage_stream_enabled: true,
|
||||
});
|
||||
expect(store.state.organizationData.organizationSettings.usage_stream_enabled).toBe(true);
|
||||
expect(store.state.organizationData.organizationSettings.scrape_interval).toBe(15);
|
||||
});
|
||||
|
||||
it("shows the waiting-for-data graphic when the usage stream is missing", async () => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
usage_stream_enabled: true,
|
||||
} as any;
|
||||
const wrapper = mountUsage();
|
||||
await flushPromises();
|
||||
// No error yet → no overlay.
|
||||
expect(wrapper.find('[data-test="usage-waiting-for-data"]').exists()).toBe(false);
|
||||
// Chart search errors because the org's usage stream doesn't exist yet.
|
||||
wrapper
|
||||
.findComponent({ name: "PanelSchemaRenderer" })
|
||||
.vm.$emit("error", { message: "Search stream not found: usage" });
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="usage-waiting-for-data"]').exists()).toBe(true);
|
||||
// When data lands the error clears → overlay goes away.
|
||||
wrapper.findComponent({ name: "PanelSchemaRenderer" }).vm.$emit("error", null);
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="usage-waiting-for-data"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show the waiting graphic for unrelated chart errors", async () => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
usage_stream_enabled: true,
|
||||
} as any;
|
||||
const wrapper = mountUsage();
|
||||
await flushPromises();
|
||||
wrapper
|
||||
.findComponent({ name: "PanelSchemaRenderer" })
|
||||
.vm.$emit("error", { message: "SQL parse error near GROUP" });
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="usage-waiting-for-data"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,879 +0,0 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line local/no-hardcoded-px -- mixed with vh/vw — vh tracks the window while rem tracks font-size; keep the expression unit-consistent -->
|
||||
<div class="w-full p-3" style="height: calc(100vh - 130px)">
|
||||
<!-- Billing usage tiles (always shown). When self-usage reporting is
|
||||
enabled, the calendar in the toolbar drives the range and a daily
|
||||
chart is appended below. -->
|
||||
<div>
|
||||
<div v-if="Object.keys(usageData).length === 0">
|
||||
<div class="text-center text-xl font-medium font-semibold">
|
||||
{{ t("billing.messageDataNotFound") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- usage section new -->
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<div
|
||||
v-if="showContractCreditMessage"
|
||||
data-test="contract-ai-credits-exhausted"
|
||||
role="status"
|
||||
class="border-status-warning-text bg-status-warning-bg text-status-warning-text rounded-default border px-4 py-3 text-sm"
|
||||
>
|
||||
{{ t("billing.aiContractExhaustedMessage") }}
|
||||
</div>
|
||||
<!-- tab-info-section -->
|
||||
<!-- this will be unlocked when we get the rum sessions , error tracking from BE -->
|
||||
<div v-if="false" class="grid w-full grid-cols-2 gap-4">
|
||||
<div
|
||||
class="bg-card-glass-bg border-card-glass-border rounded-default flex min-h-32 flex-col justify-between border p-4 transition-shadow duration-200 ease-in-out"
|
||||
>
|
||||
<div class="rounded-default flex h-full flex-col justify-between gap-4">
|
||||
<!-- Top Section (60%) -->
|
||||
<div class="flex flex-col justify-between">
|
||||
<!-- Title row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-text-heading text-left text-(length:--text-sm) leading-(--leading-base) font-semibold tracking-normal"
|
||||
>
|
||||
{{ t("billing.errorTracking") }}
|
||||
</div>
|
||||
<div class="opacity-80">
|
||||
<img :src="errorTrackingIcon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Section (40%) -->
|
||||
<div
|
||||
class="text-text-body flex items-end text-left text-(length:--text-2xl) leading-(--leading-xl) font-semibold tracking-normal"
|
||||
>
|
||||
{{ "300" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-card-glass-bg border-card-glass-border rounded-default flex min-h-32 flex-col justify-between border p-4 transition-shadow duration-200 ease-in-out"
|
||||
>
|
||||
<div class="rounded-default flex h-full flex-col justify-between gap-4">
|
||||
<!-- Top Section (60%) -->
|
||||
<div class="flex flex-col justify-between">
|
||||
<!-- Title row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="text-text-heading text-left text-(length:--text-sm) leading-(--leading-base) font-semibold tracking-normal"
|
||||
data-test="billings-usage-tile-title"
|
||||
>
|
||||
{{ t("billing.rumSession") }}
|
||||
</div>
|
||||
<div class="opacity-80">
|
||||
<img :src="rumSessionIcon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Section (40%) -->
|
||||
<div
|
||||
class="text-text-body flex items-end text-left text-(length:--text-2xl) leading-(--leading-xl) font-semibold tracking-normal"
|
||||
>
|
||||
{{ "20" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Billable usage tiles — one row of six compact stat cards. Wraps to
|
||||
3-per-row on narrower screens. Values auto-scale from the GB/MB
|
||||
base so large numbers stay readable. -->
|
||||
<div
|
||||
v-if="!dataLoading && Object.keys(usageData).length > 0"
|
||||
class="grid w-full grid-cols-3 gap-3 max-lg:flex max-lg:flex-wrap max-lg:gap-1.5 xl:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="tile in usageTiles"
|
||||
:key="tile.key"
|
||||
data-test="billings-usage-tile"
|
||||
class="usage-tile bg-card-glass-bg border-card-glass-border rounded-default flex flex-col gap-2 border px-3 py-3 transition-shadow duration-200 ease-in-out max-lg:shrink-0 max-lg:basis-auto max-lg:flex-row-reverse max-lg:items-center max-lg:gap-1.5 max-lg:px-1.5 max-lg:py-1"
|
||||
:title="lgUp ? undefined : tile.label"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<OTruncatedText
|
||||
as="div"
|
||||
class="text-text-secondary text-(length:--text-xs) font-medium max-lg:hidden"
|
||||
data-test="billings-usage-tile-title"
|
||||
>
|
||||
{{ tile.label }}
|
||||
</OTruncatedText>
|
||||
<div
|
||||
class="bg-bg-gray rounded-default flex h-7 w-7 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="tile.icon" class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 whitespace-nowrap">
|
||||
<span
|
||||
class="text-text-body text-(length:--text-xl) leading-none font-bold max-lg:text-(length:--text-lg)"
|
||||
>
|
||||
{{ tile.value }}
|
||||
</span>
|
||||
<span class="text-text-secondary text-(length:--text-xs) font-medium">
|
||||
{{ tile.unit }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="usageCost[tile.key]"
|
||||
class="text-text-secondary text-(length:--text-xs) font-medium max-lg:hidden"
|
||||
>
|
||||
{{ "$" + usageCost[tile.key] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- charts section -->
|
||||
<div></div>
|
||||
</div>
|
||||
<div v-if="dataLoading" class="text-center text-xl font-medium font-semibold">
|
||||
<OSpinner size="md" class="mx-auto mt-3 block text-center" />
|
||||
</div>
|
||||
|
||||
<!-- Self-usage reporting ON → append a daily line chart (Ingestion +
|
||||
Query) below the cards, driven by the same calendar range above. -->
|
||||
<div
|
||||
v-if="usageStreamEnabled"
|
||||
data-test="usage-daily-chart"
|
||||
class="bg-card-glass-bg border-card-glass-border rounded-default mt-4 border p-4"
|
||||
>
|
||||
<div class="text-text-heading mb-2 text-(length:--text-sm) font-semibold">
|
||||
{{ t("billing.usageTrends.dailyUsage") }}
|
||||
</div>
|
||||
<div class="relative h-90 w-full">
|
||||
<PanelSchemaRenderer
|
||||
v-if="combinedSchema && dailyTimeObj"
|
||||
:key="'chart-r-' + dailyChartKey"
|
||||
class="h-full w-full"
|
||||
:panelSchema="combinedSchema"
|
||||
:selectedTimeObj="dailyTimeObj"
|
||||
:variablesData="{}"
|
||||
:dashboardId="''"
|
||||
:folderId="''"
|
||||
searchType="dashboards"
|
||||
:allowAnnotationsAPI="false"
|
||||
@error="onChartError"
|
||||
/>
|
||||
<!-- Until the org reports any usage its `usage` stream doesn't exist,
|
||||
so the search errors. Show the illustrated empty state OVER the
|
||||
chart; it clears itself once data lands and the error resets. -->
|
||||
<div
|
||||
v-if="usageStreamMissing"
|
||||
data-test="usage-waiting-for-data"
|
||||
class="bg-card-glass-bg absolute inset-0 z-2 flex items-center justify-center"
|
||||
>
|
||||
<OEmptyState
|
||||
size="block"
|
||||
illustration="wave-bars"
|
||||
:title="t('billing.usageTrends.waitingTitle')"
|
||||
:description="t('billing.usageTrends.waitingForData')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reporting OFF → illustrated empty state inviting the user to enable
|
||||
usage reporting so the daily chart can populate. Uses the app-wide
|
||||
OEmptyState primitive with the animated bar-chart illustration. -->
|
||||
<OEmptyState
|
||||
v-else
|
||||
data-test="usage-enable-cta"
|
||||
class="border-card-glass-border rounded-default bg-card-glass-bg mt-4 border"
|
||||
size="block"
|
||||
illustration="wave-bars"
|
||||
:title="t('billing.usageTrends.enableTitle')"
|
||||
:description="t('billing.usageTrends.enableSubtitle')"
|
||||
:action-label="t('billing.usageTrends.enableButton')"
|
||||
action-icon="check"
|
||||
@action="showEnableConfirm = true"
|
||||
/>
|
||||
|
||||
<!-- Confirm before enabling — turning this on starts writing the org's
|
||||
own usage stream, so we ask first. -->
|
||||
<ConfirmDialog
|
||||
v-model="showEnableConfirm"
|
||||
data-test="usage-enable-confirm"
|
||||
:title="t('billing.usageTrends.enableConfirmTitle')"
|
||||
:message="t('billing.usageTrends.enableConfirmMessage')"
|
||||
:ok-label="t('billing.usageTrends.enableButton')"
|
||||
@update:ok="onConfirmEnable"
|
||||
@update:cancel="showEnableConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, watch, computed, inject } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import useTheme from "@/composables/useTheme";
|
||||
import { useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import BillingService from "@/services/billings";
|
||||
import { updateOrgSettingsMutation } from "@/services/organizations.queries";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { useRouter } from "vue-router";
|
||||
import { getImageURL } from "@/utils/zincutils";
|
||||
import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { buildUsageCombinedLinePanelSchema } from "./usageDailyPanelSchema";
|
||||
import config from "@/aws-exports";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Usage",
|
||||
components: {
|
||||
PanelSchemaRenderer,
|
||||
OSpinner,
|
||||
OEmptyState,
|
||||
OTruncatedText,
|
||||
ConfirmDialog,
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18nTyped();
|
||||
const { lgUp } = useBreakpoint();
|
||||
const store = useStore();
|
||||
// A store read, not useOrgId(): two functions below bind a local `orgId` that a same-named ref would shadow.
|
||||
const updateOrgSettings = useMutation(() =>
|
||||
updateOrgSettingsMutation(store.state.selectedOrganization.identifier),
|
||||
);
|
||||
const { isDark } = useTheme();
|
||||
const router = useRouter();
|
||||
const dataLoading = ref(false);
|
||||
const lastUsageUpdated = ref(0);
|
||||
const pipelinesPanelDataKey = ref(0);
|
||||
const elapsedText = ref("Just now");
|
||||
const startTime = ref(new Date().getTime() * 1000);
|
||||
const endTime = ref(new Date().getTime() * 1000);
|
||||
const usageData = ref<any>({
|
||||
ingestion: "0.00",
|
||||
search: "0.00",
|
||||
functions: "0.00",
|
||||
pipeline: "0.00",
|
||||
remotepipeline: "0.00",
|
||||
dataretention: "0.00",
|
||||
ai_credits: "0.00",
|
||||
});
|
||||
const usageCost = ref<any>({
|
||||
ingestion: null,
|
||||
search: null,
|
||||
functions: null,
|
||||
pipeline: null,
|
||||
remotepipeline: null,
|
||||
dataretention: null,
|
||||
ai_credits: null,
|
||||
});
|
||||
const aiUsage = ref<any>(null);
|
||||
let chartData: any = ref({});
|
||||
// The member-org selector lives in Billing.vue (rendered beside this
|
||||
// component) and shares the current selection via this injected reactive.
|
||||
const usageMember = inject<{ selected: string }>("usageMember", undefined as any);
|
||||
const selectedMember = computed(() => usageMember?.selected || "");
|
||||
const showContractCreditMessage = computed(
|
||||
() => !selectedMember.value && aiUsage.value?.requires_additional_credits === true,
|
||||
);
|
||||
|
||||
// --- Daily chart (appended below the cards when self-usage is on) -----
|
||||
// The existing cards (billing API) stay; when reporting is enabled we also
|
||||
// render a daily line chart from the org's own `usage` stream, and the
|
||||
// calendar (in the Billing toolbar) drives BOTH the cards and the chart.
|
||||
const usageStreamEnabled = computed<boolean>(
|
||||
() => !!store.state?.organizationData?.organizationSettings?.usage_stream_enabled,
|
||||
);
|
||||
|
||||
const enablingUsage = ref(false);
|
||||
// Controls the "are you sure?" dialog shown before we enable reporting.
|
||||
const showEnableConfirm = ref(false);
|
||||
|
||||
// Persist an explicit opt-in. Merge with the current settings so we don't
|
||||
// clobber other org-parameter fields, then update the store so
|
||||
// usageStreamEnabled recomputes and the chart block replaces this CTA.
|
||||
const enableUsageReporting = async () => {
|
||||
if (enablingUsage.value) return;
|
||||
enablingUsage.value = true;
|
||||
const currentSettings = store.state?.organizationData?.organizationSettings ?? {};
|
||||
// Merged for the store only: the POST carries just the opt-in, since the backend applies what is present.
|
||||
const updatedSettings = {
|
||||
...currentSettings,
|
||||
usage_stream_enabled: true,
|
||||
};
|
||||
try {
|
||||
await updateOrgSettings.mutateAsync({ usage_stream_enabled: true });
|
||||
store.dispatch("setOrganizationSettings", updatedSettings);
|
||||
toast({
|
||||
variant: "success",
|
||||
message: t("billing.usageTrends.enablePending"),
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: e?.message ?? t("billing.failedToEnableUsageReporting"),
|
||||
timeout: 5000,
|
||||
});
|
||||
} finally {
|
||||
enablingUsage.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm-dialog OK handler: close the dialog, then run the enable flow.
|
||||
const onConfirmEnable = () => {
|
||||
showEnableConfirm.value = false;
|
||||
enableUsageReporting();
|
||||
};
|
||||
|
||||
// The date-range picker lives in the Billing toolbar (next to GB/MB) and
|
||||
// shares its resolved window (microseconds) here via inject. `key` bumps
|
||||
// on every change. Defaults to the last 7 days until the picker resolves.
|
||||
const usageRange = inject<{ start: number; end: number; key: number }>(
|
||||
"usageRange",
|
||||
undefined as any,
|
||||
);
|
||||
const dailyChartKey = computed(() => usageRange?.key ?? 0);
|
||||
|
||||
// Chart uses the exact micros window. selectedTimeObj wants Date objects
|
||||
// wrapping the microsecond value (see LLMSchemaPanel.vue — do NOT /1000).
|
||||
const dailyTimeObj = computed(() => ({
|
||||
start_time: new Date(
|
||||
usageRange?.start ?? (new Date().getTime() - 7 * 24 * 60 * 60 * 1000) * 1000,
|
||||
),
|
||||
end_time: new Date(usageRange?.end ?? new Date().getTime() * 1000),
|
||||
}));
|
||||
|
||||
// Cards use the billing API, which only accepts relative range strings
|
||||
// (`<N>days`, integer). Derive N from the calendar window so the cards
|
||||
// cover the same span the chart does. Guard hard: a valid, whole, >=1 day
|
||||
// count — anything else falls back to 7days (the API rejects bad strings
|
||||
// with a 400).
|
||||
const MICROS_PER_DAY = 24 * 60 * 60 * 1000 * 1000;
|
||||
const calendarRangeString = computed<string>(() => {
|
||||
const start = Number(usageRange?.start);
|
||||
const end = Number(usageRange?.end);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
||||
return "7days";
|
||||
}
|
||||
const days = Math.max(1, Math.round((end - start) / MICROS_PER_DAY));
|
||||
return `${days}days`;
|
||||
});
|
||||
|
||||
// Combined daily line panel (Ingestion + Query, one query), rebuilt on
|
||||
// org / GB-MB change.
|
||||
const combinedSchema = computed(() => {
|
||||
const orgId = store.state.selectedOrganization.identifier;
|
||||
const dt = usageDataType.value === "mb" ? "mb" : "gb";
|
||||
return buildUsageCombinedLinePanelSchema({ orgId, dataType: dt, t });
|
||||
});
|
||||
|
||||
// The org's `usage` stream doesn't exist until it has reported some usage,
|
||||
// so the chart's first search fails with "stream not found: usage". We
|
||||
// catch that here and show the illustrated empty state OVER the chart
|
||||
// (the chart stays mounted underneath). When usage finally lands the
|
||||
// search succeeds, the error clears, and the overlay goes away.
|
||||
const usageStreamMissing = ref(false);
|
||||
const onChartError = (err: any) => {
|
||||
const msg = (err?.message ?? "").toString().toLowerCase();
|
||||
usageStreamMissing.value =
|
||||
!!err?.message && msg.includes("stream not found") && msg.includes("usage");
|
||||
};
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const loadAiCreditStatus = async () => {
|
||||
if (config.isCloud !== "true") return;
|
||||
const orgId = store.state.selectedOrganization.identifier;
|
||||
try {
|
||||
aiUsage.value = (await BillingService.get_ai_usage(orgId)).data;
|
||||
} catch {
|
||||
aiUsage.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
selectUsageDate();
|
||||
await loadAiCreditStatus();
|
||||
});
|
||||
const usageDate: any = computed(() => {
|
||||
return router.currentRoute.value.query.usage_date ?? "30days";
|
||||
});
|
||||
const usageDataType: any = computed(() => {
|
||||
return router.currentRoute.value.query.data_type ?? "gb";
|
||||
});
|
||||
// The range that drives the cards: when self-usage is on the calendar
|
||||
// controls it (derived <N>days); otherwise the billing dropdown does.
|
||||
const effectiveUsageDate = computed(() =>
|
||||
usageStreamEnabled.value ? calendarRangeString.value : usageDate.value,
|
||||
);
|
||||
watch(effectiveUsageDate, () => {
|
||||
getUsage();
|
||||
});
|
||||
watch(usageDataType, () => {
|
||||
getUsage();
|
||||
});
|
||||
watch(selectedMember, () => {
|
||||
getUsage();
|
||||
});
|
||||
const getUsage = () => {
|
||||
const dismiss = toast({
|
||||
variant: "loading",
|
||||
message: t("toastMessages.billings.pleaseWaitWhileLoadingUsageData"),
|
||||
timeout: 0,
|
||||
});
|
||||
dataLoading.value = true;
|
||||
BillingService.get_data_usage(
|
||||
store.state.selectedOrganization.identifier,
|
||||
effectiveUsageDate.value,
|
||||
usageDataType.value,
|
||||
selectedMember.value || undefined,
|
||||
)
|
||||
.then((res) => {
|
||||
dataLoading.value = false;
|
||||
// reset the data
|
||||
for (const key of Object.keys(usageData.value)) {
|
||||
usageData.value[key] = "0.00";
|
||||
usageCost.value[key] = null;
|
||||
}
|
||||
res.data.data.forEach((item: any) => {
|
||||
const numericValue = parseFloat(item.value);
|
||||
// Map API event names to usageData keys
|
||||
const eventKeyMap: Record<string, string> = {
|
||||
ingestion: "ingestion",
|
||||
search: "search",
|
||||
functions: "functions",
|
||||
pipeline: "pipeline",
|
||||
remotepipeline: "remotepipeline",
|
||||
dataretention: "dataretention",
|
||||
aicredits: "ai_credits",
|
||||
};
|
||||
const key = eventKeyMap[item.event.toLowerCase()];
|
||||
if (key) {
|
||||
usageData.value[key] = numericValue.toFixed(2);
|
||||
if (item.cost) {
|
||||
usageCost.value[key] = item.cost.toFixed(2);
|
||||
} else {
|
||||
usageCost.value[key] = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
startTime.value = res.data.start_time;
|
||||
endTime.value = res.data.end_time;
|
||||
|
||||
dismiss();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: e.message,
|
||||
timeout: 5000,
|
||||
});
|
||||
dataLoading.value = false;
|
||||
});
|
||||
};
|
||||
const selectUsageDate = () => {
|
||||
chartData.value = {};
|
||||
getUsage();
|
||||
};
|
||||
const errorTrackingIcon = getImageURL("images/usage/error_tracking.svg");
|
||||
const rumSessionIcon = getImageURL("images/usage/rum_session.svg");
|
||||
const ingestionIcon = getImageURL("images/usage/ingestion.svg");
|
||||
const searchIcon = getImageURL("images/usage/search.svg");
|
||||
const functionsIcon = getImageURL("images/usage/function.svg");
|
||||
const pipelineIcon = getImageURL("images/usage/pipeline.svg");
|
||||
const remotePipelineIcon = getImageURL("images/usage/remote_pipeline.svg");
|
||||
const dataRetentionIcon = getImageURL("images/usage/data_retention.svg");
|
||||
const aiIcon = computed(() =>
|
||||
isDark.value
|
||||
? getImageURL("images/common/ai_icon_dark.svg")
|
||||
: getImageURL("images/common/ai_icon_gradient.svg"),
|
||||
);
|
||||
|
||||
// Auto-scale a byte value UP from the page's base unit (the GB/MB toggle)
|
||||
// so large numbers stay readable: e.g. base GB → 10930.96 shows as
|
||||
// "10.93 TB", 1095652 as "1.07 PB"; small values keep the base unit.
|
||||
// Promotes only upward (never below the toggle's base).
|
||||
const BYTE_UNITS = ["MB", "GB", "TB", "PB", "EB"];
|
||||
const scaleByteValue = (
|
||||
rawValue: string | number,
|
||||
baseUnit: string,
|
||||
): { value: string; unit: string } => {
|
||||
const num = Number(rawValue);
|
||||
if (!Number.isFinite(num)) {
|
||||
return { value: String(rawValue ?? "0.00"), unit: baseUnit };
|
||||
}
|
||||
let idx = BYTE_UNITS.indexOf(baseUnit.toUpperCase());
|
||||
if (idx === -1) return { value: num.toFixed(2), unit: baseUnit };
|
||||
let val = num;
|
||||
// Promote while the value has 4+ integer digits (>= 1000) and a bigger
|
||||
// unit exists.
|
||||
while (val >= 1000 && idx < BYTE_UNITS.length - 1) {
|
||||
val /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return { value: val.toFixed(2), unit: BYTE_UNITS[idx] };
|
||||
};
|
||||
|
||||
// The billable usage tiles (one row). `type` picks formatting: byte
|
||||
// metrics auto-scale from the GB/MB toggle; AI credits is a plain count.
|
||||
const usageTiles = computed(() => {
|
||||
const base = usageDataType.value.toUpperCase(); // GB | MB
|
||||
const byteTile = (key: string, label: I18nText, icon: any) => {
|
||||
const scaled = scaleByteValue(usageData.value[key] ?? 0, base);
|
||||
return { key, label, icon, value: scaled.value, unit: scaled.unit };
|
||||
};
|
||||
return [
|
||||
byteTile("ingestion", t("billing.ingestion"), ingestionIcon),
|
||||
byteTile("search", t("billing.search"), searchIcon),
|
||||
byteTile("pipeline", t("billing.pipelines"), pipelineIcon),
|
||||
byteTile("remotepipeline", t("billing.remotePipelines"), remotePipelineIcon),
|
||||
byteTile("dataretention", t("billing.dataRetention"), dataRetentionIcon),
|
||||
{
|
||||
key: "ai_credits",
|
||||
label: t("billing.aiBillableCredits"),
|
||||
icon: aiIcon.value,
|
||||
// Credits are whole units; the 2-decimal string is for byte metrics.
|
||||
value: String(Math.round(Number(usageData.value.ai_credits ?? 0))),
|
||||
unit: t("billing.credits"),
|
||||
},
|
||||
];
|
||||
});
|
||||
//this is the example data that needs to be used to get the chart in usage page
|
||||
//we just need to have the data in the format of the dataModel
|
||||
//eg: date and value and in the array format
|
||||
const dataModel = ref({
|
||||
chartType: "bar",
|
||||
options: {
|
||||
backgroundColor: "transparent",
|
||||
legend: {
|
||||
show: false,
|
||||
type: "scroll",
|
||||
orient: "horizontal",
|
||||
padding: [10, 20, 10, 10],
|
||||
tooltip: {
|
||||
show: true,
|
||||
padding: 10,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
textStyle: {
|
||||
width: 100,
|
||||
overflow: "truncate",
|
||||
rich: {
|
||||
a: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
b: {
|
||||
fontStyle: "normal",
|
||||
},
|
||||
},
|
||||
},
|
||||
left: "0",
|
||||
top: "bottom",
|
||||
},
|
||||
grid: {
|
||||
containLabel: true,
|
||||
left: 30,
|
||||
right: 20,
|
||||
top: "15",
|
||||
bottom: 35,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
textStyle: {
|
||||
color: "#000",
|
||||
fontSize: 12,
|
||||
},
|
||||
enterable: true,
|
||||
backgroundColor: "rgba(255,255,255,1)",
|
||||
extraCssText: "max-height: 12.5rem; overflow: auto; max-width: 25rem",
|
||||
axisPointer: {
|
||||
type: "cross",
|
||||
label: {
|
||||
fontsize: 12,
|
||||
precision: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: "time",
|
||||
position: "bottom",
|
||||
inverse: false,
|
||||
name: "Timestamp",
|
||||
label: {
|
||||
show: false,
|
||||
position: "None",
|
||||
rotate: 0,
|
||||
},
|
||||
nameLocation: "middle",
|
||||
nameGap: 25,
|
||||
nameTextStyle: {
|
||||
fontWeight: "bold",
|
||||
fontSize: 14,
|
||||
},
|
||||
axisLabel: {
|
||||
interval: "auto",
|
||||
overflow: "none",
|
||||
hideOverlap: true,
|
||||
width: 120,
|
||||
margin: 10,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
align: "left",
|
||||
alignWithLabel: false,
|
||||
length: 5,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "K8s Container Name",
|
||||
nameLocation: "middle",
|
||||
nameGap: 61,
|
||||
nameTextStyle: {
|
||||
fontWeight: "bold",
|
||||
fontSize: 14,
|
||||
},
|
||||
axisLabel: {},
|
||||
splitLine: {
|
||||
show: true,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
orient: "vertical",
|
||||
show: true,
|
||||
showTitle: false,
|
||||
tooltip: {
|
||||
show: false,
|
||||
},
|
||||
itemSize: 0,
|
||||
itemGap: 0,
|
||||
bottom: "100%",
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "K8s Container Name",
|
||||
type: "bar",
|
||||
emphasis: {
|
||||
focus: "series",
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: "None",
|
||||
rotate: 0,
|
||||
},
|
||||
originalSeriesName: "",
|
||||
markLine: {
|
||||
silent: true,
|
||||
animation: false,
|
||||
data: [],
|
||||
},
|
||||
connectNulls: false,
|
||||
large: true,
|
||||
color: "#64b5f6",
|
||||
data: [
|
||||
["2025-05-29T11:38:00.000Z", 525],
|
||||
["2025-05-29T11:38:10.000Z", 26185],
|
||||
["2025-05-29T11:38:20.000Z", 19537],
|
||||
["2025-05-29T11:38:30.000Z", 24852],
|
||||
["2025-05-29T11:38:40.000Z", 12448],
|
||||
["2025-05-29T11:38:50.000Z", 15757],
|
||||
["2025-05-29T11:39:00.000Z", 16502],
|
||||
["2025-05-29T11:39:10.000Z", 21424],
|
||||
["2025-05-29T11:39:20.000Z", 13249],
|
||||
["2025-05-29T11:39:30.000Z", 22533],
|
||||
["2025-05-29T11:39:40.000Z", 17767],
|
||||
["2025-05-29T11:39:50.000Z", 11406],
|
||||
["2025-05-29T11:40:00.000Z", 19588],
|
||||
["2025-05-29T11:40:10.000Z", 13109],
|
||||
["2025-05-29T11:40:20.000Z", 14540],
|
||||
["2025-05-29T11:40:30.000Z", 14074],
|
||||
["2025-05-29T11:40:40.000Z", 12856],
|
||||
["2025-05-29T11:40:50.000Z", 8782],
|
||||
["2025-05-29T11:41:00.000Z", 14729],
|
||||
["2025-05-29T11:41:10.000Z", 14617],
|
||||
["2025-05-29T11:41:20.000Z", 21772],
|
||||
["2025-05-29T11:41:30.000Z", 15147],
|
||||
["2025-05-29T11:41:40.000Z", 17362],
|
||||
["2025-05-29T11:41:50.000Z", 15018],
|
||||
["2025-05-29T11:42:00.000Z", 17007],
|
||||
["2025-05-29T11:42:10.000Z", 16545],
|
||||
["2025-05-29T11:42:20.000Z", 17489],
|
||||
["2025-05-29T11:42:30.000Z", 19863],
|
||||
["2025-05-29T11:42:40.000Z", 18351],
|
||||
["2025-05-29T11:42:50.000Z", 10001],
|
||||
["2025-05-29T11:43:00.000Z", 21750],
|
||||
["2025-05-29T11:43:10.000Z", 23619],
|
||||
["2025-05-29T11:43:20.000Z", 14901],
|
||||
["2025-05-29T11:43:30.000Z", 19604],
|
||||
["2025-05-29T11:43:40.000Z", 14491],
|
||||
["2025-05-29T11:43:50.000Z", 18249],
|
||||
["2025-05-29T11:44:00.000Z", 14324],
|
||||
["2025-05-29T11:44:10.000Z", 11322],
|
||||
["2025-05-29T11:44:20.000Z", 10481],
|
||||
["2025-05-29T11:44:30.000Z", 18098],
|
||||
["2025-05-29T11:44:40.000Z", 14593],
|
||||
["2025-05-29T11:44:50.000Z", 24532],
|
||||
["2025-05-29T11:45:00.000Z", 11336],
|
||||
["2025-05-29T11:45:10.000Z", 17646],
|
||||
["2025-05-29T11:45:20.000Z", 16908],
|
||||
["2025-05-29T11:45:30.000Z", 13049],
|
||||
["2025-05-29T11:45:40.000Z", 19015],
|
||||
["2025-05-29T11:45:50.000Z", 15213],
|
||||
["2025-05-29T11:46:00.000Z", 17953],
|
||||
["2025-05-29T11:46:10.000Z", 19638],
|
||||
["2025-05-29T11:46:20.000Z", 10446],
|
||||
["2025-05-29T11:46:30.000Z", 17906],
|
||||
["2025-05-29T11:46:40.000Z", 15080],
|
||||
["2025-05-29T11:46:50.000Z", 23020],
|
||||
["2025-05-29T11:47:00.000Z", 22451],
|
||||
["2025-05-29T11:47:10.000Z", 21097],
|
||||
["2025-05-29T11:47:20.000Z", 16644],
|
||||
["2025-05-29T11:47:30.000Z", 18096],
|
||||
["2025-05-29T11:47:40.000Z", 14492],
|
||||
["2025-05-29T11:47:50.000Z", 13806],
|
||||
["2025-05-29T11:48:00.000Z", 13533],
|
||||
["2025-05-29T11:48:10.000Z", 12718],
|
||||
["2025-05-29T11:48:20.000Z", 15700],
|
||||
["2025-05-29T11:48:30.000Z", 11800],
|
||||
["2025-05-29T11:48:40.000Z", 13252],
|
||||
["2025-05-29T11:48:50.000Z", 14647],
|
||||
["2025-05-29T11:49:00.000Z", 10797],
|
||||
["2025-05-29T11:49:10.000Z", 18977],
|
||||
["2025-05-29T11:49:20.000Z", 14971],
|
||||
["2025-05-29T11:49:30.000Z", 19181],
|
||||
["2025-05-29T11:49:40.000Z", 14652],
|
||||
["2025-05-29T11:49:50.000Z", 15085],
|
||||
["2025-05-29T11:50:00.000Z", 11316],
|
||||
["2025-05-29T11:50:10.000Z", 19070],
|
||||
["2025-05-29T11:50:20.000Z", 11385],
|
||||
["2025-05-29T11:50:30.000Z", 18561],
|
||||
["2025-05-29T11:50:40.000Z", 13120],
|
||||
["2025-05-29T11:50:50.000Z", 17771],
|
||||
["2025-05-29T11:51:00.000Z", 17426],
|
||||
["2025-05-29T11:51:10.000Z", 19740],
|
||||
["2025-05-29T11:51:20.000Z", 26322],
|
||||
["2025-05-29T11:51:30.000Z", 15417],
|
||||
["2025-05-29T11:51:40.000Z", 18328],
|
||||
["2025-05-29T11:51:50.000Z", 14118],
|
||||
["2025-05-29T11:52:00.000Z", 20130],
|
||||
["2025-05-29T11:52:10.000Z", 14243],
|
||||
["2025-05-29T11:52:20.000Z", 15377],
|
||||
["2025-05-29T11:52:30.000Z", 19846],
|
||||
["2025-05-29T11:52:40.000Z", 17345],
|
||||
["2025-05-29T11:52:50.000Z", 12196],
|
||||
["2025-05-29T11:53:00.000Z", 6227],
|
||||
],
|
||||
zlevel: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
data: [["Invalid Date", null]],
|
||||
markLine: {
|
||||
itemStyle: {
|
||||
color: "rgba(0, 191, 255, 0.5)",
|
||||
},
|
||||
silent: false,
|
||||
animation: false,
|
||||
data: [],
|
||||
},
|
||||
markArea: {
|
||||
itemStyle: {
|
||||
color: "rgba(0, 191, 255, 0.15)",
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
zlevel: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
extras: {
|
||||
panelId: "Panel_ID5876310",
|
||||
isTimeSeries: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
lgUp,
|
||||
t,
|
||||
store,
|
||||
chartData,
|
||||
dataLoading,
|
||||
options: ["30days", "60days", "3months", "6months"],
|
||||
selectUsageDate,
|
||||
startTime,
|
||||
endTime,
|
||||
usageDate,
|
||||
usageDataType,
|
||||
lastUsageUpdated,
|
||||
elapsedText,
|
||||
errorTrackingIcon,
|
||||
rumSessionIcon,
|
||||
pipelinesPanelDataKey,
|
||||
dataModel,
|
||||
ingestionIcon,
|
||||
searchIcon,
|
||||
functionsIcon,
|
||||
usageData,
|
||||
usageCost,
|
||||
usageTiles,
|
||||
aiUsage,
|
||||
showContractCreditMessage,
|
||||
getUsage,
|
||||
router,
|
||||
pipelineIcon,
|
||||
remotePipelineIcon,
|
||||
dataRetentionIcon,
|
||||
aiIcon,
|
||||
selectedMember,
|
||||
usageStreamEnabled,
|
||||
enablingUsage,
|
||||
enableUsageReporting,
|
||||
showEnableConfirm,
|
||||
onConfirmEnable,
|
||||
dailyTimeObj,
|
||||
combinedSchema,
|
||||
dailyChartKey,
|
||||
usageStreamMissing,
|
||||
onChartError,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-border-default flex items-center border-b px-4 py-2.5"
|
||||
data-test="billings-usagebillstrip-root"
|
||||
>
|
||||
<div
|
||||
class="rounded-default focus-visible:ring-accent/40 flex h-7 min-w-0 flex-1 gap-0.5 overflow-hidden outline-none focus-visible:ring-2"
|
||||
role="tablist"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabel ?? t('billing.usageV2.costByMeter')"
|
||||
data-test="billings-usagebillstrip-bar"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<OTooltip v-for="row in segments" :key="row.key" side="bottom" :delay="150" max-width="18rem">
|
||||
<div
|
||||
role="tab"
|
||||
:aria-selected="row.key === selected"
|
||||
:aria-label="ariaLabelOf(row)"
|
||||
class="flex h-full min-w-1.5 items-center gap-1 overflow-hidden border-2 px-2 whitespace-nowrap transition-colors hover:brightness-95"
|
||||
:class="row.drillable ? 'cursor-pointer' : ''"
|
||||
:style="segmentStyle(row)"
|
||||
:data-test="`billings-usagebillstrip-segment-${row.key}`"
|
||||
@click="choose(row)"
|
||||
>
|
||||
<span v-if="shareOf(row) >= NAME_MIN_SHARE" class="text-2xs truncate font-semibold">
|
||||
{{ row.label }}
|
||||
</span>
|
||||
<span v-if="shareOf(row) >= PERCENT_MIN_SHARE" class="text-2xs shrink-0 opacity-80">
|
||||
{{ percentText(row) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.discount > 0 && shareOf(row) >= TAG_MIN_SHARE"
|
||||
class="text-3xs text-status-success-text bg-surface-base shrink-0 rounded-full px-1.5 font-semibold"
|
||||
>
|
||||
{{ t("billing.usageV2.discountBadge", { percent: row.discountPercent }) }}
|
||||
</span>
|
||||
</div>
|
||||
<template #content>
|
||||
<div
|
||||
class="flex min-w-44 flex-col gap-1 text-xs"
|
||||
:data-test="`billings-usagebillstrip-tip-${row.key}`"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 font-semibold">
|
||||
<span
|
||||
class="rounded-default h-2.5 w-2.5 shrink-0"
|
||||
:style="{ backgroundColor: colorOf(row.key) }"
|
||||
/>
|
||||
<span class="truncate">{{ row.label }}</span>
|
||||
</div>
|
||||
<div class="opacity-75">{{ detailOf(row) }}</div>
|
||||
<template v-if="row.discount > 0">
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="opacity-75">{{ t("billing.usageV2.colGross") }}</span>
|
||||
<span>{{ formatCost(row.gross) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="opacity-75">{{ t("billing.usageV2.discount") }}</span>
|
||||
<span>
|
||||
{{ formatCost(-row.discount) }}
|
||||
{{ t("billing.usageV2.percentInParens", { percent: row.discountPercent }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex justify-between gap-4 font-semibold">
|
||||
<span>{{ t("billing.usageV2.colBilled") }}</span>
|
||||
<span>{{ formatCost(row.cost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="opacity-75">{{ t("billing.usageV2.meterShare") }}</span>
|
||||
<span>{{ percentText(row) }}</span>
|
||||
</div>
|
||||
<div v-if="row.drillable && row.key !== selected && clickHint" class="mt-1 opacity-75">
|
||||
{{ clickHint }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import { formatCost, type BillRow } from "./meteringModel";
|
||||
import { chartTextColor } from "@/utils/chartTheme";
|
||||
import { getContrastColor } from "@/utils/dashboard/chartColorUtils";
|
||||
import { chartBg } from "@/utils/chartTheme";
|
||||
import { meterColorFor, shadeOf } from "./usageCharts";
|
||||
|
||||
/** Share of the bill a segment needs before it has room for its name. */
|
||||
const NAME_MIN_SHARE = 14;
|
||||
|
||||
/** Share of the bill a segment needs before it has room for its percentage. */
|
||||
const PERCENT_MIN_SHARE = 5;
|
||||
|
||||
/** Share of the bill a segment needs before its discount badge fits beside the label. */
|
||||
const TAG_MIN_SHARE = 24;
|
||||
|
||||
/** Luminance above this reads as a light fill, which needs dark label text. */
|
||||
const CONTRAST_THRESHOLD = 0.5;
|
||||
|
||||
/** Unselected segments are a tint of their meter, so the selected one stands out. */
|
||||
const TINT_STEP = 3;
|
||||
|
||||
const props = defineProps<{
|
||||
rows: BillRow[];
|
||||
/** Billed total after discounts; every share is of this. */
|
||||
total: number;
|
||||
selected: string | null;
|
||||
/** Colour of a row's segment; meters by default, so a super org can colour its orgs. */
|
||||
colorFor?: (key: string) => string;
|
||||
ariaLabel?: I18nText;
|
||||
/** Last line of a segment's tooltip, saying what a click opens. */
|
||||
clickHint?: I18nText;
|
||||
}>();
|
||||
const emit = defineEmits<{ select: [key: string | null] }>();
|
||||
|
||||
const colorOf = (key: string) => (props.colorFor ?? (meterColorFor as (k: string) => string))(key);
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const segments = computed(() =>
|
||||
props.rows.filter((row) => row.cost > 0).sort((a, b) => b.cost - a.cost),
|
||||
);
|
||||
|
||||
const shareOf = (row: BillRow) => (props.total > 0 ? (row.cost / props.total) * 100 : 0);
|
||||
|
||||
const percentText = (row: BillRow) => raw(`${shareOf(row).toFixed(1)}%`);
|
||||
|
||||
const segmentStyle = (row: BillRow) => {
|
||||
const color = colorOf(row.key);
|
||||
const isSelected = row.key === props.selected;
|
||||
const fullColor = props.selected === null || isSelected;
|
||||
const background = fullColor ? color : shadeOf(color, TINT_STEP, chartBg());
|
||||
return {
|
||||
// Grow by share, not by dollars: grow factors summing under 1 leave the row part-empty.
|
||||
flex: `${shareOf(row)} 1 0%`,
|
||||
backgroundColor: background,
|
||||
color: getContrastColor(background, chartTextColor(), CONTRAST_THRESHOLD),
|
||||
borderColor: isSelected ? shadeOf(color, 3, chartTextColor()) : "transparent",
|
||||
};
|
||||
};
|
||||
|
||||
const titleOf = (row: BillRow) =>
|
||||
row.discount > 0
|
||||
? t("billing.usageV2.segmentTitleDiscounted", {
|
||||
meter: row.label,
|
||||
billed: formatCost(row.cost),
|
||||
gross: formatCost(row.gross),
|
||||
discount: formatCost(row.discount),
|
||||
share: percentText(row),
|
||||
})
|
||||
: t("billing.usageV2.segmentTitle", {
|
||||
meter: row.label,
|
||||
billed: formatCost(row.cost),
|
||||
share: percentText(row),
|
||||
});
|
||||
|
||||
const ariaLabelOf = (row: BillRow) => titleOf(row);
|
||||
|
||||
/** The line under the name: an org's ingested volume, or a meter's volume at its rate. */
|
||||
const detailOf = (row: BillRow) =>
|
||||
row.subline ?? t("billing.usageV2.volumeAtRate", { volume: row.volume, rate: row.rate });
|
||||
|
||||
const choose = (row: BillRow) => {
|
||||
if (row.drillable) emit("select", row.key);
|
||||
};
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
emit("select", null);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
||||
event.preventDefault();
|
||||
const drillable = segments.value.filter((row) => row.drillable);
|
||||
const index = drillable.findIndex((row) => row.key === props.selected);
|
||||
const step = event.key === "ArrowRight" ? 1 : -1;
|
||||
const next = drillable[Math.min(Math.max(index + step, 0), drillable.length - 1)];
|
||||
if (next) emit("select", next.key);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { mockMeteringResponse } from "./meteringMock";
|
||||
|
||||
const getSettings = vi.fn();
|
||||
const post = vi.fn();
|
||||
vi.mock("@/services/organizations", () => ({
|
||||
default: {
|
||||
get_organization_settings: (...args: unknown[]) => getSettings(...args),
|
||||
post_organization_settings: (...args: unknown[]) => post(...args),
|
||||
},
|
||||
}));
|
||||
vi.mock("./useOrgCosts", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return { useOrgCosts: () => ({ costs: ref({}), loading: ref(false), failed: ref(false) }) };
|
||||
});
|
||||
|
||||
const { default: UsageBudget } = await import("./UsageBudget.vue");
|
||||
const { default: UsageSuperBudget } = await import("./UsageSuperBudget.vue");
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
const details = mockMeteringResponse().upcoming!;
|
||||
const billingOrgs = [
|
||||
{ id: "payer", name: "Payer", isSelf: true },
|
||||
{ id: "member-a", name: "Member A", isSelf: false },
|
||||
];
|
||||
// A super org keeps its overall budget under "total"; any other org under its own id.
|
||||
const stored = {
|
||||
data: {
|
||||
data: {
|
||||
budget_config: [
|
||||
{ org_id: "total", total_budget_amount: 500, warn_at_amount: 400, paused: false },
|
||||
{
|
||||
org_id: store.state.selectedOrganization.identifier,
|
||||
total_budget_amount: 500,
|
||||
warn_at_amount: 400,
|
||||
paused: false,
|
||||
},
|
||||
{ org_id: "member-a", total_budget_amount: 100, warn_at_amount: 80, paused: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const byTestId = (wrapper: VueWrapper, name: string, id: string) =>
|
||||
wrapper.findAllComponents({ name }).find((c) => c.vm.$attrs["data-test"] === id)!;
|
||||
|
||||
// Save sends the whole list, so a list built without the stored one would erase it.
|
||||
describe.each([
|
||||
{
|
||||
view: "UsageBudget",
|
||||
component: UsageBudget,
|
||||
prefix: "usagebudget",
|
||||
props: { details, projected: null },
|
||||
},
|
||||
{
|
||||
view: "UsageSuperBudget",
|
||||
component: UsageSuperBudget,
|
||||
prefix: "usagesuperbudget",
|
||||
props: { details, billingOrgs, projected: null },
|
||||
},
|
||||
])("$view when the stored budgets are not loaded", ({ component, prefix, props }) => {
|
||||
const mountView = () => mount(component as any, { props, global: { plugins: [i18n, store] } });
|
||||
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset();
|
||||
post.mockReset().mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
it("says so instead of showing an empty form, and loads them on Retry", async () => {
|
||||
getSettings.mockRejectedValueOnce(new Error("Network Error")).mockResolvedValue(stored);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find(`[data-test="billings-${prefix}-error"]`).exists()).toBe(true);
|
||||
expect(wrapper.find(`[data-test="billings-${prefix}-root"]`).exists()).toBe(false);
|
||||
|
||||
wrapper.findComponent({ name: "OEmptyState" }).vm.$emit("action");
|
||||
await flushPromises();
|
||||
expect(wrapper.find(`[data-test="billings-${prefix}-root"]`).exists()).toBe(true);
|
||||
expect(byTestId(wrapper, "OInput", `billings-${prefix}-total`).props("modelValue")).toBe(500);
|
||||
});
|
||||
|
||||
it("does not save while they are still loading", async () => {
|
||||
getSettings.mockReturnValue(new Promise(() => {}));
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
byTestId(wrapper, "OInput", `billings-${prefix}-total`).vm.$emit("update:modelValue", 50);
|
||||
await flushPromises();
|
||||
const save = byTestId(wrapper, "OButton", `billings-${prefix}-save`);
|
||||
expect(save.props("disabled")).toBe(true);
|
||||
save.vm.$emit("click");
|
||||
await flushPromises();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The budget job emails every admin each hour while spend is at or over the warn line, so $0 is never sent.
|
||||
describe("an emptied Warn % field", () => {
|
||||
const savedFor = (orgId: string) =>
|
||||
post.mock.calls[0][1].budget_config.find((c: { org_id: string }) => c.org_id === orgId);
|
||||
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset().mockResolvedValue(stored);
|
||||
post.mockReset().mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
it("keeps the org's warn line at its share when the budget is saved", async () => {
|
||||
const wrapper = mount(UsageBudget, {
|
||||
props: { details, projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
byTestId(wrapper, "OInput", "billings-usagebudget-warn-percent").vm.$emit(
|
||||
"update:modelValue",
|
||||
"",
|
||||
);
|
||||
byTestId(wrapper, "OInput", "billings-usagebudget-total").vm.$emit("update:modelValue", 600);
|
||||
await flushPromises();
|
||||
byTestId(wrapper, "OButton", "billings-usagebudget-save").vm.$emit("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(savedFor(store.state.selectedOrganization.identifier)).toMatchObject({
|
||||
total_budget_amount: 600,
|
||||
warn_at_amount: 480,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a member's warn line in the super org table", async () => {
|
||||
const wrapper = mount(UsageSuperBudget, {
|
||||
props: { details, billingOrgs, projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
byTestId(wrapper, "OInput", "billings-usagesuperbudget-warn-member-a").vm.$emit(
|
||||
"update:modelValue",
|
||||
"0",
|
||||
);
|
||||
byTestId(wrapper, "OInput", "billings-usagesuperbudget-budget-member-a").vm.$emit(
|
||||
"update:modelValue",
|
||||
200,
|
||||
);
|
||||
await flushPromises();
|
||||
byTestId(wrapper, "OButton", "billings-usagesuperbudget-save").vm.$emit("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(savedFor("member-a")).toMatchObject({ total_budget_amount: 200, warn_at_amount: 160 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("super org Budget saving", () => {
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset().mockResolvedValue(stored);
|
||||
});
|
||||
|
||||
it("keeps an edit typed while a save is running, as still unsaved", async () => {
|
||||
let finishSave!: (value: unknown) => void;
|
||||
post.mockReset().mockReturnValue(new Promise((resolve) => (finishSave = resolve)));
|
||||
const wrapper = mount(UsageSuperBudget, {
|
||||
props: { details, billingOrgs, projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
const budgetOf = (id: string) =>
|
||||
byTestId(wrapper, "OInput", `billings-usagesuperbudget-budget-${id}`);
|
||||
|
||||
budgetOf("member-a").vm.$emit("update:modelValue", 200);
|
||||
await flushPromises();
|
||||
byTestId(wrapper, "OButton", "billings-usagesuperbudget-save").vm.$emit("click");
|
||||
await flushPromises();
|
||||
|
||||
// The payer's budget changes before the save comes back.
|
||||
budgetOf("payer").vm.$emit("update:modelValue", 300);
|
||||
finishSave({ data: {} });
|
||||
await flushPromises();
|
||||
|
||||
expect(budgetOf("payer").props("modelValue")).toBe(300);
|
||||
expect(budgetOf("member-a").props("modelValue")).toBe(200);
|
||||
const bar = wrapper.find('[data-test="billings-usagesuperbudget-savebar"]').text();
|
||||
expect(bar).not.toContain("All changes saved");
|
||||
});
|
||||
|
||||
it("reads All changes saved after a save, with no draft left pending", async () => {
|
||||
post.mockReset().mockResolvedValue({ data: {} });
|
||||
const wrapper = mount(UsageSuperBudget, {
|
||||
props: { details, billingOrgs, projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
byTestId(wrapper, "OInput", "billings-usagesuperbudget-budget-member-a").vm.$emit(
|
||||
"update:modelValue",
|
||||
150,
|
||||
);
|
||||
byTestId(wrapper, "OInput", "billings-usagesuperbudget-warn-member-a").vm.$emit(
|
||||
"update:modelValue",
|
||||
"33.3",
|
||||
);
|
||||
await flushPromises();
|
||||
byTestId(wrapper, "OButton", "billings-usagesuperbudget-save").vm.$emit("click");
|
||||
await flushPromises();
|
||||
|
||||
const bar = wrapper.find('[data-test="billings-usagesuperbudget-savebar"]').text();
|
||||
expect(bar).toContain("All changes saved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Budget Save after a one-cent change", () => {
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset().mockResolvedValue(stored);
|
||||
});
|
||||
|
||||
it("turns Save on when the warn amount moves by one cent", async () => {
|
||||
const wrapper = mount(UsageBudget, {
|
||||
props: { details, projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
const save = () => byTestId(wrapper, "OButton", "billings-usagebudget-save");
|
||||
expect(save().props("disabled")).toBe(true);
|
||||
|
||||
// Saved at $400.00; 400.01 - 400 is 0.00999… in floating point.
|
||||
byTestId(wrapper, "OInput", "billings-usagebudget-warn-amount").vm.$emit(
|
||||
"update:modelValue",
|
||||
"400.01",
|
||||
);
|
||||
await flushPromises();
|
||||
expect(save().props("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Budget removal on the single-org tab", () => {
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset().mockResolvedValue(stored);
|
||||
post.mockReset().mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
it("saves a cleared amount as a removal, leaving every other org's budget", async () => {
|
||||
const wrapper = mount(UsageBudget, {
|
||||
props: { details, projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
byTestId(wrapper, "OInput", "billings-usagebudget-total").vm.$emit("update:modelValue", "");
|
||||
await flushPromises();
|
||||
const save = byTestId(wrapper, "OButton", "billings-usagebudget-save");
|
||||
expect(save.props("disabled")).toBe(false);
|
||||
save.vm.$emit("click");
|
||||
await flushPromises();
|
||||
|
||||
const sent = post.mock.calls[0][1].budget_config as { org_id: string }[];
|
||||
// Gone, not stored as $0, which the hourly budget job would treat as always crossed.
|
||||
expect(sent.map((entry) => entry.org_id)).not.toContain(
|
||||
store.state.selectedOrganization.identifier,
|
||||
);
|
||||
expect(sent.map((entry) => entry.org_id)).toEqual(["total", "member-a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- A failed read is not "no budgets": saving then would replace every stored budget. -->
|
||||
<div v-if="loadFailed" data-test="billings-usagebudget-error">
|
||||
<OEmptyState
|
||||
size="hero"
|
||||
variant="error"
|
||||
illustration="broken-panel"
|
||||
:title="t('billing.usageV2.budgetLoadFailedTitle')"
|
||||
:description="t('billing.usageV2.budgetLoadFailed')"
|
||||
:action-label="t('common.retry')"
|
||||
action-icon="refresh"
|
||||
@action="loadSettings"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="@container/budget flex flex-col gap-3 pb-3"
|
||||
data-test="billings-usagebudget-root"
|
||||
>
|
||||
<KpiCardRow :columns="4" class="shrink-0" data-test="billings-usagebudget-kpis">
|
||||
<UsageKpiCard
|
||||
v-for="card in kpiCards"
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:icon="card.icon"
|
||||
:value="card.value"
|
||||
:hint="card.hint"
|
||||
:tone="card.tone"
|
||||
:data-test="`billings-usagebudget-kpi-${card.key}`"
|
||||
/>
|
||||
</KpiCardRow>
|
||||
|
||||
<div
|
||||
class="grid items-stretch gap-3 @min-[62rem]/budget:grid-cols-[minmax(0,1.55fr)_minmax(18.75rem,0.85fr)]"
|
||||
>
|
||||
<!-- ── Spend against budget ─────────────────────────── -->
|
||||
<section
|
||||
class="border-border-default rounded-default @container/budget-rows flex min-w-0 flex-col overflow-hidden border"
|
||||
data-test="billings-usagebudget-spend"
|
||||
>
|
||||
<UsagePaneHeader :title="t('billing.usageV2.spendAgainstBudget')" :subtitle="cycleHint" />
|
||||
|
||||
<div class="px-4 pt-3 pb-1.5">
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
<span class="text-text-heading text-xl font-bold tabular-nums">
|
||||
{{ formatCost(spent) }}
|
||||
</span>
|
||||
<span v-if="hasBudget" class="text-text-secondary text-sm tabular-nums">
|
||||
{{
|
||||
t("billing.usageV2.ofBudget", {
|
||||
budget: formatCost(draftTotal),
|
||||
percent: usedPercent.toFixed(1),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="hasBudget">
|
||||
<div class="relative mt-2">
|
||||
<OProgressBar
|
||||
:value="Math.min(usedPercent / 100, 1)"
|
||||
:variant="
|
||||
spendTone === 'over' ? 'danger' : spendTone === 'warn' ? 'warning' : 'default'
|
||||
"
|
||||
data-test="billings-usagebudget-meter"
|
||||
/>
|
||||
<span
|
||||
class="bg-text-secondary absolute -top-1 h-4 w-0.5"
|
||||
:style="{ left: `calc(${warnPercent}% - 0.0625rem)` }"
|
||||
:title="t('billing.usageV2.warnAt')"
|
||||
data-test="billings-usagebudget-warn-mark"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-text-secondary mt-1.5 flex justify-between text-xs tabular-nums">
|
||||
<span>
|
||||
{{
|
||||
spent >= draftTotal
|
||||
? t("billing.usageV2.overBy", { amount: formatCost(spent - draftTotal) })
|
||||
: t("billing.usageV2.leftInBudget", { amount: formatCost(draftTotal - spent) })
|
||||
}}
|
||||
</span>
|
||||
<span>{{ t("billing.usageV2.warnAtPercent", { percent: warnPercentLabel }) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-text-secondary mt-1 text-sm" data-test="billings-usagebudget-unset">
|
||||
{{ t("billing.usageV2.setBudgetFirst") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Each meter's share of the budget, so an overrun can be traced to what drove it. -->
|
||||
<div
|
||||
class="grid gap-x-4.5 gap-y-0.5 px-4 pt-1.5 pb-3.5 @min-[25rem]/budget-rows:grid-cols-2"
|
||||
data-test="billings-usagebudget-meters"
|
||||
>
|
||||
<div
|
||||
v-for="row in meterRows"
|
||||
:key="row.key"
|
||||
class="grid min-w-0 grid-cols-[0.625rem_minmax(0,1fr)_auto] items-center gap-x-2.5 gap-y-1.5 py-2"
|
||||
:data-test="`billings-usagebudget-meter-${row.key}`"
|
||||
>
|
||||
<span
|
||||
class="rounded-default h-2.5 w-2.5"
|
||||
:style="{ backgroundColor: meterColorFor(row.key) }"
|
||||
/>
|
||||
<span class="truncate font-medium">{{ row.label }}</span>
|
||||
<span class="text-right font-semibold tabular-nums">{{ formatCost(row.cost) }}</span>
|
||||
<OProgressBar
|
||||
class="col-span-2 col-start-2"
|
||||
size="xs"
|
||||
:value="hasBudget ? Math.min(row.cost / draftTotal, 1) : 0"
|
||||
:color="meterColorFor(row.key)"
|
||||
/>
|
||||
<span
|
||||
class="text-text-secondary col-span-2 col-start-2 flex justify-between gap-2 text-xs whitespace-nowrap tabular-nums"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
hasBudget
|
||||
? t("billing.usageV2.shareOfBudget", {
|
||||
percent: ((row.cost / draftTotal) * 100).toFixed(1),
|
||||
})
|
||||
: ""
|
||||
}}
|
||||
</span>
|
||||
<span>{{ t("billing.usageV2.perDay", { cost: formatCost(row.cost / day) }) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Budget settings ──────────────────────────────── -->
|
||||
<section
|
||||
class="border-border-default rounded-default flex min-w-0 flex-col overflow-hidden border"
|
||||
data-test="billings-usagebudget-settings"
|
||||
>
|
||||
<UsagePaneHeader :title="t('billing.usageV2.budgetSettings')" :subtitle="cycleLabel" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4 p-4">
|
||||
<OInput
|
||||
:model-value="draftTotal || undefined"
|
||||
type="number"
|
||||
size="md"
|
||||
prefix="$"
|
||||
:label="t('billing.usageV2.budgetAmount')"
|
||||
:placeholder="raw(suggestedBudget)"
|
||||
:help-text="
|
||||
projected !== null
|
||||
? t('billing.usageV2.budgetAmountHelp', { amount: formatCost(projected) })
|
||||
: undefined
|
||||
"
|
||||
data-test="billings-usagebudget-total"
|
||||
@update:model-value="setTotal"
|
||||
/>
|
||||
|
||||
<UsageWarnAt
|
||||
v-model="warnPercent"
|
||||
:budget="draftTotal"
|
||||
data-test="billings-usagebudget-warn"
|
||||
/>
|
||||
|
||||
<div class="border-border-default border-t" />
|
||||
|
||||
<div
|
||||
class="border-border-default rounded-default flex items-center gap-3 border px-3 py-2.5"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold">{{ t("billing.usageV2.emailAlerts") }}</div>
|
||||
<div class="text-text-secondary text-xs">
|
||||
{{
|
||||
alertsOn
|
||||
? t("billing.usageV2.alertsOnHint")
|
||||
: t("billing.usageV2.alertsPausedHint")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<OSwitch
|
||||
v-model="alertsOn"
|
||||
size="sm"
|
||||
:aria-label="t('billing.usageV2.emailAlerts')"
|
||||
data-test="billings-usagebudget-alerts"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OButton
|
||||
variant="primary"
|
||||
class="mt-auto"
|
||||
:disabled="!canSave"
|
||||
:loading="saving"
|
||||
data-test="billings-usagebudget-save"
|
||||
@click="save"
|
||||
>
|
||||
{{ t("billing.usageV2.saveBudget") }}
|
||||
</OButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import KpiCardRow from "@/components/common/KpiCardRow.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import organizations from "@/services/organizations";
|
||||
import UsageKpiCard from "./UsageKpiCard.vue";
|
||||
import UsagePaneHeader from "./UsagePaneHeader.vue";
|
||||
import UsageWarnAt from "./UsageWarnAt.vue";
|
||||
import {
|
||||
buildMeterRows,
|
||||
cycleProgress,
|
||||
formatCost,
|
||||
formatCycle,
|
||||
splitByUse,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
import { meterColorFor } from "./usageCharts";
|
||||
import {
|
||||
budgetFor,
|
||||
budgetReachedDay,
|
||||
budgetTone,
|
||||
DEFAULT_WARN_PERCENT,
|
||||
warnAmountFor,
|
||||
warnPercentFor,
|
||||
withBudget,
|
||||
sameCents,
|
||||
type BudgetConfig,
|
||||
} from "./budgetModel";
|
||||
|
||||
const props = defineProps<{
|
||||
details: MeteringDetails | null;
|
||||
/** The cycle's projected total, worked out once by the page so every tab shows the same figure. */
|
||||
projected: number | null;
|
||||
}>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const orgId = computed<string>(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
// ── Cycle ────────────────────────────────────────────────────────
|
||||
|
||||
const spent = computed(() => Number(props.details?.total_cost) || 0);
|
||||
const progress = computed(() =>
|
||||
props.details ? cycleProgress(props.details) : { day: 1, totalDays: 1, elapsed: 0 },
|
||||
);
|
||||
const day = computed(() => progress.value.day);
|
||||
const projected = computed(() => props.projected);
|
||||
|
||||
const cycleLabel = computed(() => (props.details ? formatCycle(props.details, t) : raw("")));
|
||||
const cycleHint = computed(() =>
|
||||
raw(
|
||||
`${cycleLabel.value} · ${t("billing.usageV2.cycleDay", {
|
||||
day: progress.value.day,
|
||||
total: progress.value.totalDays,
|
||||
})}`,
|
||||
),
|
||||
);
|
||||
|
||||
const meterRows = computed(() => splitByUse(buildMeterRows(props.details, t)).used);
|
||||
|
||||
// ── Saved and draft budget ───────────────────────────────────────
|
||||
|
||||
const savedConfigs = ref<BudgetConfig[]>([]);
|
||||
const saved = computed(() => budgetFor(savedConfigs.value, orgId.value));
|
||||
|
||||
const draftTotal = ref(0);
|
||||
/** The percent is the source of truth for the warn line, so a new budget keeps the same share. */
|
||||
const warnPercent = ref(DEFAULT_WARN_PERCENT);
|
||||
const alertsOn = ref(true);
|
||||
const saving = ref(false);
|
||||
|
||||
const resetDraft = () => {
|
||||
const config = saved.value;
|
||||
draftTotal.value = config?.total_budget_amount ?? 0;
|
||||
warnPercent.value = config
|
||||
? warnPercentFor(config.warn_at_amount, config.total_budget_amount)
|
||||
: DEFAULT_WARN_PERCENT;
|
||||
alertsOn.value = config ? !config.paused : true;
|
||||
};
|
||||
|
||||
/** Save waits for the stored list: it sends the whole list, so one built without it would erase the rest. */
|
||||
const loaded = ref(false);
|
||||
const loadFailed = ref(false);
|
||||
|
||||
const loadSettings = async () => {
|
||||
loaded.value = false;
|
||||
loadFailed.value = false;
|
||||
try {
|
||||
const res = await organizations.get_organization_settings(orgId.value);
|
||||
savedConfigs.value = res?.data?.data?.budget_config ?? [];
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
loadFailed.value = true;
|
||||
}
|
||||
resetDraft();
|
||||
};
|
||||
|
||||
onMounted(loadSettings);
|
||||
watch(orgId, loadSettings);
|
||||
|
||||
const hasBudget = computed(() => draftTotal.value > 0);
|
||||
const warnAmount = computed(() => warnAmountFor(warnPercent.value, draftTotal.value));
|
||||
/** One decimal at most, so 80 reads "80" and a typed dollar amount still shows its share. */
|
||||
const warnPercentLabel = computed(() => Math.round(warnPercent.value * 10) / 10);
|
||||
const usedPercent = computed(() => (hasBudget.value ? (spent.value / draftTotal.value) * 100 : 0));
|
||||
const spendTone = computed(() => budgetTone(spent.value, draftTotal.value, warnAmount.value));
|
||||
|
||||
/** An empty budget field suggests the projected cost, raised to the next round number. */
|
||||
const suggestedBudget = computed(() => {
|
||||
const base = projected.value ?? spent.value;
|
||||
return base > 0 ? String(Math.ceil(base / 10) * 10) : "";
|
||||
});
|
||||
|
||||
const toNumber = (value: unknown) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
};
|
||||
const setTotal = (value: unknown) => {
|
||||
draftTotal.value = toNumber(value);
|
||||
};
|
||||
|
||||
const draftEntry = computed<BudgetConfig>(() => ({
|
||||
org_id: orgId.value,
|
||||
total_budget_amount: draftTotal.value,
|
||||
warn_at_amount: warnAmount.value,
|
||||
paused: !alertsOn.value,
|
||||
}));
|
||||
|
||||
const dirty = computed(() => {
|
||||
const config = saved.value;
|
||||
const draft = draftEntry.value;
|
||||
if (!config) return draft.total_budget_amount > 0;
|
||||
return (
|
||||
config.total_budget_amount !== draft.total_budget_amount ||
|
||||
!sameCents(config.warn_at_amount, draft.warn_at_amount) ||
|
||||
config.paused !== draft.paused
|
||||
);
|
||||
});
|
||||
|
||||
/** A cleared amount is a change too: saving it removes this org's budget. */
|
||||
const canSave = computed(() => loaded.value && dirty.value && !saving.value);
|
||||
|
||||
/** Sends only `budget_config`; the backend leaves every setting it is not sent untouched. */
|
||||
const save = async () => {
|
||||
if (!canSave.value) return;
|
||||
saving.value = true;
|
||||
const next = withBudget(savedConfigs.value, draftEntry.value);
|
||||
try {
|
||||
await organizations.post_organization_settings(orgId.value, { budget_config: next });
|
||||
savedConfigs.value = next;
|
||||
store.dispatch("setOrganizationSettings", {
|
||||
...(store.state?.organizationData?.organizationSettings ?? {}),
|
||||
budget_config: next,
|
||||
});
|
||||
toast({
|
||||
variant: "success",
|
||||
message: hasBudget.value
|
||||
? t("billing.usageV2.budgetSaved")
|
||||
: t("billing.usageV2.budgetRemoved"),
|
||||
timeout: 4000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message:
|
||||
raw(e?.response?.data?.message ?? e?.message) || t("billing.usageV2.budgetSaveFailed"),
|
||||
timeout: 5000,
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── KPIs ─────────────────────────────────────────────────────────
|
||||
|
||||
const kpiCards = computed(() => {
|
||||
const total = draftTotal.value;
|
||||
const dash = raw("—");
|
||||
const proj = projected.value;
|
||||
const projTone =
|
||||
proj === null || !hasBudget.value ? undefined : budgetTone(proj, total, warnAmount.value);
|
||||
const reached = budgetReachedDay(spent.value, day.value, total, progress.value.totalDays);
|
||||
const tone = (value: string | undefined) =>
|
||||
value === "over" ? ("danger" as const) : value === "warn" ? ("warn" as const) : undefined;
|
||||
|
||||
const cards: {
|
||||
key: string;
|
||||
label: I18nText;
|
||||
icon: IconName;
|
||||
value: I18nText;
|
||||
hint: I18nText;
|
||||
tone?: "warn" | "danger";
|
||||
}[] = [
|
||||
{
|
||||
key: "budget",
|
||||
label: t("billing.usageV2.budgetKpi"),
|
||||
icon: "attach-money",
|
||||
value: hasBudget.value ? formatCost(total) : t("billing.usageV2.budgetNotSet"),
|
||||
hint: t("billing.usageV2.budgetThisCycle"),
|
||||
},
|
||||
{
|
||||
key: "spent",
|
||||
label: t("billing.usageV2.costSoFar"),
|
||||
icon: "show-chart",
|
||||
value: formatCost(spent.value),
|
||||
hint: hasBudget.value
|
||||
? t("billing.usageV2.spentOfBudget", {
|
||||
percent: usedPercent.value.toFixed(1),
|
||||
day: progress.value.day,
|
||||
total: progress.value.totalDays,
|
||||
})
|
||||
: t("billing.usageV2.cycleDay", {
|
||||
day: progress.value.day,
|
||||
total: progress.value.totalDays,
|
||||
}),
|
||||
tone: tone(spendTone.value),
|
||||
},
|
||||
{
|
||||
key: "projected",
|
||||
label: t("billing.usageV2.projectedTotal"),
|
||||
icon: "trending-up",
|
||||
value: proj === null ? dash : formatCost(proj),
|
||||
hint:
|
||||
proj === null
|
||||
? t("billing.usageV2.tooEarlyToProject")
|
||||
: !hasBudget.value
|
||||
? raw("")
|
||||
: proj > total
|
||||
? t("billing.usageV2.overBudgetBy", { amount: formatCost(proj - total) })
|
||||
: t("billing.usageV2.toSpare", { amount: formatCost(total - proj) }),
|
||||
tone: tone(projTone),
|
||||
},
|
||||
{
|
||||
key: "reached",
|
||||
label: t("billing.usageV2.budgetReached"),
|
||||
icon: "bar-chart",
|
||||
value: !hasBudget.value
|
||||
? dash
|
||||
: reached
|
||||
? t("billing.usageV2.reachedOnDay", { day: reached })
|
||||
: t("billing.usageV2.notThisCycle"),
|
||||
hint:
|
||||
hasBudget.value && reached
|
||||
? t("billing.usageV2.atPacePerDay", { cost: formatCost(spent.value / day.value) })
|
||||
: t("billing.usageV2.atCurrentPace"),
|
||||
tone: hasBudget.value && reached ? "warn" : undefined,
|
||||
},
|
||||
];
|
||||
return cards;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/locales";
|
||||
|
||||
vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: "PanelSchemaRendererStub",
|
||||
emits: ["error", "loading-state-change"],
|
||||
setup: () => () => h("div"),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { default: UsageCachedChart } = await import("./UsageCachedChart.vue");
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
describe("UsageCachedChart", () => {
|
||||
const mountChart = () =>
|
||||
mount(UsageCachedChart, {
|
||||
props: {
|
||||
panelSchema: {},
|
||||
selectedTimeObj: { start_time: new Date(0), end_time: new Date(1) },
|
||||
},
|
||||
global: { plugins: [i18n] },
|
||||
});
|
||||
|
||||
const waiting = '[data-test="billings-usagechart-waiting"]';
|
||||
|
||||
it("says usage is on its way while the org's usage stream does not exist yet", async () => {
|
||||
const wrapper = mountChart();
|
||||
const renderer = wrapper.findComponent({ name: "PanelSchemaRendererStub" });
|
||||
|
||||
renderer.vm.$emit("error", { message: "Search stream not found: usage", code: 404 });
|
||||
await flushPromises();
|
||||
expect(wrapper.find(waiting).text()).toContain("Waiting for usage data");
|
||||
|
||||
// The renderer clears its error once the first usage lands.
|
||||
renderer.vm.$emit("error", { message: "", code: "" });
|
||||
await flushPromises();
|
||||
expect(wrapper.find(waiting).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves any other error to the renderer", async () => {
|
||||
const wrapper = mountChart();
|
||||
wrapper
|
||||
.findComponent({ name: "PanelSchemaRendererStub" })
|
||||
.vm.$emit("error", { message: "Query timed out", code: 500 });
|
||||
await flushPromises();
|
||||
expect(wrapper.find(waiting).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!-- One usage chart with its own loading state, so a kept-alive copy comes back already drawn. -->
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<!-- The renderer reports "loading" only once its query starts, so it would flash "no data" first. -->
|
||||
<UsageChartSkeleton v-if="busy" class="absolute inset-0 z-10 p-2" />
|
||||
<!-- Hidden, not removed, while usage is on its way: it keeps querying and clears the state itself. -->
|
||||
<PanelSchemaRenderer
|
||||
class="h-full w-full"
|
||||
:class="{ invisible: streamMissing }"
|
||||
:panel-schema="panelSchema"
|
||||
:selected-time-obj="selectedTimeObj"
|
||||
:variables-data="{}"
|
||||
@loading-state-change="busy = $event"
|
||||
@error="onError"
|
||||
/>
|
||||
<div
|
||||
v-if="streamMissing"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center"
|
||||
data-test="billings-usagechart-waiting"
|
||||
>
|
||||
<OEmptyState
|
||||
size="compact"
|
||||
illustration="wave-bars"
|
||||
:title="t('billing.usageTrends.waitingTitle')"
|
||||
:description="t('billing.usageTrends.waitingForData')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue";
|
||||
import UsageChartSkeleton from "./UsageChartSkeleton.vue";
|
||||
import { USAGE_STREAM_NAME } from "./usageQueries";
|
||||
|
||||
defineProps<{
|
||||
panelSchema: Record<string, unknown>;
|
||||
selectedTimeObj: { start_time: Date; end_time: Date };
|
||||
}>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const busy = ref(true);
|
||||
|
||||
/** The renderer re-emits its error whenever it changes, an empty one included, so this clears itself. */
|
||||
const streamMissing = ref(false);
|
||||
|
||||
const onError = (error: { message?: string } | null) => {
|
||||
const message = String(error?.message ?? "").toLowerCase();
|
||||
streamMissing.value = message.includes("stream not found") && message.includes(USAGE_STREAM_NAME);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-surface-base flex h-full w-full items-end gap-1 overflow-hidden"
|
||||
data-test="billings-usagechartskeleton"
|
||||
>
|
||||
<OSkeleton v-for="(bar, index) in BAR_HEIGHTS" :key="index" class="flex-1" :class="bar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
|
||||
/** One bar per day of a cycle's first half, so the placeholder is as dense as the chart. */
|
||||
const BAR_HEIGHTS = [
|
||||
"h-1/4",
|
||||
"h-2/5",
|
||||
"h-3/5",
|
||||
"h-1/3",
|
||||
"h-4/5",
|
||||
"h-1/2",
|
||||
"h-3/4",
|
||||
"h-2/5",
|
||||
"h-3/5",
|
||||
"h-1/4",
|
||||
"h-1/2",
|
||||
"h-2/3",
|
||||
"h-1/3",
|
||||
"h-3/5",
|
||||
"h-2/5",
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/locales";
|
||||
import { formatSizeFromMB } from "@/utils/formatters";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { mockMeteringResponse } from "./meteringMock";
|
||||
import {
|
||||
browserStepRuns,
|
||||
monthlyPace,
|
||||
type EstimateInputs,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
|
||||
// The two history reads, released by each test when it wants them to land.
|
||||
let releaseHistory!: () => void;
|
||||
// Search history: one search that scanned 300 GB, far more than this cycle's billed scan.
|
||||
const BIG_SEARCH = { total: 307_200, seconds: 60, searches: 1 };
|
||||
let scanHistory: Record<string, number> = BIG_SEARCH;
|
||||
const search = vi.fn(
|
||||
(_sql: string, options: { key: string }) =>
|
||||
new Promise((resolve) => {
|
||||
const previous = releaseHistory;
|
||||
releaseHistory = () => {
|
||||
previous?.();
|
||||
// 10 streams of 50 MB.
|
||||
resolve(options.key === "scan" ? [scanHistory] : [{ total: 500, streams: 10 }]);
|
||||
};
|
||||
}),
|
||||
);
|
||||
vi.mock("./useUsageSearch", () => ({ useUsageSearch: () => ({ search }) }));
|
||||
|
||||
const { default: UsageEstimator } = await import("./UsageEstimator.vue");
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
const field = (wrapper: VueWrapper, key: string) =>
|
||||
wrapper
|
||||
.findAllComponents({ name: "OInput" })
|
||||
.find((input) => input.vm.$attrs["data-test"] === `billings-usageestimator-${key}`)!;
|
||||
|
||||
describe("UsageEstimator My Current Usage", () => {
|
||||
const base = mockMeteringResponse().upcoming!;
|
||||
// A little searching and one slow browser check this cycle.
|
||||
const details: MeteringDetails = {
|
||||
...base,
|
||||
query: { ...base.query, metered_amount: 1000 },
|
||||
synthetics_browser: { ...base.synthetics_browser, metered_amount: 5000 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
search.mockClear();
|
||||
releaseHistory = () => {};
|
||||
scanHistory = BIG_SEARCH;
|
||||
});
|
||||
|
||||
const mountEstimator = () =>
|
||||
mount(UsageEstimator, { props: { details }, global: { plugins: [i18n, store] } });
|
||||
|
||||
it("keeps a small search and check as one of each, sized to match the bill", async () => {
|
||||
const wrapper = mountEstimator();
|
||||
await flushPromises();
|
||||
releaseHistory();
|
||||
await flushPromises();
|
||||
|
||||
// Under one typical search a month: one smaller search, not zero searches.
|
||||
const scannedMb = monthlyPace(1000, details);
|
||||
expect(field(wrapper, "searchCount").props("modelValue")).toBe(1);
|
||||
// The search line scans what the bill does, so the estimate matches it.
|
||||
expect(wrapper.find('[data-test="billings-usageestimator-line-query"]').text()).toContain(
|
||||
`${formatSizeFromMB(scannedMb)} scanned`,
|
||||
);
|
||||
|
||||
// A third of a default check: one check that runs a third as often.
|
||||
const perCheck = browserStepRuns({
|
||||
browserChecks: 1,
|
||||
browserSteps: 5,
|
||||
browserRegions: 1,
|
||||
browserDevices: 1,
|
||||
browserIntervalSec: 300,
|
||||
} as EstimateInputs);
|
||||
const exact = monthlyPace(5000, details) / perCheck;
|
||||
expect(field(wrapper, "browserChecks").props("modelValue")).toBe(1);
|
||||
expect(field(wrapper, "browserIntervalSec").props("modelValue")).toBe(Math.round(300 / exact));
|
||||
});
|
||||
|
||||
it("prices a search by the data it scans, not by how long it runs", async () => {
|
||||
// The issue's history: 10 searches scanned 2,970 MB in all, each done in 0.3 seconds.
|
||||
scanHistory = { total: 2970, seconds: 3, searches: 10 };
|
||||
const wrapper = mountEstimator();
|
||||
await flushPromises();
|
||||
releaseHistory();
|
||||
await flushPromises();
|
||||
|
||||
field(wrapper, "searchCount").vm.$emit("update:modelValue", 10);
|
||||
await flushPromises();
|
||||
// 10 searches of about 297 MB each (≈ 2.9 GB, ≈ $0.03), not 10 hour-long scans at 59 GB a
|
||||
// minute. The size per search is squared with this cycle's billed scan, so it is near, not exact.
|
||||
const line = wrapper.find('[data-test="billings-usageestimator-line-query"]').text();
|
||||
const scannedGb = Number(/([\d.]+) GB scanned/.exec(line)?.[1]);
|
||||
expect(Math.abs(scannedGb - 2.9) / 2.9).toBeLessThan(0.05);
|
||||
expect(line).toContain("$0.03");
|
||||
});
|
||||
|
||||
it("says it is reading history instead of showing defaults as current usage", async () => {
|
||||
const wrapper = mountEstimator();
|
||||
await flushPromises();
|
||||
const loading = () => wrapper.findComponent({ name: "OInnerLoading" }).props("showing");
|
||||
expect(loading()).toBe(true);
|
||||
|
||||
releaseHistory();
|
||||
await flushPromises();
|
||||
expect(loading()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a preset picked while history loads", async () => {
|
||||
const wrapper = mountEstimator();
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: "OToggleGroup" }).vm.$emit("update:modelValue", "twice");
|
||||
releaseHistory();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent({ name: "OToggleGroup" }).props("modelValue")).toBe("twice");
|
||||
const current = Number(field(wrapper, "searchCount").props("modelValue"));
|
||||
expect(current).toBe(2);
|
||||
});
|
||||
|
||||
it("reads a super org's history across the whole group, the same orgs its bill covers", async () => {
|
||||
mount(UsageEstimator, {
|
||||
props: {
|
||||
details,
|
||||
billingOrgs: [
|
||||
{ id: "payer", name: "Payer", isSelf: true },
|
||||
{ id: "child", name: "Child", isSelf: false },
|
||||
],
|
||||
},
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2);
|
||||
for (const [sql] of search.mock.calls) expect(sql).not.toContain("org_id =");
|
||||
});
|
||||
|
||||
it("starts the two synthetics blocks on one row, so neither leaves a hole beside a short field", async () => {
|
||||
const wrapper = mountEstimator();
|
||||
await flushPromises();
|
||||
const order = wrapper
|
||||
.findAll('[data-test^="billings-usageestimator-field-"]')
|
||||
.map((block) => block.attributes("data-test")!.replace("billings-usageestimator-field-", ""));
|
||||
// Two columns: the tall browser block must open a row and the API block sit beside it.
|
||||
const browser = order.indexOf("browserChecks");
|
||||
expect(browser % 2).toBe(0);
|
||||
expect(order[browser + 1]).toBe("apiChecks");
|
||||
expect(order).toHaveLength(browser + 2);
|
||||
});
|
||||
|
||||
it("puts each count slider's thumb where its box says, including the defaults", async () => {
|
||||
const wrapper = mountEstimator();
|
||||
await flushPromises();
|
||||
releaseHistory();
|
||||
await flushPromises();
|
||||
// A browser draws a range input's thumb at the nearest min + n x step, so a value off that
|
||||
// grid shows somewhere else: the old duration slider showed 61 for a box reading 60.
|
||||
const onGrid = (key: string) => {
|
||||
const slider = wrapper
|
||||
.findAllComponents({ name: "OSlider" })
|
||||
.find((item) => item.vm.$attrs["data-test"] === `billings-usageestimator-${key}-slider`)!;
|
||||
const steps =
|
||||
(Number(slider.props("modelValue")) - Number(slider.props("min"))) /
|
||||
Number(slider.props("step"));
|
||||
return Math.abs(steps - Math.round(steps)) < 1e-9;
|
||||
};
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: "OSlider" }).every((slider) => {
|
||||
const key = String(slider.vm.$attrs["data-test"]).replace(
|
||||
/^billings-usageestimator-|-slider$/g,
|
||||
"",
|
||||
);
|
||||
// Sizes keep decimals, where the step is only how far a drag moves; counts must sit exactly.
|
||||
return key === "ingestGb" || key === "scanPerSearchMb" || onGrid(key);
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
for (const [key, value] of [
|
||||
["searchCount", 7],
|
||||
["aiCredits", 13],
|
||||
["pipelineCount", 3],
|
||||
] as const) {
|
||||
field(wrapper, key).vm.$emit("update:modelValue", value);
|
||||
await flushPromises();
|
||||
expect(onGrid(key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a value typed before the history lands as typed", async () => {
|
||||
const wrapper = mountEstimator();
|
||||
await flushPromises();
|
||||
|
||||
field(wrapper, "ingestGb").vm.$emit("update:modelValue", 500);
|
||||
releaseHistory();
|
||||
await flushPromises();
|
||||
|
||||
expect(field(wrapper, "ingestGb").props("modelValue")).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,614 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="@container/estimator flex h-full flex-col gap-3"
|
||||
data-test="billings-usageestimator-root"
|
||||
>
|
||||
<OEmptyState
|
||||
v-if="!details"
|
||||
size="block"
|
||||
:title="t('billing.usageV2.noPricingTitle')"
|
||||
:description="t('billing.usageV2.noPricingSubtitle')"
|
||||
data-test="billings-usageestimator-no-pricing"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- Presets, so the first view is a real scenario rather than a blank form. -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-text-secondary text-xs">{{ t("billing.usageV2.scenarioFrom") }}</span>
|
||||
<OToggleGroup
|
||||
:model-value="scenario"
|
||||
data-test="billings-usageestimator-scenario"
|
||||
@update:model-value="onScenarioChange"
|
||||
>
|
||||
<OToggleGroupItem
|
||||
v-for="option in SCENARIOS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
size="sm"
|
||||
>
|
||||
{{ t(option.labelKey) }}
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
icon-left="refresh"
|
||||
class="ms-auto"
|
||||
data-test="billings-usageestimator-reset"
|
||||
@click="applyScenario('current')"
|
||||
>
|
||||
{{ t("billing.usageV2.resetEstimate") }}
|
||||
</OButton>
|
||||
</div>
|
||||
|
||||
<!-- Side by side, the panes fill the page and only the form scrolls, so the estimate never leaves the screen. -->
|
||||
<div
|
||||
class="relative grid items-stretch gap-3 @min-[60rem]/estimator:min-h-0 @min-[60rem]/estimator:flex-1 @min-[60rem]/estimator:grid-cols-[minmax(0,1.35fr)_minmax(20rem,0.95fr)] @min-[60rem]/estimator:grid-rows-[minmax(0,1fr)]"
|
||||
>
|
||||
<!-- Until history lands the fields hold placeholders, not this org's usage. -->
|
||||
<OInnerLoading
|
||||
:showing="loadingHistory"
|
||||
size="sm"
|
||||
:label="t('billing.usageV2.loadingHistory')"
|
||||
/>
|
||||
<!-- ── Inputs ───────────────────────────────────────── -->
|
||||
<section
|
||||
class="border-border-default rounded-default flex min-w-0 flex-col overflow-hidden border"
|
||||
>
|
||||
<UsagePaneHeader
|
||||
:title="t('billing.usageV2.expectedUsage')"
|
||||
:subtitle="t('billing.usageV2.expectedUsageHint')"
|
||||
>
|
||||
<template #actions>
|
||||
<OPopover align="end" :aria-label="t('billing.usageV2.assumptions')">
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon-left="tune"
|
||||
data-test="billings-usageestimator-assumptions"
|
||||
>
|
||||
{{ t("billing.usageV2.assumptions") }}
|
||||
</OButton>
|
||||
</template>
|
||||
<div
|
||||
class="flex w-80 flex-col gap-3 p-3"
|
||||
data-test="billings-usageestimator-assumptions-panel"
|
||||
>
|
||||
<p class="text-text-heading text-sm font-semibold">
|
||||
{{ t("billing.usageV2.assumptions") }}
|
||||
<span class="text-text-secondary text-xs font-normal">
|
||||
{{ t("billing.usageV2.assumptionsFrom") }}
|
||||
</span>
|
||||
</p>
|
||||
<OInput
|
||||
:model-value="inputs.avgStreamMb"
|
||||
type="number"
|
||||
size="md"
|
||||
:label="t('billing.usageV2.avgStreamSize')"
|
||||
:suffix="rawMb"
|
||||
data-test="billings-usageestimator-avg-stream"
|
||||
@update:model-value="setField('avgStreamMb', $event)"
|
||||
/>
|
||||
</div>
|
||||
</OPopover>
|
||||
</template>
|
||||
</UsagePaneHeader>
|
||||
|
||||
<div
|
||||
class="grid min-h-0 gap-x-5 gap-y-4 overflow-y-auto p-4 @min-[36rem]/estimator:grid-cols-2"
|
||||
data-test="billings-usageestimator-fields"
|
||||
>
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="flex min-w-0 flex-col gap-1.5"
|
||||
:data-test="`billings-usageestimator-field-${field.key}`"
|
||||
>
|
||||
<div v-if="field.title" class="text-xs font-semibold">{{ field.title }}</div>
|
||||
<OInput
|
||||
:model-value="inputs[field.key]"
|
||||
type="number"
|
||||
size="md"
|
||||
:label="t(field.labelKey)"
|
||||
:suffix="field.suffix"
|
||||
:help-text="field.key === 'scanPerSearchMb' ? scanNote : field.rate"
|
||||
:data-test="`billings-usageestimator-${field.key}`"
|
||||
@update:model-value="setField(field.key, $event)"
|
||||
>
|
||||
<template v-if="field.rateHint" #tooltip>
|
||||
<OTooltip :content="field.rateHint" max-width="18rem" />
|
||||
</template>
|
||||
</OInput>
|
||||
<OSlider
|
||||
:model-value="Math.min(inputs[field.key], field.max)"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
:aria-label="t(field.labelKey)"
|
||||
:data-test="`billings-usageestimator-${field.key}-slider`"
|
||||
@update:model-value="setField(field.key, $event)"
|
||||
/>
|
||||
<!-- A check's shape only scales its count, so it takes small boxes rather than a slider each. -->
|
||||
<template v-if="field.shape">
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-2 pt-1">
|
||||
<OInput
|
||||
v-for="part in field.shape"
|
||||
:key="part.key"
|
||||
:model-value="inputs[part.key]"
|
||||
type="number"
|
||||
size="sm"
|
||||
width="xs"
|
||||
:label="t(part.labelKey)"
|
||||
:suffix="part.suffix"
|
||||
:data-test="`billings-usageestimator-${part.key}`"
|
||||
@update:model-value="setField(part.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-text-secondary text-xs">{{ runNotes[field.key] }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Estimate ─────────────────────────────────────── -->
|
||||
<section
|
||||
class="border-border-default rounded-default flex min-w-0 flex-col overflow-hidden border"
|
||||
data-test="billings-usageestimator-result"
|
||||
>
|
||||
<UsagePaneHeader :title="t('billing.usageV2.yourEstimate')" />
|
||||
|
||||
<ul class="m-0 flex min-h-0 list-none flex-col overflow-y-auto p-0">
|
||||
<li
|
||||
v-for="line in sortedLines"
|
||||
:key="line.key"
|
||||
class="flex flex-col gap-1.5 px-4 py-2.5"
|
||||
:class="line.cost < CENT ? 'text-text-secondary' : ''"
|
||||
:data-test="`billings-usageestimator-line-${line.key}`"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="rounded-default h-2.5 w-2.5 shrink-0"
|
||||
:style="{ backgroundColor: meterColorFor(line.key) }"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate text-sm font-medium">{{ line.label }}</span>
|
||||
<span class="text-text-secondary text-xs">{{ line.quantityText }}</span>
|
||||
</span>
|
||||
<span class="ms-auto text-sm font-semibold">{{ formatCost(line.cost) }}</span>
|
||||
</div>
|
||||
<OProgressBar :value="shareOf(line)" :color="meterColorFor(line.key)" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="border-border-default bg-surface-subtle mt-auto border-t px-4 py-3">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-sm font-semibold">{{ t("billing.usageV2.estimatedTotal") }}</span>
|
||||
<span class="ms-auto text-2xl leading-none font-semibold">
|
||||
{{ formatCost(estimate.total) }}
|
||||
</span>
|
||||
<span class="text-text-secondary text-xs">{{ t("billing.usageV2.perMonth") }}</span>
|
||||
</div>
|
||||
<p class="text-text-secondary mt-1.5 text-xs">
|
||||
{{ t("billing.usageV2.estimateDisclaimer") }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OSlider from "@/lib/forms/Slider/OSlider.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
|
||||
import OInnerLoading from "@/lib/feedback/InnerLoading/OInnerLoading.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
|
||||
import { addCommasToNumber, formatSizeFromMB } from "@/utils/formatters";
|
||||
import UsagePaneHeader from "./UsagePaneHeader.vue";
|
||||
import { useUsageSearch } from "./useUsageSearch";
|
||||
import {
|
||||
apiCheckRuns,
|
||||
browserStepRuns,
|
||||
estimateCost,
|
||||
formatCost,
|
||||
fitWholeCount,
|
||||
formatRate,
|
||||
METERS,
|
||||
monthlyPace,
|
||||
type BillingOrg,
|
||||
type EstimateInputs,
|
||||
type EstimateLine,
|
||||
type MeteringDetails,
|
||||
type MeterKey,
|
||||
} from "./meteringModel";
|
||||
import { meterColorFor } from "./usageCharts";
|
||||
import { averageStreamSizeSql, searchScanSizeSql } from "./usageQueries";
|
||||
|
||||
type FieldKey = keyof EstimateInputs;
|
||||
|
||||
/** A field that only shapes a check: a small box under the check's count, with no slider. */
|
||||
interface ShapeDef {
|
||||
key: FieldKey;
|
||||
labelKey: I18nKey;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
type ScenarioKey = "current" | "twice" | "fiveTimes";
|
||||
|
||||
interface FieldDef {
|
||||
key: FieldKey;
|
||||
labelKey: I18nKey;
|
||||
meter?: MeterKey;
|
||||
/** Shows the meter's name above the field, for a count that heads its own block. */
|
||||
titled?: boolean;
|
||||
shape?: ShapeDef[];
|
||||
suffix: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
const MB_PER_GB = 1024;
|
||||
|
||||
/** Below a cent a line is noise, so it renders quietly rather than as a real cost. */
|
||||
const CENT = 0.005;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
details: MeteringDetails | null;
|
||||
/** A super org's orgs, itself first; empty for any other org. */
|
||||
billingOrgs?: BillingOrg[];
|
||||
}>(),
|
||||
{ billingOrgs: () => [] },
|
||||
);
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const rawMb = "MB";
|
||||
|
||||
const SCENARIOS: { value: ScenarioKey; labelKey: I18nKey }[] = [
|
||||
{ value: "current", labelKey: "billing.usageV2.scenarioCurrent" },
|
||||
{ value: "twice", labelKey: "billing.usageV2.scenarioTwice" },
|
||||
{ value: "fiveTimes", labelKey: "billing.usageV2.scenarioFiveTimes" },
|
||||
];
|
||||
|
||||
/** Growth multiplies the volumes a plan grows by, never the conversion assumptions. */
|
||||
const GROWN_FIELDS: FieldKey[] = [
|
||||
"ingestGb",
|
||||
"searchCount",
|
||||
"pipelineCount",
|
||||
"aiCredits",
|
||||
"browserChecks",
|
||||
"apiChecks",
|
||||
];
|
||||
|
||||
const FIELDS: FieldDef[] = [
|
||||
{
|
||||
key: "ingestGb",
|
||||
labelKey: "billing.usageV2.ingestPerMonth",
|
||||
meter: "ingestion",
|
||||
suffix: "GB",
|
||||
min: 0,
|
||||
max: 2000,
|
||||
step: 10,
|
||||
},
|
||||
{
|
||||
key: "searchCount",
|
||||
labelKey: "billing.usageV2.searchesPerMonth",
|
||||
meter: "query",
|
||||
suffix: "",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "scanPerSearchMb",
|
||||
labelKey: "billing.usageV2.scanPerSearch",
|
||||
suffix: "MB",
|
||||
min: 0,
|
||||
max: 10240,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "pipelineCount",
|
||||
labelKey: "billing.usageV2.pipelineCount",
|
||||
meter: "pipeline",
|
||||
suffix: "",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "retentionDays",
|
||||
labelKey: "billing.usageV2.retentionDays",
|
||||
meter: "retention",
|
||||
suffix: "days",
|
||||
min: 1,
|
||||
max: 365,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "aiCredits",
|
||||
labelKey: "billing.usageV2.aiCreditsPerMonth",
|
||||
meter: "ai",
|
||||
suffix: "",
|
||||
min: 0,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "browserChecks",
|
||||
labelKey: "billing.usageV2.browserChecks",
|
||||
meter: "synthetics_browser",
|
||||
titled: true,
|
||||
suffix: "",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
shape: [
|
||||
{ key: "browserSteps", labelKey: "billing.usageV2.stepsPerCheck", suffix: "" },
|
||||
{ key: "browserRegions", labelKey: "billing.usageV2.checkRegions", suffix: "" },
|
||||
{ key: "browserDevices", labelKey: "billing.usageV2.checkDevices", suffix: "" },
|
||||
{ key: "browserIntervalSec", labelKey: "billing.usageV2.checkInterval", suffix: "sec" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "apiChecks",
|
||||
labelKey: "billing.usageV2.apiChecks",
|
||||
meter: "synthetics_protocol",
|
||||
titled: true,
|
||||
suffix: "",
|
||||
min: 0,
|
||||
max: 500,
|
||||
step: 1,
|
||||
shape: [
|
||||
{ key: "apiRegions", labelKey: "billing.usageV2.checkRegions", suffix: "" },
|
||||
{ key: "apiIntervalSec", labelKey: "billing.usageV2.checkInterval", suffix: "sec" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULTS: EstimateInputs = {
|
||||
ingestGb: 100,
|
||||
searchCount: 50,
|
||||
pipelineCount: 5,
|
||||
retentionDays: 30,
|
||||
aiCredits: 0,
|
||||
avgStreamMb: 0,
|
||||
scanPerSearchMb: 0,
|
||||
browserChecks: 0,
|
||||
browserSteps: 5,
|
||||
browserRegions: 1,
|
||||
browserDevices: 1,
|
||||
browserIntervalSec: 300,
|
||||
apiChecks: 0,
|
||||
apiRegions: 1,
|
||||
apiIntervalSec: 300,
|
||||
};
|
||||
|
||||
/** Runs one check makes in a month at the default shape, which turns billed runs back into a check count. */
|
||||
const BROWSER_RUNS_PER_CHECK = browserStepRuns({ ...DEFAULTS, browserChecks: 1 });
|
||||
const API_RUNS_PER_CHECK = apiCheckRuns({ ...DEFAULTS, apiChecks: 1 });
|
||||
|
||||
const inputs = reactive<EstimateInputs>({ ...DEFAULTS });
|
||||
|
||||
const scenario = ref<ScenarioKey>("current");
|
||||
|
||||
const round = (value: number) => Number(value.toFixed(2));
|
||||
|
||||
/** Four significant digits, for a size that can be a fraction of a megabyte. */
|
||||
const significant = (value: number) => Number(value.toPrecision(4));
|
||||
|
||||
/** Fields the user typed in; history that lands later must not overwrite them. */
|
||||
const edited = new Set<FieldKey>();
|
||||
|
||||
const setField = (key: FieldKey, value: unknown) => {
|
||||
const next = Number(value);
|
||||
inputs[key] = Number.isFinite(next) && next > 0 ? next : 0;
|
||||
edited.add(key);
|
||||
};
|
||||
|
||||
const currentScenario = computed<EstimateInputs>(() => {
|
||||
const details = props.details;
|
||||
const assumptions = {
|
||||
avgStreamMb: inputs.avgStreamMb,
|
||||
scanPerSearchMb: inputs.scanPerSearchMb,
|
||||
};
|
||||
if (!details) return { ...DEFAULTS, ...assumptions };
|
||||
const perMonth = (amount: number | string | undefined) => monthlyPace(amount, details);
|
||||
const scannedMb = perMonth(details.query?.metered_amount);
|
||||
const pipelineMb = perMonth(details.pipeline?.metered_amount);
|
||||
const searchCount =
|
||||
assumptions.scanPerSearchMb > 0
|
||||
? fitWholeCount(scannedMb / assumptions.scanPerSearchMb).count
|
||||
: scannedMb > 0
|
||||
? DEFAULTS.searchCount
|
||||
: 0;
|
||||
const browser = fitWholeCount(
|
||||
perMonth(details.synthetics_browser?.metered_amount) / BROWSER_RUNS_PER_CHECK,
|
||||
);
|
||||
const api = fitWholeCount(
|
||||
perMonth(details.synthetics_protocol?.metered_amount) / API_RUNS_PER_CHECK,
|
||||
);
|
||||
return {
|
||||
...assumptions,
|
||||
ingestGb: round(perMonth(details.ingestion?.metered_amount) / MB_PER_GB),
|
||||
searchCount,
|
||||
scanPerSearchMb:
|
||||
searchCount > 0 ? significant(scannedMb / searchCount) : assumptions.scanPerSearchMb,
|
||||
pipelineCount:
|
||||
assumptions.avgStreamMb > 0
|
||||
? Math.round(pipelineMb / assumptions.avgStreamMb)
|
||||
: DEFAULTS.pipelineCount,
|
||||
retentionDays: DEFAULTS.retentionDays,
|
||||
aiCredits: Math.round(perMonth(details.ai?.metered_amount)),
|
||||
browserChecks: browser.count,
|
||||
browserSteps: DEFAULTS.browserSteps,
|
||||
browserRegions: DEFAULTS.browserRegions,
|
||||
browserDevices: DEFAULTS.browserDevices,
|
||||
browserIntervalSec: Math.round(DEFAULTS.browserIntervalSec / browser.scale),
|
||||
apiChecks: api.count,
|
||||
apiRegions: DEFAULTS.apiRegions,
|
||||
apiIntervalSec: Math.round(DEFAULTS.apiIntervalSec / api.scale),
|
||||
};
|
||||
});
|
||||
|
||||
const applyScenario = (key: ScenarioKey) => {
|
||||
// A preset fills every field on purpose, so nothing typed before it is protected any more.
|
||||
edited.clear();
|
||||
scenario.value = key;
|
||||
const base = currentScenario.value;
|
||||
const factor = key === "twice" ? 2 : key === "fiveTimes" ? 5 : 1;
|
||||
Object.assign(inputs, base);
|
||||
for (const field of GROWN_FIELDS) inputs[field] = round(base[field] * factor);
|
||||
};
|
||||
|
||||
const onScenarioChange = (value: unknown) => {
|
||||
if (typeof value === "string" && value) applyScenario(value as ScenarioKey);
|
||||
};
|
||||
|
||||
const estimate = computed(() => estimateCost(props.details, inputs));
|
||||
|
||||
const meterLabel = (key: MeterKey) => {
|
||||
const def = METERS.find((entry) => entry.key === key);
|
||||
return def ? t(def.labelKey) : raw(key);
|
||||
};
|
||||
|
||||
const scannedMb = computed(() => Number(inputs.searchCount) * Number(inputs.scanPerSearchMb));
|
||||
|
||||
const scannedText = computed(() => raw(formatSizeFromMB(scannedMb.value)));
|
||||
|
||||
/** Search is billed on the data it scans, so the size field states the month's total. */
|
||||
const scanNote = computed(() => t("billing.usageV2.scanNote", { volume: scannedText.value }));
|
||||
|
||||
const runCount = (runs: number) => raw(addCommasToNumber(Math.round(runs)));
|
||||
|
||||
/** What a check's count and shape add up to, under its boxes. */
|
||||
const runNotes = computed<Partial<Record<FieldKey, I18nText>>>(() => ({
|
||||
browserChecks: t("billing.usageV2.browserRunsNote", {
|
||||
runs: runCount(browserStepRuns(inputs)),
|
||||
}),
|
||||
apiChecks: t("billing.usageV2.apiRunsNote", { runs: runCount(apiCheckRuns(inputs)) }),
|
||||
}));
|
||||
|
||||
const quantityText = (line: EstimateLine): I18nText => {
|
||||
const volume = raw(formatSizeFromMB(line.quantity));
|
||||
switch (line.key) {
|
||||
case "query":
|
||||
return t("billing.usageV2.qtyScanned", { volume });
|
||||
case "pipeline":
|
||||
return t("billing.usageV2.qtyProcessed", { volume });
|
||||
case "retention":
|
||||
return t("billing.usageV2.qtyKeptFor", { volume, days: inputs.retentionDays });
|
||||
case "ai":
|
||||
return t("billing.usageV2.qtyCredits", { count: line.quantity });
|
||||
case "synthetics_browser":
|
||||
return t("billing.usageV2.qtyStepRuns", { count: runCount(line.quantity) });
|
||||
case "synthetics_protocol":
|
||||
return t("billing.usageV2.qtyRuns", { count: runCount(line.quantity) });
|
||||
default:
|
||||
return volume;
|
||||
}
|
||||
};
|
||||
|
||||
const sortedLines = computed(() =>
|
||||
[...estimate.value.lines]
|
||||
.map((line) => ({ ...line, label: meterLabel(line.key), quantityText: quantityText(line) }))
|
||||
.sort((a, b) => b.cost - a.cost),
|
||||
);
|
||||
|
||||
/** Bars compare against the biggest line, so the shape survives a small total. */
|
||||
const shareOf = (line: EstimateLine) => {
|
||||
const max = sortedLines.value[0]?.cost ?? 0;
|
||||
return max > 0 ? line.cost / max : 0;
|
||||
};
|
||||
|
||||
/** A meter with no price of its own reads as included rather than as free. */
|
||||
const fields = computed(() =>
|
||||
FIELDS.map((field) => {
|
||||
const def = field.meter ? METERS.find((entry) => entry.key === field.meter) : undefined;
|
||||
const meter = field.meter ? props.details?.[field.meter] : undefined;
|
||||
const title = field.titled && field.meter ? meterLabel(field.meter) : undefined;
|
||||
if (!def || !meter) return { ...field, title, rate: undefined, rateHint: undefined };
|
||||
const basis = t(def.billedAsKey);
|
||||
const priced = Number(meter.base_cost) > 0;
|
||||
const rate = priced ? formatRate(meter, def, t) : t("billing.usageV2.rateIncluded");
|
||||
const rateHint = priced
|
||||
? t("billing.usageV2.rateTooltip", { rate, basis })
|
||||
: t("billing.usageV2.rateIncludedTooltip", { basis });
|
||||
return { ...field, title, rate, rateHint };
|
||||
}),
|
||||
);
|
||||
|
||||
const { search } = useUsageSearch();
|
||||
|
||||
const runScalarQuery = async (sql: string, key: string) => {
|
||||
const hits = await search(sql, {
|
||||
start: (props.details?.cycle_start ?? 0) * 1_000_000,
|
||||
end: (props.details?.cycle_end ?? 0) * 1_000_000,
|
||||
size: 1,
|
||||
key,
|
||||
});
|
||||
return hits[0] ?? {};
|
||||
};
|
||||
|
||||
const loadingHistory = ref(false);
|
||||
|
||||
/** Defaults come from history; a failed query just leaves the field at zero for the user to fill. */
|
||||
const loadDefaults = async () => {
|
||||
if (!props.details) return;
|
||||
loadingHistory.value = true;
|
||||
// A super org's bill covers its whole group, so the assumptions that turn it into counts do too.
|
||||
const orgId = props.billingOrgs.length ? null : store.state.selectedOrganization.identifier;
|
||||
try {
|
||||
const [streams, scan] = await Promise.all([
|
||||
runScalarQuery(averageStreamSizeSql(orgId), "streams"),
|
||||
runScalarQuery(searchScanSizeSql(orgId), "scan"),
|
||||
]);
|
||||
const streamCount = Number(streams.streams) || 0;
|
||||
if (!edited.has("avgStreamMb")) {
|
||||
inputs.avgStreamMb = streamCount > 0 ? round(Number(streams.total) / streamCount) : 0;
|
||||
}
|
||||
// A search is priced by the data it scans; how long it ran does not enter the bill.
|
||||
const searches = Number(scan.searches) || 0;
|
||||
if (!edited.has("scanPerSearchMb")) {
|
||||
inputs.scanPerSearchMb = searches > 0 ? significant(Number(scan.total) / searches) : 0;
|
||||
}
|
||||
} catch {
|
||||
inputs.avgStreamMb = inputs.avgStreamMb || 0;
|
||||
inputs.scanPerSearchMb = inputs.scanPerSearchMb || 0;
|
||||
}
|
||||
loadingHistory.value = false;
|
||||
// The answers land a moment after the tab opens; a preset picked or a value typed by then stays.
|
||||
if (!edited.size) applyScenario(scenario.value);
|
||||
};
|
||||
|
||||
onMounted(loadDefaults);
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!-- One usage figure in the same card shape the LLM Insights KPI row uses. -->
|
||||
<template>
|
||||
<div
|
||||
class="bg-card-glass-bg rounded-default border-border-default flex flex-col gap-1 border px-3.5 py-2.5 max-lg:shrink-0 max-lg:basis-auto max-lg:px-1.5 max-lg:py-1"
|
||||
:title="lgUp ? undefined : label"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between gap-2 max-lg:mb-0">
|
||||
<OTruncatedText
|
||||
as="div"
|
||||
class="text-2xs text-text-secondary leading-normal font-semibold max-lg:hidden"
|
||||
>
|
||||
{{ label }}
|
||||
</OTruncatedText>
|
||||
<span
|
||||
v-if="icon"
|
||||
class="rounded-default bg-surface-subtle text-text-secondary inline-flex h-6 w-6 shrink-0 items-center justify-center"
|
||||
>
|
||||
<OIcon :name="icon" size="sm" />
|
||||
</span>
|
||||
</div>
|
||||
<!-- Loading keeps the card's shape, so nothing moves when the figures land. -->
|
||||
<template v-if="loading">
|
||||
<OSkeleton type="text" class="h-6 w-20" data-test="usage-kpi-card-loading" />
|
||||
<OSkeleton type="text" class="h-3 w-28 max-lg:hidden" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="truncate text-2xl leading-none font-semibold max-lg:text-lg"
|
||||
:class="
|
||||
tone === 'danger'
|
||||
? 'text-status-error-text'
|
||||
: tone === 'warn'
|
||||
? 'text-status-warning-text'
|
||||
: positive
|
||||
? 'text-status-success-text'
|
||||
: 'text-text-heading'
|
||||
"
|
||||
>
|
||||
{{ value }}
|
||||
<span v-if="valueSuffix" class="text-text-secondary text-xs font-medium">
|
||||
{{ valueSuffix }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hint && !loading" class="text-2xs text-text-secondary truncate max-lg:hidden">
|
||||
{{ hint }}
|
||||
<span v-if="struck" class="line-through">{{ struck }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
|
||||
defineProps<{
|
||||
label: I18nText;
|
||||
icon?: IconName;
|
||||
value: I18nText;
|
||||
hint?: I18nText;
|
||||
/** A figure the value replaced, such as the pre-discount amount, shown struck through. */
|
||||
struck?: I18nText;
|
||||
/** A value that is good news for the reader, such as a saving. */
|
||||
positive?: boolean;
|
||||
/** A small figure beside the value, such as the percentage a saving represents. */
|
||||
valueSuffix?: I18nText;
|
||||
/** Bad news for the reader, such as spend near or past a budget. Wins over `positive`. */
|
||||
tone?: "warn" | "danger";
|
||||
/** The figures are still being fetched; value and hint show as skeleton bars. */
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const { lgUp } = useBreakpoint();
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, h, KeepAlive } from "vue";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { mockMeteringResponse } from "./meteringMock";
|
||||
|
||||
let releaseSchema!: (value: unknown) => void;
|
||||
vi.mock("@/composables/query/queryClient", () => ({
|
||||
queryClient: {
|
||||
invalidateQueries: vi.fn(),
|
||||
fetchQuery: vi.fn(() => new Promise((resolve) => (releaseSchema = resolve))),
|
||||
},
|
||||
}));
|
||||
vi.mock("./useUsageSearch", () => ({
|
||||
useUsageSearch: () => ({ search: vi.fn().mockResolvedValue([]), cancel: vi.fn() }),
|
||||
}));
|
||||
|
||||
const { default: UsageMeterBreakdown } = await import("./UsageMeterBreakdown.vue");
|
||||
const { queryClient } = await import("@/composables/query/queryClient");
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
describe("UsageMeterBreakdown group-by sections", () => {
|
||||
it("sends every grouping once the schema is read, marking the unrecorded ones", async () => {
|
||||
const wrapper = mount(UsageMeterBreakdown, {
|
||||
props: { meterKey: "query", details: mockMeteringResponse().upcoming!, refreshToken: 0 },
|
||||
global: { plugins: [i18n, store], stubs: { OTable: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
// Before the schema lands nothing is sent, so the control never shows a list it then greys out.
|
||||
expect(wrapper.emitted("sections")).toBeUndefined();
|
||||
|
||||
// This org's usage has streams and alerts, but no user, dashboard or scheduled pipeline yet.
|
||||
releaseSchema({
|
||||
schema: ["stream_name", "alert_name", "event", "size"].map((name) => ({ name })),
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const [payload] = wrapper.emitted("sections")!.at(-1) as any[];
|
||||
expect(payload.sections.map((item: { id: string }) => item.id)).toContain("dashboard");
|
||||
expect(payload.unavailable).toEqual(expect.arrayContaining(["user", "dashboard", "scheduled"]));
|
||||
expect(payload.unavailable).not.toContain("alert");
|
||||
expect(payload.activeId).not.toBe("dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageMeterBreakdown kept alive", () => {
|
||||
const details = mockMeteringResponse().upcoming!;
|
||||
const schema = {
|
||||
schema: ["stream_name", "alert_name", "event", "size"].map((name) => ({ name })),
|
||||
};
|
||||
|
||||
// The page credits every report to the meter on screen, as the real Overview does.
|
||||
const heard: string[] = [];
|
||||
const Page = defineComponent({
|
||||
props: { meter: { type: String, required: true } },
|
||||
setup: (props) => () =>
|
||||
h(KeepAlive, null, [
|
||||
h(UsageMeterBreakdown, {
|
||||
key: props.meter,
|
||||
meterKey: props.meter as any,
|
||||
details,
|
||||
refreshToken: 0,
|
||||
onSections: () => heard.push(`sections for ${props.meter}`),
|
||||
"onTop-names": () => heard.push(`top-names for ${props.meter}`),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
it("stays quiet while hidden, then reports its results when shown again", async () => {
|
||||
const page = mount(Page, {
|
||||
props: { meter: "query" },
|
||||
global: { plugins: [i18n, store], stubs: { OTable: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
const releaseSearchSchema = releaseSchema;
|
||||
|
||||
// The user moves to Ingestion before Search has read its schema.
|
||||
await page.setProps({ meter: "ingestion" });
|
||||
await flushPromises();
|
||||
releaseSearchSchema(schema);
|
||||
await flushPromises();
|
||||
expect(heard).toEqual([]);
|
||||
|
||||
// Back on Search, its groupings and top names arrive for the right meter.
|
||||
await page.setProps({ meter: "query" });
|
||||
await flushPromises();
|
||||
expect(heard).toEqual(["sections for query", "top-names for query"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageMeterBreakdown for an org with no bill", () => {
|
||||
it("lists volume and share only, with no cost column", async () => {
|
||||
const wrapper = mount(UsageMeterBreakdown, {
|
||||
props: {
|
||||
meterKey: "ingestion",
|
||||
details: mockMeteringResponse().upcoming!,
|
||||
refreshToken: 0,
|
||||
volumeOnly: true,
|
||||
},
|
||||
global: { plugins: [i18n, store], stubs: { OTable: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const columns = wrapper.findComponent({ name: "OTable" }).props("columns") as { id: string }[];
|
||||
expect(columns.map((column) => column.id)).toEqual(["name", "volume", "share"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageMeterBreakdown inside a member org", () => {
|
||||
// In cloud a 401 signs the session out, and a super org admin need not belong to its members.
|
||||
const listKeys = () =>
|
||||
(queryClient.fetchQuery as ReturnType<typeof vi.fn>).mock.calls.map((call) =>
|
||||
JSON.stringify((call[0] as { queryKey: unknown }).queryKey),
|
||||
);
|
||||
|
||||
const openPipelines = async (memberIsMine: boolean) => {
|
||||
(queryClient.fetchQuery as ReturnType<typeof vi.fn>).mockClear();
|
||||
store.state.organizations = memberIsMine
|
||||
? [{ identifier: "member-org", name: "Member" }]
|
||||
: [{ identifier: store.state.selectedOrganization.identifier, name: "Payer" }];
|
||||
mount(UsageMeterBreakdown, {
|
||||
props: {
|
||||
meterKey: "pipeline",
|
||||
details: mockMeteringResponse().upcoming!,
|
||||
refreshToken: 0,
|
||||
scopeOrgId: "member-org",
|
||||
},
|
||||
global: { plugins: [i18n, store], stubs: { OTable: true } },
|
||||
});
|
||||
releaseSchema({ schema: ["stream_name", "event", "size"].map((name) => ({ name })) });
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
it("does not ask a member org the admin does not belong to for its pipeline names", async () => {
|
||||
await openPipelines(false);
|
||||
expect(listKeys().some((key) => key.includes("member-org"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still names the pipelines of a member org the admin belongs to", async () => {
|
||||
await openPipelines(true);
|
||||
expect(listKeys().some((key) => key.includes("member-org"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
:key="`${meterKey}-${activeSection?.id ?? ''}`"
|
||||
class="animate-in fade-in flex h-full min-h-0 flex-col"
|
||||
data-test="billings-usagemeterbreakdown-root"
|
||||
>
|
||||
<OEmptyState
|
||||
v-if="streamMissing"
|
||||
size="inline"
|
||||
:title="t('billing.usageTrends.waitingTitle')"
|
||||
:description="t('billing.usageTrends.waitingForData')"
|
||||
data-test="billings-usagemeterbreakdown-stream-missing"
|
||||
/>
|
||||
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<OTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
row-key="name"
|
||||
pagination="none"
|
||||
sorting="none"
|
||||
:show-global-filter="false"
|
||||
class="min-h-0 flex-1"
|
||||
:loading="loading && !rows.length"
|
||||
:streaming="loading && rows.length > 0"
|
||||
:error="error"
|
||||
show-index
|
||||
:default-columns="false"
|
||||
:column-visibility="columnVisibility"
|
||||
data-test="billings-usagemeterbreakdown-table"
|
||||
>
|
||||
<template #cell-share="{ row }">
|
||||
<div
|
||||
class="grid w-full min-w-32 grid-cols-[minmax(2.5rem,1fr)_2.75rem] items-center gap-2.5"
|
||||
>
|
||||
<OProgressBar :value="row.share / 100" :color="meterColorFor(meterKey)" />
|
||||
<span>{{ formatShare(row.share) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- In #pagination-bar rather than a column footer: a footer only reaches the pane's edge while rows overflow. -->
|
||||
<template v-if="rows.length" #pagination-bar>
|
||||
<div
|
||||
class="bg-table-header-bg border-table-header-border text-text-body flex flex-col gap-1 border-t py-2 text-xs"
|
||||
data-test="billings-usagemeterbreakdown-total"
|
||||
>
|
||||
<div
|
||||
v-if="rest > 0"
|
||||
class="text-text-secondary grid italic"
|
||||
:style="{ gridTemplateColumns: gridTemplate }"
|
||||
>
|
||||
<span />
|
||||
<span class="px-2">{{ t("billing.usageV2.everythingElse") }}</span>
|
||||
<span v-if="!isMobile" class="px-2">{{ formatQuantity(rest) }}</span>
|
||||
<span v-if="!volumeOnly" class="px-2">{{
|
||||
isRetention ? EM_DASH : formatCost(rest * rate)
|
||||
}}</span>
|
||||
<span class="grid grid-cols-[minmax(2.5rem,1fr)_2.75rem] items-center gap-2.5 px-2">
|
||||
<OProgressBar :value="restShare / 100" />
|
||||
<span>{{ formatShare(restShare) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid font-semibold" :style="{ gridTemplateColumns: gridTemplate }">
|
||||
<span />
|
||||
<span class="px-2">{{ t("billing.usageV2.colTotal") }}</span>
|
||||
<span v-if="!isMobile" class="px-2">{{ formatQuantity(total) }}</span>
|
||||
<span v-if="!volumeOnly" class="px-2">{{
|
||||
isRetention ? EM_DASH : formatCost(total * rate)
|
||||
}}</span>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #error>
|
||||
<div class="flex flex-col items-center gap-2 p-4">
|
||||
<span class="text-text-secondary text-sm">
|
||||
{{ t("billing.usageV2.drilldownFailed") }}
|
||||
</span>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
data-test="billings-usagemeterbreakdown-retry"
|
||||
@click="retry"
|
||||
>
|
||||
{{ t("billing.usageV2.retry") }}
|
||||
</OButton>
|
||||
</div>
|
||||
</template>
|
||||
</OTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onDeactivated, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { pipelinesQuery } from "@/services/pipelines.queries";
|
||||
import { syntheticsMonitorsQuery } from "@/services/synthetics.queries";
|
||||
import { streamNameListQuery, streamSchemaQuery } from "@/services/stream.queries";
|
||||
import { formatSizeFromMB } from "@/utils/formatters";
|
||||
import {
|
||||
effectiveRate,
|
||||
formatCost,
|
||||
METERS,
|
||||
type MeterKey,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
import {
|
||||
availableSections,
|
||||
DRILLDOWNS,
|
||||
DRILLDOWN_LIMIT,
|
||||
meterTotalSql,
|
||||
USAGE_STREAM_NAME,
|
||||
type DrilldownSection,
|
||||
} from "./usageQueries";
|
||||
import { CHART_TOP, meterColorFor } from "./usageCharts";
|
||||
import { useUsageSearch } from "./useUsageSearch";
|
||||
import { totalsGridTemplate } from "./totalsBar";
|
||||
|
||||
interface BreakdownRow {
|
||||
name: string;
|
||||
volume: number;
|
||||
cost: number;
|
||||
share: number;
|
||||
/** Days; 0 means the stream follows the org default. Retention rows only. */
|
||||
retentionDays?: number;
|
||||
}
|
||||
|
||||
interface RawRow {
|
||||
name: string;
|
||||
volume: number;
|
||||
retentionDays?: number;
|
||||
}
|
||||
|
||||
const RETENTION_STREAM_TYPES = ["logs", "metrics", "traces"];
|
||||
|
||||
const props = defineProps<{
|
||||
meterKey: MeterKey;
|
||||
details: MeteringDetails;
|
||||
group?: string;
|
||||
/** Bumped by the page's Refresh, which must reach the server for every read here. */
|
||||
refreshToken: number;
|
||||
scopeOrgId?: string;
|
||||
/** Price per unit to use instead of the bill's, so a member's rows add up to its share. */
|
||||
rate?: number;
|
||||
/** An org with no bill: volume and share only, with no cost column. */
|
||||
volumeOnly?: boolean;
|
||||
}>();
|
||||
/** Every grouping the meter offers; `unavailable` lists those this org has never recorded. */
|
||||
type SectionsPayload = {
|
||||
sections: DrilldownSection[];
|
||||
unavailable: string[];
|
||||
activeId: string | undefined;
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:group": [group: string];
|
||||
"top-names": [payload: { section: DrilldownSection | null; names: string[] }];
|
||||
/** The page renders the group-by control in the pane header, so the table starts at the same place in both views. */
|
||||
sections: [payload: SectionsPayload];
|
||||
}>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const orgId = useOrgId();
|
||||
const store = useStore();
|
||||
/** The org whose usage this breakdown counts. */
|
||||
const usageOrgId = computed(() => props.scopeOrgId ?? orgId.value);
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
const rows = ref<BreakdownRow[]>([]);
|
||||
/** Volume past the top rows; it lives in the footer so rank numbers cover real entities only. */
|
||||
const rest = ref(0);
|
||||
const total = ref(0);
|
||||
/** Starts true: the schema read runs before the first query, and an idle empty table would flash "no data". */
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const streamMissing = ref(false);
|
||||
const schemaFields = ref<Set<string>>(new Set());
|
||||
/** Set once the schema read settles, so the group-by control never shows a list it then greys out. */
|
||||
const schemaRead = ref(false);
|
||||
|
||||
const isRetention = computed(() => props.meterKey === "retention");
|
||||
|
||||
const meterDef = computed(() => METERS.find((entry) => entry.key === props.meterKey));
|
||||
|
||||
const meter = computed(() => props.details[props.meterKey]);
|
||||
|
||||
const meterLabel = computed(() =>
|
||||
meterDef.value ? t(meterDef.value.labelKey) : raw(props.meterKey),
|
||||
);
|
||||
|
||||
const allSections = computed<DrilldownSection[]>(() => DRILLDOWNS[props.meterKey] ?? []);
|
||||
|
||||
/** The groupings that can be queried: a field the stream never recorded would fail the SQL. */
|
||||
const sections = computed<DrilldownSection[]>(() =>
|
||||
availableSections(allSections.value, schemaFields.value),
|
||||
);
|
||||
|
||||
let shown = true;
|
||||
|
||||
/** All groupings every time, so the control does not change shape; the ones with no data are disabled. */
|
||||
const emitSections = () => {
|
||||
// Nothing until the schema is read: the chart starts its query from these, and the control must not change shape.
|
||||
if (!shown || (!isRetention.value && !schemaRead.value)) return;
|
||||
const available = new Set(sections.value.map((item) => item.id));
|
||||
emit("sections", {
|
||||
sections: allSections.value,
|
||||
unavailable: allSections.value.filter((item) => !available.has(item.id)).map((item) => item.id),
|
||||
activeId: activeSection.value?.id,
|
||||
});
|
||||
};
|
||||
|
||||
const activeSection = computed(
|
||||
() => sections.value.find((item) => item.id === props.group) ?? sections.value[0],
|
||||
);
|
||||
|
||||
/** AI credit rows count credits; every other section counts megabytes. */
|
||||
const isCount = computed(() => activeSection.value?.unit === "count");
|
||||
|
||||
const formatQuantity = (value: number): I18nText =>
|
||||
isCount.value ? raw(String(Math.round(value))) : raw(formatSizeFromMB(value));
|
||||
|
||||
const groupLabel = computed(() => t(activeSection.value?.columnKey ?? "billing.usageV2.colStream"));
|
||||
|
||||
function formatShare(value: number): I18nText {
|
||||
return raw(`${value.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const formatRetention = (days: number | undefined) =>
|
||||
days
|
||||
? t("billing.usageV2.retentionDaysValue", { days })
|
||||
: t("billing.usageV2.orgDefaultRetention");
|
||||
|
||||
/** Volume is the first column a phone drops; share still carries the ranking. */
|
||||
const columnVisibility = computed(() => ({ volume: !isMobile.value }));
|
||||
|
||||
const restShare = computed(() => (total.value > 0 ? (rest.value / total.value) * 100 : 0));
|
||||
|
||||
const rate = computed(() => props.rate ?? effectiveRate(meter.value));
|
||||
|
||||
const EM_DASH = raw("—");
|
||||
|
||||
const columns = computed<OTableColumnDef<BreakdownRow>[]>(() => {
|
||||
const name: OTableColumnDef<BreakdownRow> = {
|
||||
id: "name",
|
||||
header: isRetention.value ? t("billing.usageV2.colStream") : groupLabel.value,
|
||||
accessorKey: "name",
|
||||
meta: { isName: true, autoWidth: true },
|
||||
minSize: 160,
|
||||
};
|
||||
const volume: OTableColumnDef<BreakdownRow> = {
|
||||
id: "volume",
|
||||
header: isRetention.value
|
||||
? t("billing.usageV2.colCurrentSize")
|
||||
: isCount.value && activeSection.value?.quantityKey
|
||||
? t(activeSection.value.quantityKey)
|
||||
: t("billing.usageV2.colVolume"),
|
||||
accessorFn: (row: BreakdownRow) => formatQuantity(row.volume),
|
||||
size: 104,
|
||||
};
|
||||
const share: OTableColumnDef<BreakdownRow> = {
|
||||
id: "share",
|
||||
header: isRetention.value
|
||||
? t("billing.usageV2.colStorageShare")
|
||||
: t("billing.usageV2.shareOfMeter", { meter: meterLabel.value }),
|
||||
accessorKey: "share",
|
||||
size: 160,
|
||||
};
|
||||
const third: OTableColumnDef<BreakdownRow> = isRetention.value
|
||||
? {
|
||||
id: "retentionDays",
|
||||
header: t("billing.usageV2.colRetentionPeriod"),
|
||||
accessorFn: (row: BreakdownRow) => formatRetention(row.retentionDays),
|
||||
size: 136,
|
||||
}
|
||||
: {
|
||||
id: "cost",
|
||||
header: t("billing.usageV2.colEstCost"),
|
||||
accessorFn: (row: BreakdownRow) => formatCost(row.cost),
|
||||
size: 88,
|
||||
};
|
||||
return props.volumeOnly && !isRetention.value
|
||||
? [name, volume, share]
|
||||
: [name, volume, third, share];
|
||||
});
|
||||
|
||||
/** OTable drops the index column on a phone, so the bar drops it too. */
|
||||
const gridTemplate = computed(() =>
|
||||
totalsGridTemplate(columns.value, columnVisibility.value, !isMobile.value),
|
||||
);
|
||||
|
||||
const toRows = (raws: RawRow[], sum: number): BreakdownRow[] => {
|
||||
const shown = raws.reduce((acc, entry) => acc + entry.volume, 0);
|
||||
const remainder = Math.max(sum - shown, 0);
|
||||
rest.value = sum > 0 && remainder / sum > 0.0005 ? remainder : 0;
|
||||
return raws.map((entry) => ({
|
||||
name: entry.name,
|
||||
volume: entry.volume,
|
||||
retentionDays: entry.retentionDays,
|
||||
cost: entry.volume * rate.value,
|
||||
share: sum > 0 ? Math.min((entry.volume / sum) * 100, 100) : 0,
|
||||
}));
|
||||
};
|
||||
|
||||
/** Forces a cached read back to the server, as the refresh rule requires. */
|
||||
const cachedRead = async <T,>(options: { queryKey: readonly unknown[] }, force: boolean) => {
|
||||
if (force) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
return queryClient.fetchQuery(options as never) as Promise<T>;
|
||||
};
|
||||
|
||||
const { search } = useUsageSearch();
|
||||
|
||||
/** Keyed, so switching meter or group stops the superseded query on the server. */
|
||||
const runSearch = (
|
||||
sql: string,
|
||||
size: number,
|
||||
key: string,
|
||||
onRows?: (rows: Record<string, unknown>[]) => void,
|
||||
) =>
|
||||
search(sql, {
|
||||
start: props.details.cycle_start * 1_000_000,
|
||||
end: props.details.cycle_end * 1_000_000,
|
||||
size,
|
||||
key,
|
||||
onRows,
|
||||
});
|
||||
|
||||
const loadRetention = async (force: boolean) => {
|
||||
const lists = await Promise.all(
|
||||
RETENTION_STREAM_TYPES.map((type) =>
|
||||
cachedRead<any[]>(streamNameListQuery(orgId.value, type), force).catch(() => []),
|
||||
),
|
||||
);
|
||||
const all = lists.flat().map((stream: any) => ({
|
||||
name: String(stream?.name ?? ""),
|
||||
volume: Number(stream?.stats?.storage_size) || 0,
|
||||
retentionDays: Number(stream?.settings?.data_retention) || 0,
|
||||
}));
|
||||
const sum = all.reduce((acc, stream) => acc + stream.volume, 0);
|
||||
return { raws: all.sort((a, b) => b.volume - a.volume).slice(0, DRILLDOWN_LIMIT), sum };
|
||||
};
|
||||
|
||||
type Labeler = (raws: RawRow[]) => RawRow[];
|
||||
|
||||
const keepNames: Labeler = (raws) => raws;
|
||||
|
||||
const canReadNames = computed(
|
||||
() =>
|
||||
usageOrgId.value === orgId.value ||
|
||||
(store.state.organizations ?? []).some((org: any) => org.identifier === usageOrgId.value),
|
||||
);
|
||||
|
||||
/** Pipeline and synthetics usage is recorded by id; a deleted entity keeps its id with a suffix. */
|
||||
const nameLabeler = async (force: boolean): Promise<Labeler> => {
|
||||
const source =
|
||||
props.meterKey === "pipeline"
|
||||
? {
|
||||
options: pipelinesQuery(usageOrgId.value),
|
||||
idOf: (pipeline: any) => String(pipeline?.pipeline_id ?? pipeline?.id),
|
||||
missingKey: "billing.usageV2.unknownPipeline" as I18nKey,
|
||||
}
|
||||
: props.meterKey === "synthetics_browser" || props.meterKey === "synthetics_protocol"
|
||||
? {
|
||||
options: syntheticsMonitorsQuery(usageOrgId.value),
|
||||
idOf: (check: any) => String(check?.id),
|
||||
missingKey: "billing.usageV2.unknownCheck" as I18nKey,
|
||||
}
|
||||
: null;
|
||||
if (!source || !canReadNames.value) return keepNames;
|
||||
try {
|
||||
const list = await cachedRead<any[]>(source.options, force);
|
||||
const names = new Map<string, string>(
|
||||
list.map((item: any) => [source.idOf(item), String(item?.name ?? "")]),
|
||||
);
|
||||
return (raws) =>
|
||||
raws.map((entry) => ({
|
||||
...entry,
|
||||
name: names.get(entry.name) || `${entry.name} (${t(source.missingKey)})`,
|
||||
}));
|
||||
} catch {
|
||||
return keepNames;
|
||||
}
|
||||
};
|
||||
|
||||
const toRaws = (hits: Record<string, unknown>[]): RawRow[] =>
|
||||
hits.map((hit) => ({ name: String(hit.name ?? ""), volume: Number(hit.volume) || 0 }));
|
||||
|
||||
/** Both queries stream running aggregates; `onPartial` gets the latest of each as they land. */
|
||||
const loadSection = async (
|
||||
spec: DrilldownSection,
|
||||
onPartial: (raws: RawRow[], sum: number) => void,
|
||||
) => {
|
||||
let partialRaws: RawRow[] = [];
|
||||
let partialSum = 0;
|
||||
const [hits, totals] = await Promise.all([
|
||||
runSearch(spec.sql(usageOrgId.value), DRILLDOWN_LIMIT, "rows", (rows) => {
|
||||
partialRaws = toRaws(rows);
|
||||
onPartial(partialRaws, partialSum);
|
||||
}),
|
||||
runSearch(meterTotalSql(usageOrgId.value, [spec.event]), 1, "total", (rows) => {
|
||||
partialSum = Number(rows[0]?.total) || 0;
|
||||
onPartial(partialRaws, partialSum);
|
||||
}),
|
||||
]);
|
||||
return { raws: toRaws(hits), sum: Number(totals[0]?.total) || 0 };
|
||||
};
|
||||
|
||||
const isStreamMissing = (e: any) => {
|
||||
const message = String(e?.response?.data?.message ?? e?.message ?? "").toLowerCase();
|
||||
return message.includes("stream not found") && message.includes(USAGE_STREAM_NAME);
|
||||
};
|
||||
|
||||
type TopNames = { section: DrilldownSection | null; names: string[] };
|
||||
|
||||
/** The last names sent up, re-sent when a kept-alive breakdown is shown again so the chart can find its view. */
|
||||
let lastTopNames: TopNames | null = null;
|
||||
|
||||
const emitTopNames = (payload: TopNames) => {
|
||||
lastTopNames = payload;
|
||||
if (shown) emit("top-names", payload);
|
||||
};
|
||||
|
||||
onDeactivated(() => {
|
||||
shown = false;
|
||||
});
|
||||
onActivated(() => {
|
||||
shown = true;
|
||||
emitSections();
|
||||
if (lastTopNames) emit("top-names", lastTopNames);
|
||||
});
|
||||
|
||||
/** A counter drops a slow earlier response that lands after the user switched meters. */
|
||||
let latestLoad = 0;
|
||||
|
||||
const load = async (force = false) => {
|
||||
const loadId = ++latestLoad;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
streamMissing.value = false;
|
||||
// Cleared, so streamed rows never sit under another group's header.
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
rest.value = 0;
|
||||
// Resolved alongside the queries, so a partial render never shows a raw id.
|
||||
let labeler: Labeler | null = null;
|
||||
const labelerReady = nameLabeler(force).then((resolved) => (labeler = resolved));
|
||||
const showPartial = (raws: RawRow[], sum: number) => {
|
||||
if (loadId !== latestLoad || !labeler) return;
|
||||
total.value = sum;
|
||||
rows.value = toRows(labeler(raws), sum);
|
||||
};
|
||||
try {
|
||||
const result = isRetention.value
|
||||
? await loadRetention(force)
|
||||
: activeSection.value
|
||||
? await loadSection(activeSection.value, showPartial)
|
||||
: { raws: [], sum: 0 };
|
||||
if (loadId !== latestLoad) return;
|
||||
// Final only: the chart colours its bands by this ranking, and a partial one would reshuffle them.
|
||||
emitTopNames({
|
||||
section: isRetention.value ? null : (activeSection.value ?? null),
|
||||
names: result.raws.slice(0, CHART_TOP).map((entry) => entry.name),
|
||||
});
|
||||
const label = await labelerReady;
|
||||
if (loadId !== latestLoad) return;
|
||||
total.value = result.sum;
|
||||
rows.value = toRows(label(result.raws), result.sum);
|
||||
} catch (e: any) {
|
||||
if (loadId !== latestLoad) return;
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
rest.value = 0;
|
||||
emitTopNames({ section: null, names: [] });
|
||||
if (isStreamMissing(e)) streamMissing.value = true;
|
||||
else error.value = String(e?.message ?? t("billing.usageV2.drilldownFailed"));
|
||||
} finally {
|
||||
if (loadId === latestLoad) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** An empty set keeps every section, so a failed schema read degrades to trying the queries. */
|
||||
const loadSchema = async (force: boolean) => {
|
||||
try {
|
||||
const schema = await cachedRead<any>(
|
||||
streamSchemaQuery(orgId.value, USAGE_STREAM_NAME, "logs"),
|
||||
force,
|
||||
);
|
||||
schemaFields.value = new Set(
|
||||
(schema?.schema ?? []).map((field: { name: string }) => field.name),
|
||||
);
|
||||
} catch {
|
||||
schemaFields.value = new Set();
|
||||
} finally {
|
||||
schemaRead.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const retry = () => load(true);
|
||||
|
||||
watch(
|
||||
() => props.meterKey,
|
||||
async () => {
|
||||
// Drop the previous meter's rows, or they read as this meter's until the query lands.
|
||||
loading.value = true;
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
rest.value = 0;
|
||||
if (!isRetention.value && !schemaFields.value.size) await loadSchema(false);
|
||||
load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch([sections, () => activeSection.value?.id, schemaRead], emitSections, { immediate: true });
|
||||
|
||||
watch(
|
||||
() => activeSection.value?.id,
|
||||
(value, previous) => {
|
||||
if (value && previous && value !== previous) load();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.refreshToken,
|
||||
async () => {
|
||||
loading.value = true;
|
||||
if (!isRetention.value) await loadSchema(true);
|
||||
load(true);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { raw } from "@/types/i18n";
|
||||
import UsageMeterTable from "./UsageMeterTable.vue";
|
||||
|
||||
describe("UsageMeterTable", () => {
|
||||
it("renders the totals row under the meters", () => {
|
||||
const row = {
|
||||
key: "ingestion",
|
||||
label: raw("Ingestion"),
|
||||
volume: raw("1 GB"),
|
||||
rate: raw("$0.50 / GB"),
|
||||
cost: 9,
|
||||
gross: 10,
|
||||
discount: 1,
|
||||
missing: false,
|
||||
discountPercent: 10,
|
||||
drillable: true,
|
||||
hasBreakdown: true,
|
||||
};
|
||||
const wrapper = mount(UsageMeterTable, {
|
||||
props: { rows: [row], total: 9, gross: 10, discount: 1 },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
|
||||
// OTable drops a slot it no longer has without a sound, so check the row is really drawn.
|
||||
const totals = wrapper.find('[data-test="billings-usagemetertable-total"]');
|
||||
expect(totals.exists()).toBe(true);
|
||||
expect(totals.text()).toContain("$9.00");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("shows only each meter's volume for an org with no bill, with no totals across units", () => {
|
||||
const row = {
|
||||
key: "ingestion",
|
||||
label: raw("Ingestion"),
|
||||
volume: raw("2.00 GB"),
|
||||
rate: raw("$0.00 / GB"),
|
||||
cost: 0,
|
||||
gross: 0,
|
||||
discount: 0,
|
||||
missing: false,
|
||||
discountPercent: 0,
|
||||
drillable: true,
|
||||
hasBreakdown: true,
|
||||
};
|
||||
const wrapper = mount(UsageMeterTable, {
|
||||
props: { rows: [row], total: 0, gross: 0, discount: 0, volumeOnly: true },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("2.00 GB");
|
||||
expect(wrapper.text()).not.toContain("$");
|
||||
expect(wrapper.find('[data-test="billings-usagemetertable-total"]').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!-- Every meter's gross, discount and billed cost; a row opens that meter's breakdown. -->
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col" data-test="billings-usagemetertable-root">
|
||||
<OTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
row-key="key"
|
||||
pagination="none"
|
||||
sorting="none"
|
||||
:show-global-filter="false"
|
||||
show-index
|
||||
:default-columns="false"
|
||||
class="min-h-0 flex-1"
|
||||
:row-class="rowClass"
|
||||
:loading="loading"
|
||||
:streaming="streaming"
|
||||
data-test="billings-usagemetertable-table"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #cell-label="{ row }">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
<span
|
||||
class="rounded-default h-2.5 w-2.5 shrink-0"
|
||||
:style="{ backgroundColor: colorOf(row.key) }"
|
||||
/>
|
||||
<span class="truncate">{{ row.label }}</span>
|
||||
</span>
|
||||
<span v-if="!volumeOnly" class="text-text-secondary ps-4.5 text-xs">
|
||||
{{
|
||||
row.subline ??
|
||||
t("billing.usageV2.volumeAtRate", { volume: row.volume, rate: row.rate })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-discount="{ row }">
|
||||
<span v-if="row.discount > 0">
|
||||
<span class="text-status-success-text font-semibold">{{
|
||||
formatCost(-row.discount)
|
||||
}}</span>
|
||||
<span class="text-text-secondary ms-1">
|
||||
{{ t("billing.usageV2.percentInParens", { percent: row.discountPercent }) }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="text-text-secondary">{{ EM_DASH }}</span>
|
||||
</template>
|
||||
<template #cell-billed="{ row }">
|
||||
<span class="font-semibold">{{ row.missing ? EM_DASH : formatCost(row.cost) }}</span>
|
||||
</template>
|
||||
<template #cell-share="{ row }">
|
||||
<div
|
||||
class="grid w-full min-w-28 grid-cols-[minmax(2.5rem,1fr)_2.75rem] items-center gap-2.5"
|
||||
>
|
||||
<OProgressBar :value="shareOf(row) / 100" :color="colorOf(row.key)" />
|
||||
<span>{{ percentText(shareOf(row)) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-open="{ row }">
|
||||
<OIcon v-if="row.drillable" name="chevron-right" size="sm" class="text-text-secondary" />
|
||||
<!-- Disabled, not hidden, so an empty meter still reads as one that has a breakdown. -->
|
||||
<span
|
||||
v-else-if="row.hasBreakdown"
|
||||
class="text-text-disabled inline-flex cursor-not-allowed"
|
||||
tabindex="0"
|
||||
:aria-label="t('billing.usageV2.noBreakdownYet')"
|
||||
:data-test="`billings-usagemetertable-open-disabled-${row.key}`"
|
||||
>
|
||||
<OIcon name="chevron-right" size="sm" />
|
||||
<OTooltip :content="t('billing.usageV2.noBreakdownYet')" />
|
||||
</span>
|
||||
</template>
|
||||
<!-- In #pagination-bar rather than a column footer: a footer only reaches the pane's edge while rows overflow. -->
|
||||
<template v-if="!volumeOnly" #pagination-bar>
|
||||
<div
|
||||
class="bg-table-header-bg border-table-header-border text-text-body grid border-t py-2 text-xs font-semibold"
|
||||
:style="{ gridTemplateColumns: gridTemplate }"
|
||||
data-test="billings-usagemetertable-total"
|
||||
>
|
||||
<span />
|
||||
<span class="px-2">{{ t("billing.usageV2.colTotal") }}</span>
|
||||
<span class="px-2">{{ formatCost(loading ? null : gross) }}</span>
|
||||
<span v-if="discount > 0 && !loading" class="text-status-success-text px-2">
|
||||
{{ formatCost(-discount) }}
|
||||
</span>
|
||||
<span class="px-2">{{ formatCost(loading ? null : total) }}</span>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</template>
|
||||
</OTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import { formatCost, type BillRow } from "./meteringModel";
|
||||
import { meterColorFor } from "./usageCharts";
|
||||
import { totalsGridTemplate } from "./totalsBar";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
|
||||
const EM_DASH = raw("—");
|
||||
|
||||
const props = defineProps<{
|
||||
rows: BillRow[];
|
||||
/** Billed total after discounts; every share is of this. */
|
||||
total: number;
|
||||
gross: number;
|
||||
discount: number;
|
||||
/** Row colour; meters by default, so a super org can list its orgs with their own colours. */
|
||||
colorFor?: (key: string) => string;
|
||||
/** Header of the name column, "Meter" by default. */
|
||||
nameHeader?: I18nText;
|
||||
/** Header of the share column, "Share of Bill" by default. */
|
||||
shareHeader?: I18nText;
|
||||
loading?: boolean;
|
||||
/** Rows are still arriving; they show with OTable's streaming indicator instead of a skeleton. */
|
||||
streaming?: boolean;
|
||||
/** An org with no bill: each meter's volume only, with no money and no totals across units. */
|
||||
volumeOnly?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ select: [key: string] }>();
|
||||
|
||||
const colorOf = (key: string) => (props.colorFor ?? (meterColorFor as (k: string) => string))(key);
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const shareOf = (row: BillRow) => (props.total > 0 ? (row.cost / props.total) * 100 : 0);
|
||||
|
||||
const percentText = (value: number) => raw(`${value.toFixed(1)}%`);
|
||||
|
||||
const labelColumn = computed<OTableColumnDef<BillRow>>(() => ({
|
||||
id: "label",
|
||||
header: props.nameHeader ?? t("billing.usageV2.colMeter"),
|
||||
accessorKey: "label",
|
||||
meta: { isName: true, autoWidth: true },
|
||||
minSize: 160,
|
||||
}));
|
||||
|
||||
const openColumn: OTableColumnDef<BillRow> = {
|
||||
id: "open",
|
||||
header: raw(""),
|
||||
hideable: false,
|
||||
size: 32,
|
||||
};
|
||||
|
||||
const columns = computed<OTableColumnDef<BillRow>[]>(() =>
|
||||
props.volumeOnly ? volumeColumns.value : costColumns.value,
|
||||
);
|
||||
|
||||
const volumeColumns = computed<OTableColumnDef<BillRow>[]>(() => [
|
||||
labelColumn.value,
|
||||
{
|
||||
id: "volume",
|
||||
header: t("billing.usageV2.colVolume"),
|
||||
accessorFn: (row: BillRow) => row.volume,
|
||||
size: 136,
|
||||
},
|
||||
openColumn,
|
||||
]);
|
||||
|
||||
const costColumns = computed<OTableColumnDef<BillRow>[]>(() => [
|
||||
labelColumn.value,
|
||||
{
|
||||
id: "gross",
|
||||
header: t("billing.usageV2.colGross"),
|
||||
accessorFn: (row: BillRow) => formatCost(row.gross),
|
||||
size: 80,
|
||||
},
|
||||
// Only when something was discounted; an all-dash column just raises the question.
|
||||
...(props.discount > 0
|
||||
? [
|
||||
{
|
||||
id: "discount",
|
||||
header: t("billing.usageV2.discount"),
|
||||
accessorKey: "discount",
|
||||
size: 112,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: "billed", header: t("billing.usageV2.colBilled"), accessorKey: "cost", size: 80 },
|
||||
{
|
||||
id: "share",
|
||||
header: props.shareHeader ?? t("billing.usageV2.meterShare"),
|
||||
accessorKey: "cost",
|
||||
size: 136,
|
||||
},
|
||||
openColumn,
|
||||
]);
|
||||
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
/** OTable drops the index column on a phone, so the bar drops it too. */
|
||||
const gridTemplate = computed(() => totalsGridTemplate(columns.value, {}, !isMobile.value));
|
||||
|
||||
const rowClass = (row: BillRow) => (row.drillable ? "cursor-pointer" : "");
|
||||
|
||||
const onRowClick = (row: BillRow) => {
|
||||
if (row.drillable) emit("select", row.key);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,396 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
|
||||
import { reactive } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { mockMeteringResponse } from "./meteringMock";
|
||||
import { DRILLDOWNS } from "./usageQueries";
|
||||
import { formatCost, subtotalCost, volumeDetails } from "./meteringModel";
|
||||
|
||||
const route = reactive<{ query: Record<string, string | undefined> }>({ query: {} });
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => route,
|
||||
useRouter: () => ({
|
||||
replace: ({ query }: { query: Record<string, string | undefined> }) => {
|
||||
route.query = query;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const search = vi.fn();
|
||||
const cancel = vi.fn();
|
||||
vi.mock("./useUsageSearch", () => ({ useUsageSearch: () => ({ search, cancel }) }));
|
||||
|
||||
// The org costs every test sees; the member view must read its numbers from here.
|
||||
const orgCosts: { value: Record<string, unknown> } = { value: {} };
|
||||
vi.mock("./useOrgCosts", async () => {
|
||||
const { ref: vueRef } = await import("vue");
|
||||
const costs = vueRef({});
|
||||
Object.defineProperty(orgCosts, "value", {
|
||||
get: () => costs.value,
|
||||
set: (v) => {
|
||||
costs.value = v;
|
||||
},
|
||||
});
|
||||
return {
|
||||
useOrgCosts: () => ({ costs, loading: vueRef(false), failed: vueRef(false) }),
|
||||
};
|
||||
});
|
||||
|
||||
const { default: UsageOverview } = await import("./UsageOverview.vue");
|
||||
|
||||
const billingOrgs = [
|
||||
{ id: "payer", name: "Payer", isSelf: true },
|
||||
{ id: "child", name: "Child", isSelf: false },
|
||||
];
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
describe("UsageOverview org drilldown", () => {
|
||||
// The child's part of the bill: 100 MB of a $90-billed Ingestion meter came to $22.50.
|
||||
const childShare = { volume: 100, gross: 25, billed: 22.5 };
|
||||
|
||||
beforeEach(() => {
|
||||
route.query = {};
|
||||
search.mockReset().mockResolvedValue([]);
|
||||
cancel.mockReset();
|
||||
orgCosts.value = {
|
||||
child: { gross: 25, billed: 22.5, ingested: 100, byMeter: { ingestion: childShare } },
|
||||
};
|
||||
});
|
||||
|
||||
const mountAt = (query: Record<string, string>) => {
|
||||
route.query = query;
|
||||
return mount(UsageOverview, {
|
||||
props: {
|
||||
details: mockMeteringResponse().upcoming,
|
||||
billingOrgs,
|
||||
lastLoadedAt: 1,
|
||||
projected: null,
|
||||
refreshing: false,
|
||||
// The page reads org costs once and hands them down.
|
||||
orgCosts: { costs: orgCosts.value as any, loading: false, failed: false },
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { UsageCachedChart: true, UsageMeterBreakdown: true, UsageBillStrip: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("builds a member's meters from its share of the bill, with no query of its own", async () => {
|
||||
const wrapper = mountAt({ org: "child" });
|
||||
await flushPromises();
|
||||
|
||||
const table = wrapper.findComponent('[data-test="billings-usageoverview-org-meters"]');
|
||||
const rows = table.props("rows") as { key: string; cost: number }[];
|
||||
expect(rows.find((row) => row.key === "ingestion")?.cost).toBe(22.5);
|
||||
// The member's meters add up to its row in Cost by Org.
|
||||
expect(table.props("total")).toBe(22.5);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prices a member's drilldown at its share, so the breakdown adds up to its row", async () => {
|
||||
const wrapper = mountAt({ org: "child", meter: "ingestion" });
|
||||
await flushPromises();
|
||||
|
||||
const breakdown = wrapper.findComponent({ name: "UsageMeterBreakdown" });
|
||||
expect(breakdown.props("rate")).toBeCloseTo(22.5 / 100, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageOverview Cost by Org", () => {
|
||||
beforeEach(() => {
|
||||
route.query = {};
|
||||
search.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("keeps an org that left the group mid-cycle, so the rows still add up to the bill", async () => {
|
||||
// "gone" was a member earlier this cycle; its usage stays on the payer's bill.
|
||||
const costs = {
|
||||
payer: { gross: 8, billed: 8, ingested: 10, byMeter: {} },
|
||||
child: { gross: 28, billed: 28, ingested: 30, byMeter: {} },
|
||||
gone: { gross: 14, billed: 14, ingested: 20, byMeter: {} },
|
||||
};
|
||||
const wrapper = mount(UsageOverview, {
|
||||
props: {
|
||||
details: mockMeteringResponse().upcoming,
|
||||
billingOrgs,
|
||||
lastLoadedAt: 1,
|
||||
projected: null,
|
||||
refreshing: false,
|
||||
orgCosts: { costs: costs as any, loading: false, failed: false },
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { UsageCachedChart: true, UsageMeterBreakdown: true, UsageBillStrip: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const table = wrapper.findComponent('[data-test="billings-usageoverview-org-table"]');
|
||||
const rows = table.props("rows") as {
|
||||
key: string;
|
||||
label: unknown;
|
||||
cost: number;
|
||||
drillable: boolean;
|
||||
}[];
|
||||
const gone = rows.find((row) => row.key === "gone")! as {
|
||||
label: unknown;
|
||||
subline: unknown;
|
||||
drillable: boolean;
|
||||
};
|
||||
// The name stays whole; the line under it says why the org is listed, so a cut name never hides it.
|
||||
expect(String(gone.label)).toBe("gone");
|
||||
expect(String(gone.subline)).toBe("Former member · 20.00 MB ingested");
|
||||
// Not a member any more, so there is no org view to open.
|
||||
expect(gone.drillable).toBe(false);
|
||||
expect(table.props("total")).toBe(50);
|
||||
// Its own colour, not the payer's: the bar and the dots must tell them apart.
|
||||
const colorFor = table.props("colorFor") as (key: string) => string;
|
||||
expect(new Set(["payer", "child", "gone"].map(colorFor)).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageOverview Cost by Org while costs load", () => {
|
||||
beforeEach(() => {
|
||||
route.query = {};
|
||||
search.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("shows placeholders, not the retention row as the whole bill", async () => {
|
||||
const wrapper = mount(UsageOverview, {
|
||||
props: {
|
||||
details: mockMeteringResponse().upcoming,
|
||||
billingOrgs,
|
||||
lastLoadedAt: 1,
|
||||
projected: null,
|
||||
refreshing: false,
|
||||
orgCosts: { costs: {}, loading: true, failed: false },
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { UsageCachedChart: true, UsageMeterBreakdown: true, UsageBillStrip: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
// No org has a cost yet, so the bar would read "Data Retention 100%" and the total its $0.58.
|
||||
expect(wrapper.findComponent({ name: "UsageBillStrip" }).exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="billings-usageoverview-strip-loading"]').exists()).toBe(true);
|
||||
const table = wrapper.findComponent('[data-test="billings-usageoverview-org-table"]');
|
||||
expect(table.props("rows")).toEqual([]);
|
||||
const footer = wrapper.find('[data-test="billings-usagemetertable-total"]');
|
||||
expect(footer.text()).not.toContain("$");
|
||||
expect(footer.text()).toContain("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageOverview meter drilldown chart", () => {
|
||||
beforeEach(() => {
|
||||
route.query = { meter: "ingestion" };
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = true;
|
||||
});
|
||||
|
||||
const mountMeter = () =>
|
||||
mount(UsageOverview, {
|
||||
props: {
|
||||
details: mockMeteringResponse().upcoming,
|
||||
lastLoadedAt: 1,
|
||||
projected: null,
|
||||
refreshing: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { UsageCachedChart: true, UsageMeterBreakdown: true, UsageBillStrip: true },
|
||||
},
|
||||
});
|
||||
|
||||
it("starts the chart once the groupings are known, without waiting for the table's ranking", async () => {
|
||||
const wrapper = mountMeter();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="billings-usageoverview-daily-pending"]').exists()).toBe(true);
|
||||
|
||||
const [byStream] = DRILLDOWNS.ingestion!;
|
||||
wrapper
|
||||
.findComponent({ name: "UsageMeterBreakdown" })
|
||||
.vm.$emit("sections", { sections: [byStream], unavailable: [], activeId: byStream.id });
|
||||
await flushPromises();
|
||||
|
||||
// No top names have arrived, yet the chart is already running its own query.
|
||||
const chart = wrapper.findComponent({ name: "UsageCachedChart" });
|
||||
expect(chart.exists()).toBe(true);
|
||||
const schema = chart.props("panelSchema") as any;
|
||||
expect(schema.queries[0].query).toContain('stream_name as "breakdown_1"');
|
||||
expect(schema.config.top_results).toBe(5);
|
||||
|
||||
// The table's ranking then recolours the bands in place, with the same query.
|
||||
wrapper
|
||||
.findComponent({ name: "UsageMeterBreakdown" })
|
||||
.vm.$emit("top-names", { section: byStream, names: ["app"] });
|
||||
await flushPromises();
|
||||
const recoloured = wrapper
|
||||
.findComponent({ name: "UsageCachedChart" })
|
||||
.props("panelSchema") as any;
|
||||
expect(recoloured.queries[0].query).toBe(schema.queries[0].query);
|
||||
expect(recoloured.config.color.colorBySeries.map((entry: any) => entry.value)).toContain("app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageOverview for an org with no bill", () => {
|
||||
const DAY = 86_400;
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - 29 * DAY;
|
||||
// 2 GB ingested, 4 GB scanned and 7 AI credits over the window; nothing priced.
|
||||
const usage = volumeDetails({ Ingestion: 2048, Search: 4096, AiCredits: 7 }, start, end);
|
||||
|
||||
const mountOverview = (props: Record<string, unknown>) =>
|
||||
mount(UsageOverview, {
|
||||
props: { lastLoadedAt: 1, projected: null, refreshing: false, ...props },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { UsageCachedChart: true, UsageMeterBreakdown: true, UsageBillStrip: true },
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
route.query = {};
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = true;
|
||||
});
|
||||
|
||||
it("shows each meter's volume on the same screen, with no money anywhere", async () => {
|
||||
const wrapper = mountOverview({ details: usage, volumeOnly: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).not.toContain("$");
|
||||
expect(wrapper.find('[data-test="billings-usageoverview-cycle"]').text()).toContain(
|
||||
"Last 30 days",
|
||||
);
|
||||
const cards = wrapper.findAll('[data-test^="billings-usageoverview-kpi-"]');
|
||||
expect(cards.map((card) => card.attributes("data-test"))).toEqual([
|
||||
"billings-usageoverview-kpi-ingestion",
|
||||
"billings-usageoverview-kpi-query",
|
||||
"billings-usageoverview-kpi-pipeline",
|
||||
"billings-usageoverview-kpi-ai",
|
||||
]);
|
||||
expect(cards[0].text()).toContain("2.00 GB");
|
||||
expect(cards[3].text()).toContain("7");
|
||||
// A share across GB and credit counts means nothing, so there is no bill strip.
|
||||
expect(wrapper.findComponent({ name: "UsageBillStrip" }).exists()).toBe(false);
|
||||
|
||||
const table = wrapper.findComponent({ name: "UsageMeterTable" });
|
||||
expect(table.props("volumeOnly")).toBe(true);
|
||||
expect((table.props("rows") as { key: string }[]).map((row) => row.key)).not.toContain(
|
||||
"retention",
|
||||
);
|
||||
const pane = wrapper.find('[data-test="billings-usageoverview-daily-pane"]');
|
||||
expect(pane.text()).toContain("Daily Volume");
|
||||
const schema = wrapper.findComponent({ name: "UsageCachedChart" }).props("panelSchema") as any;
|
||||
expect(schema.config.unit).toBe("megabytes");
|
||||
expect(schema.queries[0].query).toContain('sum(size) as "y_axis_1"');
|
||||
});
|
||||
|
||||
it("offers to turn usage reporting on while it is off", async () => {
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = false;
|
||||
const wrapper = mountOverview({ details: null, volumeOnly: true });
|
||||
await flushPromises();
|
||||
|
||||
const prompt = wrapper.findComponent('[data-test="billings-usageoverview-enable"]');
|
||||
expect(prompt.exists()).toBe(true);
|
||||
prompt.vm.$emit("action");
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent({ name: "ConfirmDialog" }).props("modelValue")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a Stripe-billed org's Overview as the bill", async () => {
|
||||
const wrapper = mountOverview({ details: mockMeteringResponse().upcoming });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="billings-usageoverview-kpi-so-far"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: "UsageBillStrip" }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: "UsageMeterTable" }).props("volumeOnly")).toBe(false);
|
||||
const pane = wrapper.find('[data-test="billings-usageoverview-daily-pane"]');
|
||||
expect(pane.text()).toContain("Daily Cost");
|
||||
const schema = wrapper.findComponent({ name: "UsageCachedChart" }).props("panelSchema") as any;
|
||||
expect(schema.config.unit).toBe("currency");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageOverview discount card", () => {
|
||||
beforeEach(() => {
|
||||
route.query = {};
|
||||
});
|
||||
|
||||
const mountWith = (details: unknown) =>
|
||||
mount(UsageOverview, {
|
||||
props: { details, lastLoadedAt: 1, projected: null, refreshing: false },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { UsageCachedChart: true, UsageMeterBreakdown: true, UsageBillStrip: true },
|
||||
},
|
||||
});
|
||||
|
||||
it("shows the discount, its share of the subtotal and the discounted meters", async () => {
|
||||
const details = mockMeteringResponse().upcoming!;
|
||||
const wrapper = mountWith(details);
|
||||
await flushPromises();
|
||||
const card = wrapper.find('[data-test="billings-usageoverview-kpi-discount"]');
|
||||
const subtotal = subtotalCost(details);
|
||||
expect(card.text()).toContain(String(formatCost(-details.total_discounted_amount)));
|
||||
expect(card.text()).toContain(
|
||||
`${((details.total_discounted_amount / subtotal) * 100).toFixed(1)}%`,
|
||||
);
|
||||
expect(card.text()).toContain("Ingestion");
|
||||
});
|
||||
|
||||
it("has no discount card when nothing was discounted", async () => {
|
||||
const wrapper = mountWith({ ...mockMeteringResponse().upcoming!, total_discounted_amount: 0 });
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="billings-usageoverview-kpi-discount"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageOverview daily chart window", () => {
|
||||
beforeEach(() => {
|
||||
route.query = {};
|
||||
store.state.organizationData.organizationSettings.usage_stream_enabled = true;
|
||||
});
|
||||
|
||||
it("opens at 00:00 UTC of the cycle's first day, so that day's bar shows, and counts from the cycle start", async () => {
|
||||
// The mock cycle starts partway through a day, as Stripe cycles do.
|
||||
const details = mockMeteringResponse().upcoming!;
|
||||
expect(details.cycle_start % 86_400).not.toBe(0);
|
||||
const wrapper = mount(UsageOverview, {
|
||||
props: { details, lastLoadedAt: 1, projected: null, refreshing: false },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { UsageCachedChart: true, UsageMeterBreakdown: true, UsageBillStrip: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const chart = wrapper.findComponent({ name: "UsageCachedChart" });
|
||||
const window = chart.props("selectedTimeObj") as { start_time: Date };
|
||||
// Microseconds in a Date, as the dashboard renderer reads it.
|
||||
expect(window.start_time.getTime()).toBe(
|
||||
Math.floor(details.cycle_start / 86_400) * 86_400 * 1_000_000,
|
||||
);
|
||||
const sql = (chart.props("panelSchema") as any).queries[0].query as string;
|
||||
expect(sql).toContain(`_timestamp >= ${details.cycle_start * 1_000_000}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,931 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col gap-2.5" data-test="billings-usageoverview-root">
|
||||
<OBanner
|
||||
v-if="missingMeterNames"
|
||||
variant="warning"
|
||||
:content="t('billing.usageV2.missingMeters', { meters: missingMeterNames })"
|
||||
data-test="billings-usageoverview-missing-banner"
|
||||
/>
|
||||
|
||||
<!-- An org with no bill shows usage from its own stream, which only exists once reporting is on. -->
|
||||
<OEmptyState
|
||||
v-if="!details && volumeOnly"
|
||||
size="hero"
|
||||
illustration="wave-bars"
|
||||
:title="
|
||||
usageStreamEnabled
|
||||
? t('billing.usageTrends.waitingTitle')
|
||||
: t('billing.usageTrends.enableTitle')
|
||||
"
|
||||
:description="
|
||||
usageStreamEnabled
|
||||
? t('billing.usageTrends.waitingForData')
|
||||
: t('billing.usageV2.enableVolumeSubtitle')
|
||||
"
|
||||
:action-label="usageStreamEnabled ? undefined : t('billing.usageTrends.enableButton')"
|
||||
data-test="billings-usageoverview-enable"
|
||||
@action="confirmingEnable = true"
|
||||
/>
|
||||
|
||||
<OEmptyState
|
||||
v-else-if="!details"
|
||||
size="block"
|
||||
:title="t('billing.usageV2.noPricingTitle')"
|
||||
:description="t('billing.usageV2.noPricingSubtitle')"
|
||||
data-test="billings-usageoverview-no-pricing"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-text-secondary text-sm" data-test="billings-usageoverview-cycle">
|
||||
<template v-if="volumeOnly">{{ windowLabel }}</template>
|
||||
<template v-else>
|
||||
{{ formatCycle(details, t) }}
|
||||
<span class="text-text-body ms-2 font-medium">
|
||||
{{ t("billing.usageV2.cycleDay", { day: progress.day, total: progress.totalDays }) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<ORefreshButton
|
||||
:last-run-at="lastLoadedAt"
|
||||
:loading="refreshing"
|
||||
data-test="billings-usageoverview-refresh"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KpiCardRow
|
||||
:columns="kpiCards.length"
|
||||
class="shrink-0"
|
||||
data-test="billings-usageoverview-kpis"
|
||||
>
|
||||
<UsageKpiCard
|
||||
v-for="card in kpiCards"
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:value="card.value"
|
||||
:icon="card.icon"
|
||||
:value-suffix="card.valueSuffix"
|
||||
:hint="card.hint"
|
||||
:struck="card.struck"
|
||||
:positive="card.positive"
|
||||
:data-test="`billings-usageoverview-kpi-${card.key}`"
|
||||
/>
|
||||
</KpiCardRow>
|
||||
|
||||
<section
|
||||
class="bg-card-glass-bg rounded-default border-border-default @container/usage-breakdown flex min-h-0 flex-1 flex-col overflow-hidden border"
|
||||
:aria-label="t('billing.usageV2.costBreakdown')"
|
||||
data-test="billings-usageoverview-breakdown"
|
||||
>
|
||||
<!-- No bill, no strip: a share across meters means nothing when GB and run counts mix. -->
|
||||
<div
|
||||
v-if="isSuperOrg && !volumeOnly && orgCostsPending"
|
||||
class="border-border-default border-b px-4 py-2.5"
|
||||
data-test="billings-usageoverview-strip-loading"
|
||||
>
|
||||
<OSkeleton class="h-7 w-full" />
|
||||
</div>
|
||||
<UsageBillStrip
|
||||
v-else-if="isSuperOrg && !volumeOnly"
|
||||
:rows="orgRows"
|
||||
:total="orgTotals.total"
|
||||
:selected="selectedOrg?.id ?? null"
|
||||
:color-for="orgColor"
|
||||
:aria-label="t('billing.usageV2.costByOrg')"
|
||||
:click-hint="t('billing.usageV2.clickForOrg')"
|
||||
@select="selectOrg"
|
||||
/>
|
||||
<UsageBillStrip
|
||||
v-else-if="!volumeOnly"
|
||||
:rows="rows"
|
||||
:total="total"
|
||||
:selected="selectedMeter"
|
||||
:click-hint="t('billing.usageV2.clickForBreakdown')"
|
||||
@select="(key) => selectMeter(key as MeterKey | null)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden @min-[56.25rem]/usage-breakdown:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]"
|
||||
>
|
||||
<div ref="breakdownAnchor" class="flex min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<UsagePaneHeader v-if="selectedMeter" :title="meterTitle" :subtitle="meterSummary">
|
||||
<template #leading>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
icon-left="chevron-left"
|
||||
:aria-label="t('billing.usageV2.allMeters')"
|
||||
data-test="billings-usageoverview-all-meters"
|
||||
@click="selectMeter(null)"
|
||||
/>
|
||||
<span
|
||||
class="rounded-default h-2.5 w-2.5 shrink-0"
|
||||
:style="{ backgroundColor: meterColorFor(selectedMeter) }"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="groupSections.length > 1" #actions>
|
||||
<span class="text-text-secondary text-2xs">{{ t("billing.usageV2.groupBy") }}</span>
|
||||
<OToggleGroup
|
||||
:model-value="activeGroupId"
|
||||
mobile-dropdown
|
||||
data-test="billings-usageoverview-groups"
|
||||
@update:model-value="onGroupChange"
|
||||
>
|
||||
<OToggleGroupItem
|
||||
v-for="item in groupSections"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
:disabled="unavailableGroupIds.includes(item.id)"
|
||||
focusable-unavailable
|
||||
:tooltip="
|
||||
unavailableGroupIds.includes(item.id)
|
||||
? t('billing.usageV2.groupNotRecorded', { group: t(item.columnKey) })
|
||||
: undefined
|
||||
"
|
||||
size="sm"
|
||||
>
|
||||
{{ t(item.columnKey) }}
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
</template>
|
||||
</UsagePaneHeader>
|
||||
<UsagePaneHeader
|
||||
v-else-if="selectedOrg"
|
||||
:title="raw(selectedOrg.name)"
|
||||
:subtitle="orgSummary"
|
||||
>
|
||||
<template #leading>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
icon-left="chevron-left"
|
||||
:aria-label="t('billing.usageV2.allOrgs')"
|
||||
data-test="billings-usageoverview-all-orgs"
|
||||
@click="selectOrg(null)"
|
||||
/>
|
||||
<span
|
||||
class="rounded-default h-2.5 w-2.5 shrink-0"
|
||||
:style="{ backgroundColor: orgColor(selectedOrg.id) }"
|
||||
/>
|
||||
</template>
|
||||
</UsagePaneHeader>
|
||||
<UsagePaneHeader
|
||||
v-else-if="isSuperOrg"
|
||||
:title="t('billing.usageV2.costByOrg')"
|
||||
:subtitle="t('billing.usageV2.orgCostEstimated')"
|
||||
/>
|
||||
<UsagePaneHeader
|
||||
v-else
|
||||
:title="
|
||||
volumeOnly ? t('billing.usageV2.usageByMeter') : t('billing.usageV2.costByMeter')
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Each meter's breakdown is kept, so drilling back into it shows its table without querying again. -->
|
||||
<KeepAlive :max="BREAKDOWN_CACHE_SIZE">
|
||||
<UsageMeterBreakdown
|
||||
v-if="selectedMeter"
|
||||
:key="`${selectedOrg?.id ?? 'self'}-${selectedMeter}`"
|
||||
:meter-key="selectedMeter"
|
||||
:scope-org-id="selectedOrg?.id"
|
||||
:rate="meterRate(selectedMeter)"
|
||||
:details="details"
|
||||
:group="selectedGroup"
|
||||
:refresh-token="refreshToken"
|
||||
:volume-only="volumeOnly"
|
||||
@update:group="selectGroup"
|
||||
@top-names="onTopNames"
|
||||
@sections="onSections"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<template v-if="!selectedMeter">
|
||||
<OEmptyState
|
||||
v-if="isSuperOrg && !selectedOrg && orgCostsFailed"
|
||||
size="inline"
|
||||
:title="t('billing.usageV2.orgCostsFailed')"
|
||||
data-test="billings-usageoverview-org-error"
|
||||
/>
|
||||
<UsageMeterTable
|
||||
v-else-if="isSuperOrg && !selectedOrg"
|
||||
:rows="orgRows"
|
||||
:total="orgTotals.total"
|
||||
:gross="orgTotals.gross"
|
||||
:discount="orgTotals.discount"
|
||||
:color-for="orgColor"
|
||||
:name-header="t('billing.usageV2.colOrganization')"
|
||||
:loading="orgCostsPending"
|
||||
:streaming="orgCostsLoading && hasOrgCosts"
|
||||
data-test="billings-usageoverview-org-table"
|
||||
@select="selectOrg"
|
||||
/>
|
||||
<UsageMeterTable
|
||||
v-else-if="selectedOrg"
|
||||
:rows="scopedRows"
|
||||
:total="scopedTotals.total"
|
||||
:gross="scopedTotals.gross"
|
||||
:discount="scopedTotals.discount"
|
||||
:share-header="t('billing.usageV2.shareOfOrg', { org: selectedOrg.name })"
|
||||
:loading="orgCostsPending"
|
||||
:streaming="orgCostsLoading && hasOrgCosts"
|
||||
data-test="billings-usageoverview-org-meters"
|
||||
@select="(key) => selectMeter(key as MeterKey)"
|
||||
/>
|
||||
<UsageMeterTable
|
||||
v-else
|
||||
:rows="meterRows"
|
||||
:total="total"
|
||||
:gross="subtotal"
|
||||
:discount="discountAmount"
|
||||
:streaming="refreshing"
|
||||
:volume-only="volumeOnly"
|
||||
@select="(key) => selectMeter(key as MeterKey)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
class="border-border-default flex min-h-0 min-w-0 flex-col overflow-hidden border-t @min-[56.25rem]/usage-breakdown:border-s @min-[56.25rem]/usage-breakdown:border-t-0"
|
||||
:aria-label="chartTitle"
|
||||
data-test="billings-usageoverview-daily-pane"
|
||||
>
|
||||
<UsagePaneHeader :title="chartTitle" :subtitle="chartSubtitle" />
|
||||
<!-- v-show, not v-if: the KeepAlive inside must survive the moment a drilldown has no chart yet. -->
|
||||
<div v-show="chartSchema" class="relative min-h-60 w-full flex-1 p-2">
|
||||
<!-- Each view is kept per key, so leaving a drilldown shows the drawn chart instead of querying again. -->
|
||||
<KeepAlive :max="CHART_CACHE_SIZE">
|
||||
<UsageCachedChart
|
||||
v-if="chartSchema"
|
||||
:key="chartKey"
|
||||
:panel-schema="chartSchema"
|
||||
:selected-time-obj="elapsedTimeObj"
|
||||
data-test="billings-usageoverview-daily-chart"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
<div v-if="chartPending" class="relative min-h-60 w-full flex-1 p-2">
|
||||
<UsageChartSkeleton data-test="billings-usageoverview-daily-pending" />
|
||||
</div>
|
||||
<OEmptyState
|
||||
v-else-if="!chartSchema"
|
||||
size="inline"
|
||||
:title="chartEmptyTitle"
|
||||
:action-label="usageStreamEnabled ? undefined : t('billing.usageTrends.enableButton')"
|
||||
data-test="billings-usageoverview-daily-empty"
|
||||
@action="confirmingEnable = true"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="confirmingEnable"
|
||||
data-test="billings-usageoverview-enable-confirm"
|
||||
:title="t('billing.usageTrends.enableConfirmTitle')"
|
||||
:message="t('billing.usageTrends.enableConfirmMessage')"
|
||||
:ok-label="t('billing.usageTrends.enableButton')"
|
||||
@update:ok="enableUsageReporting"
|
||||
@update:cancel="confirmingEnable = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import KpiCardRow from "@/components/common/KpiCardRow.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { formatSizeFromMB } from "@/utils/formatters";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import UsageKpiCard from "./UsageKpiCard.vue";
|
||||
import UsageBillStrip from "./UsageBillStrip.vue";
|
||||
import UsageMeterTable from "./UsageMeterTable.vue";
|
||||
import UsageMeterBreakdown from "./UsageMeterBreakdown.vue";
|
||||
import UsagePaneHeader from "./UsagePaneHeader.vue";
|
||||
import UsageCachedChart from "./UsageCachedChart.vue";
|
||||
import UsageChartSkeleton from "./UsageChartSkeleton.vue";
|
||||
import {
|
||||
buildMeterRows,
|
||||
cycleProgress,
|
||||
effectiveRate,
|
||||
formatCost,
|
||||
formatDay,
|
||||
formatVolume,
|
||||
formatCycle,
|
||||
METERS,
|
||||
meterForEvent,
|
||||
netCost,
|
||||
scopedMeterRows,
|
||||
splitByUse,
|
||||
subtotalCost,
|
||||
type BillingOrg,
|
||||
type BillRow,
|
||||
type MeterKey,
|
||||
type MeterRow,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
import {
|
||||
buildStackedCostPanelSchema,
|
||||
buildTrendsPanelSchema,
|
||||
CHART_TOP,
|
||||
VOLUME_METERS,
|
||||
entitySeriesColors,
|
||||
OTHERS_SERIES,
|
||||
meterColorFor,
|
||||
paletteColor,
|
||||
} from "./usageCharts";
|
||||
import {
|
||||
dailyOrgCostSql,
|
||||
dailyEntitySql,
|
||||
dailyVolumeSql,
|
||||
type DrilldownSection,
|
||||
} from "./usageQueries";
|
||||
import { cyclePrices } from "./trendsModel";
|
||||
import type { OrgCostsState } from "./useOrgCosts";
|
||||
import { useUsageReporting } from "./useUsageReporting";
|
||||
|
||||
interface KpiCard {
|
||||
key: string;
|
||||
label: I18nText;
|
||||
icon: IconName;
|
||||
valueSuffix?: I18nText;
|
||||
value: I18nText;
|
||||
hint: I18nText;
|
||||
struck?: I18nText;
|
||||
positive?: boolean;
|
||||
}
|
||||
|
||||
const EM_DASH = raw("—");
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
details: MeteringDetails | null;
|
||||
/** A super org's orgs, itself first; empty for any other org. */
|
||||
billingOrgs?: BillingOrg[];
|
||||
lastLoadedAt: number | null;
|
||||
/** The cycle's projected total, worked out once by the page so every tab shows the same figure. */
|
||||
projected: number | null;
|
||||
refreshing: boolean;
|
||||
volumeOnly?: boolean;
|
||||
/** The super org's per-org spend, read once by the page. */
|
||||
orgCosts?: OrgCostsState;
|
||||
}>(),
|
||||
{
|
||||
billingOrgs: () => [],
|
||||
volumeOnly: false,
|
||||
orgCosts: () => ({ costs: {}, loading: false, failed: false }),
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{ refresh: [] }>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
const refreshToken = ref(0);
|
||||
const breakdownAnchor = ref<HTMLElement | null>(null);
|
||||
const groupSections = ref<DrilldownSection[]>([]);
|
||||
/** The view the breakdown's groupings came from; another meter's must not shape this chart. */
|
||||
const sectionsFor = ref<string | null>(null);
|
||||
/** Groupings this org has never recorded: shown, but disabled. */
|
||||
const unavailableGroupIds = ref<string[]>([]);
|
||||
const activeGroupId = ref<string | undefined>(undefined);
|
||||
/** The table's top names, used only to colour the chart's bands as the table ranks them. */
|
||||
const chartNames = ref<string[]>([]);
|
||||
/** The view and grouping those names were ranked for. */
|
||||
const chartNamesFor = ref<string | null>(null);
|
||||
|
||||
/** Views a user moves between in one visit: all meters, a few drilldowns, a few orgs. */
|
||||
const CHART_CACHE_SIZE = 8;
|
||||
|
||||
/** Meter breakdowns kept alive at once; each holds only its own top-N rows. */
|
||||
const BREAKDOWN_CACHE_SIZE = 8;
|
||||
|
||||
const orgId = computed<string>(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
/** The org whose usage the chart and breakdown count: a super org's chosen member, or this org. */
|
||||
const usageOrgId = computed(() => selectedOrg.value?.id ?? orgId.value);
|
||||
|
||||
const {
|
||||
enabled: usageStreamEnabled,
|
||||
confirming: confirmingEnable,
|
||||
enable: enableUsageReporting,
|
||||
} = useUsageReporting();
|
||||
|
||||
/** Without a bill, retention has no usage event to count, so its row would only ever read zero. */
|
||||
const rows = computed<MeterRow[]>(() =>
|
||||
buildMeterRows(props.details, t).filter((row) => !props.volumeOnly || row.key !== "retention"),
|
||||
);
|
||||
|
||||
const split = computed(() => splitByUse(rows.value));
|
||||
|
||||
/** Every meter, priciest first; an unused one reads as $0.00 rather than a sentence under the table. */
|
||||
const meterRows = computed(() => [...rows.value].sort((a, b) => b.cost - a.cost));
|
||||
|
||||
const total = computed(() => Number(props.details?.total_cost) || 0);
|
||||
|
||||
const subtotal = computed(() => subtotalCost(props.details));
|
||||
|
||||
const discountAmount = computed(() => Number(props.details?.total_discounted_amount) || 0);
|
||||
|
||||
const progress = computed(() =>
|
||||
props.details ? cycleProgress(props.details) : { day: 0, totalDays: 0, elapsed: 0 },
|
||||
);
|
||||
|
||||
const projected = computed(() => props.projected);
|
||||
|
||||
const cycleEndText = computed(() => raw(formatDay(props.details?.cycle_end ?? 0)));
|
||||
|
||||
const isSuperOrg = computed(() => props.billingOrgs.length > 0);
|
||||
|
||||
/** An org keeps its palette slot by its place in the group; the retention row keeps its meter colour. */
|
||||
const orgColor = (key: string) => {
|
||||
const index = props.billingOrgs.findIndex((org) => org.id === key);
|
||||
if (index >= 0) return paletteColor(index);
|
||||
const former = formerOrgIds.value.indexOf(key);
|
||||
return former >= 0
|
||||
? paletteColor(props.billingOrgs.length + former)
|
||||
: meterColorFor(key as MeterKey);
|
||||
};
|
||||
|
||||
const selectedOrg = computed<BillingOrg | null>(() =>
|
||||
isSuperOrg.value ? (props.billingOrgs.find((org) => org.id === route.query.org) ?? null) : null,
|
||||
);
|
||||
|
||||
// The page reads org costs once for every tab, so Budget judges spend on these same figures.
|
||||
const orgCosts = computed(() => props.orgCosts.costs);
|
||||
const orgCostsLoading = computed(() => props.orgCosts.loading);
|
||||
const orgCostsFailed = computed(() => props.orgCosts.failed);
|
||||
|
||||
/** Partial costs stream in, so the skeleton only covers the wait for the first chunk. */
|
||||
const hasOrgCosts = computed(() => Object.keys(orgCosts.value).length > 0);
|
||||
/** Until the first org costs land, the bill's retention row would read as the whole bill. */
|
||||
const orgCostsPending = computed(() => orgCostsLoading.value && !hasOrgCosts.value);
|
||||
|
||||
/** Every org with its cost, plus retention, which writes no usage events to split by org. */
|
||||
/** Orgs still on this bill that are no longer members: one that left the group mid-cycle. */
|
||||
const formerOrgIds = computed(() => {
|
||||
const members = new Set(props.billingOrgs.map((org) => org.id));
|
||||
return Object.entries(orgCosts.value)
|
||||
.filter(([id, cost]) => id && !members.has(id) && (cost.gross > 0 || cost.billed > 0))
|
||||
.map(([id]) => id);
|
||||
});
|
||||
|
||||
const orgRow = (id: string, label: I18nText, member: boolean): BillRow => {
|
||||
const cost = orgCosts.value[id] ?? { gross: 0, billed: 0, ingested: 0, byMeter: {} };
|
||||
const discount = Math.max(cost.gross - cost.billed, 0);
|
||||
const volume = raw(formatSizeFromMB(cost.ingested));
|
||||
return {
|
||||
key: id,
|
||||
label,
|
||||
volume: EM_DASH,
|
||||
rate: EM_DASH,
|
||||
subline: member
|
||||
? t("billing.usageV2.ingestedVolume", { volume })
|
||||
: t("billing.usageV2.formerMemberIngested", { volume }),
|
||||
cost: cost.billed,
|
||||
gross: cost.gross,
|
||||
discount,
|
||||
missing: false,
|
||||
discountPercent: cost.gross > 0 ? Math.round((discount / cost.gross) * 1000) / 10 : 0,
|
||||
drillable: member,
|
||||
hasBreakdown: member,
|
||||
};
|
||||
};
|
||||
|
||||
const orgRows = computed<BillRow[]>(() => {
|
||||
if (orgCostsFailed.value || orgCostsPending.value) return [];
|
||||
const orgs = props.billingOrgs.map((org) =>
|
||||
orgRow(
|
||||
org.id,
|
||||
org.isSelf ? t("billing.usageV2.thisOrg", { org: org.name }) : raw(org.name),
|
||||
true,
|
||||
),
|
||||
);
|
||||
// An org that left mid-cycle stays on the payer's bill, so its row keeps the rows equal to the bill.
|
||||
for (const id of formerOrgIds.value) {
|
||||
const name =
|
||||
(store.state.organizations ?? []).find((org: any) => org.identifier === id)?.name ?? id;
|
||||
orgs.push(orgRow(id, raw(name), false));
|
||||
}
|
||||
const retention = props.details?.retention;
|
||||
const retentionRow = rows.value.find((row) => row.key === "retention");
|
||||
if (retention && retentionRow && retentionRow.cost > 0) {
|
||||
orgs.push({
|
||||
...retentionRow,
|
||||
subline: t("billing.usageV2.notSplitByOrg"),
|
||||
drillable: false,
|
||||
hasBreakdown: false,
|
||||
});
|
||||
}
|
||||
return orgs.sort((a, b) => b.cost - a.cost);
|
||||
});
|
||||
|
||||
const sumRows = (list: BillRow[]) => ({
|
||||
total: list.reduce((sum, row) => sum + row.cost, 0),
|
||||
gross: list.reduce((sum, row) => sum + row.gross, 0),
|
||||
discount: list.reduce((sum, row) => sum + row.discount, 0),
|
||||
});
|
||||
|
||||
const orgTotals = computed(() => sumRows(orgRows.value));
|
||||
|
||||
/** The selected org's part of each meter, from the same split as its row, so the two always agree. */
|
||||
const selectedShares = computed(() =>
|
||||
selectedOrg.value ? (orgCosts.value[selectedOrg.value.id]?.byMeter ?? {}) : {},
|
||||
);
|
||||
|
||||
/** The selected org's meters, priciest first. */
|
||||
const scopedRows = computed<MeterRow[]>(() =>
|
||||
scopedMeterRows(props.details, selectedShares.value, t).sort((a, b) => b.cost - a.cost),
|
||||
);
|
||||
|
||||
/** Inside an org, a meter is priced at that org's share of it, so its breakdown adds up to the org's row. */
|
||||
const meterRate = (key: MeterKey) => {
|
||||
const share = selectedShares.value[key];
|
||||
if (!selectedOrg.value || !share) return effectiveRate(props.details?.[key]);
|
||||
return share.volume > 0 ? share.billed / share.volume : 0;
|
||||
};
|
||||
|
||||
const scopedTotals = computed(() => sumRows(scopedRows.value));
|
||||
|
||||
const orgSummary = computed(() => {
|
||||
const org = selectedOrg.value;
|
||||
if (!org) return undefined;
|
||||
const cost = orgCosts.value[org.id]?.billed ?? 0;
|
||||
return t("billing.usageV2.orgSummary", {
|
||||
cost: formatCost(cost),
|
||||
share:
|
||||
orgTotals.value.total > 0
|
||||
? raw(`${((cost / orgTotals.value.total) * 100).toFixed(1)}%`)
|
||||
: EM_DASH,
|
||||
});
|
||||
});
|
||||
|
||||
const selectOrg = (key: string | null) => {
|
||||
if (key === (selectedOrg.value?.id ?? null)) return;
|
||||
if (key && !props.billingOrgs.some((org) => org.id === key)) return;
|
||||
router.replace({
|
||||
query: { ...route.query, org: key ?? undefined, meter: undefined, group: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
/** A super org opens a meter only inside an org; any other org opens it from the bill. */
|
||||
const meterSource = computed<MeterRow[]>(() =>
|
||||
isSuperOrg.value ? (selectedOrg.value ? scopedRows.value : []) : rows.value,
|
||||
);
|
||||
|
||||
/** No meter in the URL means the All Meters view; a meter is chosen only by the user. */
|
||||
const selectedMeter = computed<MeterKey | null>(() => {
|
||||
const match = meterSource.value.find((row) => row.drillable && row.key === route.query.meter);
|
||||
return match?.key ?? null;
|
||||
});
|
||||
|
||||
const selectedGroup = computed(() => (route.query.group ? String(route.query.group) : undefined));
|
||||
|
||||
const selectedLabel = computed(
|
||||
() => rows.value.find((row) => row.key === selectedMeter.value)?.label ?? EM_DASH,
|
||||
);
|
||||
|
||||
/** Inside an org, the meter's title carries the org, so the view says whose usage it is. */
|
||||
const meterTitle = computed(() =>
|
||||
selectedOrg.value
|
||||
? raw(`${selectedOrg.value.name} / ${selectedLabel.value}`)
|
||||
: selectedLabel.value,
|
||||
);
|
||||
|
||||
/** One line of context in the pane header, so the breakdown adds no row of its own. */
|
||||
const meterSummary = computed(() => {
|
||||
if (props.volumeOnly) {
|
||||
const meter = selectedMeter.value ? props.details?.[selectedMeter.value] : null;
|
||||
return meter ? formatVolume(meter, selectedMeter.value) : undefined;
|
||||
}
|
||||
const scoped = selectedOrg.value
|
||||
? scopedRows.value.find((row) => row.key === selectedMeter.value)
|
||||
: null;
|
||||
if (scoped) {
|
||||
const orgTotal = scopedTotals.value.total;
|
||||
return t("billing.usageV2.breakdownSummary", {
|
||||
cost: formatCost(scoped.cost),
|
||||
volume: scoped.volume,
|
||||
share: orgTotal > 0 ? raw(`${((scoped.cost / orgTotal) * 100).toFixed(1)}%`) : EM_DASH,
|
||||
});
|
||||
}
|
||||
const meter = selectedMeter.value ? props.details?.[selectedMeter.value] : null;
|
||||
if (!meter) return undefined;
|
||||
const billed = netCost(meter);
|
||||
return t("billing.usageV2.breakdownSummary", {
|
||||
cost: formatCost(billed),
|
||||
volume: formatVolume(meter, selectedMeter.value),
|
||||
share: total.value > 0 ? raw(`${((billed / total.value) * 100).toFixed(1)}%`) : EM_DASH,
|
||||
});
|
||||
});
|
||||
|
||||
const onSections = (payload: {
|
||||
sections: DrilldownSection[];
|
||||
unavailable: string[];
|
||||
activeId: string | undefined;
|
||||
}) => {
|
||||
groupSections.value = payload.sections;
|
||||
unavailableGroupIds.value = payload.unavailable;
|
||||
sectionsFor.value = viewKey.value;
|
||||
activeGroupId.value = payload.activeId;
|
||||
};
|
||||
|
||||
const onGroupChange = (value: unknown) => {
|
||||
if (typeof value === "string" && value) selectGroup(value);
|
||||
};
|
||||
|
||||
const discountedMeterNames = computed(() =>
|
||||
raw(
|
||||
split.value.used
|
||||
.filter((row) => row.discount > 0)
|
||||
.map((row) => row.label)
|
||||
.join(", "),
|
||||
),
|
||||
);
|
||||
|
||||
/** The four meters an org with no bill sees first, each by its own unit. */
|
||||
const VOLUME_CARD_METERS: MeterKey[] = ["ingestion", "query", "pipeline", "ai"];
|
||||
|
||||
const volumeCards = computed<KpiCard[]>(() =>
|
||||
METERS.filter((def) => VOLUME_CARD_METERS.includes(def.key)).map((def) => ({
|
||||
key: def.key,
|
||||
label: t(def.labelKey),
|
||||
icon: "bar-chart",
|
||||
value: formatVolume(props.details?.[def.key], def.key),
|
||||
hint: t(def.billedAsKey),
|
||||
})),
|
||||
);
|
||||
|
||||
/** "Last 30 days: Sep 20 to Oct 19", the window an org with no bill is shown. */
|
||||
const windowLabel = computed(() => {
|
||||
const start = props.details?.cycle_start ?? 0;
|
||||
const end = Math.min(props.details?.cycle_end ?? 0, Math.floor(Date.now() / 1000));
|
||||
return t("billing.usageV2.lastDaysRange", {
|
||||
// Calendar days, today included: the window starts at midnight 29 days back.
|
||||
days: Math.floor((end - start) / 86_400) + 1,
|
||||
start: raw(formatDay(start)),
|
||||
end: raw(formatDay(end)),
|
||||
});
|
||||
});
|
||||
|
||||
const kpiCards = computed<KpiCard[]>(() => {
|
||||
if (props.volumeOnly) return volumeCards.value;
|
||||
const top = split.value.used.find((row) => !row.missing);
|
||||
const discounted = discountAmount.value > 0;
|
||||
// No discount this cycle: no tile, so nobody wonders what an empty "Discount" means.
|
||||
const cards: KpiCard[] = [
|
||||
{
|
||||
key: "so-far",
|
||||
label: t("billing.usageV2.costSoFar"),
|
||||
icon: "attach-money",
|
||||
value: formatCost(total.value),
|
||||
hint: discounted
|
||||
? t("billing.usageV2.grossLabel")
|
||||
: t("billing.usageV2.billedDay", {
|
||||
day: progress.value.day,
|
||||
total: progress.value.totalDays,
|
||||
}),
|
||||
struck: discounted ? formatCost(subtotal.value) : undefined,
|
||||
},
|
||||
{
|
||||
key: "projected",
|
||||
label: t("billing.usageV2.projectedTotal"),
|
||||
icon: "trending-up",
|
||||
value: projected.value === null ? EM_DASH : formatCost(projected.value),
|
||||
hint:
|
||||
projected.value === null
|
||||
? t("billing.usageV2.tooEarlyToProject")
|
||||
: t("billing.usageV2.cycleCloses", { date: cycleEndText.value }),
|
||||
},
|
||||
{
|
||||
key: "top-meter",
|
||||
label: t("billing.usageV2.topMeter"),
|
||||
icon: "bar-chart",
|
||||
value: top ? top.label : EM_DASH,
|
||||
hint:
|
||||
top && total.value > 0
|
||||
? t("billing.usageV2.topMeterShare", {
|
||||
cost: formatCost(top.cost),
|
||||
percent: ((top.cost / total.value) * 100).toFixed(1),
|
||||
})
|
||||
: EM_DASH,
|
||||
},
|
||||
];
|
||||
if (discounted)
|
||||
cards.push({
|
||||
key: "discount",
|
||||
label: t("billing.usageV2.discount"),
|
||||
icon: "receipt-long",
|
||||
value: formatCost(-discountAmount.value),
|
||||
positive: true,
|
||||
valueSuffix:
|
||||
subtotal.value > 0
|
||||
? raw(`${((discountAmount.value / subtotal.value) * 100).toFixed(1)}%`)
|
||||
: undefined,
|
||||
hint: discountedMeterNames.value,
|
||||
});
|
||||
return cards;
|
||||
});
|
||||
|
||||
const missingMeterNames = computed(() => {
|
||||
const missing = rows.value.filter((row) => row.missing);
|
||||
return missing.length ? raw(missing.map((row) => row.label).join(", ")) : null;
|
||||
});
|
||||
|
||||
/** Elapsed days only, so the bars use the full pane width instead of empty future days. */
|
||||
/** The cycle's first moment; the charts count from here even though their window opens earlier. */
|
||||
const cycleStartUs = computed(() => (props.details?.cycle_start ?? 0) * 1_000_000);
|
||||
|
||||
const elapsedTimeObj = computed(() => {
|
||||
const dayStart = Math.floor((props.details?.cycle_start ?? 0) / 86_400) * 86_400 * 1_000_000;
|
||||
const end = Math.min((props.details?.cycle_end ?? 0) * 1_000_000, Date.now() * 1000);
|
||||
// Microseconds in a Date, as the dashboard renderer reads its time window.
|
||||
return { start_time: new Date(dayStart), end_time: new Date(Math.max(end, dayStart)) };
|
||||
});
|
||||
|
||||
const viewKey = computed(
|
||||
() => `${selectedOrg.value?.id ?? "self"}-${selectedMeter.value ?? "all"}`,
|
||||
);
|
||||
|
||||
/** The grouping the chart splits by, known once the breakdown has read the schema, before its table query ends. */
|
||||
const chartSection = computed<DrilldownSection | null>(() =>
|
||||
sectionsFor.value === viewKey.value
|
||||
? (groupSections.value.find((item) => item.id === activeGroupId.value) ?? null)
|
||||
: null,
|
||||
);
|
||||
|
||||
/** Until the breakdown names its groupings, a meter's chart is loading, not empty. */
|
||||
const chartPending = computed(
|
||||
() =>
|
||||
!!props.details &&
|
||||
usageStreamEnabled.value &&
|
||||
!!selectedMeter.value &&
|
||||
selectedMeter.value !== "retention" &&
|
||||
sectionsFor.value !== viewKey.value,
|
||||
);
|
||||
|
||||
/** Daily megabytes per data meter, or one meter's top entities, for an org with no bill. */
|
||||
const volumeChartSchema = (meterKey: MeterKey | null) => {
|
||||
if (!meterKey) {
|
||||
const events = METERS.filter((def) => VOLUME_METERS.includes(def.key)).flatMap(
|
||||
(def) => def.events,
|
||||
);
|
||||
return buildStackedCostPanelSchema({
|
||||
id: "usage-overview-volume",
|
||||
sql: dailyVolumeSql(usageOrgId.value, events),
|
||||
seriesColors: events.map((event) => ({
|
||||
value: event,
|
||||
color: meterColorFor(meterForEvent(event)?.key ?? "ingestion"),
|
||||
})),
|
||||
unit: "megabytes",
|
||||
t,
|
||||
});
|
||||
}
|
||||
const spec = chartSection.value;
|
||||
if (!spec) return null;
|
||||
const ranked = chartNamesFor.value === `${viewKey.value}-${spec.id}` ? chartNames.value : [];
|
||||
return buildStackedCostPanelSchema({
|
||||
id: `usage-overview-volume-${meterKey}-${spec.id}`,
|
||||
sql: dailyEntitySql(usageOrgId.value, spec, 1),
|
||||
seriesColors: entitySeriesColors(meterKey, ranked, OTHERS_SERIES),
|
||||
topResults: CHART_TOP,
|
||||
fixedColor: meterColorFor(meterKey),
|
||||
unit: spec.unit === "count" ? "numbers" : "megabytes",
|
||||
t,
|
||||
});
|
||||
};
|
||||
|
||||
const chartSchema = computed(() => {
|
||||
if (!props.details || !usageStreamEnabled.value) return null;
|
||||
const meterKey = selectedMeter.value;
|
||||
if (props.volumeOnly) return volumeChartSchema(meterKey);
|
||||
if (isSuperOrg.value && !selectedOrg.value) {
|
||||
const otherLabel = t("billing.usageV2.otherBand");
|
||||
return buildStackedCostPanelSchema({
|
||||
id: "usage-overview-orgs",
|
||||
sql: dailyOrgCostSql(
|
||||
cyclePrices([props.details]),
|
||||
props.billingOrgs.map((org) => ({ id: org.id, name: org.name })),
|
||||
otherLabel,
|
||||
undefined,
|
||||
cycleStartUs.value,
|
||||
),
|
||||
seriesColors: props.billingOrgs.map((org, index) => ({
|
||||
value: org.name,
|
||||
color: paletteColor(index),
|
||||
})),
|
||||
t,
|
||||
});
|
||||
}
|
||||
if (!meterKey) {
|
||||
return buildTrendsPanelSchema({
|
||||
orgId: usageOrgId.value,
|
||||
cycles: [props.details],
|
||||
bucket: "1 day",
|
||||
since: cycleStartUs.value,
|
||||
t,
|
||||
});
|
||||
}
|
||||
const spec = chartSection.value;
|
||||
if (!spec) return null;
|
||||
// Runs alongside the table; the names only recolour the bands, which needs no new query.
|
||||
const ranked = chartNamesFor.value === `${viewKey.value}-${spec.id}` ? chartNames.value : [];
|
||||
return buildStackedCostPanelSchema({
|
||||
id: `usage-overview-${meterKey}-${spec.id}`,
|
||||
sql: dailyEntitySql(usageOrgId.value, spec, meterRate(meterKey), cycleStartUs.value),
|
||||
seriesColors: entitySeriesColors(meterKey, ranked, OTHERS_SERIES),
|
||||
topResults: CHART_TOP,
|
||||
fixedColor: meterColorFor(meterKey),
|
||||
t,
|
||||
});
|
||||
});
|
||||
|
||||
/** Refresh re-runs a chart through the new bill's window, not its key; the names only recolour it in place. */
|
||||
const chartKey = computed(() => {
|
||||
const view = `${props.volumeOnly ? "volume-" : ""}${selectedOrg.value?.id ?? "orgs"}`;
|
||||
return selectedMeter.value
|
||||
? `${view}-${selectedMeter.value}-${chartSection.value?.id}`
|
||||
: `${view}-all`;
|
||||
});
|
||||
|
||||
const chartTitle = computed(() =>
|
||||
props.volumeOnly ? t("billing.usageV2.dailyVolume") : t("billing.usageV2.dailyCost"),
|
||||
);
|
||||
|
||||
const chartSubtitle = computed(() =>
|
||||
selectedMeter.value
|
||||
? t("billing.usageV2.meterByGroup", {
|
||||
meter: selectedLabel.value,
|
||||
group: chartSection.value ? t(chartSection.value.columnKey) : EM_DASH,
|
||||
})
|
||||
: selectedOrg.value
|
||||
? raw(selectedOrg.value.name)
|
||||
: isSuperOrg.value
|
||||
? t("billing.usageV2.byOrg")
|
||||
: props.volumeOnly
|
||||
? t("billing.usageV2.volumeSubtitle")
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const chartEmptyTitle = computed(() =>
|
||||
!usageStreamEnabled.value
|
||||
? t("billing.usageTrends.enableTitle")
|
||||
: selectedMeter.value === "retention"
|
||||
? t("billing.usageV2.noDailyRetention")
|
||||
: t("billing.usageTrends.waitingTitle"),
|
||||
);
|
||||
|
||||
const onTopNames = (payload: { section: DrilldownSection | null; names: string[] }) => {
|
||||
chartNames.value = payload.names;
|
||||
chartNamesFor.value = `${viewKey.value}-${payload.section?.id}`;
|
||||
};
|
||||
|
||||
const selectMeter = async (key: MeterKey | null) => {
|
||||
if (key === selectedMeter.value) return;
|
||||
router.replace({ query: { ...route.query, meter: key ?? undefined, group: undefined } });
|
||||
// Stacked on a phone, the breakdown sits below the strip, so bring it into view.
|
||||
if (key && isMobile.value) {
|
||||
await nextTick();
|
||||
breakdownAnchor.value?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
const selectGroup = (group: string) => {
|
||||
router.replace({ query: { ...route.query, group } });
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
refreshToken.value += 1;
|
||||
emit("refresh");
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col gap-2.5" data-test="billings-usageoverview-skeleton">
|
||||
<div class="flex shrink-0 items-center justify-between gap-2">
|
||||
<OSkeleton type="text" class="h-4 w-64" />
|
||||
<OSkeleton type="text" class="h-4 w-24" />
|
||||
</div>
|
||||
|
||||
<KpiCardRow :columns="4" class="shrink-0">
|
||||
<div
|
||||
v-for="card in KPI_COUNT"
|
||||
:key="card"
|
||||
class="bg-card-glass-bg rounded-default border-border-default flex flex-col gap-2 border px-3.5 py-2.5"
|
||||
>
|
||||
<OSkeleton type="text" class="h-3 w-24" />
|
||||
<OSkeleton type="text" class="h-6 w-20" />
|
||||
<OSkeleton type="text" class="h-3 w-28" />
|
||||
</div>
|
||||
</KpiCardRow>
|
||||
|
||||
<section
|
||||
class="border-border-default rounded-surface @container/usage-breakdown flex min-h-0 flex-1 flex-col overflow-hidden border"
|
||||
>
|
||||
<div class="border-border-default border-b px-4 py-2.5">
|
||||
<OSkeleton class="h-7 w-full" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 @min-[56.25rem]/usage-breakdown:grid-cols-[3fr_2fr]"
|
||||
>
|
||||
<div class="flex min-h-0 flex-col gap-2 p-4">
|
||||
<OSkeleton v-for="row in TABLE_ROWS" :key="row" type="text" class="h-5 w-full" />
|
||||
</div>
|
||||
<div
|
||||
class="border-border-default flex min-h-0 border-t p-4 @min-[56.25rem]/usage-breakdown:border-s @min-[56.25rem]/usage-breakdown:border-t-0"
|
||||
>
|
||||
<UsageChartSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
import KpiCardRow from "@/components/common/KpiCardRow.vue";
|
||||
import UsageChartSkeleton from "./UsageChartSkeleton.vue";
|
||||
|
||||
const KPI_COUNT = 4;
|
||||
|
||||
const TABLE_ROWS = 8;
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!-- A pane title bar inside a shared panel. Tall enough for a back button and a group-by toggle, so drilling in never moves the content below. -->
|
||||
<template>
|
||||
<div
|
||||
class="border-border-default flex min-h-10 w-full flex-nowrap items-center gap-2 border-b px-2 py-1"
|
||||
>
|
||||
<slot name="leading" />
|
||||
<div class="flex min-w-0 items-baseline gap-2">
|
||||
<span class="text-compact text-text-heading shrink-0 font-medium tracking-[0.02em]">
|
||||
{{ title }}
|
||||
</span>
|
||||
<span v-if="subtitle" class="text-text-secondary text-2xs truncate leading-normal">
|
||||
{{ subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="ms-auto flex shrink-0 items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
|
||||
defineProps<{ title: I18nText; subtitle?: I18nText }>();
|
||||
</script>
|
||||
@@ -0,0 +1,653 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- A failed read is not "no budgets": saving then would replace every stored budget. -->
|
||||
<div v-if="loadFailed" class="h-full min-h-0" data-test="billings-usagesuperbudget-error">
|
||||
<OEmptyState
|
||||
size="hero"
|
||||
variant="error"
|
||||
illustration="broken-panel"
|
||||
:title="t('billing.usageV2.budgetLoadFailedTitle')"
|
||||
:description="t('billing.usageV2.budgetLoadFailed')"
|
||||
:action-label="t('common.retry')"
|
||||
action-icon="refresh"
|
||||
@action="load"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="@container/super-budget flex h-full min-h-0 flex-col gap-3 overflow-y-auto"
|
||||
data-test="billings-usagesuperbudget-root"
|
||||
>
|
||||
<!-- Wide: only the org table scrolls, so the save bar stays in view. Stacked: the tab scrolls. -->
|
||||
<KpiCardRow :columns="4" class="shrink-0" data-test="billings-usagesuperbudget-kpis">
|
||||
<UsageKpiCard
|
||||
v-for="card in kpiCards"
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:icon="card.icon"
|
||||
:value="card.value"
|
||||
:hint="card.hint"
|
||||
:tone="card.tone"
|
||||
:data-test="`billings-usagesuperbudget-kpi-${card.key}`"
|
||||
/>
|
||||
</KpiCardRow>
|
||||
|
||||
<div
|
||||
class="grid items-stretch gap-3 @min-[62rem]/super-budget:min-h-0 @min-[62rem]/super-budget:flex-1 @min-[62rem]/super-budget:grid-cols-[minmax(0,1.55fr)_minmax(18.75rem,0.85fr)]"
|
||||
>
|
||||
<!-- ── Organizations: reports each org's spend and edits its budget ── -->
|
||||
<section
|
||||
class="border-border-default rounded-default flex min-h-0 min-w-0 flex-col overflow-hidden border"
|
||||
data-test="billings-usagesuperbudget-orgs"
|
||||
>
|
||||
<UsagePaneHeader
|
||||
:title="t('billing.usageV2.organizationsTitle')"
|
||||
:subtitle="t('billing.usageV2.orgCountThisCycle', { count: billingOrgs.length })"
|
||||
/>
|
||||
|
||||
<div class="shrink-0 px-4 pt-3 pb-1.5">
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
<span class="text-text-heading text-xl font-bold tabular-nums">
|
||||
{{ formatCost(spent) }}
|
||||
</span>
|
||||
<span v-if="totalDraft.budget > 0" class="text-text-secondary text-sm tabular-nums">
|
||||
{{
|
||||
t("billing.usageV2.ofBudget", {
|
||||
budget: formatCost(totalDraft.budget),
|
||||
percent: usedPercent.toFixed(1),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="totalDraft.budget > 0">
|
||||
<div class="relative mt-2">
|
||||
<OProgressBar
|
||||
:value="Math.min(usedPercent / 100, 1)"
|
||||
:variant="progressVariant(totalStatus)"
|
||||
data-test="billings-usagesuperbudget-meter"
|
||||
/>
|
||||
<span
|
||||
class="bg-text-secondary absolute -top-1 h-4 w-0.5"
|
||||
:style="{ left: `calc(${totalDraft.warnPercent}% - 0.0625rem)` }"
|
||||
:title="t('billing.usageV2.warnAt')"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-text-secondary mt-1.5 flex justify-between text-xs tabular-nums">
|
||||
<span>
|
||||
{{
|
||||
spent >= totalDraft.budget
|
||||
? t("billing.usageV2.overBy", { amount: formatCost(spent - totalDraft.budget) })
|
||||
: t("billing.usageV2.leftInBudget", {
|
||||
amount: formatCost(totalDraft.budget - spent),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span>{{
|
||||
t("billing.usageV2.warnAtPercent", { percent: percentLabel(totalDraft) })
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-text-secondary mt-1 text-sm">
|
||||
{{ t("billing.usageV2.setBudgetFirst") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OTable
|
||||
:data="orgRows"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
pagination="none"
|
||||
sorting="none"
|
||||
:show-global-filter="false"
|
||||
:default-columns="false"
|
||||
:loading="costsLoading"
|
||||
dense
|
||||
class="min-h-0 flex-1"
|
||||
data-test="billings-usagesuperbudget-table"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
<!-- Marked for the table's cut-off tooltip, so a cut name shows in full on hover. -->
|
||||
<span class="truncate font-medium" data-o2-cell-clip>{{ row.label }}</span>
|
||||
</template>
|
||||
<template #cell-spend="{ row }">
|
||||
<!-- Hovering the spend shows what makes it up, meter by meter. -->
|
||||
<OTooltip side="bottom" align="start" :delay="150" :disabled="!row.meters.length">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold tabular-nums">
|
||||
{{ costsFailed ? EM_DASH : formatCost(row.spend) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.draft.budget > 0 && !costsFailed"
|
||||
class="text-text-secondary text-xs tabular-nums"
|
||||
>
|
||||
{{ Math.round((row.spend / row.draft.budget) * 100) }}%
|
||||
</span>
|
||||
<OBadge
|
||||
v-if="!costsFailed"
|
||||
:variant="STATUS_BADGE[row.status]"
|
||||
size="sm"
|
||||
:data-test="`billings-usagesuperbudget-status-${row.id}`"
|
||||
>
|
||||
{{ t(STATUS_LABEL[row.status]) }}
|
||||
</OBadge>
|
||||
</div>
|
||||
<OProgressBar
|
||||
v-if="row.draft.budget > 0 && !costsFailed"
|
||||
size="xs"
|
||||
class="max-w-40"
|
||||
:value="Math.min(row.spend / row.draft.budget, 1)"
|
||||
:variant="progressVariant(row.status)"
|
||||
/>
|
||||
</div>
|
||||
<template #content>
|
||||
<div
|
||||
class="flex min-w-48 flex-col gap-1 text-xs"
|
||||
:data-test="`billings-usagesuperbudget-spend-tip-${row.id}`"
|
||||
>
|
||||
<div class="font-semibold">{{ row.label }}</div>
|
||||
<div
|
||||
v-for="meter in row.meters"
|
||||
:key="meter.key"
|
||||
class="flex justify-between gap-4"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="rounded-default h-2 w-2 shrink-0"
|
||||
:style="{ backgroundColor: meterColorFor(meter.key) }"
|
||||
/>
|
||||
{{ meter.label }}
|
||||
</span>
|
||||
<span class="tabular-nums">{{ formatCost(meter.cost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t pt-1 font-semibold">
|
||||
<span>{{ t("billing.usageV2.colTotal") }}</span>
|
||||
<span class="tabular-nums">{{ formatCost(row.spend) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OTooltip>
|
||||
</template>
|
||||
<template #cell-budget="{ row }">
|
||||
<!-- Fills the cell, so a narrow column never clips the input's border. -->
|
||||
<div class="w-full">
|
||||
<OInput
|
||||
:model-value="row.draft.budget || undefined"
|
||||
type="number"
|
||||
size="sm"
|
||||
prefix="$"
|
||||
:placeholder="raw('0.00')"
|
||||
:aria-label="t('billing.usageV2.budgetFor', { org: row.label })"
|
||||
:data-test="`billings-usagesuperbudget-budget-${row.id}`"
|
||||
@update:model-value="setOrgBudget(row.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-warn="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-16">
|
||||
<OInput
|
||||
:model-value="row.draft.budget > 0 ? percentLabel(row.draft) : undefined"
|
||||
type="number"
|
||||
size="sm"
|
||||
suffix="%"
|
||||
:disabled="row.draft.budget <= 0"
|
||||
:aria-label="t('billing.usageV2.warnPercentFor', { org: row.label })"
|
||||
:data-test="`billings-usagesuperbudget-warn-${row.id}`"
|
||||
@update:model-value="setOrgWarn(row.id, $event)"
|
||||
@blur="showSaved($event, percentLabel(row.draft))"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="row.draft.budget > 0"
|
||||
class="text-text-secondary text-xs whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{{ formatCost(warnAmountFor(row.draft.warnPercent, row.draft.budget)) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-alerts="{ row }">
|
||||
<OSwitch
|
||||
:model-value="row.draft.alertsOn"
|
||||
size="sm"
|
||||
:disabled="row.draft.budget <= 0"
|
||||
:aria-label="t('billing.usageV2.alertsFor', { org: row.label })"
|
||||
:data-test="`billings-usagesuperbudget-alerts-${row.id}`"
|
||||
@update:model-value="setOrgAlerts(row.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
</OTable>
|
||||
</section>
|
||||
|
||||
<!-- ── Budget settings: the whole group's budget ── -->
|
||||
<section
|
||||
class="border-border-default rounded-default flex min-h-0 min-w-0 flex-col overflow-y-auto border"
|
||||
data-test="billings-usagesuperbudget-settings"
|
||||
>
|
||||
<UsagePaneHeader
|
||||
:title="t('billing.usageV2.budgetSettings')"
|
||||
:subtitle="t('billing.usageV2.appliesToGroup')"
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-4 p-4">
|
||||
<OInput
|
||||
:model-value="totalDraft.budget || undefined"
|
||||
type="number"
|
||||
size="md"
|
||||
prefix="$"
|
||||
:label="t('billing.usageV2.overallBudgetLabel')"
|
||||
:help-text="allocationNote"
|
||||
data-test="billings-usagesuperbudget-total"
|
||||
@update:model-value="totalDraft.budget = toNumber($event)"
|
||||
/>
|
||||
|
||||
<UsageWarnAt
|
||||
v-model="totalDraft.warnPercent"
|
||||
:budget="totalDraft.budget"
|
||||
data-test="billings-usagesuperbudget-total-warn"
|
||||
/>
|
||||
|
||||
<div class="border-border-default border-t" />
|
||||
|
||||
<div
|
||||
class="border-border-default rounded-default flex items-center gap-3 border px-3 py-2.5"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold">{{ t("billing.usageV2.emailAlerts") }}</div>
|
||||
<div class="text-text-secondary text-xs">
|
||||
{{
|
||||
totalDraft.alertsOn
|
||||
? t("billing.usageV2.groupAlertsOnHint")
|
||||
: t("billing.usageV2.alertsPausedHint")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<OSwitch
|
||||
v-model="totalDraft.alertsOn"
|
||||
size="sm"
|
||||
:disabled="totalDraft.budget <= 0"
|
||||
:aria-label="t('billing.usageV2.emailAlerts')"
|
||||
data-test="billings-usagesuperbudget-total-alerts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- One bar commits every pending edit; it stays put and only disables when clean. -->
|
||||
<div
|
||||
class="border-border-default rounded-default bg-surface-subtle flex shrink-0 items-center gap-3 border px-4 py-2.5"
|
||||
data-test="billings-usagesuperbudget-savebar"
|
||||
>
|
||||
<span class="text-text-secondary min-w-0 flex-1 truncate text-sm">{{ pendingText }}</span>
|
||||
<OButton
|
||||
variant="outline"
|
||||
:disabled="!pending.any || saving"
|
||||
data-test="billings-usagesuperbudget-discard"
|
||||
@click="resetDrafts"
|
||||
>
|
||||
{{ t("billing.usageV2.discard") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="primary"
|
||||
:disabled="!pending.any || !loaded"
|
||||
:loading="saving"
|
||||
data-test="billings-usagesuperbudget-save"
|
||||
@click="save"
|
||||
>
|
||||
{{ t("billing.usageV2.saveChanges") }}
|
||||
</OButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OBadge from "@/lib/core/Badge/OBadge.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import KpiCardRow from "@/components/common/KpiCardRow.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import organizations from "@/services/organizations";
|
||||
import UsageKpiCard from "./UsageKpiCard.vue";
|
||||
import UsagePaneHeader from "./UsagePaneHeader.vue";
|
||||
import UsageWarnAt from "./UsageWarnAt.vue";
|
||||
import {
|
||||
cycleProgress,
|
||||
formatCost,
|
||||
METERS,
|
||||
type BillingOrg,
|
||||
type MeterKey,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
import {
|
||||
budgetFor,
|
||||
budgetStatus,
|
||||
nextWarnPercent,
|
||||
draftOf,
|
||||
sameBudget,
|
||||
superBudgetConfigs,
|
||||
TOTAL_BUDGET_ID,
|
||||
warnAmountFor,
|
||||
type BudgetConfig,
|
||||
type BudgetDraft,
|
||||
type BudgetStatus,
|
||||
} from "./budgetModel";
|
||||
import type { OrgCostsState } from "./useOrgCosts";
|
||||
import { meterColorFor } from "./usageCharts";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
details: MeteringDetails | null;
|
||||
/** The super org first, then its members. */
|
||||
billingOrgs: BillingOrg[];
|
||||
/** The cycle's projected total, worked out once by the page so every tab shows the same figure. */
|
||||
projected: number | null;
|
||||
/** Per-org spend, read once by the page and shared with Overview, so both judge the same figures. */
|
||||
orgCosts?: OrgCostsState;
|
||||
}>(),
|
||||
{ orgCosts: () => ({ costs: {}, loading: false, failed: false }) },
|
||||
);
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const EM_DASH = raw("—");
|
||||
|
||||
const STATUS_LABEL: Record<BudgetStatus, I18nKey> = {
|
||||
none: "billing.usageV2.statusNone",
|
||||
ok: "billing.usageV2.statusOk",
|
||||
warn: "billing.usageV2.statusWarn",
|
||||
over: "billing.usageV2.statusOver",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<BudgetStatus, BadgeVariant> = {
|
||||
none: "default-soft",
|
||||
ok: "success-soft",
|
||||
warn: "warning-soft",
|
||||
over: "error-soft",
|
||||
};
|
||||
|
||||
const progressVariant = (status: BudgetStatus) =>
|
||||
status === "over" ? "danger" : status === "warn" ? "warning" : "default";
|
||||
|
||||
const orgId = computed<string>(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
// ── Spend ────────────────────────────────────────────────────────
|
||||
|
||||
const costs = computed(() => props.orgCosts.costs);
|
||||
const costsLoading = computed(() => props.orgCosts.loading);
|
||||
const costsFailed = computed(() => props.orgCosts.failed);
|
||||
|
||||
/** The whole group's bill, which the overall budget is measured against. */
|
||||
const spent = computed(() => Number(props.details?.total_cost) || 0);
|
||||
const progress = computed(() =>
|
||||
props.details ? cycleProgress(props.details) : { day: 1, totalDays: 1, elapsed: 0 },
|
||||
);
|
||||
const projected = computed(() => props.projected);
|
||||
|
||||
// ── Saved and draft budgets ──────────────────────────────────────
|
||||
|
||||
const saved = ref<BudgetConfig[]>([]);
|
||||
const totalDraft = reactive<BudgetDraft>(draftOf(null));
|
||||
const orgDrafts = reactive<Record<string, BudgetDraft>>({});
|
||||
const saving = ref(false);
|
||||
|
||||
const savedDraft = (id: string) => draftOf(budgetFor(saved.value, id));
|
||||
|
||||
const resetDrafts = () => {
|
||||
Object.assign(totalDraft, savedDraft(TOTAL_BUDGET_ID));
|
||||
for (const key of Object.keys(orgDrafts)) delete orgDrafts[key];
|
||||
for (const org of props.billingOrgs) orgDrafts[org.id] = savedDraft(org.id);
|
||||
};
|
||||
|
||||
/** Save waits for the stored list: it sends the whole list, so one built without it would erase the rest. */
|
||||
const loaded = ref(false);
|
||||
const loadFailed = ref(false);
|
||||
|
||||
const load = async () => {
|
||||
loaded.value = false;
|
||||
loadFailed.value = false;
|
||||
try {
|
||||
const res = await organizations.get_organization_settings(orgId.value);
|
||||
saved.value = res?.data?.data?.budget_config ?? [];
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
loadFailed.value = true;
|
||||
}
|
||||
resetDrafts();
|
||||
};
|
||||
|
||||
watch([orgId, () => props.billingOrgs.map((org) => org.id).join(",")], load, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const toNumber = (value: unknown) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
};
|
||||
|
||||
const percentLabel = (draft: BudgetDraft) => Math.round(draft.warnPercent * 10) / 10;
|
||||
|
||||
const setOrgBudget = (id: string, value: unknown) => {
|
||||
if (orgDrafts[id]) orgDrafts[id].budget = toNumber(value);
|
||||
};
|
||||
const setOrgWarn = (id: string, value: unknown) => {
|
||||
if (orgDrafts[id]) orgDrafts[id].warnPercent = nextWarnPercent(value, orgDrafts[id].warnPercent);
|
||||
};
|
||||
/** An entry that was not taken, such as an emptied field, shows the kept value once focus leaves. */
|
||||
const showSaved = (event: FocusEvent, value: number) => {
|
||||
(event.target as HTMLInputElement).value = String(value);
|
||||
};
|
||||
const setOrgAlerts = (id: string, value: unknown) => {
|
||||
if (orgDrafts[id]) orgDrafts[id].alertsOn = !!value;
|
||||
};
|
||||
|
||||
// ── Rows ─────────────────────────────────────────────────────────
|
||||
|
||||
interface OrgRow {
|
||||
id: string;
|
||||
label: I18nText;
|
||||
spend: number;
|
||||
/** Spend per meter, priciest first, for the spend tooltip. */
|
||||
meters: { key: MeterKey; label: I18nText; cost: number }[];
|
||||
draft: BudgetDraft;
|
||||
status: BudgetStatus;
|
||||
}
|
||||
|
||||
const orgRows = computed<OrgRow[]>(() =>
|
||||
props.billingOrgs.map((org) => {
|
||||
const draft = orgDrafts[org.id] ?? draftOf(null);
|
||||
const spend = costs.value[org.id]?.billed ?? 0;
|
||||
const byMeter = costs.value[org.id]?.byMeter ?? {};
|
||||
return {
|
||||
id: org.id,
|
||||
label: org.isSelf ? t("billing.usageV2.thisOrg", { org: org.name }) : raw(org.name),
|
||||
spend,
|
||||
meters: METERS.filter((def) => (byMeter[def.key]?.billed ?? 0) > 0)
|
||||
.map((def) => ({
|
||||
key: def.key,
|
||||
label: t(def.labelKey),
|
||||
cost: byMeter[def.key]?.billed ?? 0,
|
||||
}))
|
||||
.sort((x, y) => y.cost - x.cost),
|
||||
draft,
|
||||
status: budgetStatus(spend, draft.budget, warnAmountFor(draft.warnPercent, draft.budget)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const columns = computed<OTableColumnDef<OrgRow>[]>(() => [
|
||||
{
|
||||
id: "name",
|
||||
header: t("billing.usageV2.colOrganization"),
|
||||
accessorKey: "label",
|
||||
meta: { isName: true, autoWidth: true },
|
||||
minSize: 160,
|
||||
},
|
||||
{ id: "spend", header: t("billing.usageV2.colSpendThisCycle"), accessorKey: "spend", size: 220 },
|
||||
{ id: "budget", header: t("billing.usageV2.colBudget"), accessorKey: "id", size: 112 },
|
||||
{ id: "warn", header: t("billing.usageV2.warnAt"), accessorKey: "id", size: 160 },
|
||||
{ id: "alerts", header: t("billing.usageV2.colAlerts"), accessorKey: "id", size: 56 },
|
||||
]);
|
||||
|
||||
const totalWarnAt = computed(() => warnAmountFor(totalDraft.warnPercent, totalDraft.budget));
|
||||
const totalStatus = computed(() => budgetStatus(spent.value, totalDraft.budget, totalWarnAt.value));
|
||||
const usedPercent = computed(() =>
|
||||
totalDraft.budget > 0 ? (spent.value / totalDraft.budget) * 100 : 0,
|
||||
);
|
||||
|
||||
/** Org budgets are separate limits, so their sum may exceed the overall budget; the page says so. */
|
||||
const allocationNote = computed(() => {
|
||||
const allocated = Object.values(orgDrafts).reduce((sum, draft) => sum + draft.budget, 0);
|
||||
if (allocated <= 0) return undefined;
|
||||
return allocated > totalDraft.budget && totalDraft.budget > 0
|
||||
? t("billing.usageV2.orgBudgetsOver", {
|
||||
amount: formatCost(allocated),
|
||||
over: formatCost(allocated - totalDraft.budget),
|
||||
})
|
||||
: t("billing.usageV2.orgBudgetsTotal", { amount: formatCost(allocated) });
|
||||
});
|
||||
|
||||
// ── Save ─────────────────────────────────────────────────────────
|
||||
|
||||
const pending = computed(() => {
|
||||
const orgs = props.billingOrgs.filter(
|
||||
(org) => orgDrafts[org.id] && !sameBudget(orgDrafts[org.id], savedDraft(org.id)),
|
||||
).length;
|
||||
const overall = !sameBudget(totalDraft, savedDraft(TOTAL_BUDGET_ID));
|
||||
return { orgs, overall, any: orgs > 0 || overall };
|
||||
});
|
||||
|
||||
const pendingText = computed(() => {
|
||||
const p = pending.value;
|
||||
if (!p.any) return t("billing.usageV2.allChangesSaved");
|
||||
const parts: string[] = [];
|
||||
if (p.orgs) parts.push(String(t("billing.usageV2.unsavedOrgs", { count: p.orgs })));
|
||||
if (p.overall) parts.push(String(t("billing.usageV2.unsavedOverall")));
|
||||
return t("billing.usageV2.unsavedChanges", { parts: raw(parts.join(" · ")) });
|
||||
});
|
||||
|
||||
/** Sends only `budget_config`: the whole array, since it replaces what is stored. */
|
||||
const save = async () => {
|
||||
if (!pending.value.any || !loaded.value) return;
|
||||
saving.value = true;
|
||||
const next = superBudgetConfigs(
|
||||
{ ...totalDraft },
|
||||
Object.fromEntries(props.billingOrgs.map((org) => [org.id, { ...orgDrafts[org.id] }])),
|
||||
);
|
||||
try {
|
||||
await organizations.post_organization_settings(orgId.value, { budget_config: next });
|
||||
// Drafts are not reset: the saved ones now match, and an edit typed during the request stays pending.
|
||||
saved.value = next;
|
||||
store.dispatch("setOrganizationSettings", {
|
||||
...(store.state?.organizationData?.organizationSettings ?? {}),
|
||||
budget_config: next,
|
||||
});
|
||||
toast({ variant: "success", message: t("billing.usageV2.budgetSaved"), timeout: 4000 });
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message:
|
||||
raw(e?.response?.data?.message ?? e?.message) || t("billing.usageV2.budgetSaveFailed"),
|
||||
timeout: 5000,
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── KPIs ─────────────────────────────────────────────────────────
|
||||
|
||||
const kpiCards = computed(() => {
|
||||
const total = totalDraft.budget;
|
||||
const tone = (status: BudgetStatus) =>
|
||||
status === "over" ? ("danger" as const) : status === "warn" ? ("warn" as const) : undefined;
|
||||
const proj = projected.value;
|
||||
const over = orgRows.value.filter((row) => row.status === "over").length;
|
||||
const near = orgRows.value.filter((row) => row.status === "warn").length;
|
||||
|
||||
const cards: {
|
||||
key: string;
|
||||
label: I18nText;
|
||||
icon: IconName;
|
||||
value: I18nText;
|
||||
hint: I18nText;
|
||||
tone?: "warn" | "danger";
|
||||
}[] = [
|
||||
{
|
||||
key: "budget",
|
||||
label: t("billing.usageV2.overallBudget"),
|
||||
icon: "attach-money",
|
||||
value: total > 0 ? formatCost(total) : t("billing.usageV2.budgetNotSet"),
|
||||
hint: t("billing.usageV2.budgetThisCycle"),
|
||||
},
|
||||
{
|
||||
key: "spent",
|
||||
label: t("billing.usageV2.costSoFar"),
|
||||
icon: "show-chart",
|
||||
value: formatCost(spent.value),
|
||||
hint:
|
||||
total > 0
|
||||
? t("billing.usageV2.spentOfBudget", {
|
||||
percent: usedPercent.value.toFixed(1),
|
||||
day: progress.value.day,
|
||||
total: progress.value.totalDays,
|
||||
})
|
||||
: t("billing.usageV2.cycleDay", {
|
||||
day: progress.value.day,
|
||||
total: progress.value.totalDays,
|
||||
}),
|
||||
tone: tone(totalStatus.value),
|
||||
},
|
||||
{
|
||||
key: "projected",
|
||||
label: t("billing.usageV2.projectedTotal"),
|
||||
icon: "trending-up",
|
||||
value: proj === null ? EM_DASH : formatCost(proj),
|
||||
hint:
|
||||
proj === null
|
||||
? t("billing.usageV2.tooEarlyToProject")
|
||||
: total <= 0
|
||||
? raw("")
|
||||
: proj > total
|
||||
? t("billing.usageV2.overBudgetBy", { amount: formatCost(proj - total) })
|
||||
: t("billing.usageV2.toSpare", { amount: formatCost(total - proj) }),
|
||||
tone: proj === null ? undefined : tone(budgetStatus(proj, total, totalWarnAt.value)),
|
||||
},
|
||||
{
|
||||
key: "attention",
|
||||
label: t("billing.usageV2.needsAttention"),
|
||||
icon: "warning",
|
||||
value: costsFailed.value ? EM_DASH : raw(String(over + near)),
|
||||
hint: t("billing.usageV2.attentionHint", { over, near }),
|
||||
tone: costsFailed.value ? undefined : over ? "danger" : near ? "warn" : undefined,
|
||||
},
|
||||
];
|
||||
return cards;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, h } from "vue";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { mockMeteringResponse } from "./meteringMock";
|
||||
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: (...args: unknown[]) => toast(...args) }));
|
||||
vi.mock("@/services/organizations", () => ({
|
||||
default: { post_organization_settings: (...args: unknown[]) => post(...args) },
|
||||
}));
|
||||
// A live route, so a query the page writes is what it reads back, as after a reload.
|
||||
const nav = vi.hoisted(() => ({ route: null as unknown as { query: Record<string, unknown> } }));
|
||||
const replace = vi.fn(({ query }: { query: Record<string, unknown> }) => {
|
||||
nav.route.query = Object.fromEntries(Object.entries(query).filter(([, v]) => v !== undefined));
|
||||
});
|
||||
vi.mock("vue-router", async () => {
|
||||
const { reactive } = await import("vue");
|
||||
nav.route = reactive({ query: {} });
|
||||
return { useRoute: () => nav.route, useRouter: () => ({ replace }) };
|
||||
});
|
||||
const search = vi.fn().mockResolvedValue([]);
|
||||
vi.mock("./useUsageSearch", () => ({ useUsageSearch: () => ({ search }) }));
|
||||
|
||||
// The picker as Trends sees it: its ‹ switch, and the setter the page keeps its label in step with.
|
||||
const setAbsoluteTime = vi.fn();
|
||||
const DateTimeStub = defineComponent({
|
||||
name: "DateTime",
|
||||
props: { disablePrevShift: Boolean },
|
||||
emits: ["on:date-change"],
|
||||
setup: (_, { expose }) => {
|
||||
expose({ setAbsoluteTime });
|
||||
return () => h("div");
|
||||
},
|
||||
});
|
||||
|
||||
const { default: UsageTrends } = await import("./UsageTrends.vue");
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
describe("UsageTrends usage reporting", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset().mockResolvedValue({ data: {} });
|
||||
// The page's copy of the settings, loaded earlier; another admin may have changed them since.
|
||||
store.state.organizationData.organizationSettings = {
|
||||
...store.state.organizationData.organizationSettings,
|
||||
usage_stream_enabled: false,
|
||||
scrape_interval: 15,
|
||||
budget_config: [{ org_id: "o", total_budget_amount: 100 }],
|
||||
};
|
||||
});
|
||||
|
||||
it("turns reporting on without writing back the page's older copy of the other settings", async () => {
|
||||
const upcoming = mockMeteringResponse().upcoming!;
|
||||
const wrapper = mount(UsageTrends, {
|
||||
props: { details: upcoming, cycles: [upcoming], billingOrgs: [], projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
wrapper.findComponent({ name: "ConfirmDialog" }).vm.$emit("update:ok");
|
||||
await flushPromises();
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post.mock.calls[0][1]).toEqual({ usage_stream_enabled: true });
|
||||
// The page still learns the new value straight away.
|
||||
expect(store.state.organizationData.organizationSettings.usage_stream_enabled).toBe(true);
|
||||
expect(store.state.organizationData.organizationSettings.scrape_interval).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageTrends usage reporting failure", () => {
|
||||
beforeEach(() => {
|
||||
toast.mockReset();
|
||||
store.state.organizationData.organizationSettings = {
|
||||
...store.state.organizationData.organizationSettings,
|
||||
usage_stream_enabled: false,
|
||||
};
|
||||
});
|
||||
|
||||
const turnOn = async () => {
|
||||
const upcoming = mockMeteringResponse().upcoming!;
|
||||
const wrapper = mount(UsageTrends, {
|
||||
props: { details: upcoming, cycles: [upcoming], billingOrgs: [], projected: null },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: "ConfirmDialog" }).vm.$emit("update:ok");
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
it("shows the server's reason, not the request library's status text", async () => {
|
||||
post.mockReset().mockRejectedValue({
|
||||
message: "Request failed with status code 400",
|
||||
response: { data: { message: "usage reporting is not available on this plan" } },
|
||||
});
|
||||
await turnOn();
|
||||
expect(toast).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "error",
|
||||
message: "usage reporting is not available on this plan",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to its own words when the server gives no reason", async () => {
|
||||
post.mockReset().mockRejectedValue({ message: "Network Error" });
|
||||
await turnOn();
|
||||
expect(toast).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ variant: "error", message: "Failed to enable usage reporting" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageTrends cost and volume", () => {
|
||||
it("switches the chart to volume without asking the server again", async () => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
...store.state.organizationData.organizationSettings,
|
||||
usage_stream_enabled: true,
|
||||
};
|
||||
const upcoming = mockMeteringResponse().upcoming!;
|
||||
const day = new Date(upcoming.cycle_start * 1000).toISOString().slice(0, 19);
|
||||
search.mockReset().mockResolvedValue([
|
||||
{ x_axis_1: day, breakdown_1: "Ingestion", y_axis_1: 10, volume: 2048 },
|
||||
{ x_axis_1: day, breakdown_1: "AiCredits", y_axis_1: 5, volume: 7 },
|
||||
]);
|
||||
const wrapper = mount(UsageTrends, {
|
||||
props: { details: upcoming, cycles: [upcoming], billingOrgs: [], projected: null },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { DateTime: DateTimeStub, AsyncComponentWrapper: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
expect(search.mock.calls[0][0]).toContain('sum(size) as "volume"');
|
||||
// Retention is billed from a separate record Trends cannot read, so its totals say they leave it out.
|
||||
expect(wrapper.find('[data-test="billings-usagetrends-chart-pane"]').text()).toContain(
|
||||
"Excludes Data Retention",
|
||||
);
|
||||
|
||||
const measure = wrapper
|
||||
.findAllComponents({ name: "OToggleGroup" })
|
||||
.find((group) => group.vm.$attrs["data-test"] === "billings-usagetrends-measure")!;
|
||||
measure.vm.$emit("update:modelValue", "volume");
|
||||
await flushPromises();
|
||||
|
||||
const header = wrapper.find('[data-test="billings-usagetrends-chart-pane"]');
|
||||
expect(header.text()).toContain("Volume Over Time");
|
||||
// The forecast is money, so it has no place on a volume chart.
|
||||
expect(wrapper.find('[data-test="billings-usagetrends-forecast-toggle"]').exists()).toBe(false);
|
||||
// Volume came back with the cost rows, so switching sends nothing.
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageTrends range in the URL", () => {
|
||||
const upcoming = mockMeteringResponse().upcoming!;
|
||||
const DAY = 86_400;
|
||||
const from = (upcoming.cycle_start + DAY) * 1_000_000;
|
||||
const to = (upcoming.cycle_start + 3 * DAY) * 1_000_000;
|
||||
|
||||
const mountTrends = () =>
|
||||
mount(UsageTrends, {
|
||||
props: { details: upcoming, cycles: [upcoming], billingOrgs: [], projected: null },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { DateTime: DateTimeStub, AsyncComponentWrapper: true },
|
||||
},
|
||||
});
|
||||
|
||||
const rangeToggle = (wrapper: ReturnType<typeof mountTrends>) =>
|
||||
wrapper
|
||||
.findAllComponents({ name: "OToggleGroup" })
|
||||
.find((group) => group.vm.$attrs["data-test"] === "billings-usagetrends-range")!;
|
||||
|
||||
beforeEach(() => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
...store.state.organizationData.organizationSettings,
|
||||
usage_stream_enabled: true,
|
||||
};
|
||||
search.mockReset().mockResolvedValue([]);
|
||||
nav.route.query = { tab: "trends" };
|
||||
});
|
||||
|
||||
it("opens on the window a reloaded link carries", async () => {
|
||||
nav.route.query = { tab: "trends", from: String(from), to: String(to) };
|
||||
const wrapper = mountTrends();
|
||||
await flushPromises();
|
||||
|
||||
expect(search.mock.calls[0][1]).toMatchObject({ start: from, end: to });
|
||||
expect(rangeToggle(wrapper).props("modelValue")).toBe("custom");
|
||||
});
|
||||
|
||||
it("writes a picked window and a preset to the URL, keeping the rest of the query", async () => {
|
||||
const wrapper = mountTrends();
|
||||
await flushPromises();
|
||||
|
||||
wrapper
|
||||
.findComponent({ name: "DateTime" })
|
||||
.vm.$emit("on:date-change", { startTime: from, endTime: to, userChangedValue: true });
|
||||
await flushPromises();
|
||||
expect(nav.route.query).toEqual({ tab: "trends", from: String(from), to: String(to) });
|
||||
expect(search.mock.lastCall![1]).toMatchObject({ start: from, end: to });
|
||||
|
||||
rangeToggle(wrapper).vm.$emit("update:modelValue", "last90");
|
||||
await flushPromises();
|
||||
expect(nav.route.query).toEqual({ tab: "trends", range: "last90" });
|
||||
|
||||
rangeToggle(wrapper).vm.$emit("update:modelValue", "current");
|
||||
await flushPromises();
|
||||
expect(nav.route.query).toEqual({ tab: "trends" });
|
||||
});
|
||||
|
||||
it("turns ‹ off at the data start, and moves the picker back to it after a step past", async () => {
|
||||
// A picked window starting a day after the data, then ‹ steps it two days back.
|
||||
nav.route.query = { tab: "trends", from: String(from), to: String(to) };
|
||||
setAbsoluteTime.mockClear();
|
||||
const wrapper = mountTrends();
|
||||
await flushPromises();
|
||||
const picker = wrapper.findComponent({ name: "DateTime" });
|
||||
expect(picker.props("disablePrevShift")).toBe(false);
|
||||
|
||||
const first = upcoming.cycle_start * 1_000_000;
|
||||
picker.vm.$emit("on:date-change", {
|
||||
startTime: first - 86_400_000_000,
|
||||
endTime: first + 86_400_000_000,
|
||||
userChangedValue: true,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
// The chart starts on the first day of data, the label says so, and ‹ goes no further.
|
||||
expect(search.mock.lastCall![1].start).toBe(first);
|
||||
expect(setAbsoluteTime).toHaveBeenLastCalledWith(first, expect.any(Number));
|
||||
expect(picker.props("disablePrevShift")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the chart alone when another part of the URL changes", async () => {
|
||||
nav.route.query = { tab: "trends", from: String(from), to: String(to) };
|
||||
mountTrends();
|
||||
await flushPromises();
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
|
||||
nav.route.query = { ...nav.route.query, tab: "overview" };
|
||||
await flushPromises();
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("asks the server once on Refresh, and the reloaded bill does not replace that read with a cached one", async () => {
|
||||
const wrapper = mountTrends();
|
||||
await flushPromises();
|
||||
search.mockClear();
|
||||
|
||||
wrapper.findComponent('[data-test="billings-usagetrends-refresh"]').vm.$emit("click");
|
||||
// The page reloads the bill: new objects, same cycle and prices.
|
||||
await wrapper.setProps({
|
||||
details: structuredClone(upcoming),
|
||||
cycles: [structuredClone(upcoming)],
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
expect(search.mock.calls[0][1]).not.toHaveProperty("refresh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageTrends projected total", () => {
|
||||
it("shows the page's projection, with the recent pace named as such", async () => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
...store.state.organizationData.organizationSettings,
|
||||
usage_stream_enabled: true,
|
||||
};
|
||||
const upcoming = mockMeteringResponse().upcoming!;
|
||||
const yesterday = new Date((Math.floor(Date.now() / 86_400_000) - 1) * 86_400_000);
|
||||
search
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
{ x_axis_1: yesterday.toISOString().slice(0, 19), breakdown_1: "Ingestion", y_axis_1: 70 },
|
||||
]);
|
||||
nav.route.query = {};
|
||||
const wrapper = mount(UsageTrends, {
|
||||
props: { details: upcoming, cycles: [upcoming], billingOrgs: [], projected: 5000 },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { DateTime: DateTimeStub, AsyncComponentWrapper: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const card = wrapper.find('[data-test="billings-usagetrends-kpi-projected"]').text();
|
||||
// The same figure Overview and Budget show, not a second projection.
|
||||
expect(card).toContain("$5,000.00");
|
||||
expect(card).toContain("at the last 7 days' pace");
|
||||
|
||||
// Read from usage records, not the bill, so it does not take the bill's name.
|
||||
const total = wrapper.find('[data-test="billings-usagetrends-kpi-total"]').text();
|
||||
expect(total).toContain("Estimated Cost So Far");
|
||||
});
|
||||
|
||||
it("names the cost an estimate on a cycle's first day, before there is a pace", async () => {
|
||||
const today = Math.floor(Date.now() / 86_400_000) * 86_400;
|
||||
const upcoming = { ...mockMeteringResponse().upcoming!, cycle_start: today };
|
||||
search.mockReset().mockResolvedValue([]);
|
||||
nav.route.query = {};
|
||||
const wrapper = mount(UsageTrends, {
|
||||
props: { details: upcoming, cycles: [upcoming], billingOrgs: [], projected: 5000 },
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { DateTime: DateTimeStub, AsyncComponentWrapper: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="billings-usagetrends-kpi-projected"]').exists()).toBe(false);
|
||||
const total = wrapper.find('[data-test="billings-usagetrends-kpi-total"]').text();
|
||||
expect(total).toContain("Estimated Cost So Far");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,757 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!-- Cost over any range the metering data covers, each day priced by the cycle it fell in. -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5" data-test="billings-usagetrends-root">
|
||||
<OEmptyState
|
||||
v-if="!usageStreamEnabled"
|
||||
size="hero"
|
||||
illustration="wave-bars"
|
||||
:title="t('billing.usageTrends.enableTitle')"
|
||||
:description="t('billing.usageTrends.enableSubtitle')"
|
||||
:action-label="t('billing.usageTrends.enableButton')"
|
||||
data-test="billings-usagetrends-enable"
|
||||
@action="showEnableConfirm = true"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<OToggleGroup
|
||||
:model-value="preset"
|
||||
data-test="billings-usagetrends-range"
|
||||
@update:model-value="onPresetChange"
|
||||
>
|
||||
<OToggleGroupItem
|
||||
v-for="option in presetOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
size="sm"
|
||||
>
|
||||
{{ t(option.labelKey) }}
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
<!-- A super org reads the whole group by default; one org narrows every figure below. -->
|
||||
<div v-if="billingOrgs.length" class="w-56">
|
||||
<OSelect
|
||||
:model-value="selectedOrg?.id ?? ALL_ORGS"
|
||||
:options="orgOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
size="sm"
|
||||
searchable
|
||||
:aria-label="t('billing.usageV2.orgFilter')"
|
||||
data-test="billings-usagetrends-org"
|
||||
@update:model-value="selectOrg"
|
||||
/>
|
||||
</div>
|
||||
<!-- The logs search bar's picker, absolute only, so `min-date` keeps it from reaching before the data; ‹ stops there too. -->
|
||||
<DateTime
|
||||
ref="picker"
|
||||
auto-apply
|
||||
disable-relative
|
||||
:disable-prev-shift="range.start <= oldest"
|
||||
menu-align="end"
|
||||
class="ms-auto"
|
||||
default-type="absolute"
|
||||
:default-absolute-time="pickerDefault"
|
||||
:min-date="minDate"
|
||||
:query-range-restriction-msg="historyNote"
|
||||
:query-range-restriction-in-hour="historyHours"
|
||||
data-test="billings-usagetrends-picker"
|
||||
@on:date-change="onPickerChange"
|
||||
/>
|
||||
<ORefreshButton
|
||||
:last-run-at="lastLoadedAt"
|
||||
:loading="loading"
|
||||
data-test="billings-usagetrends-refresh"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KpiCardRow :columns="4" class="shrink-0" data-test="billings-usagetrends-kpis">
|
||||
<UsageKpiCard
|
||||
v-for="card in kpiCards"
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:icon="card.icon"
|
||||
:value="card.value"
|
||||
:hint="card.hint"
|
||||
:loading="loading"
|
||||
:data-test="`billings-usagetrends-kpi-${card.key}`"
|
||||
/>
|
||||
</KpiCardRow>
|
||||
|
||||
<section
|
||||
class="border-border-default rounded-surface @container/usage-trends overflow-hidden border"
|
||||
>
|
||||
<div
|
||||
class="grid @min-[56.25rem]/usage-trends:grid-cols-[minmax(0,1.6fr)_minmax(17.5rem,0.9fr)]"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col" data-test="billings-usagetrends-chart-pane">
|
||||
<UsagePaneHeader :title="chartTitle" :subtitle="chartSubtitle">
|
||||
<template #actions>
|
||||
<OSwitch
|
||||
v-if="canForecast && measure === 'cost'"
|
||||
v-model="showForecast"
|
||||
size="sm"
|
||||
:label="t('billing.usageV2.forecast')"
|
||||
data-test="billings-usagetrends-forecast-toggle"
|
||||
/>
|
||||
<!-- Both views come from the same query, so switching redraws without a request. -->
|
||||
<OToggleGroup
|
||||
:model-value="measure"
|
||||
data-test="billings-usagetrends-measure"
|
||||
@update:model-value="onMeasureChange"
|
||||
>
|
||||
<OToggleGroupItem value="cost" size="sm">
|
||||
{{ t("billing.usageV2.measureCost") }}
|
||||
</OToggleGroupItem>
|
||||
<OToggleGroupItem value="volume" size="sm">
|
||||
{{ t("billing.usageV2.measureVolume") }}
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
</template>
|
||||
</UsagePaneHeader>
|
||||
<OEmptyState
|
||||
v-if="streamMissing"
|
||||
size="inline"
|
||||
:title="t('billing.usageTrends.waitingTitle')"
|
||||
:description="t('billing.usageTrends.waitingForData')"
|
||||
data-test="billings-usagetrends-empty"
|
||||
/>
|
||||
<div v-else class="relative h-90 w-full p-2">
|
||||
<UsageChartSkeleton
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-10 p-4"
|
||||
data-test="billings-usagetrends-chart-loading"
|
||||
/>
|
||||
<ChartRenderer
|
||||
class="h-full w-full"
|
||||
:data="{ options: chartOption }"
|
||||
data-test="billings-usagetrends-chart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
class="border-border-default flex min-w-0 flex-col border-t @min-[56.25rem]/usage-trends:border-s @min-[56.25rem]/usage-trends:border-t-0"
|
||||
:aria-label="t('billing.usageV2.costByMeter')"
|
||||
>
|
||||
<UsagePaneHeader :title="t('billing.usageV2.costByMeter')" :subtitle="rangeLabel" />
|
||||
<OTable
|
||||
:data="meterRows"
|
||||
:columns="meterColumns"
|
||||
row-key="key"
|
||||
pagination="none"
|
||||
sorting="none"
|
||||
:show-global-filter="false"
|
||||
:loading="waitingForFirstChunk"
|
||||
:streaming="loading && rows.length > 0"
|
||||
:error="loadError ? t('billing.usageV2.trendsLoadFailed') : null"
|
||||
:row-class="meterRowClass"
|
||||
class="min-h-0 flex-1"
|
||||
data-test="billings-usagetrends-meters"
|
||||
@row-click="toggleMeter"
|
||||
>
|
||||
<template #cell-label="{ row }">
|
||||
<span class="flex min-w-0 items-center gap-2" :title="isolateHint(row)">
|
||||
<span
|
||||
class="rounded-default h-2.5 w-2.5 shrink-0"
|
||||
:style="{ backgroundColor: meterColorFor(row.key) }"
|
||||
/>
|
||||
<span class="truncate font-medium">{{ row.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-cost="{ row }">
|
||||
<div class="flex w-full min-w-28 flex-col gap-1">
|
||||
<span class="font-semibold">{{ formatCost(row.cost, precise) }}</span>
|
||||
<OProgressBar :value="row.share" :color="meterColorFor(row.key)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #error>
|
||||
<span class="text-text-secondary p-4 text-sm">
|
||||
{{ t("billing.usageV2.trendsLoadFailed") }}
|
||||
</span>
|
||||
</template>
|
||||
</OTable>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="showEnableConfirm"
|
||||
data-test="billings-usagetrends-enable-confirm"
|
||||
:title="t('billing.usageTrends.enableConfirmTitle')"
|
||||
:message="t('billing.usageTrends.enableConfirmMessage')"
|
||||
:ok-label="t('billing.usageTrends.enableButton')"
|
||||
@update:ok="enableUsageReporting"
|
||||
@update:cancel="showEnableConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
|
||||
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import DateTime from "@/components/DateTime.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import { timestampToTimezoneDate } from "@/utils/timezone";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import KpiCardRow from "@/components/common/KpiCardRow.vue";
|
||||
import { formatSizeFromMB } from "@/utils/formatters";
|
||||
import UsageKpiCard from "./UsageKpiCard.vue";
|
||||
import UsagePaneHeader from "./UsagePaneHeader.vue";
|
||||
import UsageChartSkeleton from "./UsageChartSkeleton.vue";
|
||||
import {
|
||||
formatCost,
|
||||
formatDay,
|
||||
formatFullDay,
|
||||
METERS,
|
||||
projectCycleCost,
|
||||
type BillingOrg,
|
||||
type MeterKey,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
import { buildTrendsChartOption, meterColorFor } from "./usageCharts";
|
||||
import { useUsageSearch } from "./useUsageSearch";
|
||||
import { useUsageReporting } from "./useUsageReporting";
|
||||
import { trendsCostSql } from "./usageQueries";
|
||||
import {
|
||||
cyclePrices,
|
||||
forecastCycle,
|
||||
PRICED_EVENTS,
|
||||
summarizeTrend,
|
||||
toCostRows,
|
||||
topMeter,
|
||||
toEpochSeconds,
|
||||
trendBucket,
|
||||
type CostRow,
|
||||
} from "./trendsModel";
|
||||
|
||||
const ChartRenderer = defineAsyncComponent(
|
||||
() => import("@/components/dashboards/panels/ChartRenderer.vue"),
|
||||
);
|
||||
|
||||
type Preset = "current" | "last" | "last90" | "custom";
|
||||
|
||||
interface Window {
|
||||
/** Unix seconds. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MeterRow {
|
||||
key: MeterKey;
|
||||
label: I18nText;
|
||||
cost: number;
|
||||
/** Share of the largest meter, for the bar. */
|
||||
share: number;
|
||||
}
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
details: MeteringDetails | null;
|
||||
/** Current cycle first, then closed cycles, newest first. */
|
||||
cycles: MeteringDetails[];
|
||||
/** The oldest moment the picker may reach; seconds or microseconds, the backend sends both. */
|
||||
oldestTs?: number | null;
|
||||
/** A super org's orgs, itself first; empty for any other org. */
|
||||
billingOrgs?: BillingOrg[];
|
||||
/** The cycle's projected total, worked out once by the page so every tab shows the same figure. */
|
||||
projected: number | null;
|
||||
}>(),
|
||||
{ billingOrgs: () => [] },
|
||||
);
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const orgId = computed<string>(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** The org selector's value for the whole group. */
|
||||
const ALL_ORGS = "__all__";
|
||||
|
||||
/** Shares `?org=` with Overview, so an org picked on one tab stays picked on the other. */
|
||||
const selectedOrg = computed<BillingOrg | null>(
|
||||
() => props.billingOrgs.find((org) => org.id === route.query.org) ?? null,
|
||||
);
|
||||
|
||||
const orgOptions = computed(() => [
|
||||
{ label: t("billing.usageV2.allOrgs"), value: ALL_ORGS },
|
||||
...props.billingOrgs.map((org) => ({
|
||||
label: org.isSelf ? t("billing.usageV2.thisOrg", { org: org.name }) : raw(org.name),
|
||||
value: org.id,
|
||||
})),
|
||||
]);
|
||||
|
||||
/** Whose usage the page counts: one org, or null for a super org's whole group. */
|
||||
const usageOrg = computed<string | null>(() =>
|
||||
props.billingOrgs.length ? (selectedOrg.value?.id ?? null) : orgId.value,
|
||||
);
|
||||
|
||||
const selectOrg = (value: unknown) => {
|
||||
const id = typeof value === "string" && value !== ALL_ORGS ? value : undefined;
|
||||
router.replace({ query: { ...route.query, org: id } });
|
||||
};
|
||||
|
||||
const {
|
||||
enabled: usageStreamEnabled,
|
||||
confirming: showEnableConfirm,
|
||||
enable: enableUsageReporting,
|
||||
} = useUsageReporting();
|
||||
|
||||
// ── Range ────────────────────────────────────────────────────────
|
||||
|
||||
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
const upcoming = computed(() => props.cycles[0] ?? props.details ?? null);
|
||||
|
||||
const lastCycle = computed(() => props.cycles[1] ?? null);
|
||||
|
||||
/** Neither the picker nor a preset may reach back past the data the metering API priced. */
|
||||
const oldest = computed(() => {
|
||||
const fromApi = toEpochSeconds(props.oldestTs);
|
||||
return fromApi || upcoming.value?.cycle_start || nowSeconds() - 30 * SECONDS_PER_DAY;
|
||||
});
|
||||
|
||||
/** A range wholly before the data moves forward to start on it, keeping its length. */
|
||||
const clamp = (span: Window): Window => {
|
||||
const start = Math.max(span.start, oldest.value);
|
||||
const length = Math.max(span.end - span.start, 60);
|
||||
return { start, end: span.end > start ? span.end : start + length };
|
||||
};
|
||||
|
||||
const fromSeconds = computed(() => Math.floor(Number(route.query.from) / 1_000_000));
|
||||
const toSeconds = computed(() => Math.floor(Number(route.query.to) / 1_000_000));
|
||||
const customWindow = computed<Window | null>(() => {
|
||||
const start = fromSeconds.value;
|
||||
const end = toSeconds.value;
|
||||
return Number.isFinite(start) && Number.isFinite(end) && end > start ? { start, end } : null;
|
||||
});
|
||||
|
||||
/** `?range=` names a preset; no value is the current cycle, and a picked window wins over both. */
|
||||
const preset = computed<Preset>(() => {
|
||||
if (customWindow.value) return "custom";
|
||||
if (route.query.range === "last" && lastCycle.value) return "last";
|
||||
return route.query.range === "last90" ? "last90" : "current";
|
||||
});
|
||||
|
||||
const setRange = (query: { range?: Preset; from?: string; to?: string }) =>
|
||||
router.replace({
|
||||
query: { ...route.query, range: undefined, from: undefined, to: undefined, ...query },
|
||||
});
|
||||
|
||||
const presetOptions = computed(() => {
|
||||
const options: { value: Preset; labelKey: I18nKey }[] = [
|
||||
{ value: "current", labelKey: "billing.usageV2.rangeCurrentCycle" },
|
||||
];
|
||||
if (lastCycle.value) options.push({ value: "last", labelKey: "billing.usageV2.rangeLastCycle" });
|
||||
options.push({ value: "last90", labelKey: "billing.usageV2.rangeLast90Days" });
|
||||
return options;
|
||||
});
|
||||
|
||||
const range = computed<Window>(() => {
|
||||
const now = nowSeconds();
|
||||
if (preset.value === "custom" && customWindow.value) return clamp(customWindow.value);
|
||||
if (preset.value === "last" && lastCycle.value) {
|
||||
return clamp({ start: lastCycle.value.cycle_start, end: lastCycle.value.cycle_end });
|
||||
}
|
||||
if (preset.value === "last90") return clamp({ start: now - 90 * SECONDS_PER_DAY, end: now });
|
||||
const cycle = upcoming.value;
|
||||
return clamp(
|
||||
cycle ? { start: cycle.cycle_start, end: cycle.cycle_end } : { start: now, end: now },
|
||||
);
|
||||
});
|
||||
|
||||
const onPresetChange = (value: unknown) => {
|
||||
if (typeof value !== "string" || !value) return;
|
||||
setRange({ range: value === "current" ? undefined : (value as Preset) });
|
||||
};
|
||||
|
||||
const historyNote = computed(() =>
|
||||
String(
|
||||
t("billing.usageV2.historyStartsOn", {
|
||||
date: raw(formatFullDay(oldest.value)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const historyHours = computed(() => Math.max(Math.ceil((nowSeconds() - oldest.value) / 3_600), 1));
|
||||
|
||||
/** DateTime compares calendar days as `yyyy/MM/dd` strings in the org's timezone. */
|
||||
const minDate = computed(() =>
|
||||
timestampToTimezoneDate(oldest.value * 1000, store.state.timezone, "yyyy/MM/dd"),
|
||||
);
|
||||
|
||||
/** The picker opens on the range the page is showing, in microseconds as it expects. */
|
||||
const pickerDefault = computed(() => ({
|
||||
startTime: range.value.start * 1_000_000,
|
||||
endTime: measuredEnd.value * 1_000_000,
|
||||
}));
|
||||
|
||||
/** DateTime also emits on mount; only a change the user made should leave the preset. */
|
||||
const onPickerChange = (value: {
|
||||
startTime?: number | string;
|
||||
endTime?: number | string;
|
||||
userChangedValue?: boolean;
|
||||
}) => {
|
||||
if (!value?.userChangedValue) return;
|
||||
const start = Math.floor(Number(value.startTime) / 1_000_000);
|
||||
const end = Math.floor(Number(value.endTime) / 1_000_000);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
|
||||
setRange({
|
||||
from: String(start * 1_000_000),
|
||||
to: String(Math.min(end, nowSeconds()) * 1_000_000),
|
||||
});
|
||||
};
|
||||
|
||||
/** Usage exists only up to now, so an open cycle's KPIs and labels stop at today, not the cycle end. */
|
||||
const measuredEnd = computed(() => Math.min(range.value.end, nowSeconds()));
|
||||
|
||||
const picker = ref<{ setAbsoluteTime: (start: number, end: number) => void } | null>(null);
|
||||
|
||||
watch(
|
||||
() => [range.value.start, measuredEnd.value],
|
||||
([start, end]) => picker.value?.setAbsoluteTime(start * 1_000_000, end * 1_000_000),
|
||||
);
|
||||
|
||||
const rangeLabel = computed(() =>
|
||||
t("billing.usageV2.rangeDates", {
|
||||
start: raw(formatDay(range.value.start)),
|
||||
end: raw(formatDay(measuredEnd.value)),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────
|
||||
|
||||
const prices = computed(() => cyclePrices(props.cycles));
|
||||
|
||||
const only = ref<MeterKey | null>(null);
|
||||
|
||||
const rows = ref<CostRow[]>([]);
|
||||
/** A failed read shows as a dash, never as $0.00, which would claim there was no usage. */
|
||||
const loadError = ref(false);
|
||||
const loading = ref(true);
|
||||
const streamMissing = ref(false);
|
||||
const lastLoadedAt = ref<number | null>(null);
|
||||
|
||||
/** Chart slot width; a short custom range gets hours or minutes so it is not one bar. */
|
||||
const bucket = computed(() => trendBucket(range.value.start, range.value.end));
|
||||
|
||||
let latestLoad = 0;
|
||||
|
||||
/** The chart's own query at a daily bucket, so the totals are exactly what the bars add up to. */
|
||||
const { search } = useUsageSearch();
|
||||
|
||||
/** Days stream oldest first, so the run rate and projection are only right once all have landed; the KPIs wait for that and only the list streams. */
|
||||
const waitingForFirstChunk = computed(() => loading.value && !rows.value.length);
|
||||
|
||||
/** As text, so a reloaded bill with the same prices is the same query. */
|
||||
const costSql = computed(() =>
|
||||
trendsCostSql(usageOrg.value, prices.value, bucket.value, undefined, true),
|
||||
);
|
||||
|
||||
const loadCosts = async () => {
|
||||
if (!usageStreamEnabled.value) return;
|
||||
const loadId = ++latestLoad;
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
// Cleared, so streamed days never mix with the previous range's.
|
||||
rows.value = [];
|
||||
try {
|
||||
const hits = await search(costSql.value, {
|
||||
start: range.value.start * 1_000_000,
|
||||
end: range.value.end * 1_000_000,
|
||||
size: 10_000,
|
||||
key: "trends",
|
||||
onRows: (partial) => {
|
||||
if (loadId === latestLoad) rows.value = toCostRows(partial);
|
||||
},
|
||||
});
|
||||
if (loadId !== latestLoad) return;
|
||||
rows.value = toCostRows(hits);
|
||||
streamMissing.value = false;
|
||||
lastLoadedAt.value = Date.now();
|
||||
} catch (err: any) {
|
||||
if (loadId !== latestLoad) return;
|
||||
rows.value = [];
|
||||
// The `usage` stream only exists once the org has reported usage.
|
||||
const message = String(err?.response?.data?.message ?? err?.message ?? "").toLowerCase();
|
||||
streamMissing.value = message.includes("stream not found") && message.includes("usage");
|
||||
loadError.value = !streamMissing.value;
|
||||
} finally {
|
||||
if (loadId === latestLoad) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Values, not objects: Refresh reloads the bill, and that new copy must not re-run the read.
|
||||
watch(
|
||||
[costSql, () => range.value.start, () => range.value.end, usageStreamEnabled],
|
||||
() => loadCosts(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>();
|
||||
|
||||
/** Reloads the metering response too, so new cycles and rates come with the new usage. */
|
||||
const refresh = () => {
|
||||
emit("refresh");
|
||||
loadCosts();
|
||||
};
|
||||
|
||||
/** KPIs and the forecast count days, whatever bucket the chart uses. */
|
||||
const dayRows = computed(() =>
|
||||
rows.value.map((row) => ({
|
||||
...row,
|
||||
ts: Math.floor(row.ts / SECONDS_PER_DAY) * SECONDS_PER_DAY,
|
||||
})),
|
||||
);
|
||||
|
||||
const summary = computed(() => summarizeTrend(dayRows.value, only.value));
|
||||
|
||||
/** Two decimals would read $0.00 for a real but sub-cent amount, so it gets four. */
|
||||
const subCent = (value: number) => value > 0 && value < 0.01;
|
||||
|
||||
const precise = computed(() => subCent(summary.value.total));
|
||||
|
||||
const showForecast = ref(true);
|
||||
|
||||
/** Cost, or the data meters' raw volume, which the old daily usage chart showed. */
|
||||
const measure = ref<"cost" | "volume">("cost");
|
||||
|
||||
const onMeasureChange = (value: unknown) => {
|
||||
if (value === "cost" || value === "volume") measure.value = value;
|
||||
};
|
||||
|
||||
/** Only the open cycle has days left to project, so the toggle shows for that range alone. */
|
||||
const canForecast = computed(() => {
|
||||
const cycle = upcoming.value;
|
||||
return preset.value === "current" && !!cycle && cycle.cycle_end > nowSeconds();
|
||||
});
|
||||
|
||||
const projectedTotal = computed(() => {
|
||||
const cycle = upcoming.value;
|
||||
if (!cycle || !(props.billingOrgs.length && selectedOrg.value)) return props.projected;
|
||||
return projectCycleCost({ ...cycle, total_cost: summary.value.total });
|
||||
});
|
||||
|
||||
const forecast = computed(() => {
|
||||
const cycle = upcoming.value;
|
||||
if (!cycle || !canForecast.value || !showForecast.value) return null;
|
||||
return forecastCycle(summary.value, cycle.cycle_start, cycle.cycle_end);
|
||||
});
|
||||
|
||||
// ── KPIs ─────────────────────────────────────────────────────────
|
||||
|
||||
const meterLabel = (key: MeterKey): I18nText => {
|
||||
const def = METERS.find((entry) => entry.key === key);
|
||||
return def ? t(def.labelKey) : raw(key);
|
||||
};
|
||||
|
||||
const kpiCards = computed(() => {
|
||||
const s = summary.value;
|
||||
const f = forecast.value;
|
||||
const cycle = upcoming.value;
|
||||
const spanDays = Math.max(
|
||||
Math.ceil((measuredEnd.value - range.value.start) / SECONDS_PER_DAY),
|
||||
1,
|
||||
);
|
||||
const cycleDays = cycle ? Math.round((cycle.cycle_end - cycle.cycle_start) / SECONDS_PER_DAY) : 0;
|
||||
const cycleDay = cycle
|
||||
? Math.min(Math.floor((nowSeconds() - cycle.cycle_start) / SECONDS_PER_DAY) + 1, cycleDays)
|
||||
: 0;
|
||||
const peak = s.days.reduce((best, day) => (day.cost > best.cost ? day : best), {
|
||||
ts: 0,
|
||||
cost: 0,
|
||||
});
|
||||
const top = topMeter(s, only.value);
|
||||
const cost = (value: number) => (loadError.value ? raw("—") : formatCost(value, precise.value));
|
||||
const date = (seconds: number) => raw(formatDay(seconds));
|
||||
|
||||
const cards: { key: string; label: I18nText; icon: IconName; value: I18nText; hint: I18nText }[] =
|
||||
[
|
||||
{
|
||||
key: "total",
|
||||
// A usage-record estimate for the whole open cycle, even before the forecast has a complete day.
|
||||
label: canForecast.value
|
||||
? t("billing.usageV2.estimatedCostSoFar")
|
||||
: t("billing.usageV2.totalCost"),
|
||||
icon: "attach-money",
|
||||
value: cost(s.total),
|
||||
hint: canForecast.value
|
||||
? t("billing.usageV2.cycleDay", { day: cycleDay, total: cycleDays })
|
||||
: rangeLabel.value,
|
||||
},
|
||||
f
|
||||
? {
|
||||
key: "pace",
|
||||
label: t("billing.usageV2.runRate"),
|
||||
icon: "show-chart",
|
||||
value: t("billing.usageV2.perDay", { cost: formatCost(f.pace, subCent(f.pace)) }),
|
||||
hint: t("billing.usageV2.runRateWindow", { days: f.paceDays }),
|
||||
}
|
||||
: {
|
||||
key: "pace",
|
||||
label: t("billing.usageV2.dailyAverage"),
|
||||
icon: "show-chart",
|
||||
value: cost(s.total / spanDays),
|
||||
hint: t("billing.usageV2.acrossDays", { days: spanDays }),
|
||||
},
|
||||
f && cycle
|
||||
? {
|
||||
key: "projected",
|
||||
label: t("billing.usageV2.projectedTotal"),
|
||||
icon: "trending-up",
|
||||
value: projectedTotal.value === null ? raw("—") : cost(projectedTotal.value),
|
||||
// The recent pace is a second view, so it is named as one and not as the projection.
|
||||
hint: t("billing.usageV2.atPaceProjected", {
|
||||
cost: cost(f.projected),
|
||||
days: f.paceDays,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
key: "peak",
|
||||
label: t("billing.usageV2.peakDay"),
|
||||
icon: "trending-up",
|
||||
value: cost(peak.cost),
|
||||
hint: peak.ts ? date(peak.ts) : raw("—"),
|
||||
},
|
||||
{
|
||||
key: "top",
|
||||
label: t("billing.usageV2.topMeter"),
|
||||
icon: "bar-chart",
|
||||
value: top ? meterLabel(top.key) : raw("—"),
|
||||
hint: top
|
||||
? t("billing.usageV2.topMeterOfCost", {
|
||||
cost: cost(top.cost),
|
||||
percent: Math.round(top.share * 100),
|
||||
})
|
||||
: t("billing.usageV2.noUsageInRange"),
|
||||
},
|
||||
];
|
||||
return cards;
|
||||
});
|
||||
|
||||
// ── Chart ────────────────────────────────────────────────────────
|
||||
|
||||
const chartEvents = computed(() =>
|
||||
only.value ? (METERS.find((def) => def.key === only.value)?.events ?? []) : PRICED_EVENTS,
|
||||
);
|
||||
|
||||
const BUCKET_SECONDS: Record<string, number> = {
|
||||
"1 minute": 60,
|
||||
"1 hour": 3_600,
|
||||
"1 day": SECONDS_PER_DAY,
|
||||
};
|
||||
|
||||
const SLOT_TIME = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
const chartTitle = computed(() =>
|
||||
measure.value === "volume"
|
||||
? t("billing.usageV2.volumeOverTime")
|
||||
: forecast.value
|
||||
? t("billing.usageV2.spendToDate")
|
||||
: t("billing.usageV2.costOverTime"),
|
||||
);
|
||||
|
||||
const chartSubtitle = computed(() =>
|
||||
measure.value === "volume"
|
||||
? t("billing.usageV2.volumeSubtitle")
|
||||
: t("billing.usageV2.estimatedAtCyclePrice"),
|
||||
);
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const step = BUCKET_SECONDS[bucket.value] ?? SECONDS_PER_DAY;
|
||||
const money = (value: number) => String(formatCost(value, precise.value));
|
||||
return buildTrendsChartOption({
|
||||
rows: rows.value,
|
||||
events: chartEvents.value,
|
||||
start: range.value.start,
|
||||
end: range.value.end,
|
||||
bucket: step,
|
||||
now: nowSeconds(),
|
||||
forecast: forecast.value,
|
||||
measure: measure.value,
|
||||
meterLabel: (key) => String(meterLabel(key)),
|
||||
format: measure.value === "volume" ? (value) => formatSizeFromMB(value) : money,
|
||||
slotLabel: (ts) =>
|
||||
// Day slots start at UTC midnight; hour slots read in local time.
|
||||
step === SECONDS_PER_DAY ? formatDay(ts) : SLOT_TIME.format(new Date(ts * 1000)),
|
||||
labels: {
|
||||
spendToDate: String(t("billing.usageV2.spendToDate")),
|
||||
forecast: String(t("billing.usageV2.forecast")),
|
||||
dayTotal: String(t("billing.usageV2.dayTotal")),
|
||||
projected: String(t("billing.usageV2.atPace", { days: forecast.value?.paceDays ?? 0 })),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── Meter list ───────────────────────────────────────────────────
|
||||
|
||||
const meterRows = computed<MeterRow[]>(() => {
|
||||
const all = summarizeTrend(dayRows.value).meters;
|
||||
const max = all[0]?.cost ?? 0;
|
||||
return all.map((meter) => ({
|
||||
key: meter.key,
|
||||
label: meterLabel(meter.key),
|
||||
cost: meter.cost,
|
||||
share: max > 0 ? meter.cost / max : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const meterColumns = computed<OTableColumnDef<MeterRow>[]>(() => [
|
||||
{
|
||||
id: "label",
|
||||
header: t("billing.usageV2.colMeter"),
|
||||
accessorKey: "label",
|
||||
meta: { isName: true },
|
||||
},
|
||||
{ id: "cost", header: t("billing.usageV2.colEstCost"), accessorKey: "cost" },
|
||||
]);
|
||||
|
||||
const toggleMeter = (row: MeterRow) => {
|
||||
only.value = only.value === row.key ? null : row.key;
|
||||
};
|
||||
|
||||
const meterRowClass = (row: MeterRow) =>
|
||||
only.value === row.key
|
||||
? "cursor-pointer bg-table-row-selected-bg"
|
||||
: only.value
|
||||
? "cursor-pointer text-text-secondary"
|
||||
: "cursor-pointer";
|
||||
|
||||
const isolateHint = (row: MeterRow) =>
|
||||
only.value === row.key
|
||||
? t("billing.usageV2.showAllMeters")
|
||||
: t("billing.usageV2.showOnlyMeter", { meter: row.label });
|
||||
</script>
|
||||
@@ -0,0 +1,439 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
|
||||
import { defineComponent, reactive } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
|
||||
const mounts: Record<string, number> = {};
|
||||
const stub = (name: string) =>
|
||||
defineComponent({
|
||||
name,
|
||||
props: [
|
||||
"details",
|
||||
"billingOrgs",
|
||||
"cycles",
|
||||
"projected",
|
||||
"volumeOnly",
|
||||
"orgCosts",
|
||||
"refreshing",
|
||||
],
|
||||
setup: () => {
|
||||
mounts[name] = (mounts[name] ?? 0) + 1;
|
||||
},
|
||||
template: "<div />",
|
||||
});
|
||||
|
||||
vi.mock("./UsageOverview.vue", () => ({ default: stub("UsageOverview") }));
|
||||
vi.mock("./UsageOverviewSkeleton.vue", () => ({ default: stub("UsageOverviewSkeleton") }));
|
||||
vi.mock("./UsageTrends.vue", () => ({ default: stub("UsageTrends") }));
|
||||
vi.mock("./UsageEstimator.vue", () => ({ default: stub("UsageEstimator") }));
|
||||
vi.mock("./UsageBudget.vue", () => ({ default: stub("UsageBudget") }));
|
||||
vi.mock("./UsageSuperBudget.vue", () => ({ default: stub("UsageSuperBudget") }));
|
||||
const route = reactive<{ query: Record<string, string> }>({ query: {} });
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => route,
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
const metering = vi.fn();
|
||||
const members = vi.fn();
|
||||
const aiUsage = vi.fn();
|
||||
const membership = vi.fn().mockResolvedValue({ data: { membership: null } });
|
||||
const search = vi.fn().mockResolvedValue([]);
|
||||
vi.mock("./useUsageSearch", () => ({ useUsageSearch: () => ({ search }) }));
|
||||
vi.mock("@/services/billings", () => ({
|
||||
default: {
|
||||
get_data_usage: (...args: unknown[]) => metering(...args),
|
||||
list_billing_group_members: (...args: unknown[]) => members(...args),
|
||||
get_ai_usage: (...args: unknown[]) => aiUsage(...args),
|
||||
get_billing_group_membership: (...args: unknown[]) => membership(...args),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/aws-exports", async (importOriginal) => {
|
||||
const actual = await importOriginal<{ default: Record<string, unknown> }>();
|
||||
return { default: { ...actual.default, isCloud: "true" } };
|
||||
});
|
||||
|
||||
const { default: UsageView } = await import("./UsageView.vue");
|
||||
|
||||
const bill = {
|
||||
data: {
|
||||
price_details: {
|
||||
upcoming: { total_cost: 1, cycle_start: 1_700_000_000, cycle_end: 1_702_592_000 },
|
||||
past: [],
|
||||
oldest_ts: 1_700_000_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
describe("UsageView", () => {
|
||||
it("publishes a super org's bill and org list together, so the meter view never flashes first", async () => {
|
||||
let releaseMembers!: (value: unknown) => void;
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockReturnValue(new Promise((resolve) => (releaseMembers = resolve)));
|
||||
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
// The bill is back but the org list is not: the page must still be loading.
|
||||
expect(wrapper.findComponent({ name: "UsageOverview" }).exists()).toBe(false);
|
||||
|
||||
releaseMembers({
|
||||
data: [{ payer_org_name: "Payer", member_org_id: "child", member_org_name: "Child" }],
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const overview = wrapper.findComponent({ name: "UsageOverview" });
|
||||
expect(overview.props("details")).toMatchObject({ total_cost: 1 });
|
||||
expect(overview.props("billingOrgs").map((org: { id: string }) => org.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a visited tab alive, so switching back does not rebuild it or rerun its queries", async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
const before = { ...mounts };
|
||||
|
||||
for (const tab of ["trends", "overview", "trends", "overview"]) {
|
||||
route.query = { tab };
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
expect(mounts.UsageOverview - before.UsageOverview).toBe(0);
|
||||
expect(mounts.UsageTrends - (before.UsageTrends ?? 0)).toBe(1);
|
||||
route.query = {};
|
||||
});
|
||||
|
||||
it("gives every tab the same projected total, from the bill as it was loaded", async () => {
|
||||
// $1 by day 10 of a 30-day cycle projects to $3.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime((1_700_000_000 + 10 * 86_400) * 1000);
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent({ name: "UsageOverview" }).props("projected")).toBeCloseTo(3, 6);
|
||||
|
||||
// Budget opened five days later still shows the loaded bill's figure, not a new one.
|
||||
vi.setSystemTime((1_700_000_000 + 15 * 86_400) * 1000);
|
||||
route.query = { tab: "budget" };
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent({ name: "UsageBudget" }).props("projected")).toBeCloseTo(3, 6);
|
||||
route.query = {};
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps a super org's orgs when the org list fails on Refresh", async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({
|
||||
data: [{ payer_org_name: "Payer", member_org_id: "child", member_org_name: "Child" }],
|
||||
});
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
members.mockRejectedValue(new Error("Network Error"));
|
||||
wrapper.findComponent({ name: "UsageOverview" }).vm.$emit("refresh");
|
||||
await flushPromises();
|
||||
|
||||
const orgs = wrapper.findComponent({ name: "UsageOverview" }).props("billingOrgs");
|
||||
expect(orgs.map((org: { id: string }) => org.id)).toEqual([
|
||||
store.state.selectedOrganization.identifier,
|
||||
"child",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the newest bill when an older Refresh answers last", async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
// Two Refreshes overlap; the first one's answer arrives after the second's.
|
||||
const withCost = (total_cost: number) => ({
|
||||
data: {
|
||||
price_details: {
|
||||
...bill.data.price_details,
|
||||
upcoming: { ...bill.data.price_details.upcoming, total_cost },
|
||||
},
|
||||
},
|
||||
});
|
||||
let releaseOld!: (value: unknown) => void;
|
||||
metering.mockReturnValueOnce(new Promise((resolve) => (releaseOld = resolve)));
|
||||
metering.mockResolvedValueOnce(withCost(3));
|
||||
const overview = () => wrapper.findComponent({ name: "UsageOverview" });
|
||||
overview().vm.$emit("refresh");
|
||||
overview().vm.$emit("refresh");
|
||||
await flushPromises();
|
||||
expect(overview().props("details")).toMatchObject({ total_cost: 3 });
|
||||
|
||||
releaseOld(withCost(2));
|
||||
await flushPromises();
|
||||
expect(overview().props("details")).toMatchObject({ total_cost: 3 });
|
||||
});
|
||||
|
||||
it("does not raise a stale failure over a newer good bill, and stops loading only when the newest load ends", async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
let failOld!: (error: unknown) => void;
|
||||
let releaseNew!: (value: unknown) => void;
|
||||
metering.mockReturnValueOnce(new Promise((_, reject) => (failOld = reject)));
|
||||
metering.mockReturnValueOnce(new Promise((resolve) => (releaseNew = resolve)));
|
||||
const overview = () => wrapper.findComponent({ name: "UsageOverview" });
|
||||
overview().vm.$emit("refresh");
|
||||
overview().vm.$emit("refresh");
|
||||
await flushPromises();
|
||||
|
||||
failOld(new Error("Network Error"));
|
||||
await flushPromises();
|
||||
// The newer load is still running: no banner, and the page still says it is loading.
|
||||
expect(wrapper.find('[data-test="billings-usageview-refresh-failed"]').exists()).toBe(false);
|
||||
expect(overview().props("refreshing")).toBe(true);
|
||||
|
||||
releaseNew(bill);
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="billings-usageview-refresh-failed"]').exists()).toBe(false);
|
||||
expect(overview().props("refreshing")).toBe(false);
|
||||
});
|
||||
|
||||
it("still shows the bill when the org list fails, as a plain org", async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
const overview = wrapper.findComponent({ name: "UsageOverview" });
|
||||
expect(overview.props("details")).toMatchObject({ total_cost: 1 });
|
||||
expect(overview.props("billingOrgs")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageView org costs", () => {
|
||||
it("reads them once for Overview and Budget, and past the cache after a Refresh", async () => {
|
||||
search.mockClear();
|
||||
// A fresh object per response, as a real reload brings.
|
||||
metering.mockReset().mockImplementation(async () => structuredClone(bill));
|
||||
members.mockResolvedValue({
|
||||
data: [{ payer_org_name: "Payer", member_org_id: "child", member_org_name: "Child" }],
|
||||
});
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
const orgCostReads = () => search.mock.calls.filter((call) => call[1]?.key === "orgCosts");
|
||||
expect(orgCostReads()).toHaveLength(1);
|
||||
const shown = wrapper.findComponent({ name: "UsageOverview" }).props("orgCosts");
|
||||
|
||||
// Budget judges spend on the same read, so opening it sends no second query.
|
||||
route.query = { tab: "budget" };
|
||||
await flushPromises();
|
||||
expect(orgCostReads()).toHaveLength(1);
|
||||
expect(wrapper.findComponent({ name: "UsageSuperBudget" }).props("orgCosts")).toEqual(shown);
|
||||
|
||||
route.query = {};
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: "UsageOverview" }).vm.$emit("refresh");
|
||||
await flushPromises();
|
||||
expect(orgCostReads()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageView for an org with no bill of its own", () => {
|
||||
const tabs = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAllComponents({ name: "OTab" }).map((tab) => tab.props("name"));
|
||||
const reporting = (on: boolean) => {
|
||||
store.state.organizationData.organizationSettings = {
|
||||
...store.state.organizationData.organizationSettings,
|
||||
usage_stream_enabled: on,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
route.query = {};
|
||||
membership.mockResolvedValue({ data: { membership: null } });
|
||||
reporting(false);
|
||||
});
|
||||
|
||||
it("keeps every tab and the bill for a Stripe-billed org, from the first frame", async () => {
|
||||
let releaseBill!: (value: unknown) => void;
|
||||
metering.mockReturnValue(new Promise((resolve) => (releaseBill = resolve)));
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
// Still loading: which tabs this org has is not known yet, so a placeholder bar holds their place.
|
||||
expect(tabs(wrapper)).toEqual([]);
|
||||
expect(wrapper.find('[data-test="billings-usageview-tabs-loading"]').exists()).toBe(true);
|
||||
|
||||
releaseBill(bill);
|
||||
await flushPromises();
|
||||
|
||||
expect(tabs(wrapper)).toEqual(["overview", "trends", "budget", "estimation"]);
|
||||
const overview = wrapper.findComponent({ name: "UsageOverview" });
|
||||
expect(overview.props("volumeOnly")).toBe(false);
|
||||
expect(overview.props("details")).toMatchObject({ total_cost: 1 });
|
||||
});
|
||||
|
||||
it("shows only Overview, with 30 days of usage, when no bill comes back", async () => {
|
||||
reporting(true);
|
||||
metering.mockResolvedValue({ data: {} });
|
||||
members.mockResolvedValue({ data: [] });
|
||||
search.mockResolvedValue([
|
||||
{ event: "Ingestion", volume: 2048 },
|
||||
{ event: "AiCredits", volume: 7 },
|
||||
]);
|
||||
// An old link to Budget still lands on Overview.
|
||||
route.query = { tab: "budget" };
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
// Overview is the only view, so there is no one-tab bar, and the placeholder is gone too.
|
||||
expect(tabs(wrapper)).toEqual([]);
|
||||
expect(wrapper.find('[data-test="billings-usageview-tabs-loading"]').exists()).toBe(false);
|
||||
const overview = wrapper.findComponent({ name: "UsageOverview" });
|
||||
expect(overview.props("volumeOnly")).toBe(true);
|
||||
expect(overview.props("details").ingestion.metered_amount).toBe(2048);
|
||||
expect(overview.props("details").ai.metered_amount).toBe(7);
|
||||
expect(overview.props("details").total_cost).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a member org as one with no bill, since its super org pays", async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
membership.mockResolvedValue({
|
||||
data: { membership: { payer_org_id: "payer", payer_org_name: "Payer Org" } },
|
||||
});
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
expect(tabs(wrapper)).toEqual([]);
|
||||
// Reporting is off, so Overview offers to turn it on instead of reading a stream.
|
||||
expect(wrapper.findComponent({ name: "UsageOverview" }).props("details")).toBeNull();
|
||||
// The page says who pays, and where the costs are.
|
||||
expect(wrapper.find('[data-test="billings-usageview-payer"]').text()).toContain(
|
||||
"Billed through Payer Org",
|
||||
);
|
||||
});
|
||||
|
||||
it("names no payer for an org that pays its own bill", async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="billings-usageview-payer"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageView AI credits warning", () => {
|
||||
const usage = (extra: Record<string, unknown>) => ({
|
||||
data: { mode: "exhausted", requires_additional_credits: false, payer_org_id: null, ...extra },
|
||||
});
|
||||
|
||||
const mountView = async () => {
|
||||
metering.mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it("tells a contract org to contact sales once its credits run out", async () => {
|
||||
aiUsage.mockResolvedValue(usage({ requires_additional_credits: true }));
|
||||
const wrapper = await mountView();
|
||||
const banner = wrapper.find('[data-test="billings-usageview-ai-exhausted"]');
|
||||
expect(banner.text()).toContain("Contact your account manager");
|
||||
expect(wrapper.find('[data-test="billings-usageview-ai-exhausted-action"]').text()).toBe(
|
||||
"Contact Sales",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the paying org for a member, with nothing to click", async () => {
|
||||
aiUsage.mockResolvedValue(usage({ payer_org_id: "payer-org" }));
|
||||
const wrapper = await mountView();
|
||||
expect(wrapper.find('[data-test="billings-usageview-ai-exhausted"]').text()).toContain(
|
||||
"payer-org",
|
||||
);
|
||||
expect(wrapper.find('[data-test="billings-usageview-ai-exhausted-action"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("stays out of the way while credits remain or the read fails", async () => {
|
||||
aiUsage.mockResolvedValue({ data: { mode: "free", requires_additional_credits: false } });
|
||||
expect((await mountView()).find('[data-test="billings-usageview-ai-exhausted"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
aiUsage.mockRejectedValue(new Error("boom"));
|
||||
expect((await mountView()).find('[data-test="billings-usageview-ai-exhausted"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageView load failures", () => {
|
||||
const retryButton = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAll("button").find((button) => button.text() === "Retry");
|
||||
|
||||
it("shows a retryable error in plain words, not the raw message, when the bill cannot load", async () => {
|
||||
metering.mockRejectedValueOnce(new Error("Network Error")).mockResolvedValue(bill);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
const error = wrapper.find('[data-test="billings-usageview-error"]');
|
||||
expect(error.exists()).toBe(true);
|
||||
expect(error.text()).not.toContain("Network Error");
|
||||
expect(error.text()).toContain("network connection");
|
||||
|
||||
await retryButton(wrapper)!.trigger("click");
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent({ name: "UsageOverview" }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("says the billing service failed when the server answers with an error", async () => {
|
||||
metering.mockRejectedValue(
|
||||
Object.assign(new Error("Request failed"), { response: { status: 500 } }),
|
||||
);
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="billings-usageview-error"]').text()).toContain(
|
||||
"billing service returned an error",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the last bill on screen when a Refresh fails, and offers Retry", async () => {
|
||||
metering.mockResolvedValueOnce(bill).mockRejectedValueOnce(new Error("Network Error"));
|
||||
members.mockResolvedValue({ data: [] });
|
||||
const wrapper = mount(UsageView, { global: { plugins: [i18n, store] } });
|
||||
await flushPromises();
|
||||
|
||||
wrapper.findComponent({ name: "UsageOverview" }).vm.$emit("refresh");
|
||||
await flushPromises();
|
||||
|
||||
const overview = wrapper.findComponent({ name: "UsageOverview" });
|
||||
expect(overview.exists()).toBe(true);
|
||||
expect(overview.props("details")).toMatchObject({ total_cost: 1 });
|
||||
expect(wrapper.find('[data-test="billings-usageview-refresh-failed"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="billings-usageview-error"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-2.5 px-3" data-test="billings-usageview-root">
|
||||
<div
|
||||
v-if="loading && lastLoadedAt === null"
|
||||
class="flex h-11 shrink-0 items-center gap-6 border-b px-3 pt-0.75"
|
||||
data-test="billings-usageview-tabs-loading"
|
||||
>
|
||||
<OSkeleton
|
||||
v-for="width in TAB_SKELETON_WIDTHS"
|
||||
:key="width"
|
||||
type="text"
|
||||
:class="['h-4', width]"
|
||||
/>
|
||||
</div>
|
||||
<!-- With Overview as the only view, a one-tab bar is just noise, so it goes. -->
|
||||
<OTabs
|
||||
v-else-if="billed"
|
||||
:model-value="activeTab"
|
||||
data-test="billings-usageview-tabs"
|
||||
class="border-b"
|
||||
@update:model-value="selectTab"
|
||||
>
|
||||
<OTab name="overview" :label="t('billing.usageV2.overviewTab')" />
|
||||
<OTab name="trends" :label="t('billing.usageV2.trendsTab')" />
|
||||
<OTab name="budget" :label="t('billing.usageV2.budgetTab')" />
|
||||
<OTab name="estimation" :label="t('billing.usageV2.estimationTab')" />
|
||||
</OTabs>
|
||||
|
||||
<!-- A member's own page shows usage only, so it says where the costs went. -->
|
||||
<OBanner
|
||||
v-if="payer !== null"
|
||||
variant="info"
|
||||
icon="info"
|
||||
dense
|
||||
data-test="billings-usageview-payer"
|
||||
>
|
||||
{{ t("billing.usageV2.billedThrough", { payer }) }}
|
||||
</OBanner>
|
||||
|
||||
<!-- Account-wide, so it sits above every tab as it did on main; same cases and wording as the AI chat notice. -->
|
||||
<OBanner
|
||||
v-if="aiCreditsMessage"
|
||||
variant="warning"
|
||||
icon="warning"
|
||||
inline-actions
|
||||
dense
|
||||
data-test="billings-usageview-ai-exhausted"
|
||||
>
|
||||
<span class="font-semibold">{{ t("aiAssistant.creditsExhausted") }}</span>
|
||||
<span class="ms-1">{{ aiCreditsMessage }}</span>
|
||||
<template v-if="aiCreditsAction" #actions>
|
||||
<OButton
|
||||
variant="warning"
|
||||
size="xs"
|
||||
data-test="billings-usageview-ai-exhausted-action"
|
||||
@click="aiCreditsAction.run"
|
||||
>{{ aiCreditsAction.label }}</OButton
|
||||
>
|
||||
</template>
|
||||
</OBanner>
|
||||
|
||||
<!-- A failed Refresh keeps the last good numbers on screen and says so, rather than wiping them. -->
|
||||
<OBanner
|
||||
v-if="refreshFailed"
|
||||
variant="warning"
|
||||
icon="warning"
|
||||
inline-actions
|
||||
dense
|
||||
data-test="billings-usageview-refresh-failed"
|
||||
>
|
||||
{{ t("billing.usageV2.refreshFailed") }}
|
||||
<template #actions>
|
||||
<OButton
|
||||
variant="warning"
|
||||
size="xs"
|
||||
data-test="billings-usageview-refresh-retry"
|
||||
@click="loadDetails"
|
||||
>{{ t("common.retry") }}</OButton
|
||||
>
|
||||
</template>
|
||||
</OBanner>
|
||||
|
||||
<!-- The page's own shape, not a spinner: nothing moves once the data lands. -->
|
||||
<div v-if="loading && lastLoadedAt === null" class="min-h-0 flex-1 overflow-hidden">
|
||||
<UsageOverviewSkeleton data-test="billings-usageview-loading" />
|
||||
</div>
|
||||
|
||||
<!-- Nothing loaded yet: a load failure replaces the tabs, in plain words with a way to try again. -->
|
||||
<div v-else-if="loadFailure" class="min-h-0 flex-1" data-test="billings-usageview-error">
|
||||
<OEmptyState
|
||||
size="hero"
|
||||
variant="error"
|
||||
illustration="broken-panel"
|
||||
:title="t('billing.usageV2.loadFailedTitle')"
|
||||
:description="
|
||||
loadFailure === 'network'
|
||||
? t('billing.usageV2.loadFailedNetwork')
|
||||
: t('billing.usageV2.loadFailedServer')
|
||||
"
|
||||
:action-label="t('common.retry')"
|
||||
action-icon="refresh"
|
||||
@action="loadDetails"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="min-h-0 flex-1"
|
||||
:class="
|
||||
activeTab === 'overview' || (activeTab === 'budget' && billingOrgs.length)
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-auto'
|
||||
"
|
||||
>
|
||||
<!-- Kept alive, so a tab seen once comes back as it was left, with no new queries. -->
|
||||
<KeepAlive>
|
||||
<UsageOverview
|
||||
v-if="activeTab === 'overview'"
|
||||
:details="billed ? details : volume"
|
||||
:billing-orgs="billed ? billingOrgs : []"
|
||||
:last-loaded-at="lastLoadedAt"
|
||||
:projected="billed ? projected : null"
|
||||
:refreshing="loading"
|
||||
:volume-only="!billed"
|
||||
:org-costs="orgCosts"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
<UsageTrends
|
||||
v-else-if="activeTab === 'trends'"
|
||||
:details="details"
|
||||
:cycles="cycles"
|
||||
:oldest-ts="response?.oldest_ts"
|
||||
:billing-orgs="billingOrgs"
|
||||
:projected="projected"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
<UsageSuperBudget
|
||||
v-else-if="activeTab === 'budget' && billingOrgs.length"
|
||||
:details="details"
|
||||
:billing-orgs="billingOrgs"
|
||||
:projected="projected"
|
||||
:org-costs="orgCosts"
|
||||
/>
|
||||
<UsageBudget v-else-if="activeTab === 'budget'" :details="details" :projected="projected" />
|
||||
<UsageEstimator v-else :details="details" :billing-orgs="billingOrgs" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
|
||||
import OTab from "@/lib/navigation/Tabs/OTab.vue";
|
||||
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import config from "@/aws-exports";
|
||||
import { siteURL } from "@/constants/config";
|
||||
import BillingService, { type AiUsage } from "@/services/billings";
|
||||
import UsageOverview from "./UsageOverview.vue";
|
||||
import UsageOverviewSkeleton from "./UsageOverviewSkeleton.vue";
|
||||
import { mockMeteringResponse, USE_METERING_MOCK } from "./meteringMock";
|
||||
import UsageTrends from "./UsageTrends.vue";
|
||||
import UsageEstimator from "./UsageEstimator.vue";
|
||||
import UsageBudget from "./UsageBudget.vue";
|
||||
import UsageSuperBudget from "./UsageSuperBudget.vue";
|
||||
import { useUsageSearch } from "./useUsageSearch";
|
||||
import { useOrgCosts, type OrgCostsState } from "./useOrgCosts";
|
||||
import { eventVolumeSql, USAGE_STREAM_NAME } from "./usageQueries";
|
||||
import {
|
||||
billingGroupOrgs,
|
||||
cyclesNewestFirst,
|
||||
parseMeteringResponse,
|
||||
projectCycleCost,
|
||||
volumeDetails,
|
||||
type BillingOrg,
|
||||
type MeteringDetails,
|
||||
type MeteringResponse,
|
||||
} from "./meteringModel";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
type UsageTab = "overview" | "trends" | "budget" | "estimation";
|
||||
|
||||
const TABS: UsageTab[] = ["overview", "trends", "budget", "estimation"];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** The tab lives in the URL, so a refresh or a shared link lands where the user was. */
|
||||
/** An org with no bill has Overview only, so an old link to another tab opens Overview. */
|
||||
const activeTab = computed<UsageTab>(() => {
|
||||
const value = String(route.query.tab ?? "");
|
||||
if (!billed.value) return "overview";
|
||||
return TABS.includes(value as UsageTab) ? (value as UsageTab) : "overview";
|
||||
});
|
||||
|
||||
const selectTab = (value: unknown) => {
|
||||
if (typeof value !== "string" || !TABS.includes(value as UsageTab)) return;
|
||||
const query: LocationQueryRaw = { ...route.query, tab: value };
|
||||
if (value !== "overview") {
|
||||
delete query.meter;
|
||||
delete query.group;
|
||||
}
|
||||
router.replace({ query });
|
||||
};
|
||||
const response = ref<MeteringResponse | null>(null);
|
||||
|
||||
/** The cycle being billed now; every card, table and rate on Overview reads this one. */
|
||||
const details = computed<MeteringDetails | null>(() => response.value?.upcoming ?? null);
|
||||
|
||||
/** Current cycle first, then closed cycles, which is what a trend range counts back through. */
|
||||
const cycles = computed(() => cyclesNewestFirst(response.value));
|
||||
const loading = ref(false);
|
||||
/** Why the first load failed: no answer at all, or an error from the server. */
|
||||
const loadFailure = ref<"network" | "server" | null>(null);
|
||||
/** A Refresh failed while the page still holds an earlier bill. */
|
||||
const refreshFailed = ref(false);
|
||||
const lastLoadedAt = ref<number | null>(null);
|
||||
|
||||
/** One projection for every tab, from the bill as it was loaded, so tabs opened at different times agree. */
|
||||
const projected = computed(() => projectCycleCost(details.value, lastLoadedAt.value ?? undefined));
|
||||
|
||||
/** A super org's orgs, itself first; empty for any other org. */
|
||||
const billingOrgs = ref<BillingOrg[]>([]);
|
||||
|
||||
const payer = ref<string | null>(null);
|
||||
const isMember = computed(() => payer.value !== null);
|
||||
|
||||
const billed = computed(() => !!details.value && !isMember.value);
|
||||
|
||||
/** One grey label per tab a billed org gets, about as wide as its name. */
|
||||
const TAB_SKELETON_WIDTHS = ["w-16", "w-14", "w-14", "w-28"];
|
||||
|
||||
/** An org with no bill: each meter's volume over the last 30 days, from its own `usage` stream. */
|
||||
const volume = ref<MeteringDetails | null>(null);
|
||||
|
||||
const usageStreamEnabled = computed<boolean>(
|
||||
() => !!store.state?.organizationData?.organizationSettings?.usage_stream_enabled,
|
||||
);
|
||||
|
||||
/** The paying org's name, or `null` for an org that is not a member. A failure keeps what is shown, as the org list does. */
|
||||
const fetchPayer = async (): Promise<string | null> => {
|
||||
try {
|
||||
const res = await BillingService.get_billing_group_membership(
|
||||
store.state.selectedOrganization.identifier,
|
||||
);
|
||||
const membership = res?.data?.membership;
|
||||
return membership ? String(membership.payer_org_name || membership.payer_org_id || "") : null;
|
||||
} catch {
|
||||
return payer.value;
|
||||
}
|
||||
};
|
||||
|
||||
const VOLUME_DAYS = 30;
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
const { search } = useUsageSearch();
|
||||
|
||||
/** The stream appears only once the org reports usage, so until then every meter reads zero. */
|
||||
const fetchVolumes = async (): Promise<MeteringDetails> => {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = (Math.floor(end / SECONDS_PER_DAY) - (VOLUME_DAYS - 1)) * SECONDS_PER_DAY;
|
||||
try {
|
||||
const hits = await search(eventVolumeSql(store.state.selectedOrganization.identifier), {
|
||||
start: start * 1_000_000,
|
||||
end: end * 1_000_000,
|
||||
size: 100,
|
||||
key: "volumes",
|
||||
});
|
||||
const volumes = Object.fromEntries(
|
||||
hits.map((hit) => [String(hit.event ?? ""), Number(hit.volume) || 0]),
|
||||
);
|
||||
return volumeDetails(volumes, start, end);
|
||||
} catch (e: any) {
|
||||
const message = String(e?.response?.data?.message ?? e?.message ?? "").toLowerCase();
|
||||
if (message.includes("stream not found") && message.includes(USAGE_STREAM_NAME)) {
|
||||
return volumeDetails({}, start, end);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBillingOrgs = async (): Promise<BillingOrg[]> => {
|
||||
const orgId = store.state.selectedOrganization.identifier;
|
||||
try {
|
||||
const res = await BillingService.list_billing_group_members(orgId);
|
||||
return billingGroupOrgs(res?.data, orgId);
|
||||
} catch {
|
||||
return billingOrgs.value[0]?.id === orgId ? billingOrgs.value : [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Never cached by house rule; the range segment is ignored and the cycle comes back. */
|
||||
const fetchMetering = async () => {
|
||||
if (USE_METERING_MOCK) return mockMeteringResponse();
|
||||
const apiResponse = await BillingService.get_data_usage(
|
||||
store.state.selectedOrganization.identifier,
|
||||
"30days",
|
||||
);
|
||||
return parseMeteringResponse(apiResponse?.data?.price_details);
|
||||
};
|
||||
|
||||
const aiUsage = ref<AiUsage | null>(null);
|
||||
|
||||
/** Cloud only, as on main; a failed read just leaves the warning out. */
|
||||
/** Overlapping Refreshes can answer out of order; only the newest load may publish. */
|
||||
let latestLoad = 0;
|
||||
|
||||
const loadAiUsage = async (loadId: number) => {
|
||||
if (config.isCloud !== "true") return;
|
||||
try {
|
||||
const usage = (await BillingService.get_ai_usage(store.state.selectedOrganization.identifier))
|
||||
.data;
|
||||
if (loadId === latestLoad) aiUsage.value = usage;
|
||||
} catch {
|
||||
if (loadId === latestLoad) aiUsage.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Why AI stopped, for a contract, a billing-group member, or an org that pays by subscription. */
|
||||
const aiCreditsMessage = computed(() => {
|
||||
const usage = aiUsage.value;
|
||||
if (usage?.mode !== "exhausted") return null;
|
||||
if (usage.requires_additional_credits) return t("billing.aiContractExhaustedMessage");
|
||||
if (usage.payer_org_id)
|
||||
return t("billing.aiPayerSubscribeMessage", { payer: usage.payer_org_id });
|
||||
return t("billing.aiExhaustedMessage");
|
||||
});
|
||||
|
||||
/** A member org has nothing to do here: its message names the org that pays. */
|
||||
const aiCreditsAction = computed(() => {
|
||||
const usage = aiUsage.value;
|
||||
if (usage?.mode !== "exhausted") return null;
|
||||
if (usage.requires_additional_credits) {
|
||||
return {
|
||||
label: t("billing.contactLabel"),
|
||||
run: () => window.open(siteURL.contactSales, "_blank"),
|
||||
};
|
||||
}
|
||||
if (usage.payer_org_id) return null;
|
||||
return {
|
||||
label: t("billing.plansLabel"),
|
||||
run: () =>
|
||||
router.push({
|
||||
name: "plans",
|
||||
query: { org_identifier: store.state.selectedOrganization.identifier },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const loadDetails = async () => {
|
||||
const loadId = ++latestLoad;
|
||||
void loadAiUsage(loadId);
|
||||
loading.value = true;
|
||||
refreshFailed.value = false;
|
||||
try {
|
||||
// Published together: the bill alone renders the plain meter view, which then flips to the org view.
|
||||
const [metering, orgs, payerName] = await Promise.all([
|
||||
fetchMetering(),
|
||||
fetchBillingOrgs(),
|
||||
fetchPayer(),
|
||||
]);
|
||||
const usageOnly = !metering?.upcoming || payerName !== null;
|
||||
const volumes = usageOnly && usageStreamEnabled.value ? await fetchVolumes() : null;
|
||||
if (loadId !== latestLoad) return;
|
||||
response.value = metering;
|
||||
billingOrgs.value = orgs;
|
||||
payer.value = payerName;
|
||||
volume.value = volumes;
|
||||
lastLoadedAt.value = Date.now();
|
||||
loadFailure.value = null;
|
||||
} catch (e: any) {
|
||||
if (loadId !== latestLoad) return;
|
||||
// The raw text ("Network Error") is not shown; the kind of failure picks the advice.
|
||||
if (response.value) refreshFailed.value = true;
|
||||
else loadFailure.value = e?.response ? "server" : "network";
|
||||
} finally {
|
||||
if (loadId === latestLoad) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
void loadDetails();
|
||||
};
|
||||
|
||||
const orgCostRead = useOrgCosts(
|
||||
computed(() => (billed.value ? details.value : null)),
|
||||
computed(() => (billed.value ? billingOrgs.value : [])),
|
||||
);
|
||||
const orgCosts = computed<OrgCostsState>(() => ({
|
||||
costs: orgCostRead.costs.value,
|
||||
loading: orgCostRead.loading.value,
|
||||
failed: orgCostRead.failed.value,
|
||||
}));
|
||||
|
||||
onMounted(loadDetails);
|
||||
|
||||
/** Turning reporting on from Overview starts the usage view of an org with no bill. */
|
||||
watch(usageStreamEnabled, (on) => {
|
||||
if (on && !billed.value) void loadDetails();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-semibold">{{ t("billing.usageV2.warnAt") }}</span>
|
||||
<!-- OInput sizes to its parent, so each field gets its own grid cell. -->
|
||||
<div class="grid grid-cols-[6.5rem_auto_minmax(0,1fr)] items-center gap-2">
|
||||
<div class="min-w-0">
|
||||
<OInput
|
||||
:model-value="hasBudget ? percentLabel : undefined"
|
||||
type="number"
|
||||
size="md"
|
||||
suffix="%"
|
||||
:disabled="!hasBudget"
|
||||
:aria-label="t('billing.usageV2.warnPercent')"
|
||||
:data-test="`${dataTest}-percent`"
|
||||
@update:model-value="setPercent"
|
||||
@blur="showSaved($event, percentLabel)"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-text-secondary text-sm" aria-hidden="true">=</span>
|
||||
<div class="min-w-0">
|
||||
<OInput
|
||||
:model-value="hasBudget ? amount : undefined"
|
||||
type="number"
|
||||
size="md"
|
||||
prefix="$"
|
||||
:disabled="!hasBudget"
|
||||
:aria-label="t('billing.usageV2.warnAmount')"
|
||||
:data-test="`${dataTest}-amount`"
|
||||
@update:model-value="setAmount"
|
||||
@blur="showSaved($event, amount)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="hasBudget" class="text-text-secondary text-xs">
|
||||
{{ t("billing.usageV2.warnHelp", { amount: formatCost(amount) }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import { formatCost } from "./meteringModel";
|
||||
import { nextWarnPercent, warnAmountFor, warnPercentFor } from "./budgetModel";
|
||||
|
||||
const props = defineProps<{
|
||||
/** The budget the warn line is a share of. */
|
||||
budget: number;
|
||||
dataTest: string;
|
||||
}>();
|
||||
|
||||
/** Percent of the budget, 0 to 100. */
|
||||
const percent = defineModel<number>({ required: true });
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const hasBudget = computed(() => props.budget > 0);
|
||||
const amount = computed(() => warnAmountFor(percent.value, props.budget));
|
||||
/** One decimal at most, so 80 reads "80" and a typed dollar amount still shows its share. */
|
||||
const percentLabel = computed(() => Math.round(percent.value * 10) / 10);
|
||||
|
||||
/** An entry that was not taken, such as an emptied field, shows the kept value once focus leaves. */
|
||||
const showSaved = (event: FocusEvent, value: number) => {
|
||||
(event.target as HTMLInputElement).value = String(value);
|
||||
};
|
||||
|
||||
const setPercent = (value: unknown) => {
|
||||
percent.value = nextWarnPercent(value, percent.value);
|
||||
};
|
||||
/** A dollar figure goes through the same rule as a percent, so $0 cannot set a 0% line either. */
|
||||
const setAmount = (value: unknown) => {
|
||||
const share = value === "" || value == null ? 0 : warnPercentFor(Number(value), props.budget);
|
||||
percent.value = nextWarnPercent(share, percent.value);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
budgetFor,
|
||||
budgetStatus,
|
||||
configOf,
|
||||
draftOf,
|
||||
nextWarnPercent,
|
||||
sameBudget,
|
||||
sameCents,
|
||||
superBudgetConfigs,
|
||||
TOTAL_BUDGET_ID,
|
||||
budgetReachedDay,
|
||||
budgetTone,
|
||||
warnAmountFor,
|
||||
warnPercentFor,
|
||||
withBudget,
|
||||
} from "./budgetModel";
|
||||
|
||||
const entry = (org_id: string, total = 100) => ({
|
||||
org_id,
|
||||
total_budget_amount: total,
|
||||
warn_at_amount: total * 0.8,
|
||||
paused: false,
|
||||
});
|
||||
|
||||
describe("budgetModel", () => {
|
||||
it("replaces only this org's entry and keeps the billing group's other budgets", () => {
|
||||
const saved = withBudget([entry("child"), entry("me")], entry("me", 500));
|
||||
expect(saved).toHaveLength(2);
|
||||
expect(budgetFor(saved, "child")?.total_budget_amount).toBe(100);
|
||||
expect(budgetFor(saved, "me")?.total_budget_amount).toBe(500);
|
||||
expect(withBudget(null, entry("me"))).toEqual([entry("me")]);
|
||||
});
|
||||
|
||||
it("links the warn amount and percent both ways", () => {
|
||||
expect(warnAmountFor(80, 45)).toBe(36);
|
||||
expect(warnPercentFor(36, 45)).toBe(80);
|
||||
expect(warnAmountFor(150, 45)).toBe(45);
|
||||
expect(warnPercentFor(10, 0)).toBe(80);
|
||||
});
|
||||
|
||||
it("tones spend by the warn line and the budget", () => {
|
||||
expect(budgetTone(10, 100, 80)).toBe("ok");
|
||||
expect(budgetTone(80, 100, 80)).toBe("warn");
|
||||
expect(budgetTone(100, 100, 80)).toBe("over");
|
||||
expect(budgetTone(10, 0, 0)).toBe("ok");
|
||||
});
|
||||
|
||||
it("finds the day the budget runs out at the current pace", () => {
|
||||
expect(budgetReachedDay(30, 18, 45, 30)).toBe(27);
|
||||
expect(budgetReachedDay(10, 18, 45, 30)).toBeNull();
|
||||
expect(budgetReachedDay(0, 18, 45, 30)).toBeNull();
|
||||
});
|
||||
|
||||
describe("super org", () => {
|
||||
const draft = (budget: number, warnPercent = 80, alertsOn = true) => ({
|
||||
budget,
|
||||
warnPercent,
|
||||
alertsOn,
|
||||
});
|
||||
|
||||
it("saves the overall budget under total, then one entry per org with a budget", () => {
|
||||
const configs = superBudgetConfigs(draft(600, 75, false), {
|
||||
sup: draft(200),
|
||||
m1: draft(100, 50),
|
||||
m2: draft(0),
|
||||
});
|
||||
expect(configs).toEqual([
|
||||
{ org_id: TOTAL_BUDGET_ID, total_budget_amount: 600, warn_at_amount: 450, paused: true },
|
||||
{ org_id: "sup", total_budget_amount: 200, warn_at_amount: 160, paused: false },
|
||||
{ org_id: "m1", total_budget_amount: 100, warn_at_amount: 50, paused: false },
|
||||
]);
|
||||
// No overall budget: only the org entries are sent.
|
||||
expect(superBudgetConfigs(draft(0), { m1: draft(100) }).map((c) => c.org_id)).toEqual(["m1"]);
|
||||
});
|
||||
|
||||
it("round-trips a saved entry through the draft, so an untouched row is not a change", () => {
|
||||
const saved = { org_id: "m1", total_budget_amount: 90, warn_at_amount: 67.5, paused: true };
|
||||
const d = draftOf(saved);
|
||||
expect(d).toEqual({ budget: 90, warnPercent: 75, alertsOn: false });
|
||||
expect(configOf("m1", d)).toEqual(saved);
|
||||
expect(sameBudget(d, draftOf(saved))).toBe(true);
|
||||
expect(sameBudget(d, { ...d, alertsOn: true })).toBe(false);
|
||||
expect(draftOf(null)).toEqual({ budget: 0, warnPercent: 80, alertsOn: true });
|
||||
});
|
||||
|
||||
it("says there is no budget rather than calling an org on track", () => {
|
||||
expect(budgetStatus(50, 0, 0)).toBe("none");
|
||||
expect(budgetStatus(50, 100, 80)).toBe("ok");
|
||||
expect(budgetStatus(85, 100, 80)).toBe("warn");
|
||||
expect(budgetStatus(120, 100, 80)).toBe("over");
|
||||
});
|
||||
});
|
||||
|
||||
// A 0% line is always crossed, and the budget job checks every hour, so it would email all day.
|
||||
it("keeps the current warn percent for an empty field or one under 1%", () => {
|
||||
expect(nextWarnPercent("", 80)).toBe(80);
|
||||
expect(nextWarnPercent(null, 80)).toBe(80);
|
||||
expect(nextWarnPercent("0", 80)).toBe(80);
|
||||
expect(nextWarnPercent(0.5, 80)).toBe(80);
|
||||
expect(nextWarnPercent("abc", 80)).toBe(80);
|
||||
expect(nextWarnPercent("50", 80)).toBe(50);
|
||||
expect(nextWarnPercent(150, 80)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("budgetModel cents", () => {
|
||||
// In floating point 36.01 - 36 is 0.00999…, which a "< 0.01" test reads as no change.
|
||||
it("tells a one-cent change apart, and treats sub-cent noise as the same amount", () => {
|
||||
expect(sameCents(36.01, 36)).toBe(false);
|
||||
expect(sameCents(400.01, 400)).toBe(false);
|
||||
expect(sameCents(36, 36.004)).toBe(true);
|
||||
const saved = { budget: 100, warnPercent: 36, alertsOn: true };
|
||||
expect(sameBudget(saved, { ...saved, warnPercent: 36.01 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("budgetModel removing a budget", () => {
|
||||
it("drops this org's entry for a cleared budget and keeps every other org's", () => {
|
||||
const stored = [
|
||||
{ org_id: "a", total_budget_amount: 100, warn_at_amount: 80, paused: false },
|
||||
{ org_id: "b", total_budget_amount: 50, warn_at_amount: 40, paused: false },
|
||||
];
|
||||
const cleared = { org_id: "a", total_budget_amount: 0, warn_at_amount: 0, paused: false };
|
||||
expect(withBudget(stored, cleared)).toEqual([stored[1]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/** One entry of the org setting `budget_config`; the backend stores the threshold in dollars. */
|
||||
export interface BudgetConfig {
|
||||
org_id: string;
|
||||
total_budget_amount: number;
|
||||
warn_at_amount: number;
|
||||
/** Email alerts are off while true. */
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export type BudgetTone = "ok" | "warn" | "over";
|
||||
|
||||
/** A new budget warns at this share until the user picks another. */
|
||||
export const DEFAULT_WARN_PERCENT = 80;
|
||||
|
||||
export function budgetFor(
|
||||
configs: BudgetConfig[] | null | undefined,
|
||||
orgId: string,
|
||||
): BudgetConfig | null {
|
||||
return (configs ?? []).find((config) => config.org_id === orgId) ?? null;
|
||||
}
|
||||
|
||||
export function withBudget(
|
||||
configs: BudgetConfig[] | null | undefined,
|
||||
entry: BudgetConfig,
|
||||
): BudgetConfig[] {
|
||||
const others = (configs ?? []).filter((config) => config.org_id !== entry.org_id);
|
||||
// A cleared budget leaves the list: stored as $0 it would be a $0 warn line, crossed every hour.
|
||||
return entry.total_budget_amount > 0 ? [...others, entry] : others;
|
||||
}
|
||||
|
||||
const cents = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
export function sameCents(a: number, b: number): boolean {
|
||||
return Math.round(a * 100) === Math.round(b * 100);
|
||||
}
|
||||
|
||||
/** The two warn fields stay linked through the percent, so a new budget keeps the same share. */
|
||||
export function warnAmountFor(percent: number, total: number): number {
|
||||
return total > 0 ? cents((total * clampPercent(percent)) / 100) : 0;
|
||||
}
|
||||
|
||||
export function warnPercentFor(amount: number, total: number): number {
|
||||
return total > 0 ? clampPercent((amount / total) * 100) : DEFAULT_WARN_PERCENT;
|
||||
}
|
||||
|
||||
/** Below this, a warn line rounds to $0, which the hourly budget job treats as always crossed. */
|
||||
export const MIN_WARN_PERCENT = 1;
|
||||
|
||||
/** A typed warn percent; an empty field or one under 1% keeps the current one. */
|
||||
export function nextWarnPercent(value: unknown, current: number): number {
|
||||
const n = Number(value);
|
||||
return value !== "" && value != null && n >= MIN_WARN_PERCENT ? clampPercent(n) : current;
|
||||
}
|
||||
|
||||
export function clampPercent(value: number): number {
|
||||
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : 0;
|
||||
}
|
||||
|
||||
export function budgetTone(amount: number, total: number, warnAt: number): BudgetTone {
|
||||
if (total <= 0) return "ok";
|
||||
if (amount >= total) return "over";
|
||||
return amount >= warnAt ? "warn" : "ok";
|
||||
}
|
||||
|
||||
/** The cycle day spend reaches the budget at today's average pace; null when it does not this cycle. */
|
||||
export function budgetReachedDay(
|
||||
spent: number,
|
||||
day: number,
|
||||
total: number,
|
||||
totalDays: number,
|
||||
): number | null {
|
||||
if (total <= 0 || day <= 0 || spent <= 0) return null;
|
||||
const reached = Math.ceil(total / (spent / day));
|
||||
return reached <= totalDays ? reached : null;
|
||||
}
|
||||
|
||||
/** A super org's overall budget: the whole group's bill, beside one entry per org. */
|
||||
export const TOTAL_BUDGET_ID = "total";
|
||||
|
||||
export type BudgetStatus = "none" | BudgetTone;
|
||||
|
||||
/** Like `budgetTone`, but says so when there is no budget to be over. */
|
||||
export function budgetStatus(amount: number, total: number, warnAt: number): BudgetStatus {
|
||||
return total > 0 ? budgetTone(amount, total, warnAt) : "none";
|
||||
}
|
||||
|
||||
/** A budget as the page edits it: the warn line is a percent until it is saved. */
|
||||
export interface BudgetDraft {
|
||||
budget: number;
|
||||
warnPercent: number;
|
||||
alertsOn: boolean;
|
||||
}
|
||||
|
||||
export function draftOf(config: BudgetConfig | null): BudgetDraft {
|
||||
return config
|
||||
? {
|
||||
budget: config.total_budget_amount,
|
||||
warnPercent: warnPercentFor(config.warn_at_amount, config.total_budget_amount),
|
||||
alertsOn: !config.paused,
|
||||
}
|
||||
: { budget: 0, warnPercent: DEFAULT_WARN_PERCENT, alertsOn: true };
|
||||
}
|
||||
|
||||
export function configOf(orgId: string, draft: BudgetDraft): BudgetConfig {
|
||||
return {
|
||||
org_id: orgId,
|
||||
total_budget_amount: draft.budget,
|
||||
warn_at_amount: warnAmountFor(draft.warnPercent, draft.budget),
|
||||
paused: !draft.alertsOn,
|
||||
};
|
||||
}
|
||||
|
||||
/** Two drafts save the same config, so an edit typed back to its old value is not a change. */
|
||||
export function sameBudget(a: BudgetDraft, b: BudgetDraft): boolean {
|
||||
const x = configOf("", a);
|
||||
const y = configOf("", b);
|
||||
return (
|
||||
x.total_budget_amount === y.total_budget_amount &&
|
||||
sameCents(x.warn_at_amount, y.warn_at_amount) &&
|
||||
x.paused === y.paused
|
||||
);
|
||||
}
|
||||
|
||||
export function superBudgetConfigs(
|
||||
total: BudgetDraft,
|
||||
orgs: Record<string, BudgetDraft>,
|
||||
): BudgetConfig[] {
|
||||
const entries = Object.entries(orgs)
|
||||
.filter(([, draft]) => draft.budget > 0)
|
||||
.map(([orgId, draft]) => configOf(orgId, draft));
|
||||
return total.budget > 0 ? [configOf(TOTAL_BUDGET_ID, total), ...entries] : entries;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mockMeteringDetails, mockMeteringResponse } from "./meteringMock";
|
||||
import { cycleProgress, cyclesNewestFirst, projectCycleCost, subtotalCost } from "./meteringModel";
|
||||
|
||||
describe("meteringMock", () => {
|
||||
const now = Date.UTC(2026, 8, 22, 12);
|
||||
const mock = mockMeteringDetails(now);
|
||||
|
||||
it("keeps today inside the cycle, past the projection warm-up", () => {
|
||||
expect(cycleProgress(mock, now).day).toBe(12);
|
||||
expect(projectCycleCost(mock, now)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("adds up the way the real API does: meters minus discount is the total", () => {
|
||||
expect(subtotalCost(mock) - Number(mock.total_discounted_amount)).toBeCloseTo(mock.total_cost);
|
||||
});
|
||||
|
||||
it("returns closed cycles behind the current one, newest first", () => {
|
||||
const cycles = cyclesNewestFirst(mockMeteringResponse(now));
|
||||
expect(cycles).toHaveLength(3);
|
||||
for (let index = 1; index < cycles.length; index += 1) {
|
||||
expect(cycles[index].cycle_end).toBeLessThanOrEqual(cycles[index - 1].cycle_start);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import type { MeteringDetails, MeteringResponse } from "./meteringModel";
|
||||
|
||||
/** Dev builds only, so a stray env flag can never put fake prices in front of a customer. */
|
||||
export const USE_METERING_MOCK = import.meta.env.DEV && import.meta.env.VITE_USAGE_MOCK === "true";
|
||||
|
||||
const DAY_SECONDS = 86_400;
|
||||
|
||||
const CYCLE_DAYS = 30;
|
||||
|
||||
/** Day of the cycle the mock pretends it is, so the projection is past its warm-up. */
|
||||
const MOCK_CYCLE_DAY = 12;
|
||||
|
||||
/** A real metering response, with the cycle moved so today falls inside it. */
|
||||
export function mockMeteringDetails(now: number = Date.now()): MeteringDetails {
|
||||
const cycleStart = Math.floor(now / 1000 - (MOCK_CYCLE_DAY - 0.5) * DAY_SECONDS);
|
||||
return {
|
||||
total_cost: 0.69,
|
||||
total_discounted_amount: 0.08,
|
||||
cycle_start: cycleStart,
|
||||
cycle_end: cycleStart + CYCLE_DAYS * DAY_SECONDS,
|
||||
ingestion: {
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 1016,
|
||||
metered_cost: 0.5,
|
||||
percent_discount: 10,
|
||||
discounted_amount: 0.06,
|
||||
},
|
||||
query: {
|
||||
base_cost: 0.01,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 6634,
|
||||
metered_cost: 0.07,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
retention: {
|
||||
base_cost: 1.3563368e-7,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 114235,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
pipeline: {
|
||||
base_cost: 0.2,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 499,
|
||||
metered_cost: 0.2,
|
||||
percent_discount: 10,
|
||||
discounted_amount: 0.02,
|
||||
},
|
||||
remote_pipeline: {
|
||||
base_cost: 0.3,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
ai: {
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
synthetics_browser: {
|
||||
base_cost: 0.0002574,
|
||||
unit_divisor: 1,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
synthetics_protocol: {
|
||||
base_cost: 0.00000494,
|
||||
unit_divisor: 1,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
missing_details: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Two closed cycles behind the current one, so the trend ranges have something to chart. */
|
||||
export function mockMeteringResponse(now: number = Date.now()): MeteringResponse {
|
||||
const upcoming = mockMeteringDetails(now);
|
||||
const cycleSeconds = upcoming.cycle_end - upcoming.cycle_start;
|
||||
const past = [1, 2].map((back) => {
|
||||
const cycleStart = upcoming.cycle_start - back * cycleSeconds;
|
||||
return {
|
||||
...mockMeteringDetails(now),
|
||||
cycle_start: cycleStart,
|
||||
cycle_end: cycleStart + cycleSeconds,
|
||||
};
|
||||
});
|
||||
return { upcoming, past, oldest_ts: past[past.length - 1].cycle_start };
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TranslateFn } from "@/types/i18n";
|
||||
import { raw } from "@/types/i18n";
|
||||
import {
|
||||
billingGroupOrgs,
|
||||
buildMeterRows,
|
||||
cycleProgress,
|
||||
apiCheckRuns,
|
||||
browserStepRuns,
|
||||
estimateCost,
|
||||
retentionDailyRate,
|
||||
fitWholeCount,
|
||||
runsPerMonth,
|
||||
effectiveRate,
|
||||
projectCycleCost,
|
||||
scopedMeterRows,
|
||||
splitByUse,
|
||||
subtotalCost,
|
||||
formatCost,
|
||||
formatCycle,
|
||||
formatRate,
|
||||
formatVolume,
|
||||
formatGbHours,
|
||||
METERS,
|
||||
meterForEvent,
|
||||
monthlyPace,
|
||||
unitRate,
|
||||
volumeDetails,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
|
||||
const t = ((key: string, params?: Record<string, unknown>) =>
|
||||
raw(params ? `${key}:${JSON.stringify(params)}` : key)) as unknown as TranslateFn;
|
||||
|
||||
const details: MeteringDetails = {
|
||||
total_cost: 0.69,
|
||||
total_discounted_amount: 0.08,
|
||||
cycle_start: 1789046852,
|
||||
cycle_end: 1791638852,
|
||||
ingestion: {
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 1016,
|
||||
metered_cost: 0.5,
|
||||
percent_discount: 10,
|
||||
discounted_amount: 0.06,
|
||||
},
|
||||
query: {
|
||||
base_cost: 0.01,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 6634,
|
||||
metered_cost: 0.07,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
retention: {
|
||||
base_cost: 1.3563368e-7,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 114235,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
pipeline: {
|
||||
base_cost: 0.2,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 499,
|
||||
metered_cost: 0.2,
|
||||
percent_discount: 10,
|
||||
discounted_amount: 0.02,
|
||||
},
|
||||
remote_pipeline: {
|
||||
base_cost: 0.3,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
ai: {
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1,
|
||||
metered_amount: 1036,
|
||||
metered_cost: 518,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
synthetics_browser: {
|
||||
base_cost: 0.00025739999999999997,
|
||||
unit_divisor: 1,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
synthetics_protocol: {
|
||||
base_cost: 0.00000494,
|
||||
unit_divisor: 1,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
},
|
||||
missing_details: [],
|
||||
};
|
||||
|
||||
describe("meteringModel", () => {
|
||||
it("formats a byte meter from megabytes and a count meter as a count", () => {
|
||||
expect(formatVolume(details.ingestion)).toBe("1016.00 MB");
|
||||
expect(formatVolume(details.query)).toBe("6.48 GB");
|
||||
expect(formatVolume(details.ai)).toBe("1,036");
|
||||
expect(formatVolume({ ...details.query, metered_amount: 1361.92 }, "retention")).toBe(
|
||||
"1.33 GB-hours",
|
||||
);
|
||||
expect(formatGbHours(2_560_000)).toBe("2,500.00 GB-hours");
|
||||
});
|
||||
|
||||
it("renders a dash when a meter is absent", () => {
|
||||
expect(formatVolume(undefined)).toBe("—");
|
||||
expect(formatCost(undefined)).toBe("—");
|
||||
expect(formatCost(null)).toBe("—");
|
||||
});
|
||||
|
||||
it("formats cost in dollars", () => {
|
||||
expect(formatCost(0.69)).toBe("$0.69");
|
||||
expect(formatCost(0)).toBe("$0.00");
|
||||
});
|
||||
|
||||
it("keeps three significant digits on a sub-cent rate", () => {
|
||||
const synthetics = METERS.find((def) => def.key === "synthetics_browser")!;
|
||||
// $0.0002574: four fixed decimals rounded it to $0.0003, 16% high.
|
||||
expect(formatRate(details.synthetics_browser, synthetics, t)).toContain("$0.000257");
|
||||
});
|
||||
|
||||
it("never shows a real but tiny rate as $0.00, and prices retention per GB-hour", () => {
|
||||
const meter = (key: string) => METERS.find((def) => def.key === key)!;
|
||||
const protocol = { ...details.synthetics_protocol, base_cost: 0.00000494 };
|
||||
expect(formatRate(protocol, meter("synthetics_protocol"), t)).toContain('"rate":"$0.00000494"');
|
||||
// Retention is metered in MB-hours and Stripe divides by 1024, so its unit is the GB-hour.
|
||||
const retention = { ...details.retention, base_cost: 1.3563368e-7, unit_divisor: 1024 };
|
||||
const retentionRate = formatRate(retention, meter("retention"), t);
|
||||
expect(retentionRate).toContain('"rate":"$0.000000136"');
|
||||
expect(retentionRate).toContain('"unit":"billing.usageV2.unitGbHour"');
|
||||
// A rate of a cent or more reads as before.
|
||||
expect(formatRate({ ...details.ingestion, base_cost: 0.5 }, meter("ingestion"), t)).toContain(
|
||||
'"rate":"$0.50"',
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the per stored unit rate", () => {
|
||||
expect(unitRate(details.ingestion)).toBeCloseTo(0.5 / 1024);
|
||||
expect(unitRate(details.ai)).toBe(0.5);
|
||||
expect(unitRate({ ...details.ai, unit_divisor: 0 })).toBe(0);
|
||||
expect(unitRate(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it("maps a usage stream event back to its meter", () => {
|
||||
expect(meterForEvent("Search")?.key).toBe("query");
|
||||
expect(meterForEvent("AiCredits")?.key).toBe("ai");
|
||||
expect(meterForEvent("NewIncident")).toBeUndefined();
|
||||
expect(meterForEvent("Functions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds one row per meter, in display order", () => {
|
||||
const rows = buildMeterRows(details, t);
|
||||
expect(rows.map((row) => row.key)).toEqual(METERS.map((def) => def.key));
|
||||
expect(rows[0].gross).toBe(0.5);
|
||||
expect(rows[0].discount).toBe(0.06);
|
||||
expect(rows[0].cost).toBeCloseTo(0.44);
|
||||
expect(rows.every((row) => row.missing === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("says what was billed when per-started-GB rounding moves the amount, and only then", () => {
|
||||
// 297 MB at $0.50 per GB came to $0.50: Stripe billed one whole GB.
|
||||
const small = buildMeterRows(
|
||||
{
|
||||
...details,
|
||||
ingestion: {
|
||||
...details.ingestion,
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 297,
|
||||
metered_cost: 0.5,
|
||||
},
|
||||
},
|
||||
t,
|
||||
).find((row) => row.key === "ingestion")!;
|
||||
expect(String(small.subline)).toContain("billing.usageV2.volumeAtRateBilled");
|
||||
expect(String(small.subline)).toContain('"amount":"1"');
|
||||
expect(String(small.subline)).toContain('"unit":"billing.usageV2.unitGb"');
|
||||
|
||||
// 2,855.15 GB billed as 2,856: rounding moves it 0.03%, so the row keeps its plain line.
|
||||
const large = buildMeterRows(
|
||||
{
|
||||
...details,
|
||||
ingestion: {
|
||||
...details.ingestion,
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 2_923_673.6,
|
||||
metered_cost: 1428,
|
||||
},
|
||||
},
|
||||
t,
|
||||
).find((row) => row.key === "ingestion")!;
|
||||
expect(large.subline).toBeUndefined();
|
||||
|
||||
// A counted meter is billed exactly as counted.
|
||||
const counted = buildMeterRows(details, t).find((row) => row.key === "ai")!;
|
||||
expect(counted.subline).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks a meter the backend failed to price", () => {
|
||||
const rows = buildMeterRows(
|
||||
{ ...details, missing_details: [{ kind: "retention", error: "boom" }] },
|
||||
t,
|
||||
);
|
||||
expect(rows.find((row) => row.key === "retention")?.missing).toBe(true);
|
||||
expect(rows.find((row) => row.key === "ingestion")?.missing).toBe(false);
|
||||
});
|
||||
|
||||
it("opens a breakdown only for a meter with something metered", () => {
|
||||
const drillable = (key: string) =>
|
||||
buildMeterRows(details, t).find((row) => row.key === key)?.drillable;
|
||||
expect(drillable("ingestion")).toBe(true);
|
||||
// Free to store, but 114 GB is still worth breaking down.
|
||||
expect(drillable("retention")).toBe(true);
|
||||
// Nothing metered this cycle: a click would open an empty table.
|
||||
expect(drillable("synthetics_browser")).toBe(false);
|
||||
expect(drillable("remote_pipeline")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no rows when the organization has no pricing", () => {
|
||||
expect(buildMeterRows(null, t)).toEqual([]);
|
||||
});
|
||||
|
||||
it("formats the billing cycle from Unix seconds", () => {
|
||||
expect(formatCycle(details, t)).toContain("billing.usageV2.cycleRange");
|
||||
expect(formatCycle(details, t)).toContain("2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("meteringModel cycle math", () => {
|
||||
const start = details.cycle_start * 1000;
|
||||
const day = 86_400_000;
|
||||
|
||||
it("counts the cycle day from its start, capped at the cycle length", () => {
|
||||
expect(cycleProgress(details, start + 1).day).toBe(1);
|
||||
expect(cycleProgress(details, start + 10.5 * day).day).toBe(11);
|
||||
expect(cycleProgress(details, start + 99 * day).day).toBe(30);
|
||||
expect(cycleProgress(details, start).totalDays).toBe(30);
|
||||
});
|
||||
|
||||
it("reads usage so far as a month from the days that have passed", () => {
|
||||
// 900 GB by day 9 is 100 GB a day, so 3,000 GB a month, not 900 spread over the whole cycle.
|
||||
expect(monthlyPace(900, details, start + 9 * day)).toBeCloseTo(3000);
|
||||
// An hour in still counts as one day, so a cycle's first hours do not swing it.
|
||||
expect(monthlyPace(10, details, start + day / 24)).toBeCloseTo(300);
|
||||
// Once the cycle is over, the whole cycle has passed.
|
||||
expect(monthlyPace(900, details, start + 99 * day)).toBeCloseTo(900);
|
||||
});
|
||||
|
||||
it("hides the projection for the first days of a cycle", () => {
|
||||
expect(projectCycleCost(details, start + 1.5 * day)).toBeNull();
|
||||
expect(projectCycleCost(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("projects the run rate to the end of the cycle", () => {
|
||||
expect(projectCycleCost(details, start + 15 * day)).toBeCloseTo(0.69 * 2);
|
||||
expect(projectCycleCost(details, start + 30 * day)).toBeCloseTo(0.69);
|
||||
});
|
||||
|
||||
it("sums per meter cost before discounts", () => {
|
||||
expect(subtotalCost(details)).toBeCloseTo(518.77);
|
||||
expect(subtotalCost(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("prices at what was billed, falling back to list price when nothing was metered", () => {
|
||||
expect(effectiveRate(details.ingestion)).toBeCloseTo(0.44 / 1016, 10);
|
||||
expect(effectiveRate(details.remote_pipeline)).toBeCloseTo(0.3 / 1024);
|
||||
});
|
||||
|
||||
it("puts the most expensive meter first and folds unused meters away", () => {
|
||||
const { used, unused } = splitByUse(buildMeterRows(details, t));
|
||||
expect(used[0].key).toBe("ai");
|
||||
expect(used.map((row) => row.key)).not.toContain("remote_pipeline");
|
||||
expect(unused.map((row) => row.key)).toContain("synthetics_browser");
|
||||
});
|
||||
|
||||
it("keeps an unpriced meter visible even at zero cost", () => {
|
||||
const rows = buildMeterRows(
|
||||
{ ...details, missing_details: [{ kind: "synthetics_browser", error: "x" }] },
|
||||
t,
|
||||
);
|
||||
expect(splitByUse(rows).used.map((row) => row.key)).toContain("synthetics_browser");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateCost", () => {
|
||||
const priced = {
|
||||
cycle_start: 1_757_462_400,
|
||||
cycle_end: 1_760_054_400,
|
||||
ingestion: { base_cost: 0.5, unit_divisor: 1024, metered_amount: 0, metered_cost: 0 },
|
||||
query: { base_cost: 0.01, unit_divisor: 1024, metered_amount: 0, metered_cost: 0 },
|
||||
pipeline: { base_cost: 0.2, unit_divisor: 1024, metered_amount: 0, metered_cost: 0 },
|
||||
retention: { base_cost: 0, unit_divisor: 1024, metered_amount: 1000, metered_cost: 30 },
|
||||
ai: { base_cost: 0.5, unit_divisor: 1, metered_amount: 0, metered_cost: 0 },
|
||||
} as unknown as MeteringDetails;
|
||||
|
||||
const input = {
|
||||
ingestGb: 100,
|
||||
searchCount: 10,
|
||||
pipelineCount: 5,
|
||||
retentionDays: 30,
|
||||
aiCredits: 4,
|
||||
avgStreamMb: 200,
|
||||
scanPerSearchMb: 6000,
|
||||
browserChecks: 0,
|
||||
browserSteps: 5,
|
||||
browserRegions: 1,
|
||||
browserDevices: 1,
|
||||
browserIntervalSec: 300,
|
||||
apiChecks: 0,
|
||||
apiRegions: 1,
|
||||
apiIntervalSec: 300,
|
||||
};
|
||||
|
||||
it("prices synthetics per run: browser per step, region and device; API per region", () => {
|
||||
const withSynthetics = {
|
||||
...priced,
|
||||
synthetics_browser: { base_cost: 0.0002574, unit_divisor: 1 },
|
||||
synthetics_protocol: { base_cost: 0.00000494, unit_divisor: 1 },
|
||||
} as unknown as MeteringDetails;
|
||||
const plan = {
|
||||
...input,
|
||||
browserChecks: 10,
|
||||
browserSteps: 5,
|
||||
browserRegions: 2,
|
||||
browserDevices: 2,
|
||||
apiChecks: 20,
|
||||
apiRegions: 3,
|
||||
};
|
||||
// Every 300 seconds is 8,640 runs in a 30 day month.
|
||||
expect(runsPerMonth(300)).toBe(8640);
|
||||
expect(browserStepRuns(plan)).toBe(1_728_000);
|
||||
expect(apiCheckRuns(plan)).toBe(518_400);
|
||||
const { lines } = estimateCost(withSynthetics, plan);
|
||||
const line = (key: string) => lines.find((entry) => entry.key === key);
|
||||
expect(line("synthetics_browser")).toMatchObject({ quantity: 1_728_000, unit: "run" });
|
||||
expect(line("synthetics_browser")?.cost).toBeCloseTo(1_728_000 * 0.0002574, 6);
|
||||
expect(line("synthetics_protocol")?.cost).toBeCloseTo(518_400 * 0.00000494, 6);
|
||||
});
|
||||
|
||||
it("never runs a check with no interval", () => {
|
||||
expect(runsPerMonth(0)).toBe(0);
|
||||
expect(apiCheckRuns({ ...input, apiChecks: 5, apiIntervalSec: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it("prices every meter from the API rate and sums them", () => {
|
||||
const { lines, total } = estimateCost(priced, input);
|
||||
const cost = (key: string) => lines.find((line) => line.key === key)?.cost ?? 0;
|
||||
expect(cost("ingestion")).toBeCloseTo(50, 5);
|
||||
expect(cost("query")).toBeCloseTo((10 * 6000 * 0.01) / 1024, 5);
|
||||
expect(cost("pipeline")).toBeCloseTo((5 * 200 * 0.2) / 1024, 5);
|
||||
expect(cost("ai")).toBeCloseTo(2, 5);
|
||||
expect(total).toBeCloseTo(
|
||||
lines.reduce((sum, line) => sum + line.cost, 0),
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it("prices retention from the org's own cost per stored megabyte per day", () => {
|
||||
const { lines } = estimateCost(priced, input);
|
||||
const retention = lines.find((line) => line.key === "retention");
|
||||
// 30 dollars over 1000 MB-hours is 0.03 per MB-hour, so 0.72 per MB per day.
|
||||
expect(retention?.cost).toBeCloseTo(100 * 1024 * 30 * 0.72, 5);
|
||||
});
|
||||
|
||||
it("converts billed MB-hours to a daily rate, falling back to list price before any usage", () => {
|
||||
const at = (retention: Record<string, number>) =>
|
||||
retentionDailyRate({ ...priced, retention } as unknown as MeteringDetails);
|
||||
// 72 dollars over 72,000 MB-hours is 0.001 per MB-hour, 0.024 per MB-day.
|
||||
expect(
|
||||
at({ base_cost: 0, unit_divisor: 1024, metered_amount: 72_000, metered_cost: 72 }),
|
||||
).toBeCloseTo(0.024, 10);
|
||||
// 1.024 dollars per GB-hour is 0.001 per MB-hour.
|
||||
expect(
|
||||
at({ base_cost: 1.024, unit_divisor: 1024, metered_amount: 0, metered_cost: 0 }),
|
||||
).toBeCloseTo(0.024, 10);
|
||||
expect(retentionDailyRate(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns nothing to price without a cycle", () => {
|
||||
expect(estimateCost(null, input)).toEqual({ lines: [], total: 0 });
|
||||
});
|
||||
|
||||
describe("super org", () => {
|
||||
it("lists the super org first, then its members, and nothing for any other org", () => {
|
||||
const orgs = billingGroupOrgs(
|
||||
[
|
||||
{
|
||||
payer_org_id: "sup",
|
||||
payer_org_name: "Acme",
|
||||
member_org_id: "m1",
|
||||
member_org_name: "EU",
|
||||
},
|
||||
{ payer_org_id: "sup", payer_org_name: "Acme", member_org_id: "m2", member_org_name: "" },
|
||||
],
|
||||
"sup",
|
||||
);
|
||||
expect(orgs).toEqual([
|
||||
{ id: "sup", name: "Acme", isSelf: true },
|
||||
{ id: "m1", name: "EU", isSelf: false },
|
||||
{ id: "m2", name: "m2", isSelf: false },
|
||||
]);
|
||||
expect(billingGroupOrgs([], "sup")).toEqual([]);
|
||||
expect(billingGroupOrgs(null, "sup")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows one org's share of each meter, without retention", () => {
|
||||
const rows = scopedMeterRows(
|
||||
details,
|
||||
{
|
||||
ingestion: { volume: 2048, gross: 1, billed: 0.9 },
|
||||
query: { volume: 0, gross: 0, billed: 0 },
|
||||
},
|
||||
t,
|
||||
);
|
||||
const ingestion = rows.find((row) => row.key === "ingestion")!;
|
||||
expect(ingestion.gross).toBeCloseTo(1);
|
||||
expect(ingestion.discount).toBeCloseTo(0.1);
|
||||
expect(ingestion.discountPercent).toBe(10);
|
||||
expect(ingestion.cost).toBeCloseTo(0.9);
|
||||
expect(ingestion.drillable).toBe(true);
|
||||
expect(rows.find((row) => row.key === "query")!.drillable).toBe(false);
|
||||
expect(rows.find((row) => row.key === "pipeline")!.drillable).toBe(false);
|
||||
expect(rows.some((row) => row.key === "retention")).toBe(false);
|
||||
expect(scopedMeterRows(null, {}, t)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("meteringModel billing dates", () => {
|
||||
// The daily usage buckets start at UTC midnight; west of UTC a browser-zone label is a day early.
|
||||
it("reads billing days in UTC, whatever the browser's time zone", async () => {
|
||||
const zone = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
try {
|
||||
vi.resetModules();
|
||||
const model = await import("./meteringModel");
|
||||
const oct12 = Date.UTC(2026, 9, 12) / 1000;
|
||||
expect(new Date(oct12 * 1000).getDate()).toBe(11);
|
||||
expect(model.formatDay(oct12)).toBe("Oct 12");
|
||||
expect(model.formatFullDay(oct12)).toBe("Oct 12, 2026");
|
||||
} finally {
|
||||
process.env.TZ = zone;
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("meteringModel usage without a bill", () => {
|
||||
it("adds each meter's events into its volume, in megabytes or a count, with no money", () => {
|
||||
const usage = volumeDetails(
|
||||
{ Ingestion: 2048, AiCredits: 5, NewIncident: 2, Other: 9 },
|
||||
10,
|
||||
20,
|
||||
);
|
||||
expect(usage.ingestion).toMatchObject({
|
||||
metered_amount: 2048,
|
||||
unit_divisor: 1024,
|
||||
metered_cost: 0,
|
||||
});
|
||||
expect(usage.ai).toMatchObject({ metered_amount: 5, unit_divisor: 1, metered_cost: 0 });
|
||||
expect(usage.query.metered_amount).toBe(0);
|
||||
expect(usage).toMatchObject({ total_cost: 0, cycle_start: 10, cycle_end: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("meteringModel whole counts for the estimator", () => {
|
||||
it("rounds to a whole count, at least one once there is usage, with the size scaled to fit", () => {
|
||||
expect(fitWholeCount(1 / 3)).toEqual({ count: 1, scale: 1 / 3 });
|
||||
const three = fitWholeCount(3.4);
|
||||
expect(three.count).toBe(3);
|
||||
expect(three.count * three.scale).toBeCloseTo(3.4, 9);
|
||||
expect(fitWholeCount(0)).toEqual({ count: 0, scale: 1 });
|
||||
expect(fitWholeCount(Number.NaN)).toEqual({ count: 0, scale: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,624 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import type { I18nKey, I18nText, TranslateFn } from "@/types/i18n";
|
||||
import { raw } from "@/types/i18n";
|
||||
import { addCommasToNumber, formatSizeFromMB } from "@/utils/formatters";
|
||||
|
||||
export interface PricingDetails {
|
||||
base_cost: number;
|
||||
unit_divisor: number;
|
||||
metered_amount: number;
|
||||
metered_cost: number;
|
||||
percent_discount?: number | null;
|
||||
discounted_amount?: number | null;
|
||||
}
|
||||
|
||||
export interface MissingDetail {
|
||||
kind: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface MeteringDetails {
|
||||
total_cost: number;
|
||||
total_discounted_amount?: number | null;
|
||||
cycle_start: number;
|
||||
cycle_end: number;
|
||||
ingestion: PricingDetails;
|
||||
query: PricingDetails;
|
||||
retention: PricingDetails;
|
||||
pipeline: PricingDetails;
|
||||
remote_pipeline: PricingDetails;
|
||||
ai: PricingDetails;
|
||||
synthetics_browser: PricingDetails;
|
||||
synthetics_protocol: PricingDetails;
|
||||
missing_details: MissingDetail[];
|
||||
}
|
||||
|
||||
export interface MeteringResponse {
|
||||
upcoming: MeteringDetails;
|
||||
past: MeteringDetails[];
|
||||
oldest_ts?: number | null;
|
||||
}
|
||||
|
||||
/** Accepts the flat pre-cycles body too, so an older backend still renders. */
|
||||
export function parseMeteringResponse(body: unknown): MeteringResponse | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const value = body as Partial<MeteringResponse> & Partial<MeteringDetails>;
|
||||
if (value.upcoming) {
|
||||
return {
|
||||
upcoming: value.upcoming,
|
||||
past: Array.isArray(value.past) ? value.past : [],
|
||||
oldest_ts: value.oldest_ts ?? null,
|
||||
};
|
||||
}
|
||||
if (typeof value.cycle_start !== "number") return null;
|
||||
return { upcoming: body as MeteringDetails, past: [], oldest_ts: value.cycle_start };
|
||||
}
|
||||
|
||||
export function cyclesNewestFirst(response: MeteringResponse | null): MeteringDetails[] {
|
||||
if (!response) return [];
|
||||
const past = response.past
|
||||
.filter((cycle) => cycle.cycle_end > cycle.cycle_start)
|
||||
.sort((a, b) => b.cycle_start - a.cycle_start);
|
||||
return [response.upcoming, ...past];
|
||||
}
|
||||
|
||||
export type MeterKey =
|
||||
| "ingestion"
|
||||
| "query"
|
||||
| "retention"
|
||||
| "pipeline"
|
||||
| "remote_pipeline"
|
||||
| "ai"
|
||||
| "synthetics_browser"
|
||||
| "synthetics_protocol";
|
||||
|
||||
export interface MeterDef {
|
||||
key: MeterKey;
|
||||
labelKey: I18nKey;
|
||||
/** The rate's denominator, e.g. "$0.06 per GB". */
|
||||
rateUnitKey: I18nKey;
|
||||
/** `event` values in the org's `usage` stream. Empty when the meter emits none. */
|
||||
events: string[];
|
||||
/** What the meter counts, in words, e.g. "Data scanned by searches". */
|
||||
billedAsKey: I18nKey;
|
||||
}
|
||||
|
||||
/** One row of a bill table or strip: a meter, or a member org of a super org. */
|
||||
export interface BillRow {
|
||||
key: string;
|
||||
label: I18nText;
|
||||
volume: I18nText;
|
||||
rate: I18nText;
|
||||
/** Billed cost after this meter's discount; every share on the page is of the billed total. */
|
||||
cost: number;
|
||||
/** Cost before discount, straight from `metered_cost`. */
|
||||
gross: number;
|
||||
discount: number;
|
||||
/** The backend reported this meter under `missing_details`, so its cost is unknown. */
|
||||
missing: boolean;
|
||||
discountPercent: number;
|
||||
/** The breakdown opens: the meter has one, and something was metered this cycle. */
|
||||
drillable: boolean;
|
||||
/** The meter has a breakdown at all; with `drillable` false it is only empty this cycle. */
|
||||
hasBreakdown: boolean;
|
||||
/** Replaces the "volume at rate" line under the label, for rows that are not meters. */
|
||||
subline?: I18nText;
|
||||
}
|
||||
|
||||
export interface MeterRow extends BillRow {
|
||||
key: MeterKey;
|
||||
}
|
||||
|
||||
export interface CycleProgress {
|
||||
day: number;
|
||||
totalDays: number;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
/** Display order. Both charts iterate this, so a meter keeps one palette slot across tabs. */
|
||||
export const METERS: MeterDef[] = [
|
||||
{
|
||||
key: "ingestion",
|
||||
labelKey: "billing.ingestion",
|
||||
rateUnitKey: "billing.usageV2.unitGb",
|
||||
events: ["Ingestion"],
|
||||
billedAsKey: "billing.usageV2.billedIngestion",
|
||||
},
|
||||
{
|
||||
key: "query",
|
||||
labelKey: "billing.search",
|
||||
rateUnitKey: "billing.usageV2.unitGb",
|
||||
events: ["Search"],
|
||||
billedAsKey: "billing.usageV2.billedQuery",
|
||||
},
|
||||
{
|
||||
key: "pipeline",
|
||||
labelKey: "billing.pipelines",
|
||||
rateUnitKey: "billing.usageV2.unitGb",
|
||||
events: ["Pipeline"],
|
||||
billedAsKey: "billing.usageV2.billedPipeline",
|
||||
},
|
||||
{
|
||||
key: "remote_pipeline",
|
||||
labelKey: "billing.remotePipelines",
|
||||
rateUnitKey: "billing.usageV2.unitGb",
|
||||
events: ["RemotePipeline"],
|
||||
billedAsKey: "billing.usageV2.billedRemotePipeline",
|
||||
},
|
||||
{
|
||||
key: "retention",
|
||||
labelKey: "billing.dataRetention",
|
||||
// Metered in MB-hours; Stripe divides by 1024, so the price is per GB-hour.
|
||||
rateUnitKey: "billing.usageV2.unitGbHour",
|
||||
events: [],
|
||||
billedAsKey: "billing.usageV2.billedRetention",
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
labelKey: "billing.aiCredits",
|
||||
rateUnitKey: "billing.usageV2.unitCredit",
|
||||
// NewIncident is a size-1 activity record beside the incident's AiCredits; the backend never bills it.
|
||||
events: ["AiCredits"],
|
||||
billedAsKey: "billing.usageV2.billedAi",
|
||||
},
|
||||
{
|
||||
key: "synthetics_browser",
|
||||
labelKey: "billing.usageV2.syntheticsBrowser",
|
||||
rateUnitKey: "billing.usageV2.unitRun",
|
||||
events: ["SyntheticsBrowserSteps"],
|
||||
billedAsKey: "billing.usageV2.billedSynthetics",
|
||||
},
|
||||
{
|
||||
key: "synthetics_protocol",
|
||||
labelKey: "billing.usageV2.syntheticsProtocol",
|
||||
rateUnitKey: "billing.usageV2.unitRun",
|
||||
events: ["SyntheticsProtocolSteps"],
|
||||
billedAsKey: "billing.usageV2.billedSynthetics",
|
||||
},
|
||||
];
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
const MIN_PROJECTION_DAYS = 3;
|
||||
|
||||
const USD = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/** Rates run to four decimals because synthetics costs a fraction of a cent per run. */
|
||||
const USD_RATE = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
});
|
||||
|
||||
/** Under a cent a rate keeps three significant digits, so a real price never reads $0.00. */
|
||||
const USD_TINY_RATE = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumSignificantDigits: 3,
|
||||
});
|
||||
|
||||
const USD_PRECISE = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
});
|
||||
|
||||
const GB_HOURS = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const CYCLE_DATE = new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const DAY_DATE = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
/** "Oct 12" for a moment in Unix seconds, read in UTC. */
|
||||
export const formatDay = (seconds: number): string => DAY_DATE.format(new Date(seconds * 1000));
|
||||
|
||||
/** "Oct 12, 2026" for a moment in Unix seconds, read in UTC. */
|
||||
export const formatFullDay = (seconds: number): string =>
|
||||
CYCLE_DATE.format(new Date(seconds * 1000));
|
||||
|
||||
/** `precise` shows four decimals, for a range so short that two decimals would read $0.00. */
|
||||
export function formatCost(amount: number | null | undefined, precise = false): I18nText {
|
||||
if (amount === null || amount === undefined || !Number.isFinite(Number(amount))) return raw("—");
|
||||
return raw((precise ? USD_PRECISE : USD).format(Number(amount)));
|
||||
}
|
||||
|
||||
/** A `unit_divisor` of 1024 means `metered_amount` is megabytes, MB-hours for retention. A 1 means it is a count. */
|
||||
export function formatVolume(
|
||||
meter: PricingDetails | null | undefined,
|
||||
key?: MeterKey | null,
|
||||
): I18nText {
|
||||
const amount = meter?.metered_amount;
|
||||
if (amount === null || amount === undefined || !Number.isFinite(Number(amount))) return raw("—");
|
||||
if (Number(meter?.unit_divisor) === 1) return raw(addCommasToNumber(Math.round(amount)));
|
||||
if (key === "retention") return raw(formatGbHours(amount));
|
||||
return raw(formatSizeFromMB(amount));
|
||||
}
|
||||
|
||||
/** Always GB-hours, the unit retention is priced in, so the amount reads against its rate. */
|
||||
export function formatGbHours(mbHours: number): string {
|
||||
return `${GB_HOURS.format(mbHours / 1024)} GB-hours`;
|
||||
}
|
||||
|
||||
export function formatRate(
|
||||
meter: PricingDetails | null | undefined,
|
||||
def: MeterDef,
|
||||
t: TranslateFn,
|
||||
): I18nText {
|
||||
const base = Number(meter?.base_cost);
|
||||
if (!Number.isFinite(base)) return raw("—");
|
||||
return t("billing.usageV2.ratePerUnit", {
|
||||
rate: (base > 0 && base < 0.01 ? USD_TINY_RATE : USD_RATE).format(base),
|
||||
unit: t(def.rateUnitKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCycle(details: MeteringDetails, t: TranslateFn): I18nText {
|
||||
return t("billing.usageV2.cycleRange", {
|
||||
start: formatFullDay(details.cycle_start),
|
||||
end: formatFullDay(details.cycle_end),
|
||||
});
|
||||
}
|
||||
|
||||
/** Cost per stored unit, used to price a `usage` stream volume on the client. */
|
||||
export function unitRate(meter: PricingDetails | null | undefined): number {
|
||||
const base = Number(meter?.base_cost);
|
||||
const divisor = Number(meter?.unit_divisor);
|
||||
if (!Number.isFinite(base) || !Number.isFinite(divisor) || divisor === 0) return 0;
|
||||
return base / divisor;
|
||||
}
|
||||
|
||||
/** The `usage` stream event names this meter covers, keyed by event for a chart lookup. */
|
||||
export function meterForEvent(event: string): MeterDef | undefined {
|
||||
return METERS.find((def) => def.events.includes(event));
|
||||
}
|
||||
|
||||
function billedAs(
|
||||
meter: PricingDetails | null | undefined,
|
||||
def: MeterDef,
|
||||
t: TranslateFn,
|
||||
): I18nText | undefined {
|
||||
const base = Number(meter?.base_cost);
|
||||
const divisor = Number(meter?.unit_divisor);
|
||||
const cost = Number(meter?.metered_cost);
|
||||
if (!(divisor > 1 && base > 0 && cost > 0)) return undefined;
|
||||
const billed = cost / base;
|
||||
if (billed <= ((Number(meter?.metered_amount) || 0) / divisor) * 1.01) return undefined;
|
||||
return t("billing.usageV2.volumeAtRateBilled", {
|
||||
volume: formatVolume(meter, def.key),
|
||||
rate: formatRate(meter, def, t),
|
||||
amount: raw(addCommasToNumber(Math.round(billed * 100) / 100)),
|
||||
unit: t(def.rateUnitKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMeterRows(
|
||||
details: MeteringDetails | null | undefined,
|
||||
t: TranslateFn,
|
||||
): MeterRow[] {
|
||||
if (!details) return [];
|
||||
const missing = new Set((details.missing_details ?? []).map((entry) => entry.kind));
|
||||
return METERS.map((def) => {
|
||||
const meter = details[def.key];
|
||||
const hasBreakdown = def.events.length > 0 || def.key === "retention";
|
||||
return {
|
||||
key: def.key,
|
||||
label: t(def.labelKey),
|
||||
volume: formatVolume(meter, def.key),
|
||||
rate: formatRate(meter, def, t),
|
||||
subline: billedAs(meter, def, t),
|
||||
cost: netCost(meter),
|
||||
gross: Number(meter?.metered_cost) || 0,
|
||||
discount: Number(meter?.discounted_amount) || 0,
|
||||
missing: missing.has(def.key),
|
||||
discountPercent: Number(meter?.percent_discount) || 0,
|
||||
// A breakdown of a meter with nothing metered would open onto an empty table.
|
||||
drillable: hasBreakdown && Number(meter?.metered_amount) > 0,
|
||||
hasBreakdown,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Meters that count credits or runs rather than megabytes. */
|
||||
const COUNTED_METERS: MeterKey[] = ["ai", "synthetics_browser", "synthetics_protocol"];
|
||||
|
||||
export function volumeDetails(
|
||||
volumes: Record<string, number>,
|
||||
start: number,
|
||||
end: number,
|
||||
): MeteringDetails {
|
||||
const meters = Object.fromEntries(
|
||||
METERS.map((def) => [
|
||||
def.key,
|
||||
{
|
||||
base_cost: 0,
|
||||
unit_divisor: COUNTED_METERS.includes(def.key) ? 1 : 1024,
|
||||
metered_amount: def.events.reduce((sum, event) => sum + (Number(volumes[event]) || 0), 0),
|
||||
metered_cost: 0,
|
||||
},
|
||||
]),
|
||||
) as Record<MeterKey, PricingDetails>;
|
||||
return { ...meters, total_cost: 0, cycle_start: start, cycle_end: end, missing_details: [] };
|
||||
}
|
||||
|
||||
/** Used meters by cost, highest first; unused ones fold into a single line. */
|
||||
export function splitByUse(rows: MeterRow[]): { used: MeterRow[]; unused: MeterRow[] } {
|
||||
const used = rows.filter((row) => row.cost > 0 || row.missing).sort((a, b) => b.cost - a.cost);
|
||||
const unused = rows.filter((row) => row.cost <= 0 && !row.missing);
|
||||
return { used, unused };
|
||||
}
|
||||
|
||||
/** Sum of per meter cost before discounts; `total_cost` is this minus the discount. */
|
||||
export function subtotalCost(details: MeteringDetails | null | undefined): number {
|
||||
if (!details) return 0;
|
||||
return METERS.reduce((sum, def) => sum + (Number(details[def.key]?.metered_cost) || 0), 0);
|
||||
}
|
||||
|
||||
/** A meter's billed cost: `metered_cost` minus its `discounted_amount`, both server numbers. */
|
||||
export function netCost(meter: PricingDetails | null | undefined): number {
|
||||
const gross = Number(meter?.metered_cost) || 0;
|
||||
const discount = Number(meter?.discounted_amount) || 0;
|
||||
return Math.max(gross - discount, 0);
|
||||
}
|
||||
|
||||
/** What one stored unit actually cost this cycle, discount included, falling back to list price. */
|
||||
export function effectiveRate(meter: PricingDetails | null | undefined): number {
|
||||
const amount = Number(meter?.metered_amount);
|
||||
if (Number.isFinite(amount) && amount > 0 && Number(meter?.metered_cost) > 0) {
|
||||
return netCost(meter) / amount;
|
||||
}
|
||||
return unitRate(meter);
|
||||
}
|
||||
|
||||
export function cycleProgress(details: MeteringDetails, now: number = Date.now()): CycleProgress {
|
||||
const start = details.cycle_start * 1000;
|
||||
const end = details.cycle_end * 1000;
|
||||
const length = Math.max(end - start, 1);
|
||||
const elapsed = Math.min(Math.max((now - start) / length, 0), 1);
|
||||
const totalDays = Math.max(Math.round(length / DAY_MS), 1);
|
||||
const day = Math.min(Math.max(Math.ceil((now - start) / DAY_MS), 1), totalDays);
|
||||
return { day, totalDays, elapsed };
|
||||
}
|
||||
|
||||
/** A straight line run rate; null before a few days have passed, because early cycles swing wildly. */
|
||||
export function projectCycleCost(
|
||||
details: MeteringDetails | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): number | null {
|
||||
if (!details) return null;
|
||||
const { day, elapsed } = cycleProgress(details, now);
|
||||
if (day < MIN_PROJECTION_DAYS || elapsed <= 0) return null;
|
||||
return (Number(details.total_cost) || 0) / elapsed;
|
||||
}
|
||||
|
||||
/** One month of planned usage, in the units people plan in rather than billing units. */
|
||||
export interface EstimateInputs {
|
||||
ingestGb: number;
|
||||
searchCount: number;
|
||||
pipelineCount: number;
|
||||
retentionDays: number;
|
||||
aiCredits: number;
|
||||
/** Converts a pipeline count into processed megabytes. */
|
||||
avgStreamMb: number;
|
||||
/** Converts a search count into scanned megabytes. */
|
||||
scanPerSearchMb: number;
|
||||
browserChecks: number;
|
||||
browserSteps: number;
|
||||
browserRegions: number;
|
||||
browserDevices: number;
|
||||
browserIntervalSec: number;
|
||||
apiChecks: number;
|
||||
apiRegions: number;
|
||||
apiIntervalSec: number;
|
||||
}
|
||||
|
||||
export interface EstimateLine {
|
||||
key: MeterKey;
|
||||
/** Megabytes for a volume meter, credits for AI, runs for synthetics. */
|
||||
quantity: number;
|
||||
unit: "mb" | "credit" | "run";
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface Estimate {
|
||||
lines: EstimateLine[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const MB_PER_GB = 1024;
|
||||
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
const HOURS_PER_DAY = 24;
|
||||
|
||||
const DAYS_PER_MONTH = 30;
|
||||
|
||||
const finite = (value: number) => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
||||
|
||||
export function monthlyPace(
|
||||
amount: number | string | undefined,
|
||||
details: MeteringDetails,
|
||||
now: number = Date.now(),
|
||||
): number {
|
||||
const cycleDays = (details.cycle_end - details.cycle_start) / SECONDS_PER_DAY;
|
||||
const daysPassed = Math.max(cycleProgress(details, now).elapsed * cycleDays, 1);
|
||||
return ((Number(amount) || 0) / daysPassed) * DAYS_PER_MONTH;
|
||||
}
|
||||
|
||||
export function fitWholeCount(exact: number): { count: number; scale: number } {
|
||||
if (!(exact > 0)) return { count: 0, scale: 1 };
|
||||
const count = Math.max(1, Math.round(exact));
|
||||
return { count, scale: exact / count };
|
||||
}
|
||||
|
||||
/** Scheduled runs in a 30 day month; a check with no interval never runs. */
|
||||
export function runsPerMonth(intervalSec: number): number {
|
||||
const interval = finite(intervalSec);
|
||||
return interval > 0 ? (DAYS_PER_MONTH * SECONDS_PER_DAY) / interval : 0;
|
||||
}
|
||||
|
||||
/** Browser checks bill per step run: every step, in every region, on every device, each time it runs. */
|
||||
export function browserStepRuns(input: EstimateInputs): number {
|
||||
return (
|
||||
finite(input.browserChecks) *
|
||||
finite(input.browserSteps) *
|
||||
finite(input.browserRegions) *
|
||||
finite(input.browserDevices) *
|
||||
runsPerMonth(input.browserIntervalSec)
|
||||
);
|
||||
}
|
||||
|
||||
/** API checks bill per run, in every region. */
|
||||
export function apiCheckRuns(input: EstimateInputs): number {
|
||||
return finite(input.apiChecks) * finite(input.apiRegions) * runsPerMonth(input.apiIntervalSec);
|
||||
}
|
||||
|
||||
/** Cost per stored megabyte per day: the org's own billed rate once it has retention usage, else list price. */
|
||||
export function retentionDailyRate(details: MeteringDetails | null | undefined): number {
|
||||
const meter = details?.retention;
|
||||
if (!meter) return 0;
|
||||
const amount = Number(meter.metered_amount) || 0;
|
||||
const cost = Number(meter.metered_cost) || 0;
|
||||
// `metered_amount` is MB-hours, not stored MB, so cost / amount is already per MB-hour and must not be divided by the cycle's days.
|
||||
const perMbHour = amount > 0 && cost > 0 ? cost / amount : unitRate(meter);
|
||||
return perMbHour * HOURS_PER_DAY;
|
||||
}
|
||||
|
||||
/** What a month of the given usage would cost at list price, one line per meter. */
|
||||
export function estimateCost(
|
||||
details: MeteringDetails | null | undefined,
|
||||
input: EstimateInputs,
|
||||
): Estimate {
|
||||
if (!details) return { lines: [], total: 0 };
|
||||
const num = (value: number) => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
||||
const ingestMb = num(input.ingestGb) * MB_PER_GB;
|
||||
const scannedMb = num(input.searchCount) * num(input.scanPerSearchMb);
|
||||
const pipelineMb = num(input.pipelineCount) * num(input.avgStreamMb);
|
||||
const credits = num(input.aiCredits);
|
||||
const browserRuns = browserStepRuns(input);
|
||||
const apiRuns = apiCheckRuns(input);
|
||||
// Typed here, so each meter key stays a MeterKey rather than widening to string.
|
||||
const priced: EstimateLine[] = [
|
||||
{
|
||||
key: "ingestion",
|
||||
quantity: ingestMb,
|
||||
unit: "mb",
|
||||
cost: ingestMb * unitRate(details.ingestion),
|
||||
},
|
||||
{ key: "query", quantity: scannedMb, unit: "mb", cost: scannedMb * unitRate(details.query) },
|
||||
{
|
||||
key: "pipeline",
|
||||
quantity: pipelineMb,
|
||||
unit: "mb",
|
||||
cost: pipelineMb * unitRate(details.pipeline),
|
||||
},
|
||||
{
|
||||
key: "retention",
|
||||
quantity: ingestMb,
|
||||
unit: "mb",
|
||||
cost: ingestMb * num(input.retentionDays) * retentionDailyRate(details),
|
||||
},
|
||||
{ key: "ai", quantity: credits, unit: "credit", cost: credits * unitRate(details.ai) },
|
||||
{
|
||||
key: "synthetics_browser",
|
||||
quantity: browserRuns,
|
||||
unit: "run",
|
||||
cost: browserRuns * unitRate(details.synthetics_browser),
|
||||
},
|
||||
{
|
||||
key: "synthetics_protocol",
|
||||
quantity: apiRuns,
|
||||
unit: "run",
|
||||
cost: apiRuns * unitRate(details.synthetics_protocol),
|
||||
},
|
||||
];
|
||||
const lines = priced.map((line) => ({
|
||||
...line,
|
||||
cost: Number.isFinite(line.cost) ? line.cost : 0,
|
||||
}));
|
||||
return { lines, total: lines.reduce((sum, line) => sum + line.cost, 0) };
|
||||
}
|
||||
|
||||
/** One org's part of one meter on a super org's bill. */
|
||||
export interface MeterShare {
|
||||
/** The org's volume of this meter in the `usage` stream: megabytes, or a count. */
|
||||
volume: number;
|
||||
gross: number;
|
||||
billed: number;
|
||||
}
|
||||
|
||||
export function scopedMeterRows(
|
||||
details: MeteringDetails | null | undefined,
|
||||
shares: Partial<Record<MeterKey, MeterShare>>,
|
||||
t: TranslateFn,
|
||||
): MeterRow[] {
|
||||
if (!details) return [];
|
||||
return METERS.filter((def) => def.events.length).map((def) => {
|
||||
const meter = details[def.key];
|
||||
const share = shares[def.key] ?? { volume: 0, gross: 0, billed: 0 };
|
||||
const discount = Math.max(share.gross - share.billed, 0);
|
||||
return {
|
||||
key: def.key,
|
||||
label: t(def.labelKey),
|
||||
volume: formatVolume(meter ? { ...meter, metered_amount: share.volume } : null),
|
||||
rate: formatRate(meter, def, t),
|
||||
cost: share.billed,
|
||||
gross: share.gross,
|
||||
discount,
|
||||
missing: false,
|
||||
discountPercent: share.gross > 0 ? Math.round((discount / share.gross) * 1000) / 10 : 0,
|
||||
drillable: share.volume > 0,
|
||||
hasBreakdown: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** An org on a super org's bill: the super org itself, or one of its billing group members. */
|
||||
export interface BillingOrg {
|
||||
id: string;
|
||||
name: string;
|
||||
isSelf: boolean;
|
||||
}
|
||||
|
||||
export function billingGroupOrgs(members: unknown, selfId: string): BillingOrg[] {
|
||||
const list = Array.isArray(members) ? members : [];
|
||||
if (!list.length) return [];
|
||||
const selfName = String(list[0]?.payer_org_name || selfId);
|
||||
return [
|
||||
{ id: selfId, name: selfName, isSelf: true },
|
||||
...list.map((member: any) => ({
|
||||
id: String(member?.member_org_id ?? ""),
|
||||
name: String(member?.member_org_name || member?.member_org_id || ""),
|
||||
isSelf: false,
|
||||
})),
|
||||
].filter((org) => org.id);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { totalsGridTemplate } from "./totalsBar";
|
||||
|
||||
describe("totalsGridTemplate", () => {
|
||||
const columns = [{ id: "name" }, { id: "volume", size: 128 }, { id: "cost", size: 112 }];
|
||||
|
||||
it("mirrors the index column, the fill column and each fixed width", () => {
|
||||
expect(totalsGridTemplate(columns)).toBe("3.5rem minmax(0, 1fr) 8rem 7rem");
|
||||
});
|
||||
|
||||
it("drops a column the table hides, so the cells stay under their headers", () => {
|
||||
expect(totalsGridTemplate(columns, { volume: false })).toBe("3.5rem minmax(0, 1fr) 7rem");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import { TABLE_INDEX_COL_SIZE } from "@/lib/core/Table/OTable.types";
|
||||
|
||||
const PX_PER_REM = 16;
|
||||
|
||||
/** The totals bar's grid, derived from the table's own column sizes so the two cannot drift apart. */
|
||||
export function totalsGridTemplate(
|
||||
columns: OTableColumnDef<any>[],
|
||||
visibility: Record<string, boolean> = {},
|
||||
showIndex = true,
|
||||
): string {
|
||||
const tracks = columns
|
||||
.filter((column) => visibility[column.id ?? ""] !== false)
|
||||
.map((column) =>
|
||||
column.meta?.autoWidth || typeof column.size !== "number"
|
||||
? "minmax(0, 1fr)"
|
||||
: `${column.size / PX_PER_REM}rem`,
|
||||
);
|
||||
return [showIndex ? `${TABLE_INDEX_COL_SIZE / PX_PER_REM}rem` : null, ...tracks]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MeteringDetails } from "./meteringModel";
|
||||
import {
|
||||
bucketSeconds,
|
||||
cyclePrices,
|
||||
forecastCycle,
|
||||
priceAt,
|
||||
PRICED_EVENTS,
|
||||
priceCaseSql,
|
||||
summarizeTrend,
|
||||
toCostRows,
|
||||
toEpochSeconds,
|
||||
topMeter,
|
||||
trendBucket,
|
||||
} from "./trendsModel";
|
||||
|
||||
const DAY = 86_400;
|
||||
|
||||
const cycle = (start: number, ingestionDiscount: number) =>
|
||||
({
|
||||
cycle_start: start,
|
||||
cycle_end: start + 30 * DAY,
|
||||
ingestion: {
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: ingestionDiscount,
|
||||
},
|
||||
}) as unknown as MeteringDetails;
|
||||
|
||||
// Two cycles with different discounts, as in the design doc's worked example.
|
||||
const first = cycle(1_700_000_000, 10);
|
||||
const second = cycle(1_700_000_000 + 30 * DAY, 5);
|
||||
const prices = cyclePrices([second, first]);
|
||||
|
||||
describe("trendsModel", () => {
|
||||
it("prices a unit from base_cost, unit_divisor and that cycle's discount", () => {
|
||||
expect(prices[0].rates.Ingestion).toBeCloseTo(0.000439453125, 12);
|
||||
expect(prices[1].rates.Ingestion).toBeCloseTo(0.0004638671875, 12);
|
||||
});
|
||||
|
||||
it("prices a range that crosses two cycles by each cycle's own rate", () => {
|
||||
const inFirst = priceAt(prices, "Ingestion", first.cycle_start + DAY);
|
||||
const inSecond = priceAt(prices, "Ingestion", second.cycle_start + DAY);
|
||||
expect(inFirst).toBeCloseTo(0.000439453125, 12);
|
||||
expect(inSecond).toBeCloseTo(0.0004638671875, 12);
|
||||
});
|
||||
|
||||
it("keeps the earlier price across a gap and the first price before any cycle", () => {
|
||||
const gapped = cyclePrices([first, cycle(second.cycle_start + 10 * DAY, 5)]);
|
||||
expect(priceAt(gapped, "Ingestion", second.cycle_start + DAY)).toBeCloseTo(0.000439453125, 12);
|
||||
expect(priceAt(gapped, "Ingestion", first.cycle_start - DAY)).toBeCloseTo(0.000439453125, 12);
|
||||
});
|
||||
|
||||
it("prices only billable AI credits, not the informational NewIncident record", () => {
|
||||
const aiPrices = cyclePrices([
|
||||
{
|
||||
cycle_start: first.cycle_start,
|
||||
cycle_end: first.cycle_end,
|
||||
ai: { base_cost: 0.5, unit_divisor: 1, metered_amount: 0, metered_cost: 0 },
|
||||
} as unknown as MeteringDetails,
|
||||
]);
|
||||
const usage = [
|
||||
{ event: "AiCredits", size: 10 },
|
||||
{ event: "NewIncident", size: 1 },
|
||||
];
|
||||
const cost = usage.reduce(
|
||||
(sum, row) => sum + row.size * (aiPrices[0].rates[row.event] ?? 0),
|
||||
0,
|
||||
);
|
||||
expect(cost).toBe(5);
|
||||
expect(PRICED_EVENTS).not.toContain("NewIncident");
|
||||
expect(priceCaseSql(aiPrices)).not.toContain("NewIncident");
|
||||
});
|
||||
|
||||
it("writes the same boundaries into SQL, switching price at the next cycle's start", () => {
|
||||
const sql = priceCaseSql(prices, ["Ingestion"]);
|
||||
expect(sql).toBe(
|
||||
`case when event = 'Ingestion' and _timestamp < ${second.cycle_start * 1_000_000} ` +
|
||||
`then ${prices[0].rates.Ingestion} when event = 'Ingestion' then ` +
|
||||
`${prices[1].rates.Ingestion} else 0 end`,
|
||||
);
|
||||
});
|
||||
|
||||
it("totals by meter and by day, and narrows the days to an isolated meter", () => {
|
||||
const rows = [
|
||||
{ ts: first.cycle_start, event: "Ingestion", cost: 0.45 },
|
||||
{ ts: first.cycle_start, event: "Search", cost: 0.05 },
|
||||
];
|
||||
const all = summarizeTrend(rows);
|
||||
expect(all.meters[0].key).toBe("ingestion");
|
||||
expect(all.total).toBeCloseTo(0.5, 6);
|
||||
const only = summarizeTrend(rows, "ingestion");
|
||||
expect(only.total).toBeCloseTo(0.45, 6);
|
||||
expect(only.days).toEqual([{ ts: first.cycle_start, cost: 0.45 }]);
|
||||
});
|
||||
|
||||
it("forecasts from the trailing week, counting days with no usage as zero", () => {
|
||||
const start = 1_700_000_000 - (1_700_000_000 % DAY);
|
||||
const now = start + 10 * DAY + 3600;
|
||||
const summary = { total: 7, days: [{ ts: start + 9 * DAY, cost: 7 }], meters: [] };
|
||||
const forecast = forecastCycle(summary, start, start + 30 * DAY, now);
|
||||
expect(forecast?.paceDays).toBe(7);
|
||||
expect(forecast?.pace).toBeCloseTo(1, 6);
|
||||
expect(forecast?.low).toBeCloseTo(7, 6);
|
||||
expect(forecastCycle(summary, start, now - 1, now)).toBeNull();
|
||||
// On a cycle's first day there is no complete day to average yet.
|
||||
expect(forecastCycle(summary, start, start + 30 * DAY, start + 3600)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the isolated meter as Top Meter, with its share of every meter's cost", () => {
|
||||
const rows = [
|
||||
{ ts: 0, event: "Ingestion", cost: 10 },
|
||||
{ ts: 0, event: "Search", cost: 2 },
|
||||
];
|
||||
expect(topMeter(summarizeTrend(rows))).toMatchObject({ key: "ingestion", cost: 10 });
|
||||
// Isolating Search once read "Ingestion · $10.00 · 500% of cost".
|
||||
const search = topMeter(summarizeTrend(rows, "query"), "query")!;
|
||||
expect(search).toMatchObject({ key: "query", cost: 2 });
|
||||
expect(search.share).toBeCloseTo(2 / 12, 6);
|
||||
expect(topMeter(summarizeTrend(rows, "pipeline"), "pipeline")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves today's unfinished day out of the run rate", () => {
|
||||
// A flat $10 a day, one hour into day 11 of 30: the pace is $10, so the cycle ends at $300.
|
||||
const start = 1_700_000_000 - (1_700_000_000 % DAY);
|
||||
const now = start + 10 * DAY + 3600;
|
||||
const days = Array.from({ length: 11 }, (_, i) => ({
|
||||
ts: start + i * DAY,
|
||||
cost: i < 10 ? 10 : 10 / 24,
|
||||
}));
|
||||
const total = days.reduce((sum, day) => sum + day.cost, 0);
|
||||
const forecast = forecastCycle({ total, days, meters: [] }, start, start + 30 * DAY, now);
|
||||
expect(forecast?.pace).toBeCloseTo(10, 6);
|
||||
expect(forecast?.projected).toBeCloseTo(300, 6);
|
||||
});
|
||||
|
||||
it("reads the backend's mixed timestamp units and picks a bucket per window", () => {
|
||||
expect(toEpochSeconds(1_789_046_852)).toBe(1_789_046_852);
|
||||
expect(toEpochSeconds(1_789_046_852_000_000)).toBe(1_789_046_852);
|
||||
expect(bucketSeconds("2026-09-12T00:00:00")).toBe(Date.UTC(2026, 8, 12) / 1000);
|
||||
expect(trendBucket(0, 15 * 60)).toBe("1 minute");
|
||||
expect(trendBucket(0, 24 * 3600)).toBe("1 hour");
|
||||
expect(trendBucket(0, 30 * DAY)).toBe("1 day");
|
||||
});
|
||||
|
||||
it("reads the chart query's rows, keeping the raw volume when the query returned it", () => {
|
||||
expect(
|
||||
toCostRows([
|
||||
{ x_axis_1: "2026-10-11T00:00:00", breakdown_1: "Ingestion", y_axis_1: 10, volume: 2048 },
|
||||
{ x_axis_1: "2026-10-12T00:00:00", breakdown_1: "Search", y_axis_1: 1 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ ts: Date.UTC(2026, 9, 11) / 1000, event: "Ingestion", cost: 10, volume: 2048 },
|
||||
{ ts: Date.UTC(2026, 9, 12) / 1000, event: "Search", cost: 1, volume: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { METERS, unitRate, type MeterKey, type MeteringDetails } from "./meteringModel";
|
||||
|
||||
const MICROS_PER_SECOND = 1_000_000;
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
const SECONDS_PER_HOUR = 3_600;
|
||||
|
||||
/** Days of run rate the forecast averages, so one spike does not swing the projection. */
|
||||
const TRAILING_DAYS = 7;
|
||||
|
||||
/** Price of one unit of `size` for each `usage` event, inside one billing cycle. */
|
||||
export interface CyclePrice {
|
||||
/** Unix seconds. */
|
||||
start: number;
|
||||
end: number;
|
||||
rates: Record<string, number>;
|
||||
}
|
||||
|
||||
/** One row of the chart's own query: already priced by cycle in SQL. */
|
||||
export interface CostRow {
|
||||
/** Unix seconds of the bucket start. */
|
||||
ts: number;
|
||||
event: string;
|
||||
cost: number;
|
||||
/** Megabytes, or a count for counted meters; only present when the query asked for it. */
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface TopMeter {
|
||||
key: MeterKey;
|
||||
cost: number;
|
||||
/** Of every meter's cost in the range, so an isolated meter still reads as its true share. */
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface MeterTotal {
|
||||
key: MeterKey;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface TrendSummary {
|
||||
total: number;
|
||||
/** Cost per day bucket, oldest first. */
|
||||
days: { ts: number; cost: number }[];
|
||||
/** Every meter with a `usage` event, highest cost first. */
|
||||
meters: MeterTotal[];
|
||||
}
|
||||
|
||||
export interface Forecast {
|
||||
/** Average daily cost across the trailing window. */
|
||||
pace: number;
|
||||
/** Days that window actually covered. */
|
||||
paceDays: number;
|
||||
projected: number;
|
||||
low: number;
|
||||
high: number;
|
||||
}
|
||||
|
||||
/** Every event a meter bills, so a query and its pricing name the same set. */
|
||||
export const PRICED_EVENTS = METERS.flatMap((def) => def.events);
|
||||
|
||||
/** The backend sends seconds for a cycle but microseconds for `oldest_ts` when past invoices exist. */
|
||||
export function toEpochSeconds(value: number | null | undefined): number {
|
||||
const n = Number(value) || 0;
|
||||
if (n > 1e14) return Math.floor(n / MICROS_PER_SECOND);
|
||||
if (n > 1e11) return Math.floor(n / 1000);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Priced from `base_cost`, not the money fields: Stripe rounds each meter up to a whole unit. */
|
||||
/** `gross` prices each cycle at list rate, before its discount. */
|
||||
export function cyclePrices(cycles: MeteringDetails[], gross = false): CyclePrice[] {
|
||||
return [...cycles]
|
||||
.sort((a, b) => a.cycle_start - b.cycle_start)
|
||||
.map((cycle) => {
|
||||
const rates: Record<string, number> = {};
|
||||
for (const def of METERS) {
|
||||
const meter = cycle[def.key];
|
||||
const discount = gross
|
||||
? 0
|
||||
: Math.min(Math.max(Number(meter?.percent_discount) || 0, 0), 100);
|
||||
const rate = unitRate(meter) * (1 - discount / 100);
|
||||
for (const event of def.events) rates[event] = rate;
|
||||
}
|
||||
return { start: cycle.cycle_start, end: cycle.cycle_end, rates };
|
||||
});
|
||||
}
|
||||
|
||||
/** A moment takes the price of the latest cycle that started before it; a gap keeps the earlier price. */
|
||||
export function priceAt(prices: CyclePrice[], event: string, tsSeconds: number): number {
|
||||
if (!prices.length) return 0;
|
||||
let chosen = prices[0];
|
||||
for (const price of prices) {
|
||||
if (price.start <= tsSeconds) chosen = price;
|
||||
}
|
||||
return chosen.rates[event] ?? 0;
|
||||
}
|
||||
|
||||
/** The same rule as `priceAt`, written as SQL so the chart query prices each row by its own cycle. */
|
||||
export function priceCaseSql(prices: CyclePrice[], events: string[] = PRICED_EVENTS): string {
|
||||
if (!prices.length) return "0";
|
||||
const branches = events.flatMap((event) =>
|
||||
prices.map((price, index) => {
|
||||
const next = prices[index + 1];
|
||||
const rate = price.rates[event] ?? 0;
|
||||
const literal = event.replace(/'/g, "''");
|
||||
return next
|
||||
? `when event = '${literal}' and _timestamp < ${next.start * MICROS_PER_SECOND} then ${rate}`
|
||||
: `when event = '${literal}' then ${rate}`;
|
||||
}),
|
||||
);
|
||||
return `case ${branches.join(" ")} else 0 end`;
|
||||
}
|
||||
|
||||
/** A bucket the window can show more than a handful of, so a short range is not one bar. */
|
||||
export function trendBucket(startSeconds: number, endSeconds: number): string {
|
||||
const hours = (endSeconds - startSeconds) / SECONDS_PER_HOUR;
|
||||
if (hours <= 2) return "1 minute";
|
||||
if (hours <= 48) return "1 hour";
|
||||
return "1 day";
|
||||
}
|
||||
|
||||
/** Search returns a bucket as a UTC timestamp without a zone, or as microseconds. */
|
||||
export function bucketSeconds(value: unknown): number {
|
||||
if (typeof value === "number") return toEpochSeconds(value);
|
||||
const text = String(value ?? "");
|
||||
const iso = /[zZ]|[+-]\d\d:?\d\d$/.test(text) ? text : `${text}Z`;
|
||||
const ms = Date.parse(iso);
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
||||
}
|
||||
|
||||
/** One chart-query row per bucket and event, with the raw volume when the query asked for it. */
|
||||
export function toCostRows(hits: Record<string, unknown>[]): CostRow[] {
|
||||
return hits.map((hit) => ({
|
||||
ts: bucketSeconds(hit.x_axis_1),
|
||||
event: String(hit.breakdown_1 ?? ""),
|
||||
cost: Number(hit.y_axis_1) || 0,
|
||||
volume: Number(hit.volume) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Totals the chart query's rows by day and by meter; the days narrow to an isolated meter. */
|
||||
export function summarizeTrend(rows: CostRow[], only: MeterKey | null = null): TrendSummary {
|
||||
const eventMeter = new Map<string, MeterKey>();
|
||||
for (const def of METERS) for (const event of def.events) eventMeter.set(event, def.key);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
const byMeter = new Map<MeterKey, MeterTotal>();
|
||||
for (const def of METERS) {
|
||||
if (def.events.length) byMeter.set(def.key, { key: def.key, cost: 0 });
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const key = eventMeter.get(row.event);
|
||||
if (!key) continue;
|
||||
byMeter.get(key)!.cost += row.cost;
|
||||
if (only && key !== only) continue;
|
||||
byDay.set(row.ts, (byDay.get(row.ts) ?? 0) + row.cost);
|
||||
}
|
||||
|
||||
const days = [...byDay.entries()].map(([ts, cost]) => ({ ts, cost })).sort((a, b) => a.ts - b.ts);
|
||||
const meters = [...byMeter.values()].sort((a, b) => b.cost - a.cost);
|
||||
const total = only
|
||||
? (byMeter.get(only)?.cost ?? 0)
|
||||
: meters.reduce((sum, meter) => sum + meter.cost, 0);
|
||||
return { total, days, meters };
|
||||
}
|
||||
|
||||
/** The Top Meter card's meter: the isolated one while a meter is isolated, else the costliest. */
|
||||
export function topMeter(summary: TrendSummary, only: MeterKey | null = null): TopMeter | null {
|
||||
const all = summary.meters.reduce((sum, meter) => sum + meter.cost, 0);
|
||||
const top = only
|
||||
? summary.meters.find((meter) => meter.key === only)
|
||||
: summary.meters.find((meter) => meter.cost > 0);
|
||||
if (!top || top.cost <= 0) return null;
|
||||
return { key: top.key, cost: top.cost, share: all > 0 ? top.cost / all : 0 };
|
||||
}
|
||||
|
||||
export function forecastCycle(
|
||||
summary: TrendSummary,
|
||||
cycleStart: number,
|
||||
cycleEnd: number,
|
||||
now: number = Date.now() / 1000,
|
||||
): Forecast | null {
|
||||
const daysLeft = Math.max((cycleEnd - now) / SECONDS_PER_DAY, 0);
|
||||
if (daysLeft <= 0 || now <= cycleStart) return null;
|
||||
const costOn = new Map(summary.days.map((day) => [day.ts, day.cost]));
|
||||
const window: number[] = [];
|
||||
const today = Math.floor(now / SECONDS_PER_DAY) * SECONDS_PER_DAY;
|
||||
const first = Math.floor(cycleStart / SECONDS_PER_DAY) * SECONDS_PER_DAY;
|
||||
for (
|
||||
let day = today - SECONDS_PER_DAY;
|
||||
day >= first && window.length < TRAILING_DAYS;
|
||||
day -= SECONDS_PER_DAY
|
||||
) {
|
||||
window.push(costOn.get(day) ?? 0);
|
||||
}
|
||||
if (!window.length) return null;
|
||||
const pace = window.reduce((sum, cost) => sum + cost, 0) / window.length;
|
||||
return {
|
||||
pace,
|
||||
paceDays: window.length,
|
||||
projected: summary.total + pace * daysLeft,
|
||||
low: summary.total + Math.min(...window) * daysLeft,
|
||||
high: summary.total + Math.max(...window) * daysLeft,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTrendsChartOption,
|
||||
entitySeriesColors,
|
||||
meterColorFor,
|
||||
paletteColor,
|
||||
shadeOf,
|
||||
} from "./usageCharts";
|
||||
|
||||
describe("usageCharts", () => {
|
||||
it("never gives an org Data Retention's colour, since both sit in Cost by Org", () => {
|
||||
const retention = meterColorFor("retention");
|
||||
const orgs = Array.from({ length: 11 }, (_, index) => paletteColor(index));
|
||||
expect(orgs).not.toContain(retention);
|
||||
// Eleven orgs still get eleven different colours before the palette repeats.
|
||||
expect(new Set(orgs).size).toBe(11);
|
||||
});
|
||||
|
||||
it("keeps the base colour for the top entity and lightens the ones below it", () => {
|
||||
expect(shadeOf("#000000", 0, "#ffffff")).toBe("#000000");
|
||||
expect(shadeOf("#000000", 1, "#ffffff")).toBe("#2b2b2b");
|
||||
expect(shadeOf("#000000", 10, "#ffffff")).toBe("#d9d9d9");
|
||||
expect(shadeOf("#ffffff", 5, "#000000")).toBe("#262626");
|
||||
});
|
||||
|
||||
it("leaves a colour it cannot parse untouched", () => {
|
||||
expect(shadeOf("rgb(1, 2, 3)", 2, "#ffffff")).toBe("rgb(1, 2, 3)");
|
||||
});
|
||||
|
||||
it("maps each entity by name and gives the remainder its own colour", () => {
|
||||
const colors = entitySeriesColors("ingestion", ["a", "b"], "Other");
|
||||
expect(colors.map((entry) => entry.value)).toEqual(["a", "b", "Other"]);
|
||||
expect(colors[2].color).not.toBe(colors[0].color);
|
||||
});
|
||||
|
||||
it("draws spend to date and a forecast that ends on the projected total", () => {
|
||||
const day = 86_400;
|
||||
const option = buildTrendsChartOption({
|
||||
rows: [0, 1, 2].map((d) => ({ ts: d * day, event: "Ingestion", cost: 10 })),
|
||||
events: ["Ingestion"],
|
||||
start: 0,
|
||||
end: 5 * day,
|
||||
bucket: day,
|
||||
now: 2 * day + 60,
|
||||
forecast: { pace: 10, paceDays: 3, projected: 50, low: 40, high: 60 },
|
||||
meterLabel: (key) => key,
|
||||
format: (v) => `$${v}`,
|
||||
slotLabel: (ts) => String(ts / day),
|
||||
labels: { spendToDate: "spend", forecast: "fc", dayTotal: "total", projected: "proj" },
|
||||
});
|
||||
const byName = (name: string) => option.series.find((s: any) => s.name === name) as any;
|
||||
expect(byName("ingestion").data).toEqual([10, 10, 10, null, null]);
|
||||
expect(byName("spend").data).toEqual([10, 20, 30, null, null]);
|
||||
expect(byName("fc").data).toEqual([null, null, 30, 40, 50]);
|
||||
expect(byName("fc").endLabel.formatter).toBe("proj $50");
|
||||
});
|
||||
|
||||
it("charts the data meters' volume per day, without counted meters or a forecast", () => {
|
||||
const day = 86_400;
|
||||
const option = buildTrendsChartOption({
|
||||
rows: [
|
||||
{ ts: 0, event: "Ingestion", cost: 10, volume: 2048 },
|
||||
{ ts: 0, event: "AiCredits", cost: 5, volume: 7 },
|
||||
{ ts: day, event: "Search", cost: 1, volume: 512 },
|
||||
],
|
||||
events: ["Ingestion", "Search", "AiCredits"],
|
||||
start: 0,
|
||||
end: 3 * day,
|
||||
bucket: day,
|
||||
now: day + 60,
|
||||
forecast: { pace: 10, paceDays: 3, projected: 50, low: 40, high: 60 },
|
||||
measure: "volume",
|
||||
meterLabel: (key) => key,
|
||||
format: (v) => `${v} MB`,
|
||||
slotLabel: (ts) => String(ts / day),
|
||||
labels: { spendToDate: "spend", forecast: "fc", dayTotal: "total", projected: "proj" },
|
||||
});
|
||||
const names = option.series.map((s: any) => s.name);
|
||||
expect(names).toEqual(["ingestion", "query"]);
|
||||
const byName = (name: string) => option.series.find((s: any) => s.name === name) as any;
|
||||
expect(byName("ingestion").data).toEqual([2048, null, null]);
|
||||
expect(byName("query").data).toEqual([null, 512, null]);
|
||||
expect(option.yAxis[0].axisLabel.formatter(1024)).toBe("1024 MB");
|
||||
});
|
||||
|
||||
it("leaves the forecast out when there is none", () => {
|
||||
const option = buildTrendsChartOption({
|
||||
rows: [],
|
||||
events: ["Ingestion"],
|
||||
start: 0,
|
||||
end: 86_400 * 3,
|
||||
bucket: 86_400,
|
||||
now: 1,
|
||||
forecast: null,
|
||||
meterLabel: (k) => k,
|
||||
format: String,
|
||||
slotLabel: String,
|
||||
labels: { spendToDate: "s", forecast: "f", dayTotal: "t", projected: "p" },
|
||||
});
|
||||
expect(option.series.map((s: any) => s.name)).toEqual(["ingestion"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import type { TranslateFn } from "@/types/i18n";
|
||||
import { chartAxisLine, chartBg, chartColor, chartTextColor } from "@/utils/chartTheme";
|
||||
import { createBaseLegendConfig } from "@/utils/dashboard/legendConfiguration";
|
||||
import { meterForEvent, METERS, type MeteringDetails, type MeterKey } from "./meteringModel";
|
||||
import { cyclePrices, PRICED_EVENTS, type CostRow, type Forecast } from "./trendsModel";
|
||||
import { trendsCostSql, USAGE_STREAM_NAME } from "./usageQueries";
|
||||
|
||||
/** A `mark_line` entry of the dashboards engine: a horizontal line at `value` on the y axis. */
|
||||
export interface MarkLine {
|
||||
name: string;
|
||||
type: "yAxis";
|
||||
value: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SeriesColor {
|
||||
value: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** A meter keeps its palette slot across every usage view, so colour means one thing. */
|
||||
export function meterColorFor(key: MeterKey): string {
|
||||
const slot =
|
||||
(Math.max(
|
||||
METERS.findIndex((def) => def.key === key),
|
||||
0,
|
||||
) %
|
||||
12) +
|
||||
1;
|
||||
return chartColor(`--color-chart-series-${slot}`);
|
||||
}
|
||||
|
||||
/** A palette slot by position for a super org's orgs, skipping Data Retention's slot: both rows share Cost by Org. */
|
||||
export function paletteColor(index: number): string {
|
||||
const retention = METERS.findIndex((def) => def.key === "retention") % 12;
|
||||
const slots = Array.from({ length: 12 }, (_, slot) => slot).filter((slot) => slot !== retention);
|
||||
return chartColor(`--color-chart-series-${slots[Math.max(index, 0) % slots.length] + 1}`);
|
||||
}
|
||||
|
||||
const HEX = /^#([0-9a-f]{6})$/i;
|
||||
|
||||
/** Steps of one colour mixed toward `toward`; a non-hex value on either side is returned unchanged. */
|
||||
export function shadeOf(color: string, step: number, toward: string): string {
|
||||
const from = HEX.exec(color.trim());
|
||||
const to = HEX.exec(toward.trim());
|
||||
if (!from || !to) return color;
|
||||
const mix = Math.min(step * 0.17, 0.85);
|
||||
const channels = (hex: string) =>
|
||||
[0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16));
|
||||
const target = channels(to[1]);
|
||||
const mixed = channels(from[1]).map((channel, index) =>
|
||||
Math.round(channel + (target[index] - channel) * mix),
|
||||
);
|
||||
return `#${mixed.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** Top entities in shades of their meter, and the remainder in the neutral axis colour. */
|
||||
/** Bands a drilldown chart keeps; the table's top rows are the same entities, so they share these shades. */
|
||||
export const CHART_TOP = 5;
|
||||
|
||||
/** The renderer's own name for the band it folds everything past the top into. */
|
||||
export const OTHERS_SERIES = "others";
|
||||
|
||||
export function entitySeriesColors(
|
||||
meterKey: MeterKey,
|
||||
names: string[],
|
||||
otherLabel: string,
|
||||
): SeriesColor[] {
|
||||
const base = meterColorFor(meterKey);
|
||||
return [
|
||||
...names.map((name, index) => ({ value: name, color: shadeOf(base, index, chartBg()) })),
|
||||
{ value: otherLabel, color: chartAxisLine() },
|
||||
];
|
||||
}
|
||||
|
||||
/** Stacked cost bars priced by cycle; a real query, so it renders through `PanelSchemaRenderer`. */
|
||||
export function buildTrendsPanelSchema(opts: {
|
||||
orgId: string;
|
||||
cycles: MeteringDetails[];
|
||||
bucket: string;
|
||||
/** Narrows the chart to one meter's events. */
|
||||
events?: string[];
|
||||
decimals?: number;
|
||||
/** Horizontal reference lines, such as the run rate, drawn across the bars. */
|
||||
markLines?: MarkLine[];
|
||||
/** Counts from this moment (microseconds) when the window opens earlier. */
|
||||
since?: number;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const {
|
||||
orgId,
|
||||
cycles,
|
||||
bucket,
|
||||
events = PRICED_EVENTS,
|
||||
decimals = 2,
|
||||
markLines = [],
|
||||
since,
|
||||
t,
|
||||
} = opts;
|
||||
const seriesColors = events.map((event) => ({
|
||||
value: event,
|
||||
color: meterColorFor(meterForEvent(event)?.key ?? "ingestion"),
|
||||
}));
|
||||
return buildStackedCostPanelSchema({
|
||||
id: "usage-trends-cost",
|
||||
sql: trendsCostSql(orgId, cyclePrices(cycles), bucket, events, false, since),
|
||||
seriesColors,
|
||||
decimals,
|
||||
markLines,
|
||||
t,
|
||||
});
|
||||
}
|
||||
|
||||
/** One stacked daily cost panel shape, shared by Trends and the meter detail view. */
|
||||
export function buildStackedCostPanelSchema(opts: {
|
||||
id: string;
|
||||
sql: string;
|
||||
seriesColors: SeriesColor[];
|
||||
decimals?: number;
|
||||
markLines?: MarkLine[];
|
||||
/** Keep only this many bands and fold the rest into `OTHERS_SERIES`. */
|
||||
topResults?: number;
|
||||
/** Colour of a band `seriesColors` does not name, instead of a palette colour. */
|
||||
fixedColor?: string;
|
||||
/** What the bars measure: money by default, or megabytes or a count for an org with no bill. */
|
||||
unit?: "currency" | "megabytes" | "numbers";
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const {
|
||||
id,
|
||||
sql,
|
||||
seriesColors,
|
||||
decimals = 2,
|
||||
markLines = [],
|
||||
topResults,
|
||||
fixedColor,
|
||||
unit = "currency",
|
||||
t,
|
||||
} = opts;
|
||||
return {
|
||||
version: 2,
|
||||
id,
|
||||
title: "",
|
||||
description: "",
|
||||
type: "stacked",
|
||||
config: {
|
||||
show_legends: true,
|
||||
legends_position: "bottom",
|
||||
unit,
|
||||
unit_custom: "",
|
||||
decimals,
|
||||
axis_border_show: true,
|
||||
connect_nulls: false,
|
||||
no_value_replacement: "",
|
||||
show_symbol: false,
|
||||
base_map: { type: "osm" },
|
||||
map_view: { zoom: 1, lat: 0, lng: 0 },
|
||||
mark_line: markLines,
|
||||
...(topResults ? { top_results: topResults, top_results_others: true } : {}),
|
||||
color: {
|
||||
mode: fixedColor ? "fixed" : "palette-classic",
|
||||
fixedColor: fixedColor ? [fixedColor] : [],
|
||||
seriesBy: "last",
|
||||
colorBySeries: seriesColors,
|
||||
},
|
||||
},
|
||||
queryType: "sql",
|
||||
queries: [
|
||||
{
|
||||
query: sql,
|
||||
customQuery: true,
|
||||
vrlFunctionQuery: "",
|
||||
fields: {
|
||||
stream: USAGE_STREAM_NAME,
|
||||
stream_type: "logs",
|
||||
x: [{ alias: "x_axis_1", column: "x_axis_1", color: null, label: t("billing.time") }],
|
||||
y: [
|
||||
{
|
||||
alias: "y_axis_1",
|
||||
column: "y_axis_1",
|
||||
color: null,
|
||||
label:
|
||||
unit === "currency" ? t("billing.usageV2.colCost") : t("billing.usageV2.colVolume"),
|
||||
},
|
||||
],
|
||||
z: [],
|
||||
breakdown: [
|
||||
{
|
||||
alias: "breakdown_1",
|
||||
column: "breakdown_1",
|
||||
color: null,
|
||||
label: t("billing.event"),
|
||||
},
|
||||
],
|
||||
filter: { filterType: "group", logicalOperator: "AND", conditions: [] },
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
weight: null,
|
||||
},
|
||||
config: {
|
||||
promql_legend: "",
|
||||
layer_type: "scatter",
|
||||
weight_fixed: 1,
|
||||
limit: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
time_shift: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const DAY_SECONDS = 86_400;
|
||||
|
||||
/** Meters measured in megabytes; counted meters (credits, runs) cannot share a GB axis with them. */
|
||||
export const VOLUME_METERS: MeterKey[] = ["ingestion", "query", "pipeline", "remote_pipeline"];
|
||||
|
||||
/** Series that carry the forecast, left out of the per-day tooltip rows. */
|
||||
const FORECAST_SERIES = new Set(["lo", "hi"]);
|
||||
|
||||
export function buildTrendsChartOption(opts: {
|
||||
rows: CostRow[];
|
||||
events: string[];
|
||||
/** Unix seconds; `bucket` is the slot width in seconds. */
|
||||
start: number;
|
||||
end: number;
|
||||
bucket: number;
|
||||
now: number;
|
||||
/** Only drawn on a daily bucket, because the run rate is per day. */
|
||||
forecast: Forecast | null;
|
||||
/** Cost, or each meter's raw volume, which drops the counted meters and the forecast. */
|
||||
measure?: "cost" | "volume";
|
||||
meterLabel: (key: MeterKey) => string;
|
||||
/** Formats an axis or tooltip value: money for cost, a size for volume. */
|
||||
format: (value: number) => string;
|
||||
slotLabel: (ts: number) => string;
|
||||
labels: {
|
||||
spendToDate: string;
|
||||
forecast: string;
|
||||
dayTotal: string;
|
||||
projected: string;
|
||||
};
|
||||
}) {
|
||||
const { rows, bucket, now, format, labels } = opts;
|
||||
const byVolume = opts.measure === "volume";
|
||||
const first = Math.floor(opts.start / bucket) * bucket;
|
||||
const slots: number[] = [];
|
||||
for (let ts = first; ts < opts.end; ts += bucket) slots.push(ts);
|
||||
const n = slots.length;
|
||||
const index = new Map(slots.map((ts, i) => [ts, i]));
|
||||
|
||||
const meters = METERS.filter(
|
||||
(def) =>
|
||||
def.events.some((event) => opts.events.includes(event)) &&
|
||||
(!byVolume || VOLUME_METERS.includes(def.key)),
|
||||
);
|
||||
const meterOf = new Map<string, MeterKey>();
|
||||
for (const def of meters) for (const event of def.events) meterOf.set(event, def.key);
|
||||
|
||||
const perMeter = new Map<MeterKey, number[]>(meters.map((def) => [def.key, Array(n).fill(0)]));
|
||||
const totals: number[] = Array(n).fill(0);
|
||||
for (const row of rows) {
|
||||
const key = meterOf.get(row.event);
|
||||
const i = index.get(Math.floor(row.ts / bucket) * bucket);
|
||||
if (!key || i === undefined) continue;
|
||||
const value = byVolume ? (row.volume ?? 0) : row.cost;
|
||||
perMeter.get(key)![i] += value;
|
||||
totals[i] += value;
|
||||
}
|
||||
|
||||
// Slots up to and including the one holding `now` are actuals; the rest are future.
|
||||
const elapsed = Math.max(slots.filter((ts) => ts <= now).length, 1);
|
||||
const f = !byVolume && bucket === DAY_SECONDS && elapsed < n ? opts.forecast : null;
|
||||
const cum: number[] = [];
|
||||
totals.slice(0, elapsed).forEach((v, i) => cum.push((cum[i - 1] ?? 0) + v));
|
||||
const from = cum[elapsed - 1] ?? 0;
|
||||
const left = n - elapsed;
|
||||
// A straight path from today's spend to the target, so the last point is the target itself.
|
||||
const path = (target: number) =>
|
||||
Array<number | null>(elapsed - 1)
|
||||
.fill(null)
|
||||
.concat(Array.from({ length: left + 1 }, (_, k) => from + ((target - from) * k) / left));
|
||||
const mid = f ? path(f.projected) : [];
|
||||
const low = f ? path(f.low) : [];
|
||||
const high = f ? path(f.high) : [];
|
||||
|
||||
const accent = chartColor("--color-accent");
|
||||
const muted = chartTextColor();
|
||||
const bars = meters.map((def) => ({
|
||||
name: opts.meterLabel(def.key),
|
||||
type: "bar",
|
||||
stack: "cost",
|
||||
barMaxWidth: 20,
|
||||
yAxisIndex: 0,
|
||||
itemStyle: { color: meterColorFor(def.key) },
|
||||
data: perMeter.get(def.key)!.map((v, i) => (i < elapsed && v > 0 ? v : null)),
|
||||
}));
|
||||
const forecastSeries = f
|
||||
? [
|
||||
// Range band: an invisible lower bound, then the gap above it filled.
|
||||
{
|
||||
name: "lo",
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
stack: "band",
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
lineStyle: { opacity: 0 },
|
||||
data: low,
|
||||
},
|
||||
{
|
||||
name: "hi",
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
stack: "band",
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
lineStyle: { opacity: 0 },
|
||||
areaStyle: { color: accent, opacity: 0.12 },
|
||||
data: high.map((v, k) => (v == null ? null : v - (low[k] ?? 0))),
|
||||
},
|
||||
{
|
||||
name: labels.spendToDate,
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
symbol: "none",
|
||||
lineStyle: { color: accent, width: 2.5 },
|
||||
itemStyle: { color: accent },
|
||||
data: cum.concat(Array(left).fill(null)),
|
||||
},
|
||||
{
|
||||
name: labels.forecast,
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
symbol: "none",
|
||||
lineStyle: { color: accent, width: 2, type: "dashed" },
|
||||
itemStyle: { color: accent },
|
||||
data: mid,
|
||||
// The projected total sits on the end of the dashed line, where the eye lands.
|
||||
endLabel: {
|
||||
show: true,
|
||||
formatter: `${labels.projected} ${format(f.projected)}`,
|
||||
color: accent,
|
||||
fontWeight: 600,
|
||||
align: "right",
|
||||
offset: [-4, -14],
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const days = slots.map(opts.slotLabel);
|
||||
return {
|
||||
animation: false,
|
||||
grid: { left: 56, right: f ? 56 : 16, top: 24, bottom: 64 },
|
||||
// The dashboards' legend: one scrollable line with page arrows when meters overflow.
|
||||
legend: (() => {
|
||||
const legend = createBaseLegendConfig({ config: { legends_position: "bottom" } }, null);
|
||||
return {
|
||||
...legend,
|
||||
data: bars.map((bar) => bar.name),
|
||||
textStyle: { ...legend.textStyle, color: muted },
|
||||
};
|
||||
})(),
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: days,
|
||||
axisLine: { lineStyle: { color: chartAxisLine() } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: muted, interval: Math.max(Math.ceil(n / 8) - 1, 0) },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: "value",
|
||||
axisLabel: { color: muted, formatter: format },
|
||||
splitLine: { lineStyle: { color: chartAxisLine(), type: "dashed" } },
|
||||
},
|
||||
{
|
||||
type: "value",
|
||||
show: !!f,
|
||||
axisLabel: { color: muted, formatter: format },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
// Same tooltip as the dashboards: on <body>, so the chart pane never clips it.
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
appendToBody: true,
|
||||
className: "o2-echarts-tooltip",
|
||||
textStyle: { color: chartColor("--color-tooltip-text"), fontSize: 12 },
|
||||
backgroundColor: chartColor("--color-tooltip-bg"),
|
||||
borderColor: chartColor("--color-tooltip-border"),
|
||||
borderWidth: 1,
|
||||
padding: [8, 12],
|
||||
formatter: (
|
||||
params: { dataIndex: number; seriesName: string; value: number | null; marker: string }[],
|
||||
) => {
|
||||
const d = params[0]?.dataIndex ?? 0;
|
||||
if (f && d >= elapsed) {
|
||||
return (
|
||||
`<b>${days[d]} · ${labels.forecast}</b><br/>${format(mid[d] ?? f.projected)}` +
|
||||
`<br/><span style="opacity:.75">${format(low[d] ?? f.low)} – ${format(high[d] ?? f.high)}</span>`
|
||||
);
|
||||
}
|
||||
const lines = params
|
||||
.filter(
|
||||
(p) =>
|
||||
p.value != null &&
|
||||
!FORECAST_SERIES.has(p.seriesName) &&
|
||||
p.seriesName !== labels.spendToDate &&
|
||||
p.seriesName !== labels.forecast,
|
||||
)
|
||||
.map(
|
||||
(p) =>
|
||||
`${p.marker}${p.seriesName}<span style="float:right;margin-left:1.125rem">${format(p.value!)}</span>`,
|
||||
);
|
||||
const spend = f
|
||||
? `<br/>${labels.spendToDate}<span style="float:right;margin-left:1.125rem">${format(cum[d])}</span>`
|
||||
: "";
|
||||
return (
|
||||
`<b>${days[d]}</b><br/>${lines.join("<br/>")}<br/>${labels.dayTotal}` +
|
||||
`<span style="float:right;margin-left:1.125rem;font-weight:600">${format(totals[d])}</span>${spend}`
|
||||
);
|
||||
},
|
||||
},
|
||||
series: [...bars, ...forecastSeries],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
availableSections,
|
||||
averageStreamSizeSql,
|
||||
DRILLDOWNS,
|
||||
dailyOrgCostSql,
|
||||
dailyEntitySql,
|
||||
dailyVolumeSql,
|
||||
eventVolumeSql,
|
||||
meterTotalSql,
|
||||
orgVolumeSql,
|
||||
searchScanSizeSql,
|
||||
trendsCostSql,
|
||||
} from "./usageQueries";
|
||||
import { cyclesNewestFirst, parseMeteringResponse, type MeteringDetails } from "./meteringModel";
|
||||
import { cyclePrices } from "./trendsModel";
|
||||
|
||||
const meter = (base: number, divisor: number) => ({
|
||||
base_cost: base,
|
||||
unit_divisor: divisor,
|
||||
metered_amount: 0,
|
||||
metered_cost: 0,
|
||||
percent_discount: 0,
|
||||
discounted_amount: 0,
|
||||
});
|
||||
|
||||
const details = {
|
||||
total_cost: 0,
|
||||
total_discounted_amount: 0,
|
||||
cycle_start: 1_789_046_852,
|
||||
cycle_end: 1_791_638_852,
|
||||
ingestion: meter(0.5, 1024),
|
||||
query: meter(0.01, 1024),
|
||||
retention: meter(0, 1024),
|
||||
pipeline: meter(0.2, 1024),
|
||||
remote_pipeline: meter(0.3, 1024),
|
||||
ai: meter(0.5, 1),
|
||||
synthetics_browser: meter(0, 1),
|
||||
synthetics_protocol: meter(0, 1),
|
||||
missing_details: [],
|
||||
} as MeteringDetails;
|
||||
|
||||
describe("usageQueries", () => {
|
||||
it("escapes a single quote in the org id", () => {
|
||||
const sql = DRILLDOWNS.ingestion![0].sql("ac'me");
|
||||
expect(sql).toContain("org_id = 'ac''me'");
|
||||
});
|
||||
|
||||
it("returns name and volume from every drilldown, so one table renders them all", () => {
|
||||
for (const sections of Object.values(DRILLDOWNS)) {
|
||||
for (const section of sections) {
|
||||
const sql = section.sql("default");
|
||||
expect(sql).toContain("as name");
|
||||
expect(sql).toContain("as volume");
|
||||
expect(sql).toContain("limit 10");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes alert, dashboard and pipeline searches from the by user breakdown", () => {
|
||||
const byUser = DRILLDOWNS.query!.find((section) => section.id === "user")!;
|
||||
const sql = byUser.sql("default");
|
||||
expect(sql).toContain("alert_name is null");
|
||||
expect(sql).toContain("dashboard_name is null");
|
||||
expect(sql).toContain("derived_stream_key is null");
|
||||
});
|
||||
|
||||
it("drops a section whose field is missing from the live schema", () => {
|
||||
const sections = DRILLDOWNS.query!;
|
||||
const present = new Set(["user_email", "stream_name"]);
|
||||
const kept = availableSections(sections, present);
|
||||
expect(kept.map((section) => section.id)).toEqual(["stream"]);
|
||||
expect(availableSections(sections, new Set()).length).toBe(sections.length);
|
||||
});
|
||||
|
||||
it("keeps every section when all fields exist", () => {
|
||||
const fields = new Set([
|
||||
"user_email",
|
||||
"alert_name",
|
||||
"dashboard_name",
|
||||
"derived_stream_key",
|
||||
"stream_name",
|
||||
]);
|
||||
expect(availableSections(DRILLDOWNS.query!, fields).length).toBe(DRILLDOWNS.query!.length);
|
||||
});
|
||||
|
||||
it("prices the trends query from the API rates, never a hardcoded number", () => {
|
||||
const sql = trendsCostSql("default", cyclePrices([details]), "1 day");
|
||||
expect(sql).toContain(`when event = 'Ingestion' then ${0.5 / 1024}`);
|
||||
expect(sql).toContain("when event = 'Search' then");
|
||||
expect(sql).toContain("histogram(_timestamp, '1 day')");
|
||||
expect(sql).not.toContain("Functions");
|
||||
});
|
||||
|
||||
it("narrows the trends query to one meter's events", () => {
|
||||
const sql = trendsCostSql("default", cyclePrices([details]), "1 hour", ["Pipeline"]);
|
||||
expect(sql).toContain("event IN ('Pipeline')");
|
||||
expect(sql).not.toContain("'Ingestion'");
|
||||
});
|
||||
|
||||
it("reads the estimator defaults from ingestion and search rows", () => {
|
||||
expect(averageStreamSizeSql("default")).toContain("count(distinct stream_name)");
|
||||
// Partitions of one streamed search share its trace id before "-{n}", so they count once.
|
||||
expect(searchScanSizeSql("default")).toContain(
|
||||
"count(distinct split_part(trace_id, '-', 1)) as searches",
|
||||
);
|
||||
expect(searchScanSizeSql("default")).not.toContain("response_time");
|
||||
});
|
||||
|
||||
it("reads a super org's estimator history across the group, counting each org's streams apart", () => {
|
||||
const streams = averageStreamSizeSql(null);
|
||||
expect(streams).not.toContain("org_id =");
|
||||
expect(streams).toContain("count(distinct concat(org_id, '/', stream_name))");
|
||||
expect(searchScanSizeSql(null)).toMatch(/where event = 'Search'$/);
|
||||
});
|
||||
|
||||
it("leaves out the zero-length invoice Stripe creates at subscription, so it is never Last Cycle", () => {
|
||||
const signup = { ...details, cycle_start: 1_789_000_000, cycle_end: 1_789_000_000 };
|
||||
const closed = { ...details, cycle_start: 1_786_000_000, cycle_end: 1_788_600_000 };
|
||||
const cycles = cyclesNewestFirst({ upcoming: details, past: [signup, closed] });
|
||||
expect(cycles).toEqual([details, closed]);
|
||||
// A brand-new subscriber has only the signup invoice behind it: no Last Cycle at all.
|
||||
expect(cyclesNewestFirst({ upcoming: details, past: [signup] })).toEqual([details]);
|
||||
});
|
||||
|
||||
it("reads the cycles out of the new body and still accepts the old flat one", () => {
|
||||
const parsed = parseMeteringResponse({ upcoming: details, past: [details], oldest_ts: 1 });
|
||||
expect(parsed?.past).toHaveLength(1);
|
||||
expect(cyclesNewestFirst(parsed)).toHaveLength(2);
|
||||
const flat = parseMeteringResponse(details);
|
||||
expect(flat?.upcoming.cycle_start).toBe(details.cycle_start);
|
||||
expect(flat?.past).toEqual([]);
|
||||
expect(parseMeteringResponse(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("counts AI credits by feature, taking size as the credit count", () => {
|
||||
const [chat, incident] = DRILLDOWNS.ai ?? [];
|
||||
expect(chat.sql("default")).toContain("json_get_str(request_body, 'feature') = 'ai_chat'");
|
||||
expect(chat.sql("default")).toContain("group by user_email");
|
||||
expect(incident.sql("default")).toContain(
|
||||
"json_get_str(request_body, 'feature') = 'new_incident'",
|
||||
);
|
||||
expect(incident.sql("default")).toContain("json_get_str(request_body, 'incident_id')");
|
||||
// `size` is already the credit count, so no rate or multiplier belongs in the SQL.
|
||||
expect(incident.sql("default")).toContain("sum(size) as volume");
|
||||
expect(incident.unit).toBe("count");
|
||||
});
|
||||
|
||||
it("groups synthetics steps by check, where stream_name holds the check id", () => {
|
||||
for (const [key, event] of [
|
||||
["synthetics_browser", "SyntheticsBrowserSteps"],
|
||||
["synthetics_protocol", "SyntheticsProtocolSteps"],
|
||||
] as const) {
|
||||
const [byCheck] = DRILLDOWNS[key] ?? [];
|
||||
expect(byCheck.sql("default")).toContain(`event = '${event}'`);
|
||||
expect(byCheck.sql("default")).toContain("group by stream_name");
|
||||
expect(byCheck.unit).toBe("count");
|
||||
}
|
||||
});
|
||||
|
||||
it("measures a section's share against the same event it counts", () => {
|
||||
for (const sections of Object.values(DRILLDOWNS)) {
|
||||
for (const section of sections) {
|
||||
expect(section.sql("default")).toContain(`event = '${section.event}'`);
|
||||
}
|
||||
}
|
||||
expect(meterTotalSql("default", ["Search"])).toContain("event in ('Search')");
|
||||
});
|
||||
|
||||
it("leaves metrics searches with no stream name out of Search by Stream", () => {
|
||||
const byStream = DRILLDOWNS.query!.find((section) => section.id === "stream")!;
|
||||
expect(byStream.sql("default")).toContain("stream_name != ''");
|
||||
});
|
||||
|
||||
it("splits the chart by every entity, leaving the top few to the chart", () => {
|
||||
const sql = dailyEntitySql("default", DRILLDOWNS.ingestion![0], 0.001);
|
||||
expect(sql).toContain('stream_name as "breakdown_1"');
|
||||
expect(sql).not.toContain("case when");
|
||||
expect(sql).toContain("sum(size) * 0.001");
|
||||
expect(sql).toContain("event = 'Ingestion'");
|
||||
});
|
||||
|
||||
it("asks for every day x entity row, past the server's 1,000-row default for a query with no LIMIT", () => {
|
||||
// 1,300 streams over a month is far more than 1,000 rows; cut short, 'others' misses most of them.
|
||||
const sql = dailyEntitySql("default", DRILLDOWNS.ingestion![0], 0.001, 5);
|
||||
expect(sql).toMatch(/ LIMIT (\d+)$/);
|
||||
expect(Number(sql.match(/ LIMIT (\d+)$/)![1])).toBeGreaterThanOrEqual(31 * 1_300);
|
||||
});
|
||||
|
||||
describe("super org", () => {
|
||||
it("reads every org's volume per event, with no per-row price expressions", () => {
|
||||
const sql = orgVolumeSql();
|
||||
expect(sql).toContain("sum(size) as volume");
|
||||
expect(sql).toContain("group by org_id, event");
|
||||
expect(sql).not.toContain("org_id =");
|
||||
expect(sql).not.toContain("case");
|
||||
});
|
||||
|
||||
it("adds each bucket's raw volume to the trends query only when asked", () => {
|
||||
expect(trendsCostSql("o", [], "1 day")).not.toContain('sum(size) as "volume"');
|
||||
expect(trendsCostSql("o", [], "1 day", undefined, true)).toContain('sum(size) as "volume"');
|
||||
});
|
||||
|
||||
it("labels the daily series by org name and folds unknown orgs into Other", () => {
|
||||
const sql = dailyOrgCostSql(
|
||||
cyclePrices([details]),
|
||||
[{ id: "m1", name: "Acme's EU" }],
|
||||
"Other",
|
||||
);
|
||||
expect(sql).toContain("case org_id when 'm1' then 'Acme''s EU' else 'Other' end");
|
||||
expect(sql).toContain("histogram(_timestamp, '1 day')");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage volume queries for an org with no bill", () => {
|
||||
it("reads raw volume per event, for this org only, with the org id escaped", () => {
|
||||
const totals = eventVolumeSql("o'rg", ["Ingestion", "Search"]);
|
||||
expect(totals).toContain("sum(size) as volume");
|
||||
expect(totals).toContain("org_id = 'o''rg'");
|
||||
expect(totals).toContain("event in ('Ingestion', 'Search') group by event");
|
||||
|
||||
const daily = dailyVolumeSql("org", ["Ingestion"]);
|
||||
expect(daily).toContain(`histogram(_timestamp, '1 day') as "x_axis_1"`);
|
||||
expect(daily).toContain(`sum(size) as "y_axis_1"`);
|
||||
expect(daily).not.toContain("case");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage chart queries counting from a moment", () => {
|
||||
it("adds the time condition only when asked, so other callers are unchanged", () => {
|
||||
const [byStream] = DRILLDOWNS.ingestion!;
|
||||
expect(dailyEntitySql("org", byStream, 1)).not.toContain("_timestamp >=");
|
||||
expect(dailyEntitySql("org", byStream, 1, 1_700_000_000_000_000)).toContain(
|
||||
"AND _timestamp >= 1700000000000000",
|
||||
);
|
||||
expect(trendsCostSql("org", [], "1 day")).not.toContain("_timestamp >=");
|
||||
expect(trendsCostSql("org", [], "1 day", undefined, false, 5)).toContain("_timestamp >= 5");
|
||||
expect(dailyOrgCostSql([], [], "Other", undefined, 5)).toContain("_timestamp >= 5");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import type { I18nKey } from "@/types/i18n";
|
||||
import type { MeterKey } from "./meteringModel";
|
||||
import { PRICED_EVENTS, priceCaseSql, type CyclePrice } from "./trendsModel";
|
||||
|
||||
/** The org's own self-reporting stream. Matches the backend constant `USAGE_STREAM`. */
|
||||
export const USAGE_STREAM_NAME = "usage";
|
||||
|
||||
export const DRILLDOWN_LIMIT = 10;
|
||||
|
||||
export interface DrilldownSection {
|
||||
id: string;
|
||||
/** Chip label when a meter has more than one breakdown. */
|
||||
labelKey: I18nKey;
|
||||
/** Header of the grouping column. */
|
||||
columnKey: I18nKey;
|
||||
/** Fields this query reads beyond `event` and `size`; an absent field would fail the query. */
|
||||
requiredFields: string[];
|
||||
/** The `usage` event this section counts, which is also the denominator for its share. */
|
||||
event: string;
|
||||
/** SQL for the entity name; may differ from `groupBy` when the name is derived. */
|
||||
expression: string;
|
||||
groupBy: string;
|
||||
/** Extra filter beyond the event, e.g. excluding searches that came from alerts. */
|
||||
where: string;
|
||||
/** AI credit and synthetics step events write a count into `size`, not megabytes. */
|
||||
unit?: "mb" | "count";
|
||||
/** Header of the quantity column when `size` is a count. */
|
||||
quantityKey?: I18nKey;
|
||||
sql: (orgId: string) => string;
|
||||
}
|
||||
|
||||
type SectionSpec = Omit<DrilldownSection, "sql">;
|
||||
|
||||
function escapeLiteral(value: string): string {
|
||||
return String(value).replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/** Counts from this moment (microseconds) when a chart's window opens earlier, at a day boundary. */
|
||||
function sinceClause(since?: number): string {
|
||||
return since ? ` AND _timestamp >= ${Math.floor(since)}` : "";
|
||||
}
|
||||
|
||||
function sectionWhere(orgId: string, spec: SectionSpec): string {
|
||||
const extra = spec.where ? ` and ${spec.where}` : "";
|
||||
return `org_id = '${escapeLiteral(orgId)}' and event = '${escapeLiteral(spec.event)}'${extra}`;
|
||||
}
|
||||
|
||||
/** Every breakdown returns the same two columns, so one table renders all of them. */
|
||||
function section(spec: SectionSpec): DrilldownSection {
|
||||
return {
|
||||
...spec,
|
||||
sql: (orgId) =>
|
||||
`select ${spec.expression} as name, sum(size) as volume from "${USAGE_STREAM_NAME}" ` +
|
||||
`where ${sectionWhere(orgId, spec)} ` +
|
||||
`group by ${spec.groupBy} order by volume desc limit ${DRILLDOWN_LIMIT}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** AI credit events carry the feature and the incident inside `request_body`. */
|
||||
const AI_FEATURE = "json_get_str(request_body, 'feature')";
|
||||
|
||||
const AI_INCIDENT_ID = "json_get_str(request_body, 'incident_id')";
|
||||
|
||||
const byStream = (event: string, where = ""): DrilldownSection =>
|
||||
section({
|
||||
id: "stream",
|
||||
labelKey: "billing.usageV2.byStream",
|
||||
columnKey: "billing.usageV2.colStream",
|
||||
requiredFields: ["stream_name"],
|
||||
event,
|
||||
expression: "stream_name",
|
||||
groupBy: "stream_name",
|
||||
where,
|
||||
});
|
||||
|
||||
/** Synthetics rows carry the check id in `stream_name`; the table resolves it to the check's name. */
|
||||
const byCheck = (event: string): DrilldownSection =>
|
||||
section({
|
||||
id: "check",
|
||||
labelKey: "billing.usageV2.byCheck",
|
||||
columnKey: "billing.usageV2.colCheck",
|
||||
requiredFields: ["stream_name"],
|
||||
event,
|
||||
expression: "stream_name",
|
||||
groupBy: "stream_name",
|
||||
where: "",
|
||||
unit: "count",
|
||||
quantityKey: "billing.usageV2.colSteps",
|
||||
});
|
||||
|
||||
export const DRILLDOWNS: Partial<Record<MeterKey, DrilldownSection[]>> = {
|
||||
ingestion: [byStream("Ingestion")],
|
||||
query: [
|
||||
byStream("Search", "stream_name is not null and stream_name != ''"),
|
||||
section({
|
||||
id: "user",
|
||||
labelKey: "billing.usageV2.byUser",
|
||||
columnKey: "billing.usageV2.colUser",
|
||||
requiredFields: ["user_email", "alert_name", "dashboard_name", "derived_stream_key"],
|
||||
event: "Search",
|
||||
expression: "user_email",
|
||||
groupBy: "user_email",
|
||||
where: "alert_name is null and dashboard_name is null and derived_stream_key is null",
|
||||
}),
|
||||
section({
|
||||
id: "alert",
|
||||
labelKey: "billing.usageV2.byAlert",
|
||||
columnKey: "billing.usageV2.colAlert",
|
||||
requiredFields: ["alert_name"],
|
||||
event: "Search",
|
||||
expression: "alert_name",
|
||||
groupBy: "alert_name",
|
||||
where: "alert_name is not null",
|
||||
}),
|
||||
section({
|
||||
id: "dashboard",
|
||||
labelKey: "billing.usageV2.byDashboard",
|
||||
columnKey: "billing.usageV2.colDashboard",
|
||||
requiredFields: ["dashboard_name"],
|
||||
event: "Search",
|
||||
expression: "dashboard_name",
|
||||
groupBy: "dashboard_name",
|
||||
where: "dashboard_name is not null",
|
||||
}),
|
||||
section({
|
||||
id: "scheduled",
|
||||
labelKey: "billing.usageV2.byScheduledPipeline",
|
||||
columnKey: "billing.usageV2.colScheduledPipeline",
|
||||
requiredFields: ["derived_stream_key"],
|
||||
event: "Search",
|
||||
expression: "string_to_list(derived_stream_key, '/')[3]",
|
||||
groupBy: "derived_stream_key",
|
||||
where: "derived_stream_key is not null",
|
||||
}),
|
||||
],
|
||||
pipeline: [
|
||||
section({
|
||||
id: "pipeline",
|
||||
labelKey: "billing.usageV2.byPipeline",
|
||||
columnKey: "billing.usageV2.colPipeline",
|
||||
requiredFields: ["stream_name"],
|
||||
event: "Pipeline",
|
||||
expression: "stream_name",
|
||||
groupBy: "stream_name",
|
||||
where: "",
|
||||
}),
|
||||
],
|
||||
remote_pipeline: [
|
||||
section({
|
||||
id: "destination",
|
||||
labelKey: "billing.usageV2.byDestination",
|
||||
columnKey: "billing.usageV2.colDestination",
|
||||
requiredFields: ["stream_name"],
|
||||
event: "RemotePipeline",
|
||||
expression: "stream_name",
|
||||
groupBy: "stream_name",
|
||||
where: "",
|
||||
}),
|
||||
],
|
||||
ai: [
|
||||
section({
|
||||
id: "user",
|
||||
labelKey: "billing.usageV2.byUser",
|
||||
columnKey: "billing.usageV2.colUser",
|
||||
requiredFields: ["user_email", "request_body"],
|
||||
event: "AiCredits",
|
||||
expression: "user_email",
|
||||
groupBy: "user_email",
|
||||
where: `${AI_FEATURE} = 'ai_chat'`,
|
||||
unit: "count",
|
||||
quantityKey: "billing.usageV2.colCredits",
|
||||
}),
|
||||
section({
|
||||
id: "incident",
|
||||
labelKey: "billing.usageV2.byIncident",
|
||||
columnKey: "billing.usageV2.colIncident",
|
||||
requiredFields: ["request_body"],
|
||||
event: "AiCredits",
|
||||
expression: AI_INCIDENT_ID,
|
||||
groupBy: AI_INCIDENT_ID,
|
||||
where: `${AI_FEATURE} = 'new_incident'`,
|
||||
unit: "count",
|
||||
quantityKey: "billing.usageV2.colCredits",
|
||||
}),
|
||||
],
|
||||
synthetics_browser: [byCheck("SyntheticsBrowserSteps")],
|
||||
synthetics_protocol: [byCheck("SyntheticsProtocolSteps")],
|
||||
};
|
||||
|
||||
const DAILY_ENTITY_ROWS = 100_000;
|
||||
|
||||
/** Daily cost per entity; the chart keeps the top few itself, so it need not wait for the table's ranking. */
|
||||
export function dailyEntitySql(
|
||||
orgId: string,
|
||||
spec: DrilldownSection,
|
||||
rate: number,
|
||||
since?: number,
|
||||
): string {
|
||||
return (
|
||||
`SELECT histogram(_timestamp, '1 day') as "x_axis_1", ` +
|
||||
`sum(size) * ${rate} as "y_axis_1", ${spec.expression} as "breakdown_1" ` +
|
||||
`FROM "${USAGE_STREAM_NAME}" WHERE ${sectionWhere(orgId, spec)}${sinceClause(since)} ` +
|
||||
`GROUP BY x_axis_1, breakdown_1 ORDER BY x_axis_1 ASC LIMIT ${DAILY_ENTITY_ROWS}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Cost per event per bucket, each row priced by the cycle it falls in; SQL multiplies because the panel owns the query. */
|
||||
/** `orgId` null counts every org in the stream, which is a super org's whole group. */
|
||||
export function trendsCostSql(
|
||||
orgId: string | null,
|
||||
prices: CyclePrice[],
|
||||
bucket: string,
|
||||
events: string[] = PRICED_EVENTS,
|
||||
/** Also return each bucket's raw volume, read in the same pass, for the Volume view. */
|
||||
withVolume = false,
|
||||
since?: number,
|
||||
): string {
|
||||
const inList = events.map((event) => `'${escapeLiteral(event)}'`).join(", ");
|
||||
return (
|
||||
`SELECT histogram(_timestamp, '${escapeLiteral(bucket)}') as "x_axis_1", ` +
|
||||
`sum(size * ${priceCaseSql(prices, events)}) as "y_axis_1", ` +
|
||||
(withVolume ? `sum(size) as "volume", ` : "") +
|
||||
`event as "breakdown_1" FROM "${USAGE_STREAM_NAME}" ` +
|
||||
`WHERE ${orgId === null ? "" : `org_id = '${escapeLiteral(orgId)}' AND `}` +
|
||||
`event IN (${inList})${sinceClause(since)} GROUP BY x_axis_1, breakdown_1 ORDER BY x_axis_1 ASC`
|
||||
);
|
||||
}
|
||||
|
||||
/** Each event's volume over the query window, for an org with usage but no bill. */
|
||||
export function eventVolumeSql(orgId: string, events: string[] = PRICED_EVENTS): string {
|
||||
const inList = events.map((event) => `'${escapeLiteral(event)}'`).join(", ");
|
||||
return (
|
||||
`select event, sum(size) as volume from "${USAGE_STREAM_NAME}" ` +
|
||||
`where org_id = '${escapeLiteral(orgId)}' and event in (${inList}) group by event`
|
||||
);
|
||||
}
|
||||
|
||||
/** Each event's volume per day, the Volume chart of an org with usage but no bill. */
|
||||
export function dailyVolumeSql(orgId: string, events: string[]): string {
|
||||
const inList = events.map((event) => `'${escapeLiteral(event)}'`).join(", ");
|
||||
return (
|
||||
`SELECT histogram(_timestamp, '1 day') as "x_axis_1", sum(size) as "y_axis_1", ` +
|
||||
`event as "breakdown_1" FROM "${USAGE_STREAM_NAME}" ` +
|
||||
`WHERE org_id = '${escapeLiteral(orgId)}' AND event IN (${inList}) ` +
|
||||
`GROUP BY x_axis_1, breakdown_1 ORDER BY x_axis_1 ASC`
|
||||
);
|
||||
}
|
||||
|
||||
/** Each org's volume per event across the stream; priced in code, since a cycle gives each event one rate. */
|
||||
export function orgVolumeSql(events: string[] = PRICED_EVENTS): string {
|
||||
const inList = events.map((event) => `'${escapeLiteral(event)}'`).join(", ");
|
||||
return (
|
||||
`select org_id as name, event, sum(size) as volume from "${USAGE_STREAM_NAME}" ` +
|
||||
`where event in (${inList}) group by org_id, event`
|
||||
);
|
||||
}
|
||||
|
||||
/** Daily cost per org, labelled by org name; an org missing from `orgs` folds into `otherLabel`. */
|
||||
export function dailyOrgCostSql(
|
||||
prices: CyclePrice[],
|
||||
orgs: { id: string; name: string }[],
|
||||
otherLabel: string,
|
||||
events: string[] = PRICED_EVENTS,
|
||||
since?: number,
|
||||
): string {
|
||||
const inList = events.map((event) => `'${escapeLiteral(event)}'`).join(", ");
|
||||
const names = orgs
|
||||
.map((org) => `when '${escapeLiteral(org.id)}' then '${escapeLiteral(org.name)}'`)
|
||||
.join(" ");
|
||||
const band = names
|
||||
? `case org_id ${names} else '${escapeLiteral(otherLabel)}' end`
|
||||
: `'${escapeLiteral(otherLabel)}'`;
|
||||
return (
|
||||
`SELECT histogram(_timestamp, '1 day') as "x_axis_1", ` +
|
||||
`sum(size * ${priceCaseSql(prices, events)}) as "y_axis_1", ${band} as "breakdown_1" ` +
|
||||
`FROM "${USAGE_STREAM_NAME}" WHERE event IN (${inList})${sinceClause(since)} ` +
|
||||
`GROUP BY x_axis_1, breakdown_1 ORDER BY x_axis_1 ASC`
|
||||
);
|
||||
}
|
||||
|
||||
/** The whole meter's stream volume, so a row's share is measured against the same source as the row. */
|
||||
export function meterTotalSql(orgId: string, events: string[]): string {
|
||||
const inList = events.map((event) => `'${escapeLiteral(event)}'`).join(", ");
|
||||
return (
|
||||
`select sum(size) as total from "${USAGE_STREAM_NAME}" ` +
|
||||
`where org_id = '${escapeLiteral(orgId)}' and event in (${inList})`
|
||||
);
|
||||
}
|
||||
|
||||
/** `null` reads every org in the stream: a super org's whole group, as its bill covers. */
|
||||
const orgWhere = (orgId: string | null) =>
|
||||
orgId === null ? "where " : `where org_id = '${escapeLiteral(orgId)}' and `;
|
||||
|
||||
/** Estimator default: mean ingested megabytes per stream. Across a group, two orgs' `default` are two streams. */
|
||||
export function averageStreamSizeSql(orgId: string | null): string {
|
||||
const stream = orgId === null ? "concat(org_id, '/', stream_name)" : "stream_name";
|
||||
return (
|
||||
`select sum(size) as total, count(distinct ${stream}) as streams ` +
|
||||
`from "${USAGE_STREAM_NAME}" ${orgWhere(orgId)}event = 'Ingestion'`
|
||||
);
|
||||
}
|
||||
|
||||
export function searchScanSizeSql(orgId: string | null): string {
|
||||
return (
|
||||
`select sum(size) as total, count(distinct split_part(trace_id, '-', 1)) as searches ` +
|
||||
`from "${USAGE_STREAM_NAME}" ${orgWhere(orgId)}event = 'Search'`
|
||||
);
|
||||
}
|
||||
|
||||
/** Drops a section whose grouping field is missing from the live schema, which would error. */
|
||||
export function availableSections(
|
||||
sections: DrilldownSection[],
|
||||
schemaFields: Set<string>,
|
||||
): DrilldownSection[] {
|
||||
if (!schemaFields.size) return sections;
|
||||
return sections.filter((section) =>
|
||||
section.requiredFields.every((field) => schemaFields.has(field)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { ref } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MeteringDetails } from "./meteringModel";
|
||||
|
||||
const search = vi.fn().mockResolvedValue([]);
|
||||
vi.mock("./useUsageSearch", () => ({ useUsageSearch: () => ({ search }) }));
|
||||
|
||||
const { priceOrgVolumes, useOrgCosts } = await import("./useOrgCosts");
|
||||
|
||||
const meter = (meteredCost: number, discounted = 0) => ({
|
||||
base_cost: 0.5,
|
||||
unit_divisor: 1024,
|
||||
metered_amount: 0,
|
||||
metered_cost: meteredCost,
|
||||
percent_discount: 0,
|
||||
discounted_amount: discounted,
|
||||
});
|
||||
|
||||
// Ingestion: $100 gross, $10 off, so $90 billed. Search: $20, no discount.
|
||||
const cycle = {
|
||||
cycle_start: 1_700_000_000,
|
||||
cycle_end: 1_702_592_000,
|
||||
ingestion: meter(100, 10),
|
||||
query: meter(20),
|
||||
ai: meter(0),
|
||||
} as unknown as MeteringDetails;
|
||||
|
||||
describe("priceOrgVolumes", () => {
|
||||
const hits = [
|
||||
{ name: "payer", event: "Ingestion", volume: 300 },
|
||||
{ name: "child", event: "Ingestion", volume: 100 },
|
||||
{ name: "payer", event: "Search", volume: 50 },
|
||||
];
|
||||
|
||||
it("splits each meter's bill by volume share, so the orgs add up to the bill", () => {
|
||||
const costs = priceOrgVolumes(hits, cycle);
|
||||
// Ingestion 300:100 of $90 billed and $100 gross; all of Search's $20 to the payer.
|
||||
expect(costs.payer.billed).toBeCloseTo(67.5 + 20, 9);
|
||||
expect(costs.payer.gross).toBeCloseTo(75 + 20, 9);
|
||||
expect(costs.child.billed).toBeCloseTo(22.5, 9);
|
||||
expect(costs.child.gross).toBeCloseTo(25, 9);
|
||||
expect(costs.payer.billed + costs.child.billed).toBeCloseTo(90 + 20, 9);
|
||||
});
|
||||
|
||||
it("keeps the bill's total even when the stream's volumes are inflated", () => {
|
||||
const doubled = hits.map((hit) => ({ ...hit, volume: hit.volume * 2 }));
|
||||
const costs = priceOrgVolumes(doubled, cycle);
|
||||
expect(costs.payer.billed + costs.child.billed).toBeCloseTo(110, 9);
|
||||
});
|
||||
|
||||
it("keeps each org's part of each meter, and its ingested megabytes", () => {
|
||||
const costs = priceOrgVolumes(hits, cycle);
|
||||
expect(costs.payer.ingested).toBe(300);
|
||||
expect(costs.child.byMeter.ingestion).toEqual({
|
||||
volume: 100,
|
||||
gross: expect.closeTo(25, 9),
|
||||
billed: expect.closeTo(22.5, 9),
|
||||
});
|
||||
expect(costs.payer.byMeter.query?.billed).toBeCloseTo(20, 9);
|
||||
});
|
||||
|
||||
it("gives an event with no meter nothing and returns nothing for no rows", () => {
|
||||
expect(priceOrgVolumes([{ name: "x", event: "Functions", volume: 5 }], cycle).x).toEqual({
|
||||
gross: 0,
|
||||
billed: 0,
|
||||
ingested: 0,
|
||||
byMeter: {},
|
||||
});
|
||||
expect(priceOrgVolumes([], cycle)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useOrgCosts", () => {
|
||||
it("re-reads when a Refresh brings a new bill", async () => {
|
||||
const details = ref<MeteringDetails | null>(cycle);
|
||||
useOrgCosts(details, ref([{ id: "payer", name: "Payer", isSelf: true }]));
|
||||
await flushPromises();
|
||||
details.value = { ...cycle };
|
||||
await flushPromises();
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { computed, ref, watch, type Ref } from "vue";
|
||||
import {
|
||||
meterForEvent,
|
||||
netCost,
|
||||
type BillingOrg,
|
||||
type MeterKey,
|
||||
type MeterShare,
|
||||
type MeteringDetails,
|
||||
} from "./meteringModel";
|
||||
import { orgVolumeSql } from "./usageQueries";
|
||||
import { useUsageSearch } from "./useUsageSearch";
|
||||
|
||||
export interface OrgCost {
|
||||
gross: number;
|
||||
billed: number;
|
||||
/** Megabytes ingested this cycle. */
|
||||
ingested: number;
|
||||
/** The org's part of each meter; retention writes no usage events, so it is never here. */
|
||||
byMeter: Partial<Record<MeterKey, MeterShare>>;
|
||||
}
|
||||
|
||||
export function priceOrgVolumes(
|
||||
hits: Record<string, unknown>[],
|
||||
cycle: MeteringDetails,
|
||||
): Record<string, OrgCost> {
|
||||
const totals: Partial<Record<MeterKey, number>> = {};
|
||||
for (const hit of hits) {
|
||||
const meter = meterForEvent(String(hit.event ?? ""))?.key;
|
||||
if (meter) totals[meter] = (totals[meter] ?? 0) + (Number(hit.volume) || 0);
|
||||
}
|
||||
const byOrg: Record<string, OrgCost> = {};
|
||||
for (const hit of hits) {
|
||||
const event = String(hit.event ?? "");
|
||||
const volume = Number(hit.volume) || 0;
|
||||
const org = (byOrg[String(hit.name ?? "")] ??= {
|
||||
gross: 0,
|
||||
billed: 0,
|
||||
ingested: 0,
|
||||
byMeter: {},
|
||||
});
|
||||
if (event === "Ingestion") org.ingested += volume;
|
||||
const meter = meterForEvent(event)?.key;
|
||||
const total = meter ? (totals[meter] ?? 0) : 0;
|
||||
if (!meter || total <= 0) continue;
|
||||
const share = volume / total;
|
||||
const gross = (Number(cycle[meter]?.metered_cost) || 0) * share;
|
||||
const billed = netCost(cycle[meter]) * share;
|
||||
const part = (org.byMeter[meter] ??= { volume: 0, gross: 0, billed: 0 });
|
||||
part.volume += volume;
|
||||
part.gross += gross;
|
||||
part.billed += billed;
|
||||
org.gross += gross;
|
||||
org.billed += billed;
|
||||
}
|
||||
return byOrg;
|
||||
}
|
||||
|
||||
/** One org-cost read, as the Usage page hands it to every tab that shows per-org spend. */
|
||||
export interface OrgCostsState {
|
||||
costs: Record<string, OrgCost>;
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export function useOrgCosts(
|
||||
details: Readonly<Ref<MeteringDetails | null>>,
|
||||
billingOrgs: Readonly<Ref<BillingOrg[]>>,
|
||||
) {
|
||||
const { search } = useUsageSearch();
|
||||
const costs = ref<Record<string, OrgCost>>({});
|
||||
const loading = ref(false);
|
||||
/** A failed read shows as an error, never as every org at $0.00. */
|
||||
const failed = ref(false);
|
||||
|
||||
/** This cycle so far, in microseconds. */
|
||||
const window = computed(() => {
|
||||
const start = (details.value?.cycle_start ?? 0) * 1_000_000;
|
||||
const end = Math.min((details.value?.cycle_end ?? 0) * 1_000_000, Date.now() * 1000);
|
||||
return { start, end: Math.max(end, start) };
|
||||
});
|
||||
|
||||
let latest = 0;
|
||||
|
||||
const load = async () => {
|
||||
const cycle = details.value;
|
||||
if (!billingOrgs.value.length || !cycle) return;
|
||||
const id = ++latest;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
costs.value = {};
|
||||
try {
|
||||
// The server streams ~one partial aggregate per time slice; showing each beats a long skeleton.
|
||||
const hits = await search(orgVolumeSql(), {
|
||||
start: window.value.start,
|
||||
end: window.value.end,
|
||||
size: 1000,
|
||||
key: "orgCosts",
|
||||
onRows: (rows) => {
|
||||
if (id === latest) costs.value = priceOrgVolumes(rows, cycle);
|
||||
},
|
||||
});
|
||||
if (id !== latest) return;
|
||||
costs.value = priceOrgVolumes(hits, cycle);
|
||||
} catch {
|
||||
if (id !== latest) return;
|
||||
costs.value = {};
|
||||
failed.value = true;
|
||||
} finally {
|
||||
if (id === latest) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh always brings a new bill, so this re-reads without watching a refresh signal.
|
||||
watch([() => billingOrgs.value.length, details], load, { immediate: true });
|
||||
|
||||
return { costs, loading, failed, window };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import { updateOrgSettingsMutation } from "@/services/organizations.queries";
|
||||
|
||||
/** Turning on usage reporting, one flow for every Usage tab that offers the button. */
|
||||
export function useUsageReporting() {
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
const orgId = computed<string>(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
const enabled = computed<boolean>(
|
||||
() => !!store.state?.organizationData?.organizationSettings?.usage_stream_enabled,
|
||||
);
|
||||
/** The confirm dialog is open. */
|
||||
const confirming = ref(false);
|
||||
const enabling = ref(false);
|
||||
|
||||
const updateOrgSettings = useMutation(() => updateOrgSettingsMutation(orgId.value));
|
||||
|
||||
/** Sends only the opt-in: the backend writes every field it gets, so this page's older copy of the rest would undo newer changes. */
|
||||
const enable = async () => {
|
||||
confirming.value = false;
|
||||
if (enabling.value) return;
|
||||
enabling.value = true;
|
||||
try {
|
||||
await updateOrgSettings.mutateAsync({ usage_stream_enabled: true });
|
||||
store.dispatch("setOrganizationSettings", {
|
||||
...(store.state?.organizationData?.organizationSettings ?? {}),
|
||||
usage_stream_enabled: true,
|
||||
});
|
||||
toast({
|
||||
variant: "success",
|
||||
message: t("billing.usageTrends.enablePending"),
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
// The server's reason when it gives one, not the request library's "status code" text.
|
||||
message: raw(e?.response?.data?.message) || t("billing.failedToEnableUsageReporting"),
|
||||
timeout: 5000,
|
||||
});
|
||||
} finally {
|
||||
enabling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { enabled, confirming, enabling, enable };
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Handlers = {
|
||||
data: (payload: unknown, response: unknown) => void;
|
||||
error: (payload: unknown, response: unknown) => void;
|
||||
complete: (payload: unknown, response: unknown) => void;
|
||||
};
|
||||
|
||||
const calls: { request: any; handlers: Handlers }[] = [];
|
||||
const cancelled: string[] = [];
|
||||
|
||||
vi.mock("vuex", () => ({
|
||||
useStore: () => ({
|
||||
state: { selectedOrganization: { identifier: "org1" }, zoConfig: {} },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useStreamingSearch", () => ({
|
||||
default: () => ({
|
||||
fetchQueryDataWithHttpStream: (request: any, handlers: Handlers) => {
|
||||
calls.push({ request, handlers });
|
||||
},
|
||||
cancelStreamQueryBasedOnRequestId: ({ trace_id }: { trace_id: string }) => {
|
||||
cancelled.push(trace_id);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let traceSeq = 0;
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
generateTraceContext: () => ({ traceId: `trace-${++traceSeq}` }),
|
||||
b64EncodeUnicode: (value: string) => `b64:${value}`,
|
||||
}));
|
||||
|
||||
const { useUsageSearch } = await import("./useUsageSearch");
|
||||
|
||||
const metadata = (streaming_aggs: boolean, hits?: unknown[]) => ({
|
||||
type: "search_response_metadata",
|
||||
content: { results: { streaming_aggs, ...(hits ? { hits } : {}) } },
|
||||
});
|
||||
const hitsEvent = (hits: unknown[]) => ({
|
||||
type: "search_response_hits",
|
||||
content: { results: { hits } },
|
||||
});
|
||||
|
||||
describe("useUsageSearch", () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
cancelled.length = 0;
|
||||
});
|
||||
|
||||
const options = { start: 1, end: 2, size: 10 };
|
||||
|
||||
it("posts the SQL to the stream with the window, size and logs page type", async () => {
|
||||
const { search } = useUsageSearch();
|
||||
const pending = search("select 1", options);
|
||||
const { request, handlers } = calls[0];
|
||||
expect(request.type).toBe("search");
|
||||
expect(request.pageType).toBe("logs");
|
||||
expect(request.clear_cache).toBeUndefined();
|
||||
expect(request.queryReq.query).toEqual({
|
||||
sql: "select 1",
|
||||
start_time: 1,
|
||||
end_time: 2,
|
||||
from: 0,
|
||||
size: 10,
|
||||
});
|
||||
handlers.complete({}, {});
|
||||
await expect(pending).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("appends time partitions when the backend does not stream aggregates", async () => {
|
||||
const { search } = useUsageSearch();
|
||||
const pending = search("histogram sql", options);
|
||||
const { handlers } = calls[0];
|
||||
handlers.data({}, metadata(false));
|
||||
handlers.data({}, hitsEvent([{ day: 1 }]));
|
||||
handlers.data({}, metadata(false));
|
||||
handlers.data({}, hitsEvent([{ day: 2 }]));
|
||||
handlers.complete({}, {});
|
||||
await expect(pending).resolves.toEqual([{ day: 1 }, { day: 2 }]);
|
||||
});
|
||||
|
||||
it("replaces rows for a streaming aggregate, which resends the whole result each time", async () => {
|
||||
const { search } = useUsageSearch();
|
||||
const pending = search("aggregate sql", options);
|
||||
const { handlers } = calls[0];
|
||||
handlers.data({}, metadata(true));
|
||||
handlers.data({}, hitsEvent([{ total: 5 }]));
|
||||
handlers.data({}, metadata(true));
|
||||
handlers.data({}, hitsEvent([{ total: 9 }]));
|
||||
handlers.complete({}, {});
|
||||
await expect(pending).resolves.toEqual([{ total: 9 }]);
|
||||
});
|
||||
|
||||
it("falls back to metadata hits when no hits event arrives, as a cached response can", async () => {
|
||||
const { search } = useUsageSearch();
|
||||
const pending = search("cached sql", options);
|
||||
calls[0].handlers.data({}, metadata(false, [{ total: 3 }]));
|
||||
calls[0].handlers.complete({}, {});
|
||||
await expect(pending).resolves.toEqual([{ total: 3 }]);
|
||||
});
|
||||
|
||||
it("cancels the previous same-key query on the server and leaves it unsettled", async () => {
|
||||
const { search } = useUsageSearch();
|
||||
let firstSettled = false;
|
||||
search("first", { ...options, key: "rows" }).finally(() => {
|
||||
firstSettled = true;
|
||||
});
|
||||
const second = search("second", { ...options, key: "rows" });
|
||||
expect(cancelled).toEqual([calls[0].request.traceId]);
|
||||
calls[0].handlers.complete({}, {});
|
||||
calls[1].handlers.data({}, hitsEvent([{ n: 2 }]));
|
||||
calls[1].handlers.complete({}, {});
|
||||
await expect(second).resolves.toEqual([{ n: 2 }]);
|
||||
expect(firstSettled).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the running rows after each chunk, and none from a superseded query", async () => {
|
||||
const { search } = useUsageSearch();
|
||||
const seen: unknown[][] = [];
|
||||
const stale: unknown[][] = [];
|
||||
search("old", { ...options, key: "orgs", onRows: (rows) => stale.push(rows) });
|
||||
const pending = search("new", { ...options, key: "orgs", onRows: (rows) => seen.push(rows) });
|
||||
calls[0].handlers.data({}, hitsEvent([{ v: 0 }]));
|
||||
const { handlers } = calls[1];
|
||||
handlers.data({}, metadata(true));
|
||||
handlers.data({}, hitsEvent([{ v: 1 }]));
|
||||
handlers.data({}, hitsEvent([{ v: 2 }]));
|
||||
handlers.complete({}, {});
|
||||
await expect(pending).resolves.toEqual([{ v: 2 }]);
|
||||
expect(seen).toEqual([[{ v: 1 }], [{ v: 2 }]]);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps different keys independent", () => {
|
||||
const { search } = useUsageSearch();
|
||||
search("a", { ...options, key: "rows" });
|
||||
search("b", { ...options, key: "total" });
|
||||
expect(cancelled).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects with the server message where existing callers read it", async () => {
|
||||
const { search } = useUsageSearch();
|
||||
const pending = search("bad", options);
|
||||
calls[0].handlers.error(
|
||||
{},
|
||||
{ type: "error", content: { message: "Stream not found: usage", status: 404 } },
|
||||
);
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
message: "Stream not found: usage",
|
||||
status: 404,
|
||||
response: { data: { message: "Stream not found: usage" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { getCurrentInstance, onBeforeUnmount } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import useHttpStreaming from "@/composables/useStreamingSearch";
|
||||
import { b64EncodeUnicode, generateTraceContext } from "@/utils/zincutils";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export interface UsageSearchOptions {
|
||||
/** Microseconds. */
|
||||
start: number;
|
||||
end: number;
|
||||
size: number;
|
||||
/** A new call with the same key cancels the previous one on the server. */
|
||||
key?: string;
|
||||
/** The rows so far after each streamed chunk, for a caller that renders progressively. */
|
||||
onRows?: (rows: Row[]) => void;
|
||||
}
|
||||
|
||||
/** `usage` SQL over `_search_stream`; a call cancelled by a newer same-key call or by unmount never settles. */
|
||||
export function useUsageSearch() {
|
||||
const store = useStore();
|
||||
const { fetchQueryDataWithHttpStream, cancelStreamQueryBasedOnRequestId } = useHttpStreaming();
|
||||
|
||||
const inFlight = new Map<string, string>();
|
||||
|
||||
const cancel = (key: string) => {
|
||||
const traceId = inFlight.get(key);
|
||||
if (!traceId) return;
|
||||
inFlight.delete(key);
|
||||
cancelStreamQueryBasedOnRequestId({
|
||||
trace_id: traceId,
|
||||
org_id: store.state.selectedOrganization.identifier,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelAll = () => [...inFlight.keys()].forEach(cancel);
|
||||
|
||||
if (getCurrentInstance()) onBeforeUnmount(cancelAll);
|
||||
|
||||
let anonymous = 0;
|
||||
|
||||
const search = (sql: string, options: UsageSearchOptions): Promise<Row[]> => {
|
||||
const key = options.key ?? `anonymous-${++anonymous}`;
|
||||
cancel(key);
|
||||
const traceId = generateTraceContext().traceId;
|
||||
inFlight.set(key, traceId);
|
||||
const isCurrent = () => inFlight.get(key) === traceId;
|
||||
const useBase64 = store.state.zoConfig?.sql_base64_enabled;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let streamingAggs = false;
|
||||
let rows: Row[] = [];
|
||||
const metadataRows: Row[] = [];
|
||||
|
||||
fetchQueryDataWithHttpStream(
|
||||
{
|
||||
queryReq: {
|
||||
query: {
|
||||
sql: useBase64 ? b64EncodeUnicode(sql) : sql,
|
||||
start_time: options.start,
|
||||
end_time: options.end,
|
||||
from: 0,
|
||||
size: options.size,
|
||||
},
|
||||
...(useBase64 ? { encoding: "base64" } : {}),
|
||||
},
|
||||
type: "search",
|
||||
pageType: "logs",
|
||||
searchType: "ui",
|
||||
traceId,
|
||||
org_id: store.state.selectedOrganization.identifier,
|
||||
},
|
||||
{
|
||||
data: (_payload: unknown, response: any) => {
|
||||
const content = response?.content ?? {};
|
||||
const results = content.results ?? {};
|
||||
if (response?.type === "search_response_metadata") {
|
||||
streamingAggs ||= Boolean(results.streaming_aggs ?? content.streaming_aggs);
|
||||
// A cached response can carry its hits on metadata alone, so keep them as a fallback.
|
||||
if (Array.isArray(results.hits)) metadataRows.push(...results.hits);
|
||||
return;
|
||||
}
|
||||
if (response?.type !== "search_response_hits") return;
|
||||
const hits: Row[] = results.hits ?? content.hits ?? [];
|
||||
if (!Array.isArray(hits) || !hits.length) return;
|
||||
// Same merge as the dashboards executor: a streaming aggregate resends the whole result.
|
||||
rows = streamingAggs ? [...hits] : [...rows, ...hits];
|
||||
if (isCurrent()) options.onRows?.(rows);
|
||||
},
|
||||
error: (_payload: unknown, response: any) => {
|
||||
if (!isCurrent()) return;
|
||||
inFlight.delete(key);
|
||||
const body = response?.content ?? response ?? {};
|
||||
const error: any = new Error(
|
||||
String(body.message ?? body.error ?? body.error_detail ?? "search failed"),
|
||||
);
|
||||
error.status = body.status;
|
||||
error.code = body.code;
|
||||
error.response = { data: body };
|
||||
reject(error);
|
||||
},
|
||||
complete: () => {
|
||||
if (!isCurrent()) return;
|
||||
inFlight.delete(key);
|
||||
resolve(rows.length ? rows : metadataRows);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return { search, cancel, cancelAll };
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { gt } from "@/types/i18n";
|
||||
|
||||
import {
|
||||
buildUsageCombinedSql,
|
||||
buildUsageCombinedLinePanelSchema,
|
||||
USAGE_STREAM_NAME,
|
||||
} from "./usageDailyPanelSchema";
|
||||
|
||||
describe("usageDailyPanelSchema", () => {
|
||||
describe("buildUsageCombinedSql", () => {
|
||||
it("buckets both metrics in one query, broken down by event", () => {
|
||||
const sql = buildUsageCombinedSql({ orgId: "org-x", dataType: "mb" });
|
||||
expect(sql).toContain("histogram(_timestamp, '1 day')");
|
||||
expect(sql).toContain(`FROM "${USAGE_STREAM_NAME}"`);
|
||||
expect(sql).toContain("org_id = 'org-x'");
|
||||
expect(sql).toContain('event as "breakdown_1"');
|
||||
expect(sql).toContain("event IN ('Ingestion', 'Search', 'Pipeline', 'RemotePipeline')");
|
||||
expect(sql).toContain("GROUP BY x_axis_1, breakdown_1");
|
||||
expect(sql).toContain("ORDER BY x_axis_1 ASC");
|
||||
});
|
||||
|
||||
it("divides size by 1024 for GB, raw MB otherwise", () => {
|
||||
const gb = buildUsageCombinedSql({ orgId: "o", dataType: "gb" });
|
||||
expect(gb).toContain("sum(size) / 1024");
|
||||
const mb = buildUsageCombinedSql({ orgId: "o", dataType: "mb" });
|
||||
expect(mb).toContain("sum(size)");
|
||||
expect(mb).not.toContain("/ 1024");
|
||||
});
|
||||
|
||||
it("escapes single quotes in the org id", () => {
|
||||
const sql = buildUsageCombinedSql({ orgId: "o'x", dataType: "mb" });
|
||||
expect(sql).toContain("org_id = 'o''x'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUsageCombinedLinePanelSchema", () => {
|
||||
it("produces a version-2 line panel with an event breakdown (one query)", () => {
|
||||
const schema = buildUsageCombinedLinePanelSchema({
|
||||
orgId: "org-x",
|
||||
dataType: "gb",
|
||||
t: gt,
|
||||
});
|
||||
expect(schema.version).toBe(2);
|
||||
expect(schema.type).toBe("line");
|
||||
expect(schema.queryType).toBe("sql");
|
||||
expect(schema.queries).toHaveLength(1);
|
||||
expect(schema.queries[0].customQuery).toBe(true);
|
||||
expect(schema.queries[0].fields.stream).toBe(USAGE_STREAM_NAME);
|
||||
expect(schema.queries[0].fields.breakdown).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("labels the axis in the selected unit", () => {
|
||||
const gb = buildUsageCombinedLinePanelSchema({ orgId: "o", dataType: "gb", t: gt });
|
||||
expect(gb.config.unit_custom).toBe("GB");
|
||||
const mb = buildUsageCombinedLinePanelSchema({ orgId: "o", dataType: "mb", t: gt });
|
||||
expect(mb.config.unit_custom).toBe("MB");
|
||||
});
|
||||
|
||||
it("silences the renderer's own error text (no custom message)", () => {
|
||||
// error handling ON + no custom_error_message => both of the renderer's
|
||||
// error blocks are suppressed, so usage.vue can overlay its own graphic.
|
||||
const schema = buildUsageCombinedLinePanelSchema({
|
||||
orgId: "o",
|
||||
dataType: "gb",
|
||||
t: gt,
|
||||
});
|
||||
expect(schema.error_config.custom_error_handeling).toBe(true);
|
||||
expect(schema.error_config.custom_error_message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Builds dashboard panel schemas (version 2) for the Cloud Billing → Usage
|
||||
* daily view, so they render through the shared `PanelSchemaRenderer` (the same
|
||||
* engine dashboards use). This is UI-only: the charts fire normal SQL searches
|
||||
* against the current org's own self-reporting `usage` stream — exactly like a
|
||||
* hand-built dashboard panel — so no billing API changes are needed.
|
||||
*
|
||||
* The `usage` stream carries one row per usage event with fields:
|
||||
* `_timestamp`, `event` (Ingestion/Search/...), `size`, `stream_type`,
|
||||
* `org_id`, `unit`.
|
||||
*
|
||||
* The daily view shows one bar chart per metric (Ingestion, Search), each
|
||||
* bucketed by day with `histogram(_timestamp, '1 day')`, so users see a daily
|
||||
* run-rate and can catch a spike without waiting for the monthly total.
|
||||
*/
|
||||
|
||||
import type { TranslateFn } from "@/types/i18n";
|
||||
|
||||
/** The org's own self-reporting usage stream (populated when usage reporting
|
||||
* is enabled for the org). Matches the backend constant `USAGE_STREAM`
|
||||
* (`src/config/src/meta/self_reporting/usage.rs`), which writes to a stream
|
||||
* literally named `usage` in each org — NOT `_usage`. Single source of truth
|
||||
* if a deployment names it differently. */
|
||||
export const USAGE_STREAM_NAME = "usage";
|
||||
|
||||
export type UsageMetric = "Ingestion" | "Search" | "Pipeline" | "RemotePipeline";
|
||||
|
||||
/** The byte-based billable events charted daily (all except Functions, and
|
||||
* except Data Retention / AI Credits which use different units/streams). */
|
||||
export const CHART_METRICS: UsageMetric[] = ["Ingestion", "Search", "Pipeline", "RemotePipeline"];
|
||||
|
||||
/** Per-metric display colour (kept distinct so the lines read apart). */
|
||||
const METRIC_COLOR: Record<UsageMetric, string> = {
|
||||
Ingestion: "#5960b2",
|
||||
Search: "#e6a817",
|
||||
Pipeline: "#3fb27f",
|
||||
RemotePipeline: "#c65fb2",
|
||||
};
|
||||
|
||||
function safe(s: string): string {
|
||||
return String(s).replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/** MB→GB is done in SQL when needed so values match the card totals exactly. */
|
||||
function sizeExpr(dataType: "gb" | "mb"): string {
|
||||
return dataType === "gb" ? "sum(size) / 1024" : "sum(size)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily-bucketed SQL for all byte-based billable metrics in one query, broken
|
||||
* down by `event` (Ingestion, Search, Pipeline, Remote Pipeline — everything
|
||||
* except Functions). Powers the single combined line chart.
|
||||
*/
|
||||
export function buildUsageCombinedSql(opts: { orgId: string; dataType: "gb" | "mb" }): string {
|
||||
const { orgId, dataType } = opts;
|
||||
const events = CHART_METRICS.map((e) => `'${e}'`).join(", ");
|
||||
return (
|
||||
`SELECT histogram(_timestamp, '1 day') as "x_axis_1", ` +
|
||||
`${sizeExpr(dataType)} as "y_axis_1", ` +
|
||||
`event as "breakdown_1" ` +
|
||||
`FROM "${USAGE_STREAM_NAME}" ` +
|
||||
`WHERE org_id = '${safe(orgId)}' AND event IN (${events}) ` +
|
||||
`GROUP BY x_axis_1, breakdown_1 ORDER BY x_axis_1 ASC`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single combined daily LINE panel showing both metrics as separate
|
||||
* lines (broken down by `event`). One query for both series — Ingestion +
|
||||
* Search — day buckets on X, GB/MB on Y (converted in SQL, labelled here).
|
||||
*/
|
||||
export function buildUsageCombinedLinePanelSchema(opts: {
|
||||
orgId: string;
|
||||
dataType: "gb" | "mb";
|
||||
t: TranslateFn;
|
||||
}): any {
|
||||
const { orgId, dataType, t } = opts;
|
||||
const sql = buildUsageCombinedSql({ orgId, dataType });
|
||||
const unitLabel = dataType === "gb" ? "GB" : "MB";
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
id: "usage-daily-combined",
|
||||
title: "",
|
||||
description: "",
|
||||
type: "line",
|
||||
// Silence PanelSchemaRenderer's own error text (e.g. "stream not found:
|
||||
// usage", shown until the org reports any usage). We turn error handling
|
||||
// ON but give NO custom_error_message, so both of the renderer's error
|
||||
// blocks are suppressed — usage.vue then overlays the illustrated
|
||||
// "waiting for usage data" empty state instead.
|
||||
error_config: {
|
||||
custom_error_handeling: true,
|
||||
default_data_on_error: false,
|
||||
},
|
||||
config: {
|
||||
show_legends: true,
|
||||
legends_position: "bottom",
|
||||
unit: "custom",
|
||||
unit_custom: unitLabel,
|
||||
decimals: 2,
|
||||
axis_border_show: true,
|
||||
// Bridge gaps so each metric's line stays continuous across empty days.
|
||||
connect_nulls: true,
|
||||
no_value_replacement: "",
|
||||
show_symbol: true,
|
||||
base_map: { type: "osm" },
|
||||
map_view: { zoom: 1, lat: 0, lng: 0 },
|
||||
mark_line: [],
|
||||
// Pin the series colours so the metrics read consistently.
|
||||
color: {
|
||||
mode: "palette-classic",
|
||||
fixedColor: CHART_METRICS.map((m) => METRIC_COLOR[m]),
|
||||
seriesBy: "last",
|
||||
},
|
||||
},
|
||||
queryType: "sql",
|
||||
queries: [
|
||||
{
|
||||
query: sql,
|
||||
customQuery: true,
|
||||
vrlFunctionQuery: "",
|
||||
fields: {
|
||||
stream: USAGE_STREAM_NAME,
|
||||
stream_type: "logs",
|
||||
x: [{ alias: "x_axis_1", column: "x_axis_1", color: null, label: t("billing.time") }],
|
||||
y: [
|
||||
{
|
||||
alias: "y_axis_1",
|
||||
column: "y_axis_1",
|
||||
color: null,
|
||||
label: unitLabel,
|
||||
},
|
||||
],
|
||||
z: [],
|
||||
breakdown: [
|
||||
{
|
||||
alias: "breakdown_1",
|
||||
column: "breakdown_1",
|
||||
color: null,
|
||||
label: t("billing.event"),
|
||||
},
|
||||
],
|
||||
filter: {
|
||||
filterType: "group",
|
||||
logicalOperator: "AND",
|
||||
conditions: [],
|
||||
},
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
weight: null,
|
||||
},
|
||||
config: {
|
||||
promql_legend: "",
|
||||
layer_type: "scatter",
|
||||
weight_fixed: 1,
|
||||
limit: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
time_shift: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,9 @@ vi.mock("@/enterprise/components/billings/plans.vue", () => ({ default: { name:
|
||||
vi.mock("@/enterprise/components/billings/invoiceHistory.vue", () => ({
|
||||
default: { name: "InvoiceHistory" },
|
||||
}));
|
||||
vi.mock("@/enterprise/components/billings/usage.vue", () => ({ default: { name: "Usage" } }));
|
||||
vi.mock("@/enterprise/components/billings/usage/UsageView.vue", () => ({
|
||||
default: { name: "Usage" },
|
||||
}));
|
||||
vi.mock("@/enterprise/components/billings/BillingGroup.vue", () => ({
|
||||
default: { name: "BillingGroup" },
|
||||
}));
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
const Billing = () => import("@/enterprise/components/billings/Billing.vue");
|
||||
const Plans = () => import("@/enterprise/components/billings/plans.vue");
|
||||
const InvoiceHistory = () => import("@/enterprise/components/billings/invoiceHistory.vue");
|
||||
const Usage = () => import("@/enterprise/components/billings/usage.vue");
|
||||
const Usage = () => import("@/enterprise/components/billings/usage/UsageView.vue");
|
||||
const BillingGroup = () => import("@/enterprise/components/billings/BillingGroup.vue");
|
||||
const AzureMarketplaceSetup = () => import("@/views/AzureMarketplaceSetup.vue");
|
||||
const AwsMarketplaceSetup = () => import("@/views/AwsMarketplaceSetup.vue");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount } from "@vue/test-utils";
|
||||
import { h, nextTick, reactive } from "vue";
|
||||
import OToggleGroup from "./OToggleGroup.vue";
|
||||
import OToggleGroupItem from "./OToggleGroupItem.vue";
|
||||
import { TOOLTIP_TRIGGER_ATTR } from "@/lib/overlay/Tooltip/OTooltip.types";
|
||||
|
||||
// OToggleGroupItem requires a ToggleGroupRoot context — mount inside OToggleGroup
|
||||
function mountItem(
|
||||
@@ -48,6 +49,23 @@ describe("OToggleGroupItem", () => {
|
||||
expect(btn.classes().join(" ")).toContain("bg-toggle-item-bg");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["focusable", { focusableUnavailable: true }],
|
||||
["plain", {}],
|
||||
])("binds a disabled %s item's tooltip to the wrapper that receives the hover", (_, extra) => {
|
||||
const wrapper = mountItem({ value: "x", disabled: true, tooltip: "Why", ...extra });
|
||||
const trigger = wrapper.find(`[${TOOLTIP_TRIGGER_ATTR}]`);
|
||||
expect(trigger.element.tagName).toBe("SPAN");
|
||||
expect(trigger.classes()).toContain("cursor-not-allowed");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("binds an enabled item's tooltip to its button", () => {
|
||||
const wrapper = mountItem({ value: "x", tooltip: "Why" });
|
||||
expect(wrapper.find(`[${TOOLTIP_TRIGGER_ATTR}]`).element.tagName).toBe("BUTTON");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("sets data-disabled when disabled=true", () => {
|
||||
const wrapper = mountItem({ value: "x", disabled: true });
|
||||
const btn = wrapper.find("button");
|
||||
|
||||
@@ -129,6 +129,8 @@ const iconSize: Record<NonNullable<ToggleGroupItemProps["size"]>, "xs" | "sm" |
|
||||
:aria-labelledby="unavailable && typeof attrs['aria-label'] !== 'string' ? labelId : undefined"
|
||||
:aria-describedby="unavailable && props.tooltip ? reasonId : undefined"
|
||||
>
|
||||
<!-- First child when disabled: with nothing before it, the tooltip binds to this wrapper, the only element that gets the hover. -->
|
||||
<OTooltip v-if="props.tooltip && props.disabled" :content="props.tooltip" />
|
||||
<ToggleGroupItem
|
||||
v-bind="$attrs"
|
||||
:value="props.value"
|
||||
@@ -198,6 +200,6 @@ const iconSize: Record<NonNullable<ToggleGroupItemProps["size"]>, "xs" | "sm" |
|
||||
<span v-if="unavailable && props.tooltip" :id="reasonId" aria-hidden="true" class="sr-only">{{
|
||||
props.tooltip
|
||||
}}</span>
|
||||
<OTooltip v-if="props.tooltip" :content="props.tooltip" />
|
||||
<OTooltip v-if="props.tooltip && !props.disabled" :content="props.tooltip" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -186,4 +186,20 @@ describe("OProgressBar", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("color", () => {
|
||||
it("paints the fill with a category colour instead of the variant class", () => {
|
||||
const wrapper = mount(OProgressBar, { props: { value: 0.5, color: "rgb(1, 2, 3)" } });
|
||||
const fill = wrapper.find("[role='progressbar'] div");
|
||||
expect(fill.attributes("style")).toContain("background-color: rgb(1, 2, 3)");
|
||||
expect(fill.classes()).not.toContain("bg-progress-bar-default");
|
||||
});
|
||||
|
||||
it("keeps the variant class when no colour is given", () => {
|
||||
const wrapper = mount(OProgressBar, { props: { value: 0.5 } });
|
||||
expect(wrapper.find("[role='progressbar'] div").classes()).toContain(
|
||||
"bg-progress-bar-default",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ProgressBarProps {
|
||||
start?: number;
|
||||
/** Semantic color state. Default: "default" */
|
||||
variant?: ProgressBarVariant;
|
||||
color?: string;
|
||||
/** Track height. Default: "sm" */
|
||||
size?: ProgressBarSize;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,14 @@ const trackClasses = computed(() => [
|
||||
const fillClasses = computed(() => [
|
||||
"h-full rounded-full transition-[width] duration-300 ease-out",
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
variantFillClasses[props.variant ?? "default"],
|
||||
props.color ? "" : variantFillClasses[props.variant ?? "default"],
|
||||
]);
|
||||
|
||||
const fillStyle = computed(() => ({
|
||||
marginInlineStart: offset.value,
|
||||
width: percentage.value,
|
||||
...(props.color ? { backgroundColor: props.color } : {}),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,7 +88,7 @@ const fillClasses = computed(() => [
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div :class="fillClasses" :style="{ marginInlineStart: offset, width: percentage }">
|
||||
<div :class="fillClasses" :style="fillStyle">
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="text-progress-bar-label text-xs leading-none font-semibold select-none"
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "مقارنة الخطط",
|
||||
"contactSupport": "التواصل مع الدعم"
|
||||
},
|
||||
"previousCycle": "الدورة السابقة",
|
||||
"currentCycle": "الدورة الحالية",
|
||||
"period30Days": "30 يوما",
|
||||
"period60Days": "60 يوما",
|
||||
"period3Months": "3 أشهر",
|
||||
"period6Months": "6 أشهر",
|
||||
"pricingErrorMessage": "لم يتم تحميل تفاصيل التسعير.",
|
||||
"awsManagedMessage": "يتم التعامل مع الفوترة من خلال حسابك AWS",
|
||||
"azureManagedMessage": "يتم التعامل مع الفوترة من خلال حسابك Azure",
|
||||
"contractManagedMessage": "يتم التعامل مع الفوترة من خلال عقدك — تواصل مع مدير حسابك للتغييرات",
|
||||
"upgradeToPlanMessage": "ترقية إلى خطة للاستمرار في الاستمتاع بالخدمات بحلول OpenObserve.",
|
||||
"creditsUsedLabel": "الاعتمادات المستخدمة",
|
||||
"errorTracking": "تتبع الأخطاء",
|
||||
"rumSession": "جلسة RUM",
|
||||
"licenseExpiryContactAdmin": "يرجى تحديث رخصتك بالتواصل مع المسؤول الخاص بك.",
|
||||
"subscribed": "مشترك",
|
||||
"invoiceHistory": "تاريخ الفواتير",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "الحالة",
|
||||
"action": "الحدث",
|
||||
"manageSubscription": "إدارة الاشتراك",
|
||||
"messageDataNotFound": "البيانات غير موجودة",
|
||||
"invoiceStartDate": "تاريخ بدء الفاتورة",
|
||||
"invoiceEndDate": "تاريخ انتهاء الفاتورة",
|
||||
"downloadInvoice": "تحميل الفاتورة",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "هل تحتاج إلى حجم كبير؟ دعنا نتحدث عن التوسع! اتصل بنا للحصول على حلول مخصصة.",
|
||||
"contactSupport": "اتصل بالدعم",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "الاستخدام اليومي",
|
||||
"enableTitle": "عرض استخدامك اليومي",
|
||||
"enableSubtitle": "قم بتشغيل تقارير الاستخدام لرسم حجم الإدخال والاستعلامات يومًا بيوم. المجموعات الخاصة بك أعلاه ستظل متاحة على أي حال.",
|
||||
"enableSubtitle": "فعّل تقارير الاستخدام لرسم تكلفتك اليومية وحجمك لكل عداد. تظل فاتورتك في نظرة عامة متاحة في كلتا الحالتين.",
|
||||
"enableButton": "تمكين تقارير الاستخدام",
|
||||
"enablePending": "تم تمكين تقارير الاستخدام. ستبدأ المخططات اليومية بالامتلاء قريبًا عند تسجيل الاستخدام الجديد.",
|
||||
"enableConfirmTitle": "تمكين تقارير الاستخدام؟",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "اتصل بمدير حسابك لإضافة المزيد من اعتمادات الذكاء الاصطناعي",
|
||||
"aiPayerSubscribeMessage": "تتولى {payer} إدارة التصنيف لهذه المؤسسة. اطلب من أحد مسؤولي {payer} الاشتراك؛ ويُفاتر الاستخدام الإضافي على فاتورتها.",
|
||||
"aiFreeUsedLabel": "الأرصدة المجانية المستخدمة",
|
||||
"aiBillableCredits": "أرصدة الذكاء الاصطناعي القابلة للفاتورة",
|
||||
"aiFreeOneTime": "مخصص لمرة واحدة.",
|
||||
"aiUsedByFeature": "المساعد وتوليد الاستعلامات: {chat} · تحليل الحوادث: {incident} · إعادة التحليل: {reanalysis}",
|
||||
"aiPaygMessage": "تم نفاد الاعتمادات المجانية — الاستخدام سيتم فوترة الاشتراك",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "الخطة التي يديرها الوالد",
|
||||
"chipNoInvoices": "لا فواتير منفصلة",
|
||||
"viewOrgGroup": "مجموعة المنظمة الاستعراضية",
|
||||
"currentOrgTitle": "التنظيم الحالي",
|
||||
"memberOrgsTitle": "المنظمات الأعضاء",
|
||||
"searchMemberOrg": "منظمة البحث",
|
||||
"noMemberMatch": "لا توجد منظمات مطابقة",
|
||||
"remove": "إزالة",
|
||||
"removeTitle": "هل تريد إزالة {name} من مجموعة التصنيف الخاصة بك؟",
|
||||
"removeBody": "تصبح هذه المؤسسة مؤسسة مجانية وتدفع مقابل استخدامها الخاص. تنطبق حدود الخطة المجانية. لا يتأثر مستخدموها وبياناتها واستخدامها السابق.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "حالة التنبيه",
|
||||
"contactAdministratorToResolve": "يرجى الاتصال بمسؤولك لحل هذه المشكلة.",
|
||||
"contactAdministratorUsageReporting": "يرجى الاتصال بمسؤولك للتأكد من استعادة تقارير الاستخدام.",
|
||||
"credits": "الأرصدة",
|
||||
"dataRetention": "الاحتفاظ بالبيانات",
|
||||
"event": "الحدث",
|
||||
"ingestion": "الابتلاع",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "انتهت فترة التجربة الخاصة بك.",
|
||||
"trialDaysRemaining": "{count} يوم متبقي في حساب التجربة الخاص بك | {count} أيام متبقية في حساب التجربة الخاص بك",
|
||||
"usageReportingFailedForDays": "فشل تقرير الاستخدام لمدة {count} يوم، ميزات RBAC وSSO معطلة حتى يتم الإبلاغ عن الاستخدام بنجاح | فشل تقرير الاستخدام لمدة {count} أيام، ميزات RBAC وSSO معطلة حتى يتم الإبلاغ عن الاستخدام بنجاح",
|
||||
"usageReportingFailingForHours": "تقرير الاستخدام يفشل منذ {count} ساعة، بعض الميزات سيتم تعطيلها قريباً | تقرير الاستخدام يفشل منذ {count} ساعات، بعض الميزات سيتم تعطيلها قريباً"
|
||||
"usageReportingFailingForHours": "تقرير الاستخدام يفشل منذ {count} ساعة، بعض الميزات سيتم تعطيلها قريباً | تقرير الاستخدام يفشل منذ {count} ساعات، بعض الميزات سيتم تعطيلها قريباً",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "جيجابايت-ساعة",
|
||||
"unitCredit": "رصيد",
|
||||
"unitRun": "تشغيل",
|
||||
"ratePerUnit": "{rate} لكل {unit}",
|
||||
"cycleRange": "دورة التصنيف: {start} إلى {end}",
|
||||
"syntheticsBrowser": "الصناعات الاصطناعية (المتصفح)",
|
||||
"syntheticsProtocol": "الصناعات الاصطناعية (البروتوكول)",
|
||||
"overviewTab": "نظرة عامة",
|
||||
"trendsTab": "الاتجاهات",
|
||||
"estimationTab": "تقدير السعر",
|
||||
"costBreakdown": "تفصيل التكلفة",
|
||||
"colMeter": "العداد",
|
||||
"colVolume": "الحجم",
|
||||
"colCost": "التكلفة",
|
||||
"totalCost": "إجمالي التكلفة",
|
||||
"missingMeters": "تعذر علينا تسعير هذه العدادات: {meters}. تعرض صفوفها شرطة بدلًا من التكلفة.",
|
||||
"noPricingTitle": "التكلفة غير متاحة لهذه المؤسسة",
|
||||
"noPricingSubtitle": "لا تملك هذه المؤسسة حساب تصنيف قائمًا على القياس، لذا ليس لدينا أسعار لعرضها. لا يزال حجم الاستخدام متاحًا في علامة تبويب الاتجاهات.",
|
||||
"loadFailedTitle": "تعذر تحميل استخدامك",
|
||||
"loadFailedNetwork": "تعذر الوصول إلى الخادم. تحقق من اتصال شبكتك وحاول مرة أخرى.",
|
||||
"loadFailedServer": "أعادت خدمة التصنيف خطأً. حاول مرة أخرى بعد لحظات.",
|
||||
"refreshFailed": "تعذر تحديث الاستخدام. الأرقام المعروضة من آخر تحميل.",
|
||||
"byStream": "حسب الجدول",
|
||||
"colStream": "الجدول",
|
||||
"byUser": "حسب المستخدم",
|
||||
"colUser": "المستخدم",
|
||||
"byAlert": "حسب التنبيه",
|
||||
"colAlert": "التنبيه",
|
||||
"byDashboard": "حسب لوحة المعلومات",
|
||||
"colDashboard": "لوحة المعلومات",
|
||||
"byScheduledPipeline": "حسب مسار المعالجة المجدول",
|
||||
"colScheduledPipeline": "مسار المعالجة المجدول",
|
||||
"byPipeline": "حسب مسار المعالجة",
|
||||
"colPipeline": "مسار المعالجة",
|
||||
"byDestination": "حسب الوجهة",
|
||||
"colDestination": "الوجهة",
|
||||
"colRetentionPeriod": "فترة الاحتفاظ",
|
||||
"colCurrentSize": "الحجم الحالي",
|
||||
"drilldownFailed": "تعذر تحميل هذا التفصيل.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"unknownPipeline": "غير معروف",
|
||||
"rangeCurrentCycle": "الدورة الحالية",
|
||||
"assumptions": "الافتراضات",
|
||||
"avgStreamSize": "متوسط البيانات لكل جدول",
|
||||
"ingestPerMonth": "البيانات المبتلعة شهريًا",
|
||||
"searchesPerMonth": "عمليات البحث شهريًا",
|
||||
"scanPerSearch": "البيانات الممسوحة لكل بحث",
|
||||
"pipelineCount": "مسارات المعالجة العاملة على الجداول",
|
||||
"retentionDays": "مدة الاحتفاظ بالبيانات",
|
||||
"aiCreditsPerMonth": "أرصدة الذكاء الاصطناعي شهريًا",
|
||||
"browserChecks": "فحوصات المتصفح",
|
||||
"stepsPerCheck": "الخطوات لكل فحص",
|
||||
"checkRegions": "المناطق",
|
||||
"checkDevices": "الأجهزة",
|
||||
"checkInterval": "يعمل كل",
|
||||
"apiChecks": "فحوصات API",
|
||||
"browserRunsNote": "حوالي {runs} من تشغيلات الخطوات شهريًا.",
|
||||
"apiRunsNote": "حوالي {runs} من التشغيلات شهريًا.",
|
||||
"qtyStepRuns": "{count} من تشغيلات الخطوات",
|
||||
"qtyRuns": "{count} من التشغيلات",
|
||||
"estimatedTotal": "الإجمالي التقديري",
|
||||
"perMonth": "شهريًا",
|
||||
"estimateDisclaimer": "هذا تقدير بالسعر المدرج. لا يتضمن أي خصم على حسابك.",
|
||||
"cycleDay": "اليوم {day} من {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "الخصم",
|
||||
"costSoFar": "التكلفة حتى الآن",
|
||||
"estimatedCostSoFar": "التكلفة المقدرة حتى الآن",
|
||||
"projectedTotal": "الإجمالي المتوقع",
|
||||
"tooEarlyToProject": "من المبكر جدًا في الدورة إجراء توقع",
|
||||
"colStorageShare": "% من التخزين",
|
||||
"orgDefaultRetention": "افتراضي المؤسسة",
|
||||
"retentionDaysValue": "{days} يومًا",
|
||||
"topMeter": "العداد الأعلى",
|
||||
"topMeterShare": "{cost} · {percent}% من الفاتورة",
|
||||
"billedIngestion": "البيانات المبتلعة",
|
||||
"billedQuery": "البيانات الممسوحة بواسطة عمليات البحث",
|
||||
"billedPipeline": "البيانات المعالجة بواسطة مسارات المعالجة",
|
||||
"billedRemotePipeline": "البيانات المرسلة إلى الوجهات البعيدة",
|
||||
"billedRetention": "البيانات المخزنة بمرور الوقت",
|
||||
"billedAi": "أرصدة الذكاء الاصطناعي المستخدمة",
|
||||
"billedSynthetics": "عمليات تشغيل الفحوصات الاصطناعية",
|
||||
"meterShare": "حصة الفاتورة",
|
||||
"everythingElse": "كل ما عدا ذلك",
|
||||
"otherBand": "أخرى",
|
||||
"colTotal": "الإجمالي",
|
||||
"groupBy": "تجميع حسب",
|
||||
"groupNotRecorded": "لا يوجد استخدام مسجل لـ {group} حتى الآن.",
|
||||
"segmentTitle": "{meter}: {billed} مفوتر، {share} من الفاتورة",
|
||||
"colEstCost": "التكلفة التقديرية",
|
||||
"shareOfMeter": "حصة {meter}",
|
||||
"costByMeter": "التكلفة حسب العداد",
|
||||
"meterByGroup": "{meter} حسب {group}",
|
||||
"billedDay": "المفوتر، اليوم {day} من {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} مفوتر ({gross} إجمالي، {discount} خصم)، {share} من الفاتورة",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "تتم الفوترة عبر {payer}. تظهر التكاليف والميزانيات في صفحة الاستخدام الخاصة بتلك المؤسسة.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}، تُفوتر كـ {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "الإجمالي",
|
||||
"colBilled": "المفوتر",
|
||||
"allMeters": "كل العدادات",
|
||||
"noDailyRetention": "لا يتوفر سجل يومي لمدة الاحتفاظ. يعرض الجدول سعة تخزين كل جدول بدلاً من ذلك.",
|
||||
"grossLabel": "الإجمالي",
|
||||
"cycleCloses": "تُغلق الدورة في {date}",
|
||||
"dailyCost": "التكلفة اليومية",
|
||||
"breakdownSummary": "{cost} مفوتر · {volume} · {share} من الفاتورة",
|
||||
"scenarioFrom": "البدء من",
|
||||
"scenarioCurrent": "استخدامي الحالي",
|
||||
"scenarioTwice": "نمو 2×",
|
||||
"scenarioFiveTimes": "نمو 5×",
|
||||
"resetEstimate": "إعادة تعيين",
|
||||
"expectedUsage": "الاستخدام الشهري المتوقع",
|
||||
"expectedUsageHint": "تمت التعبئة مسبقًا من هذه الدورة حتى الآن",
|
||||
"loadingHistory": "جارٍ قراءة سجل الاستخدام…",
|
||||
"yourEstimate": "تقديرك",
|
||||
"assumptionsFrom": "من آخر 30 يومًا لديك",
|
||||
"scanNote": "يفحص نحو {volume} شهريًا.",
|
||||
"qtyScanned": "{volume} تم فحصه",
|
||||
"qtyProcessed": "{volume} تمت معالجته",
|
||||
"qtyKeptFor": "{volume} لمدة {days} أيام",
|
||||
"qtyCredits": "{count} من الأرصدة",
|
||||
"rateIncluded": "مضمّن في خطتك",
|
||||
"rateTooltip": "{rate}. تتم المحاسبة على أساس {basis}.",
|
||||
"rateIncludedTooltip": "{basis} مضمّن في خطتك، لذا لا يضيف شيئًا إلى هذا التقدير.",
|
||||
"byIncident": "حسب الحادث",
|
||||
"colIncident": "الحادث",
|
||||
"colCredits": "الأرصدة",
|
||||
"byCheck": "حسب الفحص",
|
||||
"colCheck": "الفحص",
|
||||
"colSteps": "الخطوات",
|
||||
"unknownCheck": "فحص محذوف",
|
||||
"noBreakdownYet": "لا يوجد استخدام في هذه الدورة، لذا لا يوجد ما يمكن تفصيله بعد.",
|
||||
"rangeLastCycle": "الدورة الأخيرة",
|
||||
"rangeLast90Days": "آخر 90 يومًا",
|
||||
"runRate": "معدل التشغيل",
|
||||
"dailyAverage": "المتوسط اليومي",
|
||||
"peakDay": "يوم الذروة",
|
||||
"runRateWindow": "آخر {days} أيام",
|
||||
"acrossDays": "على مدى {days} أيام",
|
||||
"atPaceProjected": "{cost} على وتيرة آخر {days} يومًا",
|
||||
"atPace": "على وتيرة {days} يومًا",
|
||||
"topMeterOfCost": "{cost} · {percent}% من التكلفة",
|
||||
"perDay": "{cost} يوميًا",
|
||||
"spendToDate": "الإنفاق حتى تاريخه",
|
||||
"costOverTime": "التكلفة بمرور الوقت",
|
||||
"volumeOverTime": "الحجم بمرور الوقت",
|
||||
"volumeSubtitle": "الابتلاع والبحث ومسارات المعالجة، يوميًا",
|
||||
"usageByMeter": "الاستخدام حسب العداد",
|
||||
"dailyVolume": "الحجم اليومي",
|
||||
"lastDaysRange": "آخر {days} يومًا: من {start} إلى {end}",
|
||||
"enableVolumeSubtitle": "فعّل الإبلاغ عن الاستخدام لمعرفة مقدار ما يستهلكه كل عداد، يومًا بيوم.",
|
||||
"measureCost": "التكلفة",
|
||||
"measureVolume": "الحجم",
|
||||
"estimatedAtCyclePrice": "مقدّرة من الاستخدام وفق سعر كل دورة. لا تشمل الاحتفاظ بالبيانات.",
|
||||
"showOnlyMeter": "إظهار {meter} فقط",
|
||||
"showAllMeters": "إظهار جميع العدادات",
|
||||
"noUsageInRange": "لا يوجد استخدام في هذا النطاق",
|
||||
"trendsLoadFailed": "تعذر تحميل الاستخدام لهذا النطاق.",
|
||||
"rangeDates": "{start} إلى {end}",
|
||||
"forecast": "التوقعات",
|
||||
"dayTotal": "إجمالي اليوم",
|
||||
"costByOrg": "التكلفة حسب المؤسسة",
|
||||
"orgFilter": "المؤسسة",
|
||||
"organizationsTitle": "المؤسسات",
|
||||
"orgCountThisCycle": "{count} من المؤسسات · هذه الدورة",
|
||||
"overallBudget": "الميزانية الإجمالية",
|
||||
"overallBudgetLabel": "الميزانية الإجمالية لدورة الفوترة هذه",
|
||||
"appliesToGroup": "فاتورة المجموعة بأكملها",
|
||||
"orgBudgetsTotal": "مجموع ميزانيات المؤسسات {amount}",
|
||||
"orgBudgetsOver": "مجموع ميزانيات المؤسسات {amount}، أي {over} أكثر من الميزانية الإجمالية",
|
||||
"needsAttention": "يتطلب الانتباه",
|
||||
"attentionHint": "{over} تجاوزت الميزانية، {near} قاربت حد التحذير",
|
||||
"colSpendThisCycle": "إنفاق هذه الدورة",
|
||||
"colBudget": "الميزانية",
|
||||
"colAlerts": "تنبيهات",
|
||||
"statusNone": "لا توجد ميزانية",
|
||||
"statusOk": "على المسار الصحيح",
|
||||
"statusWarn": "قريب من الحد",
|
||||
"statusOver": "تجاوز الميزانية",
|
||||
"budgetFor": "ميزانية {org}",
|
||||
"warnPercentFor": "نسبة التحذير لـ {org}",
|
||||
"alertsFor": "تنبيهات البريد الإلكتروني لـ {org}",
|
||||
"groupAlertsOnHint": "يتلقى المسؤولون رسائل البريد الإلكتروني ما دام إنفاق المجموعة يبقى أعلى من مبلغ التحذير في هذه الدورة. أوقف مؤقتًا لإيقافها. لكل صف مؤسسة مفتاحه الخاص.",
|
||||
"unsavedChanges": "غير محفوظ: {parts}",
|
||||
"unsavedOrgs": "{count} مؤسسة",
|
||||
"unsavedOverall": "الإعدادات العامة",
|
||||
"allChangesSaved": "تم حفظ جميع التغييرات",
|
||||
"discard": "تجاهل",
|
||||
"saveChanges": "حفظ التغييرات",
|
||||
"orgCostsFailed": "تعذر تحميل التكلفة حسب المؤسسة. حدّث للمحاولة مرة أخرى.",
|
||||
"clickForOrg": "انقر لعرض عدادات هذه المؤسسة",
|
||||
"clickForBreakdown": "انقر لعرض تفصيل ما يساهم في هذا العداد",
|
||||
"allOrgs": "كل المؤسسات",
|
||||
"colOrganization": "المؤسسة",
|
||||
"shareOfOrg": "الحصة من {org}",
|
||||
"thisOrg": "{org} (هذه المؤسسة)",
|
||||
"formerMemberIngested": "عضو سابق · {volume} مُبتلع",
|
||||
"ingestedVolume": "{volume} مُبتلَع",
|
||||
"notSplitByOrg": "تُفوتر على المجموعة بأكملها، ولا تُقسّم حسب المؤسسة.",
|
||||
"orgSummary": "{cost} مفوتر · {share} من الفاتورة",
|
||||
"orgCostEstimated": "هذه الفاتورة مقسّمة حسب حصة كل مؤسسة من الاستخدام",
|
||||
"byOrg": "حسب المؤسسة",
|
||||
"historyStartsOn": "يبدأ سجل الاستخدام في {date}، وهو بداية أقدم دورة فوترة تتوفر لدينا بيانات استخدام لها. لا توجد بيانات استخدام لعرضها في الأيام السابقة.",
|
||||
"budgetTab": "الميزانية",
|
||||
"budgetKpi": "الميزانية",
|
||||
"budgetNotSet": "غير محددة",
|
||||
"budgetThisCycle": "دورة الفوترة هذه",
|
||||
"spentOfBudget": "{percent}% من الميزانية · اليوم {day} من {total}",
|
||||
"overBudgetBy": "تجاوز الميزانية بمقدار {amount}",
|
||||
"toSpare": "{amount} متبقٍ",
|
||||
"budgetReached": "تم بلوغ الميزانية",
|
||||
"reachedOnDay": "اليوم {day}",
|
||||
"notThisCycle": "ليس في هذه الدورة",
|
||||
"atPacePerDay": "بمعدل {cost} يوميًا",
|
||||
"atCurrentPace": "بالوتيرة الحالية",
|
||||
"spendAgainstBudget": "الإنفاق مقابل الميزانية",
|
||||
"ofBudget": "من {budget} · {percent}%",
|
||||
"leftInBudget": "{amount} متبقٍ",
|
||||
"overBy": "تجاوز بمقدار {amount}",
|
||||
"warnAtPercent": "التحذير عند {percent}%",
|
||||
"shareOfBudget": "{percent}% من الميزانية",
|
||||
"setBudgetFirst": "حدّد ميزانية لتتبع إنفاق هذه الدورة مقابلها.",
|
||||
"budgetSettings": "إعدادات الميزانية",
|
||||
"budgetAmount": "ميزانية دورة الفوترة الحالية",
|
||||
"budgetAmountHelp": "بالمعدل الحالي، ستنتهي هذه الدورة عند {amount} تقريبًا",
|
||||
"warnAt": "التحذير عند",
|
||||
"warnAmount": "مبلغ التحذير بالدولار",
|
||||
"warnPercent": "مبلغ التحذير كنسبة مئوية من الميزانية",
|
||||
"warnHelp": "التنبيه عند تجاوز الإنفاق {amount}",
|
||||
"emailAlerts": "تنبيهات البريد الإلكتروني",
|
||||
"alertsOnHint": "يتلقى المسؤولون رسائل بريد إلكتروني ما دام الإنفاق أعلى من مبلغ التحذير في هذه الدورة. أوقف مؤقتًا لإيقافها.",
|
||||
"alertsPausedHint": "لن تُرسل أي رسائل بريد إلكتروني حتى تعيد تشغيل هذا.",
|
||||
"saveBudget": "حفظ الإعدادات",
|
||||
"budgetSaved": "تم حفظ الميزانية",
|
||||
"budgetRemoved": "تمت إزالة الميزانية",
|
||||
"budgetSaveFailed": "تعذّر حفظ الميزانية",
|
||||
"budgetLoadFailedTitle": "تعذّر تحميل ميزانياتك",
|
||||
"budgetLoadFailed": "يظل التحرير متوقفًا حتى يتم تحميلها، لذا لا يمكن للحفظ أن يستبدل ميزانيات تم تعيينها بالفعل. أعد المحاولة بعد قليل."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "لم يتم العثور على رمز السوق",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Pläne vergleichen",
|
||||
"contactSupport": "Support kontaktieren"
|
||||
},
|
||||
"previousCycle": "Vorheriger Zyklus",
|
||||
"currentCycle": "Aktueller Zyklus",
|
||||
"period30Days": "30 Tage",
|
||||
"period60Days": "60 Tage",
|
||||
"period3Months": "3 Monate",
|
||||
"period6Months": "6 Monate",
|
||||
"pricingErrorMessage": "Preisdetails konnten nicht geladen werden.",
|
||||
"awsManagedMessage": "Die Abrechnung erfolgt über Ihr AWS-Konto.",
|
||||
"azureManagedMessage": "Die Abrechnung erfolgt über Ihr Azure-Konto.",
|
||||
"contractManagedMessage": "Die Abrechnung erfolgt über Ihren Vertrag – wenden Sie sich für Änderungen an Ihren Kundenbetreuer.",
|
||||
"upgradeToPlanMessage": "Upgraden Sie auf einen Plan, um die Dienste von OpenObserve weiterhin zu nutzen.",
|
||||
"creditsUsedLabel": "Verwendete Credits",
|
||||
"errorTracking": "Fehlerverfolgung",
|
||||
"rumSession": "RUM-Sitzung",
|
||||
"licenseExpiryContactAdmin": "Bitte aktualisieren Sie Ihre Lizenz, indem Sie sich an Ihren Administrator wenden.",
|
||||
"subscribed": "Abonniert",
|
||||
"invoiceHistory": "Rechnungshistorie",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Status",
|
||||
"action": "Aktion",
|
||||
"manageSubscription": "Abonnement verwalten",
|
||||
"messageDataNotFound": "Daten wurden nicht gefunden",
|
||||
"invoiceStartDate": "Startdatum der Rechnung",
|
||||
"invoiceEndDate": "Enddatum der Rechnung",
|
||||
"downloadInvoice": "Rechnung herunterladen",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Sie benötigen ein hohes Volumen? Lassen Sie uns über Skalierung sprechen! Kontaktieren Sie uns für maßgeschneiderte Lösungen.",
|
||||
"contactSupport": "Support kontaktieren",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Täglicher Gebrauch",
|
||||
"enableTitle": "Sehen Sie Ihre tägliche Nutzung",
|
||||
"enableSubtitle": "Aktivieren Sie die Nutzungsberichterstattung, um Ihr Aufnahme- und Abfragevolumen Tag für Tag zu erfassen. Ihre Gesamtzahlen oben bleiben in jedem Fall verfügbar.",
|
||||
"enableSubtitle": "Aktivieren Sie die Nutzungsberichte, um Ihre täglichen Kosten und Mengen je Zähler als Diagramm darzustellen. Ihre Rechnung auf der Übersicht bleibt in jedem Fall verfügbar.",
|
||||
"enableButton": "Nutzungsberichterstattung aktivieren",
|
||||
"enablePending": "Nutzungsberichterstattung aktiviert. Ihr tägliches Diagramm wird sich bald füllen, sobald neue Nutzung erfasst wird.",
|
||||
"enableConfirmTitle": "Nutzungsberichterstattung aktivieren?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Kontaktieren Sie Ihren Account-Manager, um weitere AI-Guthaben hinzuzufügen",
|
||||
"aiPayerSubscribeMessage": "Die Abrechnung für diese Organisation wird von {payer} verwaltet. Bitten Sie einen Administrator von {payer} zu abonnieren; die weitere Nutzung wird über dessen Rechnung abgerechnet.",
|
||||
"aiFreeUsedLabel": "verbrauchte kostenlose Credits",
|
||||
"aiBillableCredits": "Abrechenbare KI-Credits",
|
||||
"aiFreeOneTime": "Ein einmaliges Kontingent.",
|
||||
"aiUsedByFeature": "Assistent und Abfragegenerierung: {chat} · Vorfallanalyse: {incident} · Neuanalyse: {reanalysis}",
|
||||
"aiPaygMessage": "Kostenlose Credits sind aufgebraucht — Nutzung wird dem Abonnement in Rechnung gestellt",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Vom Elternteil verwalteter Plan",
|
||||
"chipNoInvoices": "Keine separaten Rechnungen",
|
||||
"viewOrgGroup": "Organisationsgruppe anzeigen",
|
||||
"currentOrgTitle": "Aktuelle Organisation",
|
||||
"memberOrgsTitle": "Mitgliedsorganisationen",
|
||||
"searchMemberOrg": "Organisation suchen",
|
||||
"noMemberMatch": "Keine passenden Organisationen",
|
||||
"remove": "Entfernen",
|
||||
"removeTitle": "{name} aus Ihrer Abrechnungsgruppe entfernen?",
|
||||
"removeBody": "Diese Organisation wird zu einer kostenlosen Organisation und bezahlt ihre eigene Nutzung. Es gelten die Limits des kostenlosen Plans. Ihre Benutzer, Daten und bisherige Nutzung sind nicht betroffen.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Alarmstatus",
|
||||
"contactAdministratorToResolve": "Bitte kontaktieren Sie Ihren Administrator, um dieses Problem zu beheben.",
|
||||
"contactAdministratorUsageReporting": "Bitte kontaktieren Sie Ihren Administrator, um sicherzustellen, dass die Nutzungsberichterstattung wiederhergestellt wird.",
|
||||
"credits": "Guthaben",
|
||||
"dataRetention": "Datenaufbewahrung",
|
||||
"event": "Ereignis",
|
||||
"ingestion": "Datenerfassung",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Ihre Testphase ist abgelaufen.",
|
||||
"trialDaysRemaining": "Noch {count} Tag in Ihrem Testkonto | Noch {count} Tage in Ihrem Testkonto",
|
||||
"usageReportingFailedForDays": "Die Nutzungsberichterstattung ist seit {count} Tag fehlgeschlagen; RBAC- und SSO-Funktionen sind deaktiviert, bis die Nutzung erfolgreich gemeldet wird | Die Nutzungsberichterstattung ist seit {count} Tagen fehlgeschlagen; RBAC- und SSO-Funktionen sind deaktiviert, bis die Nutzung erfolgreich gemeldet wird",
|
||||
"usageReportingFailingForHours": "Die Nutzungsberichterstattung schlägt seit {count} Stunde fehl; einige Funktionen werden bald deaktiviert | Die Nutzungsberichterstattung schlägt seit {count} Stunden fehl; einige Funktionen werden bald deaktiviert"
|
||||
"usageReportingFailingForHours": "Die Nutzungsberichterstattung schlägt seit {count} Stunde fehl; einige Funktionen werden bald deaktiviert | Die Nutzungsberichterstattung schlägt seit {count} Stunden fehl; einige Funktionen werden bald deaktiviert",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-Stunde",
|
||||
"unitCredit": "Guthaben",
|
||||
"unitRun": "Ausführung",
|
||||
"ratePerUnit": "{rate} pro {unit}",
|
||||
"cycleRange": "Abrechnungszeitraum: {start} bis {end}",
|
||||
"syntheticsBrowser": "Synthetics (Browser)",
|
||||
"syntheticsProtocol": "Synthetics (Protokoll)",
|
||||
"overviewTab": "Übersicht",
|
||||
"trendsTab": "Trends",
|
||||
"estimationTab": "Preisschätzung",
|
||||
"costBreakdown": "Kostenaufschlüsselung",
|
||||
"colMeter": "Zähler",
|
||||
"colVolume": "Menge",
|
||||
"colCost": "Kosten",
|
||||
"totalCost": "Gesamtkosten",
|
||||
"missingMeters": "Für diese Zähler konnte kein Preis ermittelt werden: {meters}. In ihren Zeilen steht statt der Kosten ein Gedankenstrich.",
|
||||
"noPricingTitle": "Für diese Organisation sind keine Kosten verfügbar",
|
||||
"noPricingSubtitle": "Diese Organisation hat kein Abrechnungskonto mit Zählern, daher können wir keine Preise anzeigen. Die Nutzungsmenge ist weiterhin auf der Registerkarte Trends verfügbar.",
|
||||
"loadFailedTitle": "Ihre Nutzung konnte nicht geladen werden",
|
||||
"loadFailedNetwork": "Der Server war nicht erreichbar. Prüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.",
|
||||
"loadFailedServer": "Der Abrechnungsdienst hat einen Fehler zurückgegeben. Versuchen Sie es gleich noch einmal.",
|
||||
"refreshFailed": "Die Nutzung konnte nicht aktualisiert werden. Die angezeigten Zahlen stammen aus dem letzten Ladevorgang.",
|
||||
"byStream": "Nach Stream",
|
||||
"colStream": "Stream",
|
||||
"byUser": "Nach Benutzer",
|
||||
"colUser": "Benutzer",
|
||||
"byAlert": "Nach Alarm",
|
||||
"colAlert": "Alarm",
|
||||
"byDashboard": "Nach Dashboard",
|
||||
"colDashboard": "Dashboard",
|
||||
"byScheduledPipeline": "Nach geplanter Rohrleitung",
|
||||
"colScheduledPipeline": "Geplante Rohrleitung",
|
||||
"byPipeline": "Nach Rohrleitung",
|
||||
"colPipeline": "Rohrleitung",
|
||||
"byDestination": "Nach Ziel",
|
||||
"colDestination": "Ziel",
|
||||
"colRetentionPeriod": "Aufbewahrungszeitraum",
|
||||
"colCurrentSize": "Aktuelle Größe",
|
||||
"drilldownFailed": "Diese Aufschlüsselung konnte nicht geladen werden.",
|
||||
"retry": "Erneut versuchen",
|
||||
"unknownPipeline": "Unbekannt",
|
||||
"rangeCurrentCycle": "Aktueller Zyklus",
|
||||
"assumptions": "Annahmen",
|
||||
"avgStreamSize": "Durchschnittliche Datenmenge pro Stream",
|
||||
"ingestPerMonth": "Erfasste Daten pro Monat",
|
||||
"searchesPerMonth": "Suchvorgänge pro Monat",
|
||||
"scanPerSearch": "Gescannte Daten pro Suche",
|
||||
"pipelineCount": "Rohrleitungen, die auf Streams laufen",
|
||||
"retentionDays": "Datenaufbewahrung für",
|
||||
"aiCreditsPerMonth": "KI-Credits pro Monat",
|
||||
"browserChecks": "Browser-Prüfungen",
|
||||
"stepsPerCheck": "Schritte pro Prüfung",
|
||||
"checkRegions": "Regionen",
|
||||
"checkDevices": "Geräte",
|
||||
"checkInterval": "Ausführung alle",
|
||||
"apiChecks": "API-Prüfungen",
|
||||
"browserRunsNote": "Etwa {runs} Schrittausführungen pro Monat.",
|
||||
"apiRunsNote": "Etwa {runs} Ausführungen pro Monat.",
|
||||
"qtyStepRuns": "{count} Schrittausführungen",
|
||||
"qtyRuns": "{count} Ausführungen",
|
||||
"estimatedTotal": "Geschätzte Gesamtsumme",
|
||||
"perMonth": "pro Monat",
|
||||
"estimateDisclaimer": "Dies ist eine Schätzung zum Listenpreis. Rabatte auf Ihr Konto sind nicht enthalten.",
|
||||
"cycleDay": "Tag {day} von {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Rabatt",
|
||||
"costSoFar": "Bisherige Kosten",
|
||||
"estimatedCostSoFar": "Bisher geschätzte Kosten",
|
||||
"projectedTotal": "Prognostizierte Gesamtsumme",
|
||||
"tooEarlyToProject": "Zu früh im Zyklus für eine Prognose",
|
||||
"colStorageShare": "% des Speichers",
|
||||
"orgDefaultRetention": "Organisationsstandard",
|
||||
"retentionDaysValue": "{days} Tage",
|
||||
"topMeter": "Top-Messgröße",
|
||||
"topMeterShare": "{cost} · {percent}% der Rechnung",
|
||||
"billedIngestion": "Erfasste Daten",
|
||||
"billedQuery": "Von Suchen gescannte Daten",
|
||||
"billedPipeline": "Von Rohrleitungen verarbeitete Daten",
|
||||
"billedRemotePipeline": "An entfernte Ziele gesendete Daten",
|
||||
"billedRetention": "Über die Zeit gespeicherte Daten",
|
||||
"billedAi": "Verwendete KI-Credits",
|
||||
"billedSynthetics": "Synthetische Prüfläufe",
|
||||
"meterShare": "Anteil an der Rechnung",
|
||||
"everythingElse": "Alles andere",
|
||||
"otherBand": "Sonstige",
|
||||
"colTotal": "Gesamt",
|
||||
"groupBy": "Gruppieren nach",
|
||||
"groupNotRecorded": "Noch keine Nutzung für {group} erfasst.",
|
||||
"segmentTitle": "{meter}: {billed} abgerechnet, {share} der Rechnung",
|
||||
"colEstCost": "Gesch. Kosten",
|
||||
"shareOfMeter": "Anteil an {meter}",
|
||||
"costByMeter": "Kosten nach Messgröße",
|
||||
"meterByGroup": "{meter} nach {group}",
|
||||
"billedDay": "Abgerechnet, Tag {day} von {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} abgerechnet ({gross} brutto, {discount} Rabatt), {share} der Rechnung",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Abgerechnet über {payer}. Kosten und Budgets finden Sie auf der Nutzungsseite dieser Organisation.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, abgerechnet als {amount} {unit}",
|
||||
"percentInParens": "({percent} %)",
|
||||
"colGross": "Brutto",
|
||||
"colBilled": "Abgerechnet",
|
||||
"allMeters": "Alle Messgrößen",
|
||||
"noDailyRetention": "Für die Aufbewahrung gibt es keinen täglichen Verlauf. Die Tabelle zeigt stattdessen den Speicher jedes Streams.",
|
||||
"grossLabel": "Brutto",
|
||||
"cycleCloses": "Zyklus endet am {date}",
|
||||
"dailyCost": "Tägliche Kosten",
|
||||
"breakdownSummary": "{cost} abgerechnet · {volume} · {share} der Rechnung",
|
||||
"scenarioFrom": "Ausgangspunkt",
|
||||
"scenarioCurrent": "Meine aktuelle Nutzung",
|
||||
"scenarioTwice": "2× Wachstum",
|
||||
"scenarioFiveTimes": "5× Wachstum",
|
||||
"resetEstimate": "Zurücksetzen",
|
||||
"expectedUsage": "Erwartete monatliche Nutzung",
|
||||
"expectedUsageHint": "Vorausgefüllt aus dem bisherigen Zyklus",
|
||||
"loadingHistory": "Ihr Nutzungsverlauf wird geladen…",
|
||||
"yourEstimate": "Ihre Schätzung",
|
||||
"assumptionsFrom": "Aus Ihren letzten 30 Tagen",
|
||||
"scanNote": "Scannt etwa {volume} pro Monat.",
|
||||
"qtyScanned": "{volume} gescannt",
|
||||
"qtyProcessed": "{volume} verarbeitet",
|
||||
"qtyKeptFor": "{volume} für {days} Tage",
|
||||
"qtyCredits": "{count} Credits",
|
||||
"rateIncluded": "In Ihrem Plan enthalten",
|
||||
"rateTooltip": "{rate}. Die Abrechnung erfolgt auf Basis von {basis}.",
|
||||
"rateIncludedTooltip": "{basis} ist in Ihrem Plan enthalten und fließt daher nicht in diese Schätzung ein.",
|
||||
"byIncident": "Nach Vorfall",
|
||||
"colIncident": "Vorfall",
|
||||
"colCredits": "Credits",
|
||||
"byCheck": "Nach Check",
|
||||
"colCheck": "Check",
|
||||
"colSteps": "Schritte",
|
||||
"unknownCheck": "Gelöschter Check",
|
||||
"noBreakdownYet": "Keine Nutzung in diesem Zyklus, daher gibt es noch nichts aufzuschlüsseln.",
|
||||
"rangeLastCycle": "Letzter Zyklus",
|
||||
"rangeLast90Days": "Letzte 90 Tage",
|
||||
"runRate": "Hochrechnung",
|
||||
"dailyAverage": "Täglicher Durchschnitt",
|
||||
"peakDay": "Spitzentag",
|
||||
"runRateWindow": "Letzte {days} Tage",
|
||||
"acrossDays": "Über {days} Tage",
|
||||
"atPaceProjected": "{cost} beim Tempo der letzten {days} Tage",
|
||||
"atPace": "Beim {days}-Tage-Tempo",
|
||||
"topMeterOfCost": "{cost} · {percent} % der Kosten",
|
||||
"perDay": "{cost} pro Tag",
|
||||
"spendToDate": "Bisherige Ausgaben",
|
||||
"costOverTime": "Kosten im Zeitverlauf",
|
||||
"volumeOverTime": "Volumen im Zeitverlauf",
|
||||
"volumeSubtitle": "Datenerfassung, Suche und Rohrleitungen, pro Tag",
|
||||
"usageByMeter": "Nutzung nach Zähler",
|
||||
"dailyVolume": "Tägliches Volumen",
|
||||
"lastDaysRange": "Letzte {days} Tage: {start} bis {end}",
|
||||
"enableVolumeSubtitle": "Aktivieren Sie die Nutzungsberichterstattung, um zu sehen, wie viel jeder Zähler Tag für Tag verbraucht.",
|
||||
"measureCost": "Kosten",
|
||||
"measureVolume": "Volumen",
|
||||
"estimatedAtCyclePrice": "Geschätzt aus der Nutzung, zum Preis des jeweiligen Zyklus. Ohne Datenaufbewahrung.",
|
||||
"showOnlyMeter": "Nur {meter} anzeigen",
|
||||
"showAllMeters": "Alle Messgrößen anzeigen",
|
||||
"noUsageInRange": "Keine Nutzung in diesem Zeitraum",
|
||||
"trendsLoadFailed": "Nutzung für diesen Zeitraum konnte nicht geladen werden.",
|
||||
"rangeDates": "{start} bis {end}",
|
||||
"forecast": "Prognose",
|
||||
"dayTotal": "Tagessumme",
|
||||
"costByOrg": "Kosten nach Organisation",
|
||||
"orgFilter": "Organisation",
|
||||
"organizationsTitle": "Organisationen",
|
||||
"orgCountThisCycle": "{count} Organisationen · dieser Zyklus",
|
||||
"overallBudget": "Gesamtbudget",
|
||||
"overallBudgetLabel": "Gesamtbudget für diesen Abrechnungszyklus",
|
||||
"appliesToGroup": "Die Rechnung der gesamten Gruppe",
|
||||
"orgBudgetsTotal": "Die Organisationsbudgets summieren sich auf {amount}",
|
||||
"orgBudgetsOver": "Die Organisationsbudgets summieren sich auf {amount}, {over} mehr als das Gesamtbudget",
|
||||
"needsAttention": "Erfordert Aufmerksamkeit",
|
||||
"attentionHint": "{over} über Budget, {near} nahe der Warnschwelle",
|
||||
"colSpendThisCycle": "Ausgaben in diesem Zyklus",
|
||||
"colBudget": "Budget",
|
||||
"colAlerts": "Alarme",
|
||||
"statusNone": "Kein Budget",
|
||||
"statusOk": "Auf Kurs",
|
||||
"statusWarn": "Nahe am Limit",
|
||||
"statusOver": "Über Budget",
|
||||
"budgetFor": "Budget für {org}",
|
||||
"warnPercentFor": "Warnschwelle in Prozent für {org}",
|
||||
"alertsFor": "E-Mail-Alarme für {org}",
|
||||
"groupAlertsOnHint": "Admins erhalten E-Mails, solange die Ausgaben der Gruppe in diesem Zyklus über dem Warnbetrag liegen. Pausieren, um sie zu stoppen. Jede Org-Zeile hat einen eigenen Schalter.",
|
||||
"unsavedChanges": "Nicht gespeichert: {parts}",
|
||||
"unsavedOrgs": "{count} Organisation(en)",
|
||||
"unsavedOverall": "Gesamteinstellungen",
|
||||
"allChangesSaved": "Alle Änderungen gespeichert",
|
||||
"discard": "Verwerfen",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"orgCostsFailed": "Kosten nach Organisation konnten nicht geladen werden. Aktualisieren, um es erneut zu versuchen.",
|
||||
"clickForOrg": "Klicken, um die Verbrauchszähler dieser Organisation anzuzeigen",
|
||||
"clickForBreakdown": "Klicken, um zu sehen, was diesen Verbrauchszähler verursacht",
|
||||
"allOrgs": "Alle Organisationen",
|
||||
"colOrganization": "Organisation",
|
||||
"shareOfOrg": "Anteil an {org}",
|
||||
"thisOrg": "{org} (diese Organisation)",
|
||||
"formerMemberIngested": "Ehemaliges Mitglied · {volume} erfasst",
|
||||
"ingestedVolume": "{volume} erfasst",
|
||||
"notSplitByOrg": "Wird für die gesamte Gruppe abgerechnet, nicht nach Organisation aufgeteilt",
|
||||
"orgSummary": "{cost} abgerechnet · {share} der Rechnung",
|
||||
"orgCostEstimated": "Diese Rechnung aufgeteilt nach dem Nutzungsanteil jeder Organisation",
|
||||
"byOrg": "Nach Organisation",
|
||||
"historyStartsOn": "Die Nutzungshistorie beginnt am {date}, dem Anfang Ihres ältesten Abrechnungszyklus, für den uns Nutzung vorliegt. Für frühere Tage sind keine Nutzungsdaten vorhanden.",
|
||||
"budgetTab": "Budget",
|
||||
"budgetKpi": "Budget",
|
||||
"budgetNotSet": "Nicht festgelegt",
|
||||
"budgetThisCycle": "Dieser Abrechnungszyklus",
|
||||
"spentOfBudget": "{percent}% des Budgets · Tag {day} von {total}",
|
||||
"overBudgetBy": "{amount} über Budget",
|
||||
"toSpare": "{amount} übrig",
|
||||
"budgetReached": "Budget erreicht",
|
||||
"reachedOnDay": "Tag {day}",
|
||||
"notThisCycle": "Nicht in diesem Zyklus",
|
||||
"atPacePerDay": "Bei {cost} pro Tag",
|
||||
"atCurrentPace": "Beim aktuellen Tempo",
|
||||
"spendAgainstBudget": "Ausgaben im Verhältnis zum Budget",
|
||||
"ofBudget": "von {budget} · {percent}%",
|
||||
"leftInBudget": "{amount} verbleibend",
|
||||
"overBy": "{amount} darüber",
|
||||
"warnAtPercent": "Warnung bei {percent}%",
|
||||
"shareOfBudget": "{percent}% des Budgets",
|
||||
"setBudgetFirst": "Legen Sie ein Budget fest, um die Ausgaben dieses Zyklus daran zu messen.",
|
||||
"budgetSettings": "Budget-Einstellungen",
|
||||
"budgetAmount": "Budget für diesen Abrechnungszeitraum",
|
||||
"budgetAmountHelp": "Bei diesem Tempo endet dieser Zeitraum bei etwa {amount}",
|
||||
"warnAt": "Warnen bei",
|
||||
"warnAmount": "Warnbetrag in Dollar",
|
||||
"warnPercent": "Warnbetrag als Prozent des Budgets",
|
||||
"warnHelp": "Alarm, wenn die Ausgaben {amount} überschreiten",
|
||||
"emailAlerts": "E-Mail-Alarme",
|
||||
"alertsOnHint": "Admins erhalten E-Mails, solange die Ausgaben in diesem Zeitraum über dem Warnbetrag bleiben. Pausieren, um sie zu stoppen.",
|
||||
"alertsPausedHint": "Keine E-Mails, bis Sie dies wieder aktivieren.",
|
||||
"saveBudget": "Einstellungen speichern",
|
||||
"budgetSaved": "Budget gespeichert",
|
||||
"budgetRemoved": "Budget entfernt",
|
||||
"budgetSaveFailed": "Budget konnte nicht gespeichert werden",
|
||||
"budgetLoadFailedTitle": "Ihre Budgets konnten nicht geladen werden",
|
||||
"budgetLoadFailed": "Bearbeiten ist erst möglich, wenn sie geladen sind, damit beim Speichern keine bereits festgelegten Budgets überschrieben werden. Versuchen Sie es gleich noch einmal."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Kein Marketplace-Token gefunden.",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Compare plans",
|
||||
"contactSupport": "Contact support"
|
||||
},
|
||||
"previousCycle": "Previous Cycle",
|
||||
"currentCycle": "Current Cycle",
|
||||
"period30Days": "30 Days",
|
||||
"period60Days": "60 Days",
|
||||
"period3Months": "3 Months",
|
||||
"period6Months": "6 Months",
|
||||
"pricingErrorMessage": "Pricing details did not load.",
|
||||
"awsManagedMessage": "Billing is handled through your AWS account",
|
||||
"azureManagedMessage": "Billing is handled through your Azure account",
|
||||
"contractManagedMessage": "Billing is handled through your contract — contact your account manager for changes",
|
||||
"upgradeToPlanMessage": "Upgrade to a plan to continue enjoying the services by OpenObserve.",
|
||||
"creditsUsedLabel": "credits used",
|
||||
"errorTracking": "Error Tracking",
|
||||
"rumSession": "RUM Session",
|
||||
"licenseExpiryContactAdmin": "Please update your license by contacting your administrator.",
|
||||
"subscribed": "Subscribed",
|
||||
"invoiceHistory": "Invoice History",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Status",
|
||||
"action": "Action",
|
||||
"manageSubscription": "Manage Subscription",
|
||||
"messageDataNotFound": "Data not found",
|
||||
"invoiceStartDate": "Invoice Start Date",
|
||||
"invoiceEndDate": "Invoice End Date",
|
||||
"downloadInvoice": "Download Invoice",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "High volume needs? Let's talk scale! Contact us for tailored solutions.",
|
||||
"contactSupport": "Contact Support",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Daily usage",
|
||||
"enableTitle": "See your daily usage",
|
||||
"enableSubtitle": "Turn on usage reporting to chart your ingestion and query volume day by day. Your totals above stay available either way.",
|
||||
"enableSubtitle": "Turn on usage reporting to chart your daily cost and volume for each meter. Your bill on Overview stays available either way.",
|
||||
"enableButton": "Enable usage reporting",
|
||||
"enablePending": "Usage reporting enabled. Your daily chart will start filling in shortly as new usage is recorded.",
|
||||
"enableConfirmTitle": "Enable usage reporting?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Contact your account manager to add more AI credits",
|
||||
"aiPayerSubscribeMessage": "Billing for this organization is managed by {payer}. Ask an admin of {payer} to subscribe; further usage is billed on its invoice.",
|
||||
"aiFreeUsedLabel": "free credits used",
|
||||
"aiBillableCredits": "Billable AI credits",
|
||||
"aiFreeOneTime": "A one-time allowance.",
|
||||
"aiUsedByFeature": "Assistant and query generation: {chat} · Incident analysis: {incident} · Reanalysis: {reanalysis}",
|
||||
"aiPaygMessage": "Free credits exhausted — usage billed to subscription",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Plan Managed By Parent",
|
||||
"chipNoInvoices": "No Separate Invoices",
|
||||
"viewOrgGroup": "View Organization Group",
|
||||
"currentOrgTitle": "Current Organization",
|
||||
"memberOrgsTitle": "Member Organizations",
|
||||
"searchMemberOrg": "Search Organization",
|
||||
"noMemberMatch": "No Matching Organizations",
|
||||
"remove": "Remove",
|
||||
"removeTitle": "Remove {name} from your billing group?",
|
||||
"removeBody": "This organization becomes a free organization and pays for its own usage. Free plan limits apply. Its users, data, and past usage are not affected.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Alert Status",
|
||||
"contactAdministratorToResolve": "Please contact your administrator to resolve this issue.",
|
||||
"contactAdministratorUsageReporting": "Please contact your administrator to ensure usage reporting is restored.",
|
||||
"credits": "Credits",
|
||||
"dataRetention": "Data Retention",
|
||||
"event": "Event",
|
||||
"ingestion": "Ingestion",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Your trial period has expired.",
|
||||
"trialDaysRemaining": "{count} Day remaining in your trial account | {count} Days remaining in your trial account",
|
||||
"usageReportingFailedForDays": "Usage reporting has failed for {count} day, RBAC and SSO features are disabled until usage is successfully reported | Usage reporting has failed for {count} days, RBAC and SSO features are disabled until usage is successfully reported",
|
||||
"usageReportingFailingForHours": "Usage reporting has been failing for {count} hour, some features will get disabled soon | Usage reporting has been failing for {count} hours, some features will get disabled soon"
|
||||
"usageReportingFailingForHours": "Usage reporting has been failing for {count} hour, some features will get disabled soon | Usage reporting has been failing for {count} hours, some features will get disabled soon",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-hour",
|
||||
"unitCredit": "Credit",
|
||||
"unitRun": "Run",
|
||||
"ratePerUnit": "{rate} per {unit}",
|
||||
"cycleRange": "Billing cycle: {start} to {end}",
|
||||
"syntheticsBrowser": "Synthetics (Browser)",
|
||||
"syntheticsProtocol": "Synthetics (Protocol)",
|
||||
"overviewTab": "Overview",
|
||||
"trendsTab": "Trends",
|
||||
"estimationTab": "Price Estimation",
|
||||
"costBreakdown": "Cost Breakdown",
|
||||
"colMeter": "Meter",
|
||||
"colVolume": "Volume",
|
||||
"colCost": "Cost",
|
||||
"totalCost": "Total Cost",
|
||||
"missingMeters": "We could not price these meters: {meters}. Their rows show a dash instead of a cost.",
|
||||
"noPricingTitle": "Cost is not available for this organization",
|
||||
"noPricingSubtitle": "This organization has no metered billing account, so we have no prices to show. Usage volume is still available on the Trends tab.",
|
||||
"loadFailedTitle": "Your usage could not be loaded",
|
||||
"loadFailedNetwork": "The server could not be reached. Check your network connection and try again.",
|
||||
"loadFailedServer": "The billing service returned an error. Try again in a moment.",
|
||||
"refreshFailed": "Usage could not be refreshed. The numbers shown are from the last load.",
|
||||
"byStream": "By Stream",
|
||||
"colStream": "Stream",
|
||||
"byUser": "By User",
|
||||
"colUser": "User",
|
||||
"byAlert": "By Alert",
|
||||
"colAlert": "Alert",
|
||||
"byDashboard": "By Dashboard",
|
||||
"colDashboard": "Dashboard",
|
||||
"byScheduledPipeline": "By Scheduled Pipeline",
|
||||
"colScheduledPipeline": "Scheduled Pipeline",
|
||||
"byPipeline": "By Pipeline",
|
||||
"colPipeline": "Pipeline",
|
||||
"byDestination": "By Destination",
|
||||
"colDestination": "Destination",
|
||||
"colRetentionPeriod": "Retention Period",
|
||||
"colCurrentSize": "Current Size",
|
||||
"drilldownFailed": "Could not load this breakdown.",
|
||||
"retry": "Retry",
|
||||
"unknownPipeline": "Unknown",
|
||||
"rangeCurrentCycle": "Current Cycle",
|
||||
"assumptions": "Assumptions",
|
||||
"avgStreamSize": "Average data per stream",
|
||||
"ingestPerMonth": "Data ingested per month",
|
||||
"searchesPerMonth": "Searches per month",
|
||||
"scanPerSearch": "Data scanned per search",
|
||||
"pipelineCount": "Pipelines running on streams",
|
||||
"retentionDays": "Data kept for",
|
||||
"aiCreditsPerMonth": "AI credits per month",
|
||||
"browserChecks": "Browser checks",
|
||||
"stepsPerCheck": "Steps per check",
|
||||
"checkRegions": "Regions",
|
||||
"checkDevices": "Devices",
|
||||
"checkInterval": "Runs every",
|
||||
"apiChecks": "API checks",
|
||||
"browserRunsNote": "About {runs} step runs per month.",
|
||||
"apiRunsNote": "About {runs} runs per month.",
|
||||
"qtyStepRuns": "{count} step runs",
|
||||
"qtyRuns": "{count} runs",
|
||||
"estimatedTotal": "Estimated total",
|
||||
"perMonth": "per month",
|
||||
"estimateDisclaimer": "This is an estimate at list price. It does not include any discount on your account.",
|
||||
"cycleDay": "Day {day} of {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Discount",
|
||||
"costSoFar": "Cost So Far",
|
||||
"estimatedCostSoFar": "Estimated Cost So Far",
|
||||
"projectedTotal": "Projected Total",
|
||||
"tooEarlyToProject": "Too early in the cycle to project",
|
||||
"colStorageShare": "% of Storage",
|
||||
"orgDefaultRetention": "Org default",
|
||||
"retentionDaysValue": "{days} days",
|
||||
"topMeter": "Top Meter",
|
||||
"topMeterShare": "{cost} · {percent}% of bill",
|
||||
"billedIngestion": "Data ingested",
|
||||
"billedQuery": "Data scanned by searches",
|
||||
"billedPipeline": "Data processed by pipelines",
|
||||
"billedRemotePipeline": "Data sent to remote destinations",
|
||||
"billedRetention": "Data stored over time",
|
||||
"billedAi": "AI credits used",
|
||||
"billedSynthetics": "Synthetic check runs",
|
||||
"meterShare": "Share of Bill",
|
||||
"everythingElse": "Everything else",
|
||||
"otherBand": "Other",
|
||||
"colTotal": "Total",
|
||||
"groupBy": "Group by",
|
||||
"groupNotRecorded": "No {group} usage recorded yet.",
|
||||
"segmentTitle": "{meter}: {billed} billed, {share} of bill",
|
||||
"colEstCost": "Est. Cost",
|
||||
"shareOfMeter": "Share of {meter}",
|
||||
"costByMeter": "Cost by Meter",
|
||||
"meterByGroup": "{meter} by {group}",
|
||||
"billedDay": "Billed, day {day} of {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} billed ({gross} gross, {discount} discount), {share} of bill",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Billed through {payer}. Costs and budgets are on that organization's Usage page.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, billed as {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "Gross",
|
||||
"colBilled": "Billed",
|
||||
"allMeters": "All Meters",
|
||||
"noDailyRetention": "Retention has no daily history. The table shows each stream's storage instead.",
|
||||
"grossLabel": "Gross",
|
||||
"cycleCloses": "Cycle closes {date}",
|
||||
"dailyCost": "Daily Cost",
|
||||
"breakdownSummary": "{cost} billed · {volume} · {share} of bill",
|
||||
"scenarioFrom": "Start From",
|
||||
"scenarioCurrent": "My Current Usage",
|
||||
"scenarioTwice": "2× Growth",
|
||||
"scenarioFiveTimes": "5× Growth",
|
||||
"resetEstimate": "Reset",
|
||||
"expectedUsage": "Expected Monthly Usage",
|
||||
"expectedUsageHint": "Prefilled from this cycle so far",
|
||||
"loadingHistory": "Reading your usage history…",
|
||||
"yourEstimate": "Your Estimate",
|
||||
"assumptionsFrom": "From your last 30 days",
|
||||
"scanNote": "Scans about {volume} per month.",
|
||||
"qtyScanned": "{volume} scanned",
|
||||
"qtyProcessed": "{volume} processed",
|
||||
"qtyKeptFor": "{volume} for {days} days",
|
||||
"qtyCredits": "{count} credits",
|
||||
"rateIncluded": "Included on your plan",
|
||||
"rateTooltip": "{rate}. You are billed on {basis}.",
|
||||
"rateIncludedTooltip": "{basis} is included on your plan, so it adds nothing to this estimate.",
|
||||
"byIncident": "By Incident",
|
||||
"colIncident": "Incident",
|
||||
"colCredits": "Credits",
|
||||
"byCheck": "By Check",
|
||||
"colCheck": "Check",
|
||||
"colSteps": "Steps",
|
||||
"unknownCheck": "Deleted check",
|
||||
"noBreakdownYet": "No usage this cycle, so there is nothing to break down yet.",
|
||||
"rangeLastCycle": "Last Cycle",
|
||||
"rangeLast90Days": "Last 90 Days",
|
||||
"runRate": "Run Rate",
|
||||
"dailyAverage": "Daily Average",
|
||||
"peakDay": "Peak Day",
|
||||
"runRateWindow": "Last {days} days",
|
||||
"acrossDays": "Across {days} days",
|
||||
"atPaceProjected": "{cost} at the last {days} days' pace",
|
||||
"atPace": "At {days}-Day Pace",
|
||||
"topMeterOfCost": "{cost} · {percent}% of cost",
|
||||
"perDay": "{cost} per day",
|
||||
"spendToDate": "Spend to Date",
|
||||
"costOverTime": "Cost Over Time",
|
||||
"volumeOverTime": "Volume Over Time",
|
||||
"volumeSubtitle": "Ingestion, search and pipelines, per day",
|
||||
"usageByMeter": "Usage by Meter",
|
||||
"dailyVolume": "Daily Volume",
|
||||
"lastDaysRange": "Last {days} days: {start} to {end}",
|
||||
"enableVolumeSubtitle": "Turn on usage reporting to see how much each meter uses, day by day.",
|
||||
"measureCost": "Cost",
|
||||
"measureVolume": "Volume",
|
||||
"estimatedAtCyclePrice": "Estimated from usage, at each cycle's price. Excludes Data Retention.",
|
||||
"showOnlyMeter": "Show only {meter}",
|
||||
"showAllMeters": "Show all meters",
|
||||
"noUsageInRange": "No usage in this range",
|
||||
"trendsLoadFailed": "Could not load usage for this range.",
|
||||
"rangeDates": "{start} to {end}",
|
||||
"forecast": "Forecast",
|
||||
"dayTotal": "Day total",
|
||||
"costByOrg": "Cost by Org",
|
||||
"orgFilter": "Organization",
|
||||
"organizationsTitle": "Organizations",
|
||||
"orgCountThisCycle": "{count} orgs · this cycle",
|
||||
"overallBudget": "Overall Budget",
|
||||
"overallBudgetLabel": "Overall budget for this billing cycle",
|
||||
"appliesToGroup": "The whole group's bill",
|
||||
"orgBudgetsTotal": "Org budgets add up to {amount}",
|
||||
"orgBudgetsOver": "Org budgets add up to {amount}, {over} more than the overall budget",
|
||||
"needsAttention": "Needs Attention",
|
||||
"attentionHint": "{over} over budget, {near} near the warn line",
|
||||
"colSpendThisCycle": "Spend This Cycle",
|
||||
"colBudget": "Budget",
|
||||
"colAlerts": "Alerts",
|
||||
"statusNone": "No budget",
|
||||
"statusOk": "On track",
|
||||
"statusWarn": "Near limit",
|
||||
"statusOver": "Over budget",
|
||||
"budgetFor": "Budget for {org}",
|
||||
"warnPercentFor": "Warn percent for {org}",
|
||||
"alertsFor": "Email alerts for {org}",
|
||||
"groupAlertsOnHint": "Admins get emails for as long as the group's spend stays above the warn amount this cycle. Pause to stop them. Each org row has its own switch.",
|
||||
"unsavedChanges": "Unsaved: {parts}",
|
||||
"unsavedOrgs": "{count} org(s)",
|
||||
"unsavedOverall": "overall settings",
|
||||
"allChangesSaved": "All changes saved",
|
||||
"discard": "Discard",
|
||||
"saveChanges": "Save Changes",
|
||||
"orgCostsFailed": "Could not load cost by org. Refresh to try again.",
|
||||
"clickForOrg": "Click to see this org's meters",
|
||||
"clickForBreakdown": "Click to see what drives this meter",
|
||||
"allOrgs": "All orgs",
|
||||
"colOrganization": "Organization",
|
||||
"shareOfOrg": "Share of {org}",
|
||||
"thisOrg": "{org} (this org)",
|
||||
"formerMemberIngested": "Former member · {volume} ingested",
|
||||
"ingestedVolume": "{volume} ingested",
|
||||
"notSplitByOrg": "Billed to the whole group, not split by org",
|
||||
"orgSummary": "{cost} billed · {share} of the bill",
|
||||
"orgCostEstimated": "This bill split by each org's share of usage",
|
||||
"byOrg": "By org",
|
||||
"historyStartsOn": "Usage history starts on {date}, the start of your oldest billing cycle we have usage for. Earlier days have no usage to show.",
|
||||
"budgetTab": "Budget",
|
||||
"budgetKpi": "Budget",
|
||||
"budgetNotSet": "Not set",
|
||||
"budgetThisCycle": "This billing cycle",
|
||||
"spentOfBudget": "{percent}% of budget · day {day} of {total}",
|
||||
"overBudgetBy": "{amount} over budget",
|
||||
"toSpare": "{amount} to spare",
|
||||
"budgetReached": "Budget Reached",
|
||||
"reachedOnDay": "Day {day}",
|
||||
"notThisCycle": "Not this cycle",
|
||||
"atPacePerDay": "At {cost} per day",
|
||||
"atCurrentPace": "At the current pace",
|
||||
"spendAgainstBudget": "Spend Against Budget",
|
||||
"ofBudget": "of {budget} · {percent}%",
|
||||
"leftInBudget": "{amount} left",
|
||||
"overBy": "{amount} over",
|
||||
"warnAtPercent": "Warn at {percent}%",
|
||||
"shareOfBudget": "{percent}% of budget",
|
||||
"setBudgetFirst": "Set a budget to track this cycle's spend against it.",
|
||||
"budgetSettings": "Budget Settings",
|
||||
"budgetAmount": "Budget for this billing cycle",
|
||||
"budgetAmountHelp": "At today's pace this cycle ends near {amount}",
|
||||
"warnAt": "Warn at",
|
||||
"warnAmount": "Warn amount in dollars",
|
||||
"warnPercent": "Warn amount as a percent of the budget",
|
||||
"warnHelp": "Alert when spend passes {amount}",
|
||||
"emailAlerts": "Email alerts",
|
||||
"alertsOnHint": "Admins get emails for as long as spend stays above the warn amount this cycle. Pause to stop them.",
|
||||
"alertsPausedHint": "No emails until you turn this back on.",
|
||||
"saveBudget": "Save Settings",
|
||||
"budgetSaved": "Budget saved",
|
||||
"budgetRemoved": "Budget removed",
|
||||
"budgetSaveFailed": "Could not save the budget",
|
||||
"budgetLoadFailedTitle": "Your budgets could not be loaded",
|
||||
"budgetLoadFailed": "Editing waits until they load, so saving cannot overwrite budgets that are already set. Try again in a moment."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "No Marketplace Token Found",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Comparar planes",
|
||||
"contactSupport": "Contactar con soporte"
|
||||
},
|
||||
"previousCycle": "Ciclo anterior",
|
||||
"currentCycle": "Ciclo actual",
|
||||
"period30Days": "30 días",
|
||||
"period60Days": "60 días",
|
||||
"period3Months": "3 meses",
|
||||
"period6Months": "6 meses",
|
||||
"pricingErrorMessage": "No se pudieron cargar los detalles de precios.",
|
||||
"awsManagedMessage": "La facturación se gestiona a través de su cuenta de AWS",
|
||||
"azureManagedMessage": "La facturación se gestiona a través de su cuenta de Azure",
|
||||
"contractManagedMessage": "La facturación se gestiona a través de su contrato — contacte a su administrador de cuenta para realizar cambios",
|
||||
"upgradeToPlanMessage": "Mejore a un plan para seguir disfrutando de los servicios de OpenObserve.",
|
||||
"creditsUsedLabel": "créditos utilizados",
|
||||
"errorTracking": "Seguimiento de errores",
|
||||
"rumSession": "Sesión de RUM",
|
||||
"licenseExpiryContactAdmin": "Actualice su licencia poniéndose en contacto con su administrador.",
|
||||
"subscribed": "Suscrito",
|
||||
"invoiceHistory": "Historial de facturas",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Estado",
|
||||
"action": "Acción",
|
||||
"manageSubscription": "Administrar suscripción",
|
||||
"messageDataNotFound": "No se han encontrado datos",
|
||||
"invoiceStartDate": "Fecha de inicio de la factura",
|
||||
"invoiceEndDate": "Fecha de finalización de la factura",
|
||||
"downloadInvoice": "Descargar factura",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "¿Necesitas un gran volumen? ¡Hablemos de la escala! Póngase en contacto con nosotros para obtener soluciones a medida.",
|
||||
"contactSupport": "Comuníquese con el soporte",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Uso diario",
|
||||
"enableTitle": "Consulte su uso diario",
|
||||
"enableSubtitle": "Active el reporte de uso para gráficar su volumen de ingesta y consultas día a día. Sus totales anteriores permanecen disponibles de cualquier manera.",
|
||||
"enableSubtitle": "Activa el informe de uso para graficar tu costo y volumen diarios de cada medidor. Tu factura en Resumen sigue disponible de todos modos.",
|
||||
"enableButton": "Habilitar reporte de uso",
|
||||
"enablePending": "Reporte de uso habilitado. Su gráfico diario comenzará a llenarse pronto a medida que se registre nuevo uso.",
|
||||
"enableConfirmTitle": "¿Habilitar reporte de uso?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Póngase en contacto con su administrador de cuenta para agregar más créditos de IA",
|
||||
"aiPayerSubscribeMessage": "La facturación de esta organización la gestiona {payer}. Pide a un administrador de {payer} que se suscriba; el uso adicional se factura en su factura.",
|
||||
"aiFreeUsedLabel": "créditos gratuitos usados",
|
||||
"aiBillableCredits": "Créditos de IA facturables",
|
||||
"aiFreeOneTime": "Una asignación única.",
|
||||
"aiUsedByFeature": "Asistente y generación de consultas: {chat} · Análisis de incidentes: {incident} · Reanálisis: {reanalysis}",
|
||||
"aiPaygMessage": "Créditos gratuitos agotados: el uso se factura a la suscripción",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Plan administrado por el padre",
|
||||
"chipNoInvoices": "Sin facturas separadas",
|
||||
"viewOrgGroup": "Ver grupo organizativo",
|
||||
"currentOrgTitle": "Organización Actual",
|
||||
"memberOrgsTitle": "Organizaciones miembro",
|
||||
"searchMemberOrg": "Buscar organización",
|
||||
"noMemberMatch": "No hay organizaciones coincidentes",
|
||||
"remove": "Quitar",
|
||||
"removeTitle": "¿Quitar a {name} de tu grupo de facturación?",
|
||||
"removeBody": "Esta organización pasa a ser una organización gratuita y paga por su propio uso. Se aplican los límites del plan gratuito. Sus usuarios, sus datos y su uso anterior no se ven afectados.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Estado de alerta",
|
||||
"contactAdministratorToResolve": "Contacta a tu administrador para resolver este problema.",
|
||||
"contactAdministratorUsageReporting": "Contacta a tu administrador para asegurar que se restablezca el reporte de uso.",
|
||||
"credits": "Créditos",
|
||||
"dataRetention": "Retención de datos",
|
||||
"event": "Evento",
|
||||
"ingestion": "Ingesta",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Tu período de prueba ha expirado.",
|
||||
"trialDaysRemaining": "{count} día restante en tu cuenta de prueba | {count} días restantes en tu cuenta de prueba",
|
||||
"usageReportingFailedForDays": "El reporte de uso ha fallado durante {count} día, las funciones de RBAC y SSO están deshabilitadas hasta que el uso se reporte correctamente | El reporte de uso ha fallado durante {count} días, las funciones de RBAC y SSO están deshabilitadas hasta que el uso se reporte correctamente",
|
||||
"usageReportingFailingForHours": "El reporte de uso ha estado fallando durante {count} hora, algunas funciones se deshabilitarán pronto | El reporte de uso ha estado fallando durante {count} horas, algunas funciones se deshabilitarán pronto"
|
||||
"usageReportingFailingForHours": "El reporte de uso ha estado fallando durante {count} hora, algunas funciones se deshabilitarán pronto | El reporte de uso ha estado fallando durante {count} horas, algunas funciones se deshabilitarán pronto",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-hora",
|
||||
"unitCredit": "Crédito",
|
||||
"unitRun": "Ejecución",
|
||||
"ratePerUnit": "{rate} por {unit}",
|
||||
"cycleRange": "Ciclo de facturación: {start} a {end}",
|
||||
"syntheticsBrowser": "Sintéticos (Navegador)",
|
||||
"syntheticsProtocol": "Sintéticos (Protocolo)",
|
||||
"overviewTab": "Resumen",
|
||||
"trendsTab": "Tendencias",
|
||||
"estimationTab": "Estimación de precios",
|
||||
"costBreakdown": "Desglose de costos",
|
||||
"colMeter": "Medidor",
|
||||
"colVolume": "Volumen",
|
||||
"colCost": "Costo",
|
||||
"totalCost": "Costo total",
|
||||
"missingMeters": "No pudimos asignar precio a estos medidores: {meters}. Sus filas muestran un guion en lugar de un costo.",
|
||||
"noPricingTitle": "El costo no está disponible para esta organización",
|
||||
"noPricingSubtitle": "Esta organización no tiene una cuenta de facturación medida, por lo que no hay precios que mostrar. El volumen de uso sigue disponible en la pestaña Tendencias.",
|
||||
"loadFailedTitle": "No se pudo cargar tu uso",
|
||||
"loadFailedNetwork": "No se pudo contactar al servidor. Verifica tu conexión de red e inténtalo de nuevo.",
|
||||
"loadFailedServer": "El servicio de facturación devolvió un error. Inténtalo de nuevo en un momento.",
|
||||
"refreshFailed": "No se pudo actualizar el uso. Los números mostrados corresponden a la última carga.",
|
||||
"byStream": "Por Stream",
|
||||
"colStream": "Stream",
|
||||
"byUser": "Por usuario",
|
||||
"colUser": "Usuario",
|
||||
"byAlert": "Por alerta",
|
||||
"colAlert": "Alerta",
|
||||
"byDashboard": "Por dashboard",
|
||||
"colDashboard": "Dashboard",
|
||||
"byScheduledPipeline": "Por tubería programada",
|
||||
"colScheduledPipeline": "Tubería programada",
|
||||
"byPipeline": "Por tubería",
|
||||
"colPipeline": "Tubería",
|
||||
"byDestination": "Por destino",
|
||||
"colDestination": "Destino",
|
||||
"colRetentionPeriod": "Período de retención",
|
||||
"colCurrentSize": "Tamaño actual",
|
||||
"drilldownFailed": "No se pudo cargar este desglose.",
|
||||
"retry": "Reintentar",
|
||||
"unknownPipeline": "Desconocido",
|
||||
"rangeCurrentCycle": "Ciclo actual",
|
||||
"assumptions": "Supuestos",
|
||||
"avgStreamSize": "Datos promedio por stream",
|
||||
"ingestPerMonth": "Datos ingeridos por mes",
|
||||
"searchesPerMonth": "Búsquedas por mes",
|
||||
"scanPerSearch": "Datos escaneados por búsqueda",
|
||||
"pipelineCount": "Tuberías en ejecución sobre streams",
|
||||
"retentionDays": "Datos conservados durante",
|
||||
"aiCreditsPerMonth": "Créditos de IA por mes",
|
||||
"browserChecks": "Comprobaciones de navegador",
|
||||
"stepsPerCheck": "Pasos por comprobación",
|
||||
"checkRegions": "Regiones",
|
||||
"checkDevices": "Dispositivos",
|
||||
"checkInterval": "Se ejecuta cada",
|
||||
"apiChecks": "Comprobaciones de API",
|
||||
"browserRunsNote": "Aproximadamente {runs} ejecuciones de pasos por mes.",
|
||||
"apiRunsNote": "Aproximadamente {runs} ejecuciones por mes.",
|
||||
"qtyStepRuns": "{count} ejecuciones de pasos",
|
||||
"qtyRuns": "{count} ejecuciones",
|
||||
"estimatedTotal": "Total estimado",
|
||||
"perMonth": "por mes",
|
||||
"estimateDisclaimer": "Esta es una estimación a precio de lista. No incluye ningún descuento de tu cuenta.",
|
||||
"cycleDay": "Día {day} de {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Descuento",
|
||||
"costSoFar": "Costo hasta ahora",
|
||||
"estimatedCostSoFar": "Costo estimado hasta ahora",
|
||||
"projectedTotal": "Total proyectado",
|
||||
"tooEarlyToProject": "Es demasiado pronto en el ciclo para proyectar",
|
||||
"colStorageShare": "% del almacenamiento",
|
||||
"orgDefaultRetention": "Predeterminado de la organización",
|
||||
"retentionDaysValue": "{days} días",
|
||||
"topMeter": "Medidor principal",
|
||||
"topMeterShare": "{cost} · {percent}% de la factura",
|
||||
"billedIngestion": "Datos ingeridos",
|
||||
"billedQuery": "Datos escaneados por búsquedas",
|
||||
"billedPipeline": "Datos procesados por tuberías",
|
||||
"billedRemotePipeline": "Datos enviados a destinos remotos",
|
||||
"billedRetention": "Datos almacenados a lo largo del tiempo",
|
||||
"billedAi": "Créditos de IA usados",
|
||||
"billedSynthetics": "Ejecuciones de comprobaciones sintéticas",
|
||||
"meterShare": "Parte de la factura",
|
||||
"everythingElse": "Todo lo demás",
|
||||
"otherBand": "Otros",
|
||||
"colTotal": "Total",
|
||||
"groupBy": "Agrupar por",
|
||||
"groupNotRecorded": "Aún no se ha registrado uso de {group}.",
|
||||
"segmentTitle": "{meter}: {billed} facturado, {share} de la factura",
|
||||
"colEstCost": "Costo est.",
|
||||
"shareOfMeter": "Parte de {meter}",
|
||||
"costByMeter": "Costo por medidor",
|
||||
"meterByGroup": "{meter} por {group}",
|
||||
"billedDay": "Facturado, día {day} de {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} facturado ({gross} bruto, {discount} de descuento), {share} de la factura",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Facturado a través de {payer}. Los costos y presupuestos están en la página Uso de esa organización.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, facturado como {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "Bruto",
|
||||
"colBilled": "Facturado",
|
||||
"allMeters": "Todos los medidores",
|
||||
"noDailyRetention": "La retención no tiene historial diario. En su lugar, la tabla muestra el almacenamiento de cada stream.",
|
||||
"grossLabel": "Bruto",
|
||||
"cycleCloses": "El ciclo finaliza el {date}",
|
||||
"dailyCost": "Costo diario",
|
||||
"breakdownSummary": "{cost} facturado · {volume} · {share} de la factura",
|
||||
"scenarioFrom": "Partir de",
|
||||
"scenarioCurrent": "Mi uso actual",
|
||||
"scenarioTwice": "Crecimiento de 2×",
|
||||
"scenarioFiveTimes": "Crecimiento de 5×",
|
||||
"resetEstimate": "Restablecer",
|
||||
"expectedUsage": "Uso mensual esperado",
|
||||
"expectedUsageHint": "Precargado con lo que va de este ciclo",
|
||||
"loadingHistory": "Leyendo tu historial de uso…",
|
||||
"yourEstimate": "Tu estimación",
|
||||
"assumptionsFrom": "Según tus últimos 30 días",
|
||||
"scanNote": "Escanea aproximadamente {volume} al mes.",
|
||||
"qtyScanned": "{volume} escaneado",
|
||||
"qtyProcessed": "{volume} procesado",
|
||||
"qtyKeptFor": "{volume} durante {days} días",
|
||||
"qtyCredits": "{count} créditos",
|
||||
"rateIncluded": "Incluido en tu plan",
|
||||
"rateTooltip": "{rate}. La facturación se basa en {basis}.",
|
||||
"rateIncludedTooltip": "{basis} está incluido en tu plan, así que no añade nada a esta estimación.",
|
||||
"byIncident": "Por incidente",
|
||||
"colIncident": "Incidente",
|
||||
"colCredits": "Créditos",
|
||||
"byCheck": "Por comprobación",
|
||||
"colCheck": "Comprobación",
|
||||
"colSteps": "Pasos",
|
||||
"unknownCheck": "Comprobación eliminada",
|
||||
"noBreakdownYet": "No hay uso en este ciclo, así que todavía no hay nada que desglosar.",
|
||||
"rangeLastCycle": "Último ciclo",
|
||||
"rangeLast90Days": "Últimos 90 días",
|
||||
"runRate": "Ritmo de gasto",
|
||||
"dailyAverage": "Promedio diario",
|
||||
"peakDay": "Día pico",
|
||||
"runRateWindow": "Últimos {days} días",
|
||||
"acrossDays": "En {days} días",
|
||||
"atPaceProjected": "{cost} al ritmo de los últimos {days} días",
|
||||
"atPace": "Al ritmo de {days} días",
|
||||
"topMeterOfCost": "{cost} · {percent}% del costo",
|
||||
"perDay": "{cost} por día",
|
||||
"spendToDate": "Gasto hasta la fecha",
|
||||
"costOverTime": "Costo a lo largo del tiempo",
|
||||
"volumeOverTime": "Volumen a lo largo del tiempo",
|
||||
"volumeSubtitle": "Ingesta, búsqueda y tuberías, por día",
|
||||
"usageByMeter": "Uso por medidor",
|
||||
"dailyVolume": "Volumen diario",
|
||||
"lastDaysRange": "Últimos {days} días: {start} a {end}",
|
||||
"enableVolumeSubtitle": "Activa el informe de uso para ver cuánto consume cada medidor, día a día.",
|
||||
"measureCost": "Costo",
|
||||
"measureVolume": "Volumen",
|
||||
"estimatedAtCyclePrice": "Estimado a partir del uso, al precio de cada ciclo. No incluye la Retención de datos.",
|
||||
"showOnlyMeter": "Mostrar solo {meter}",
|
||||
"showAllMeters": "Mostrar todos los medidores",
|
||||
"noUsageInRange": "No hay uso en este rango",
|
||||
"trendsLoadFailed": "No se pudo cargar el uso de este rango.",
|
||||
"rangeDates": "{start} a {end}",
|
||||
"forecast": "Pronóstico",
|
||||
"dayTotal": "Total del día",
|
||||
"costByOrg": "Costo por organización",
|
||||
"orgFilter": "Organización",
|
||||
"organizationsTitle": "Organizaciones",
|
||||
"orgCountThisCycle": "{count} organizaciones · este ciclo",
|
||||
"overallBudget": "Presupuesto general",
|
||||
"overallBudgetLabel": "Presupuesto general para este ciclo de facturación",
|
||||
"appliesToGroup": "La factura de todo el grupo",
|
||||
"orgBudgetsTotal": "Los presupuestos de las organizaciones suman {amount}",
|
||||
"orgBudgetsOver": "Los presupuestos de las organizaciones suman {amount}, {over} más que el presupuesto general",
|
||||
"needsAttention": "Requiere atención",
|
||||
"attentionHint": "{over} por encima del presupuesto, {near} cerca del umbral de aviso",
|
||||
"colSpendThisCycle": "Gasto de este ciclo",
|
||||
"colBudget": "Presupuesto",
|
||||
"colAlerts": "Alertas",
|
||||
"statusNone": "Sin presupuesto",
|
||||
"statusOk": "Según lo previsto",
|
||||
"statusWarn": "Cerca del límite",
|
||||
"statusOver": "Presupuesto excedido",
|
||||
"budgetFor": "Presupuesto de {org}",
|
||||
"warnPercentFor": "Porcentaje de aviso para {org}",
|
||||
"alertsFor": "Alertas por correo para {org}",
|
||||
"groupAlertsOnHint": "Los administradores reciben correos mientras el gasto del grupo se mantenga por encima del importe de aviso en este ciclo. Pausa para detenerlos. Cada fila de organización tiene su propio interruptor.",
|
||||
"unsavedChanges": "Sin guardar: {parts}",
|
||||
"unsavedOrgs": "{count} org.",
|
||||
"unsavedOverall": "ajustes generales",
|
||||
"allChangesSaved": "Todos los cambios guardados",
|
||||
"discard": "Descartar",
|
||||
"saveChanges": "Guardar cambios",
|
||||
"orgCostsFailed": "No se pudo cargar el costo por organización. Actualiza para reintentar.",
|
||||
"clickForOrg": "Haz clic para ver los medidores de esta organización",
|
||||
"clickForBreakdown": "Haz clic para ver qué compone este medidor",
|
||||
"allOrgs": "Todas las organizaciones",
|
||||
"colOrganization": "Organización",
|
||||
"shareOfOrg": "Participación en {org}",
|
||||
"thisOrg": "{org} (esta organización)",
|
||||
"formerMemberIngested": "Miembro anterior · {volume} ingeridos",
|
||||
"ingestedVolume": "{volume} ingeridos",
|
||||
"notSplitByOrg": "Facturado al grupo completo, sin dividir por organización",
|
||||
"orgSummary": "{cost} facturados · {share} de la factura",
|
||||
"orgCostEstimated": "Esta factura se divide según la proporción de uso de cada organización",
|
||||
"byOrg": "Por organización",
|
||||
"historyStartsOn": "El historial de uso comienza el {date}, el inicio de tu ciclo de facturación más antiguo del que tenemos uso. Los días anteriores no tienen uso que mostrar.",
|
||||
"budgetTab": "Presupuesto",
|
||||
"budgetKpi": "Presupuesto",
|
||||
"budgetNotSet": "No definido",
|
||||
"budgetThisCycle": "Este ciclo de facturación",
|
||||
"spentOfBudget": "{percent}% del presupuesto · día {day} de {total}",
|
||||
"overBudgetBy": "{amount} por encima del presupuesto",
|
||||
"toSpare": "{amount} de margen",
|
||||
"budgetReached": "Presupuesto alcanzado",
|
||||
"reachedOnDay": "Día {day}",
|
||||
"notThisCycle": "No en este ciclo",
|
||||
"atPacePerDay": "A {cost} por día",
|
||||
"atCurrentPace": "Al ritmo actual",
|
||||
"spendAgainstBudget": "Gasto frente al presupuesto",
|
||||
"ofBudget": "de {budget} · {percent}%",
|
||||
"leftInBudget": "{amount} restantes",
|
||||
"overBy": "{amount} de exceso",
|
||||
"warnAtPercent": "Aviso al {percent}%",
|
||||
"shareOfBudget": "{percent}% del presupuesto",
|
||||
"setBudgetFirst": "Define un presupuesto para comparar el gasto de este ciclo.",
|
||||
"budgetSettings": "Ajustes de presupuesto",
|
||||
"budgetAmount": "Presupuesto para este ciclo de facturación",
|
||||
"budgetAmountHelp": "Al ritmo actual, este ciclo terminará cerca de {amount}",
|
||||
"warnAt": "Avisar en",
|
||||
"warnAmount": "Importe de aviso en dólares",
|
||||
"warnPercent": "Importe de aviso como porcentaje del presupuesto",
|
||||
"warnHelp": "Enviar una alerta cuando el gasto supere {amount}",
|
||||
"emailAlerts": "Alertas por correo electrónico",
|
||||
"alertsOnHint": "Los administradores recibirán correos mientras el gasto siga por encima del importe de aviso en este ciclo. Pausa para detenerlos.",
|
||||
"alertsPausedHint": "No se enviarán correos hasta que vuelvas a activar esta opción.",
|
||||
"saveBudget": "Guardar ajustes",
|
||||
"budgetSaved": "Presupuesto guardado",
|
||||
"budgetRemoved": "Presupuesto eliminado",
|
||||
"budgetSaveFailed": "No se pudo guardar el presupuesto",
|
||||
"budgetLoadFailedTitle": "No se pudieron cargar tus presupuestos",
|
||||
"budgetLoadFailed": "La edición esperará a que se carguen, para que al guardar no se sobrescriban los presupuestos ya definidos. Vuelve a intentarlo en un momento."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "No se encontró ningún token de Marketplace",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Comparer les plans",
|
||||
"contactSupport": "Contacter le support"
|
||||
},
|
||||
"previousCycle": "Cycle précédent",
|
||||
"currentCycle": "Cycle actuel",
|
||||
"period30Days": "30 jours",
|
||||
"period60Days": "60 jours",
|
||||
"period3Months": "3 mois",
|
||||
"period6Months": "6 mois",
|
||||
"pricingErrorMessage": "Les détails de tarification ne se sont pas chargés.",
|
||||
"awsManagedMessage": "La facturation est gérée via votre compte AWS",
|
||||
"azureManagedMessage": "La facturation est gérée via votre compte Azure",
|
||||
"contractManagedMessage": "La facturation est gérée via votre contrat — contactez votre gestionnaire de compte pour toute modification.",
|
||||
"upgradeToPlanMessage": "Passez à un forfait pour continuer à profiter des services d'OpenObserve.",
|
||||
"creditsUsedLabel": "crédits utilisés",
|
||||
"errorTracking": "Suivi des erreurs",
|
||||
"rumSession": "Session RUM",
|
||||
"licenseExpiryContactAdmin": "Veuillez mettre à jour votre licence en contactant votre administrateur.",
|
||||
"subscribed": "Abonné",
|
||||
"invoiceHistory": "Historique des factures",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "État",
|
||||
"action": "Action",
|
||||
"manageSubscription": "Gérer l'abonnement",
|
||||
"messageDataNotFound": "Données non trouvées",
|
||||
"invoiceStartDate": "Date de début de la facture",
|
||||
"invoiceEndDate": "Date de fin de facture",
|
||||
"downloadInvoice": "Télécharger la facture",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Vous avez besoin d'un volume élevé ? Parlons d'échelle ! Contactez-nous pour des solutions sur mesure.",
|
||||
"contactSupport": "Contacter le service d'assistance",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Utilisation quotidienne",
|
||||
"enableTitle": "Consultez votre utilisation quotidienne",
|
||||
"enableSubtitle": "Activez le rapport d'utilisation pour visualiser votre volume d'ingestion et de requêtes jour par jour. Vos totaux ci-dessus restent disponibles dans les deux cas.",
|
||||
"enableSubtitle": "Activez le rapport d'utilisation pour visualiser votre coût et votre volume quotidiens pour chaque compteur. Votre facture dans Vue d'ensemble reste disponible dans tous les cas.",
|
||||
"enableButton": "Activer le rapport d'utilisation",
|
||||
"enablePending": "Rapport d'utilisation activé. Votre graphique quotidien commencera à se remplir sous peu à mesure que de nouvelles données d'utilisation sont enregistrées.",
|
||||
"enableConfirmTitle": "Activer le rapport d'utilisation ?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Contactez votre gestionnaire de compte pour ajouter plus de crédits IA",
|
||||
"aiPayerSubscribeMessage": "La facturation de cette organisation est gérée par {payer}. Demandez à un administrateur de {payer} de s'abonner ; l'utilisation supplémentaire est facturée sur sa facture.",
|
||||
"aiFreeUsedLabel": "crédits gratuits utilisés",
|
||||
"aiBillableCredits": "Crédits IA facturables",
|
||||
"aiFreeOneTime": "Une allocation unique.",
|
||||
"aiUsedByFeature": "Assistante IA et génération de requêtes : {chat} · Analyse d'incident : {incident} · Réanalyse : {reanalysis}",
|
||||
"aiPaygMessage": "Crédits gratuits épuisés — utilisation facturée à l'abonnement",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Plan géré par le parent",
|
||||
"chipNoInvoices": "Pas de factures séparées",
|
||||
"viewOrgGroup": "Afficher le groupe organisationnel",
|
||||
"currentOrgTitle": "Organisation Actuelle",
|
||||
"memberOrgsTitle": "Organisations membres",
|
||||
"searchMemberOrg": "Rechercher une organisation",
|
||||
"noMemberMatch": "Aucune organisation correspondante",
|
||||
"remove": "Supprimer",
|
||||
"removeTitle": "Supprimer {name} de votre groupe de facturation ?",
|
||||
"removeBody": "Cette organisation devient une organisation gratuite et paie pour sa propre utilisation. Les limites du plan gratuit s'appliquent. Ses utilisateurs, ses données et son utilisation passée ne sont pas affectés.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "État de l'alerte",
|
||||
"contactAdministratorToResolve": "Veuillez contacter votre administrateur pour résoudre ce problème.",
|
||||
"contactAdministratorUsageReporting": "Veuillez contacter votre administrateur pour rétablir le rapport d'utilisation.",
|
||||
"credits": "Crédits",
|
||||
"dataRetention": "Rétention des données",
|
||||
"event": "Événement",
|
||||
"ingestion": "Ingestion",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Votre période d'essai a expiré.",
|
||||
"trialDaysRemaining": "{count} jour restant dans votre compte d'essai | {count} jours restants dans votre compte d'essai",
|
||||
"usageReportingFailedForDays": "Le rapport d'utilisation a échoué pendant {count} jour, les fonctionnalités RBAC et SSO sont désactivées jusqu'à ce que l'utilisation soit signalée avec succès | Le rapport d'utilisation a échoué pendant {count} jours, les fonctionnalités RBAC et SSO sont désactivées jusqu'à ce que l'utilisation soit signalée avec succès",
|
||||
"usageReportingFailingForHours": "Le rapport d'utilisation échoue depuis {count} heure, certaines fonctionnalités seront bientôt désactivées | Le rapport d'utilisation échoue depuis {count} heures, certaines fonctionnalités seront bientôt désactivées"
|
||||
"usageReportingFailingForHours": "Le rapport d'utilisation échoue depuis {count} heure, certaines fonctionnalités seront bientôt désactivées | Le rapport d'utilisation échoue depuis {count} heures, certaines fonctionnalités seront bientôt désactivées",
|
||||
"usageV2": {
|
||||
"unitGb": "Go",
|
||||
"unitGbHour": "Go-heure",
|
||||
"unitCredit": "Crédit",
|
||||
"unitRun": "Exécution",
|
||||
"ratePerUnit": "{rate} par {unit}",
|
||||
"cycleRange": "Cycle de facturation : du {start} au {end}",
|
||||
"syntheticsBrowser": "Synthétiques (Navigateur)",
|
||||
"syntheticsProtocol": "Synthétiques (Protocole)",
|
||||
"overviewTab": "Vue d'ensemble",
|
||||
"trendsTab": "Tendances",
|
||||
"estimationTab": "Estimation des prix",
|
||||
"costBreakdown": "Répartition des coûts",
|
||||
"colMeter": "Compteur",
|
||||
"colVolume": "Volume",
|
||||
"colCost": "Coût",
|
||||
"totalCost": "Coût total",
|
||||
"missingMeters": "Nous n'avons pas pu tarifer ces compteurs : {meters}. Leurs lignes affichent un tiret à la place du coût.",
|
||||
"noPricingTitle": "Le coût n'est pas disponible pour cette organisation",
|
||||
"noPricingSubtitle": "Cette organisation n'a pas de compte de facturation à l'usage, nous n'avons donc aucun prix à afficher. Le volume d'utilisation reste disponible dans l'onglet Tendances.",
|
||||
"loadFailedTitle": "Impossible de charger votre utilisation",
|
||||
"loadFailedNetwork": "Le serveur est injoignable. Vérifiez votre connexion réseau et réessayez.",
|
||||
"loadFailedServer": "Le service de facturation a renvoyé une erreur. Réessayez dans un instant.",
|
||||
"refreshFailed": "Impossible d'actualiser l'utilisation. Les chiffres affichés proviennent du dernier chargement.",
|
||||
"byStream": "Par flux",
|
||||
"colStream": "Flux",
|
||||
"byUser": "Par utilisateur",
|
||||
"colUser": "Utilisateur",
|
||||
"byAlert": "Par alerte",
|
||||
"colAlert": "Alerte",
|
||||
"byDashboard": "Par tableau de bord",
|
||||
"colDashboard": "Tableau de bord",
|
||||
"byScheduledPipeline": "Par pipeline planifié",
|
||||
"colScheduledPipeline": "Pipeline planifié",
|
||||
"byPipeline": "Par pipeline",
|
||||
"colPipeline": "Pipeline",
|
||||
"byDestination": "Par destination",
|
||||
"colDestination": "Destination",
|
||||
"colRetentionPeriod": "Période de rétention",
|
||||
"colCurrentSize": "Taille actuelle",
|
||||
"drilldownFailed": "Impossible de charger cette répartition.",
|
||||
"retry": "Réessayer",
|
||||
"unknownPipeline": "Inconnu",
|
||||
"rangeCurrentCycle": "Cycle en cours",
|
||||
"assumptions": "Hypothèses",
|
||||
"avgStreamSize": "Volume moyen de données par flux",
|
||||
"ingestPerMonth": "Données ingérées par mois",
|
||||
"searchesPerMonth": "Recherches par mois",
|
||||
"scanPerSearch": "Données analysées par recherche",
|
||||
"pipelineCount": "Pipelines exécutés sur les flux",
|
||||
"retentionDays": "Données conservées pendant",
|
||||
"aiCreditsPerMonth": "Crédits IA par mois",
|
||||
"browserChecks": "Vérifications navigateur",
|
||||
"stepsPerCheck": "Étapes par vérification",
|
||||
"checkRegions": "Régions",
|
||||
"checkDevices": "Appareils",
|
||||
"checkInterval": "Exécution toutes les",
|
||||
"apiChecks": "Vérifications API",
|
||||
"browserRunsNote": "Environ {runs} exécutions d'étapes par mois.",
|
||||
"apiRunsNote": "Environ {runs} exécutions par mois.",
|
||||
"qtyStepRuns": "{count} exécutions d'étapes",
|
||||
"qtyRuns": "{count} exécutions",
|
||||
"estimatedTotal": "Total estimé",
|
||||
"perMonth": "par mois",
|
||||
"estimateDisclaimer": "Il s'agit d'une estimation au tarif catalogue. Elle ne tient pas compte des remises éventuelles sur votre compte.",
|
||||
"cycleDay": "Jour {day} sur {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Remise",
|
||||
"costSoFar": "Coût à ce jour",
|
||||
"estimatedCostSoFar": "Coût estimé à ce jour",
|
||||
"projectedTotal": "Total projeté",
|
||||
"tooEarlyToProject": "Trop tôt dans le cycle pour une projection",
|
||||
"colStorageShare": "% du stockage",
|
||||
"orgDefaultRetention": "Par défaut de l'organisation",
|
||||
"retentionDaysValue": "{days} jours",
|
||||
"topMeter": "Compteur principal",
|
||||
"topMeterShare": "{cost} · {percent}% de la facture",
|
||||
"billedIngestion": "Données ingérées",
|
||||
"billedQuery": "Données analysées par les recherches",
|
||||
"billedPipeline": "Données traitées par les pipelines",
|
||||
"billedRemotePipeline": "Données envoyées vers des destinations distantes",
|
||||
"billedRetention": "Données stockées au fil du temps",
|
||||
"billedAi": "Crédits IA utilisés",
|
||||
"billedSynthetics": "Exécutions de contrôles synthétiques",
|
||||
"meterShare": "Part de la facture",
|
||||
"everythingElse": "Tout le reste",
|
||||
"otherBand": "Autre",
|
||||
"colTotal": "Total",
|
||||
"groupBy": "Grouper par",
|
||||
"groupNotRecorded": "Aucune utilisation par {group} enregistrée pour l'instant.",
|
||||
"segmentTitle": "{meter} : {billed} facturé, {share} de la facture",
|
||||
"colEstCost": "Coût est.",
|
||||
"shareOfMeter": "Part de {meter}",
|
||||
"costByMeter": "Coût par compteur",
|
||||
"meterByGroup": "{meter} par {group}",
|
||||
"billedDay": "Facturé, jour {day} sur {total}",
|
||||
"segmentTitleDiscounted": "{meter} : {billed} facturé ({gross} brut, {discount} de remise), {share} de la facture",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Facturé via {payer}. Les coûts et budgets se trouvent sur la page Utilisation de cette organisation.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, facturé {amount} {unit}",
|
||||
"percentInParens": "({percent} %)",
|
||||
"colGross": "Brut",
|
||||
"colBilled": "Facturé",
|
||||
"allMeters": "Tous les compteurs",
|
||||
"noDailyRetention": "La rétention n'a pas d'historique quotidien. Le tableau affiche à la place le stockage de chaque flux.",
|
||||
"grossLabel": "Brut",
|
||||
"cycleCloses": "Le cycle se termine le {date}",
|
||||
"dailyCost": "Coût quotidien",
|
||||
"breakdownSummary": "{cost} facturé · {volume} · {share} de la facture",
|
||||
"scenarioFrom": "Partir de",
|
||||
"scenarioCurrent": "Mon utilisation actuelle",
|
||||
"scenarioTwice": "Croissance ×2",
|
||||
"scenarioFiveTimes": "Croissance ×5",
|
||||
"resetEstimate": "Réinitialiser",
|
||||
"expectedUsage": "Utilisation mensuelle prévue",
|
||||
"expectedUsageHint": "Prérempli à partir du cycle en cours",
|
||||
"loadingHistory": "Lecture de votre historique d'utilisation…",
|
||||
"yourEstimate": "Votre estimation",
|
||||
"assumptionsFrom": "D'après vos 30 derniers jours",
|
||||
"scanNote": "Analyse environ {volume} par mois.",
|
||||
"qtyScanned": "{volume} analysés",
|
||||
"qtyProcessed": "{volume} traités",
|
||||
"qtyKeptFor": "{volume} pour {days} jours",
|
||||
"qtyCredits": "{count} crédits",
|
||||
"rateIncluded": "Inclus dans votre plan",
|
||||
"rateTooltip": "{rate}. La facturation est basée sur {basis}.",
|
||||
"rateIncludedTooltip": "{basis} est inclus dans votre plan, cela n'ajoute donc rien à cette estimation.",
|
||||
"byIncident": "Par incident",
|
||||
"colIncident": "Incident",
|
||||
"colCredits": "Crédits",
|
||||
"byCheck": "Par vérification",
|
||||
"colCheck": "Vérification",
|
||||
"colSteps": "Étapes",
|
||||
"unknownCheck": "Vérification supprimée",
|
||||
"noBreakdownYet": "Aucune utilisation ce cycle, il n'y a donc rien à ventiler pour l'instant.",
|
||||
"rangeLastCycle": "Cycle précédent",
|
||||
"rangeLast90Days": "90 derniers jours",
|
||||
"runRate": "Rythme de consommation",
|
||||
"dailyAverage": "Moyenne quotidienne",
|
||||
"peakDay": "Jour de pointe",
|
||||
"runRateWindow": "{days} derniers jours",
|
||||
"acrossDays": "Sur {days} jours",
|
||||
"atPaceProjected": "{cost} au rythme des {days} derniers jours",
|
||||
"atPace": "Au rythme de {days} jours",
|
||||
"topMeterOfCost": "{cost} · {percent} % du coût",
|
||||
"perDay": "{cost} par jour",
|
||||
"spendToDate": "Dépenses à ce jour",
|
||||
"costOverTime": "Coût dans le temps",
|
||||
"volumeOverTime": "Volume dans le temps",
|
||||
"volumeSubtitle": "Ingestion, recherche et pipelines, par jour",
|
||||
"usageByMeter": "Utilisation par compteur",
|
||||
"dailyVolume": "Volume quotidien",
|
||||
"lastDaysRange": "{days} derniers jours : de {start} à {end}",
|
||||
"enableVolumeSubtitle": "Activez le rapport d'utilisation pour voir la consommation de chaque compteur, jour après jour.",
|
||||
"measureCost": "Coût",
|
||||
"measureVolume": "Volume",
|
||||
"estimatedAtCyclePrice": "Estimé à partir de l'utilisation, au prix de chaque cycle. Hors rétention des données.",
|
||||
"showOnlyMeter": "Afficher uniquement {meter}",
|
||||
"showAllMeters": "Afficher tous les compteurs",
|
||||
"noUsageInRange": "Aucune utilisation sur cette plage",
|
||||
"trendsLoadFailed": "Impossible de charger l'utilisation pour cette plage.",
|
||||
"rangeDates": "{start} au {end}",
|
||||
"forecast": "Prévision",
|
||||
"dayTotal": "Total du jour",
|
||||
"costByOrg": "Coût par organisation",
|
||||
"orgFilter": "Organisation",
|
||||
"organizationsTitle": "Organisations",
|
||||
"orgCountThisCycle": "{count} orgs · ce cycle",
|
||||
"overallBudget": "Budget global",
|
||||
"overallBudgetLabel": "Budget global pour ce cycle de facturation",
|
||||
"appliesToGroup": "La facture de tout le groupe",
|
||||
"orgBudgetsTotal": "Les budgets des organisations totalisent {amount}",
|
||||
"orgBudgetsOver": "Les budgets des organisations totalisent {amount}, soit {over} de plus que le budget global",
|
||||
"needsAttention": "Nécessite une attention",
|
||||
"attentionHint": "{over} au-dessus du budget, {near} proches du seuil d'alerte",
|
||||
"colSpendThisCycle": "Dépenses de ce cycle",
|
||||
"colBudget": "Budget",
|
||||
"colAlerts": "Alertes",
|
||||
"statusNone": "Aucun budget",
|
||||
"statusOk": "Sous contrôle",
|
||||
"statusWarn": "Proche de la limite",
|
||||
"statusOver": "Budget dépassé",
|
||||
"budgetFor": "Budget pour {org}",
|
||||
"warnPercentFor": "Pourcentage d'alerte pour {org}",
|
||||
"alertsFor": "Alertes e-mail pour {org}",
|
||||
"groupAlertsOnHint": "Les administrateurs reçoivent des e-mails tant que les dépenses du groupe restent au-dessus du montant d'alerte pendant ce cycle. Mettez en pause pour les arrêter. Chaque ligne d'organisation a son propre interrupteur.",
|
||||
"unsavedChanges": "Non enregistré : {parts}",
|
||||
"unsavedOrgs": "{count} organisation(s)",
|
||||
"unsavedOverall": "réglages globaux",
|
||||
"allChangesSaved": "Toutes les modifications sont enregistrées",
|
||||
"discard": "Abandonner",
|
||||
"saveChanges": "Enregistrer les modifications",
|
||||
"orgCostsFailed": "Impossible de charger les coûts par organisation. Actualisez pour réessayer.",
|
||||
"clickForOrg": "Cliquez pour voir les compteurs de cette organisation",
|
||||
"clickForBreakdown": "Cliquez pour voir ce qui alimente ce compteur",
|
||||
"allOrgs": "Toutes les organisations",
|
||||
"colOrganization": "Organisation",
|
||||
"shareOfOrg": "Part de {org}",
|
||||
"thisOrg": "{org} (cette organisation)",
|
||||
"formerMemberIngested": "Ancien membre · {volume} ingérés",
|
||||
"ingestedVolume": "{volume} ingérés",
|
||||
"notSplitByOrg": "Facturé à l'ensemble du groupe, non réparti par organisation",
|
||||
"orgSummary": "{cost} facturés · {share} de la facture",
|
||||
"orgCostEstimated": "Cette facture répartie selon la part d'utilisation de chaque organisation",
|
||||
"byOrg": "Par organisation",
|
||||
"historyStartsOn": "L'historique d'utilisation commence le {date}, début de votre plus ancien cycle de facturation pour lequel nous disposons d'utilisation. Les jours précédents n'ont aucune utilisation à afficher.",
|
||||
"budgetTab": "Budget",
|
||||
"budgetKpi": "Budget",
|
||||
"budgetNotSet": "Non défini",
|
||||
"budgetThisCycle": "Ce cycle de facturation",
|
||||
"spentOfBudget": "{percent} % du budget · jour {day} sur {total}",
|
||||
"overBudgetBy": "{amount} au-dessus du budget",
|
||||
"toSpare": "{amount} restants",
|
||||
"budgetReached": "Budget atteint",
|
||||
"reachedOnDay": "Jour {day}",
|
||||
"notThisCycle": "Pas sur ce cycle",
|
||||
"atPacePerDay": "À {cost} par jour",
|
||||
"atCurrentPace": "Au rythme actuel",
|
||||
"spendAgainstBudget": "Dépenses par rapport au budget",
|
||||
"ofBudget": "sur {budget} · {percent} %",
|
||||
"leftInBudget": "{amount} restants",
|
||||
"overBy": "{amount} de dépassement",
|
||||
"warnAtPercent": "Alerte à {percent} %",
|
||||
"shareOfBudget": "{percent} % du budget",
|
||||
"setBudgetFirst": "Définissez un budget pour suivre les dépenses de ce cycle par rapport à celui-ci.",
|
||||
"budgetSettings": "Réglages du budget",
|
||||
"budgetAmount": "Budget pour ce cycle de facturation",
|
||||
"budgetAmountHelp": "Au rythme actuel, ce cycle se terminera aux alentours de {amount}",
|
||||
"warnAt": "Alerter à",
|
||||
"warnAmount": "Montant d'alerte en dollars",
|
||||
"warnPercent": "Montant d'alerte en pourcentage du budget",
|
||||
"warnHelp": "Alerter lorsque les dépenses dépassent {amount}",
|
||||
"emailAlerts": "Alertes par e-mail",
|
||||
"alertsOnHint": "Les administrateurs reçoivent des e-mails tant que les dépenses restent supérieures au montant d'alerte durant ce cycle. Mettez en pause pour les interrompre.",
|
||||
"alertsPausedHint": "Aucun e-mail tant que vous ne réactivez pas ce paramètre.",
|
||||
"saveBudget": "Enregistrer les réglages",
|
||||
"budgetSaved": "Budget enregistré",
|
||||
"budgetRemoved": "Budget supprimé",
|
||||
"budgetSaveFailed": "Impossible d'enregistrer le budget",
|
||||
"budgetLoadFailedTitle": "Impossible de charger vos budgets",
|
||||
"budgetLoadFailed": "La modification attend leur chargement, afin que l'enregistrement ne puisse pas écraser des budgets déjà définis. Réessayez dans un instant."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Aucun jeton Marketplace trouvé",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Confronta i piani",
|
||||
"contactSupport": "Contatta il supporto"
|
||||
},
|
||||
"previousCycle": "Ciclo precedente",
|
||||
"currentCycle": "Ciclo corrente",
|
||||
"period30Days": "30 Giorni",
|
||||
"period60Days": "60 Giorni",
|
||||
"period3Months": "3 Mesi",
|
||||
"period6Months": "6 Mesi",
|
||||
"pricingErrorMessage": "I dettagli sui prezzi non sono stati caricati.",
|
||||
"awsManagedMessage": "La fatturazione è gestita tramite il tuo account AWS",
|
||||
"azureManagedMessage": "La fatturazione è gestita tramite il tuo account Azure",
|
||||
"contractManagedMessage": "La fatturazione è gestita tramite il tuo contratto — contatta il tuo account manager per modifiche",
|
||||
"upgradeToPlanMessage": "Passa a un piano per continuare a usufruire dei servizi di OpenObserve.",
|
||||
"creditsUsedLabel": "crediti usati",
|
||||
"errorTracking": "Tracciamento errori",
|
||||
"rumSession": "Sessione RUM",
|
||||
"licenseExpiryContactAdmin": "Aggiorna la tua licenza contattando il tuo amministratore.",
|
||||
"subscribed": "Sottoscritto",
|
||||
"invoiceHistory": "Cronologia fatture",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Stato",
|
||||
"action": "Azione",
|
||||
"manageSubscription": "Gestisci abbonamento",
|
||||
"messageDataNotFound": "Dati non trovati",
|
||||
"invoiceStartDate": "Data di inizio della fattura",
|
||||
"invoiceEndDate": "Data di fine della fattura",
|
||||
"downloadInvoice": "Scarica la fattura",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Esigenze di volume elevato? Parliamo di scala! Contattaci per soluzioni su misura.",
|
||||
"contactSupport": "Contatta l'assistenza",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Uso quotidiano",
|
||||
"enableTitle": "Visualizza il tuo utilizzo giornaliero",
|
||||
"enableSubtitle": "Attiva la segnalazione dell'utilizzo per tracciare il volume di ingestion e query giorno per giorno. I totali sopra rimangono disponibili in entrambi i casi.",
|
||||
"enableSubtitle": "Attiva il report sull'utilizzo per visualizzare in un grafico il costo e il volume giornalieri di ogni contatore. La tua fattura nella panoramica resta comunque disponibile.",
|
||||
"enableButton": "Abilita segnalazione utilizzo",
|
||||
"enablePending": "Segnalazione utilizzo abilitata. Il grafico giornaliero inizierà a popolarsi a breve con i nuovi dati di utilizzo.",
|
||||
"enableConfirmTitle": "Abilitare la segnalazione dell'utilizzo?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Contatta il tuo account manager per aggiungere più crediti AI",
|
||||
"aiPayerSubscribeMessage": "La fatturazione di questa organizzazione è gestita da {payer}. Chiedi a un amministratore di {payer} di abbonarsi; l'utilizzo successivo viene addebitato sulla sua fattura.",
|
||||
"aiFreeUsedLabel": "crediti gratuiti utilizzati",
|
||||
"aiBillableCredits": "Crediti AI fatturabili",
|
||||
"aiFreeOneTime": "Un'assegnazione una tantum.",
|
||||
"aiUsedByFeature": "Assistente e generazione query: {chat} · Analisi degli incidenti: {incident} · Ri-analisi: {reanalysis}",
|
||||
"aiPaygMessage": "Crediti gratuiti esauriti: utilizzo fatturato all'abbonamento",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Piano gestito dal genitore",
|
||||
"chipNoInvoices": "Nessuna fattura separata",
|
||||
"viewOrgGroup": "Visualizza il gruppo organizzativo",
|
||||
"currentOrgTitle": "Organizzazione Attuale",
|
||||
"memberOrgsTitle": "Organizzazioni membro",
|
||||
"searchMemberOrg": "Cerca organizzazione",
|
||||
"noMemberMatch": "Nessuna organizzazione corrispondente",
|
||||
"remove": "Rimuovi",
|
||||
"removeTitle": "Rimuovere {name} dal tuo gruppo di fatturazione?",
|
||||
"removeBody": "Questa organizzazione diventa un'organizzazione gratuita e paga per il proprio utilizzo. Si applicano i limiti del piano gratuito. I suoi utenti, i dati e l'utilizzo passato non sono interessati.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Stato avviso",
|
||||
"contactAdministratorToResolve": "Contatta l'amministratore per risolvere questo problema.",
|
||||
"contactAdministratorUsageReporting": "Contatta l'amministratore per assicurare che la segnalazione dell'utilizzo venga ripristinata.",
|
||||
"credits": "Crediti",
|
||||
"dataRetention": "Conservazione dei dati",
|
||||
"event": "Evento",
|
||||
"ingestion": "Ingestione",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Il tuo periodo di prova è scaduto.",
|
||||
"trialDaysRemaining": "{count} giorno rimanente nel tuo account di prova | {count} giorni rimanenti nel tuo account di prova",
|
||||
"usageReportingFailedForDays": "La segnalazione dell'utilizzo non riesce da {count} giorno, le funzionalità RBAC e SSO sono disabilitate finché l'utilizzo non viene segnalato correttamente | La segnalazione dell'utilizzo non riesce da {count} giorni, le funzionalità RBAC e SSO sono disabilitate finché l'utilizzo non viene segnalato correttamente",
|
||||
"usageReportingFailingForHours": "La segnalazione dell'utilizzo sta fallendo da {count} ora, alcune funzionalità verranno disabilitate presto | La segnalazione dell'utilizzo sta fallendo da {count} ore, alcune funzionalità verranno disabilitate presto"
|
||||
"usageReportingFailingForHours": "La segnalazione dell'utilizzo sta fallendo da {count} ora, alcune funzionalità verranno disabilitate presto | La segnalazione dell'utilizzo sta fallendo da {count} ore, alcune funzionalità verranno disabilitate presto",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-ora",
|
||||
"unitCredit": "Credito",
|
||||
"unitRun": "Esecuzione",
|
||||
"ratePerUnit": "{rate} per {unit}",
|
||||
"cycleRange": "Ciclo di fatturazione: dal {start} al {end}",
|
||||
"syntheticsBrowser": "Synthetics (Browser)",
|
||||
"syntheticsProtocol": "Synthetics (Protocol)",
|
||||
"overviewTab": "Panoramica",
|
||||
"trendsTab": "Tendenze",
|
||||
"estimationTab": "Stima dei prezzi",
|
||||
"costBreakdown": "Ripartizione dei costi",
|
||||
"colMeter": "Contatore",
|
||||
"colVolume": "Volume",
|
||||
"colCost": "Costo",
|
||||
"totalCost": "Costo totale",
|
||||
"missingMeters": "Non è stato possibile calcolare il prezzo di questi contatori: {meters}. Le relative righe mostrano un trattino al posto del costo.",
|
||||
"noPricingTitle": "Il costo non è disponibile per questa organizzazione",
|
||||
"noPricingSubtitle": "Questa organizzazione non ha un account di fatturazione a consumo, quindi non abbiamo prezzi da mostrare. Il volume di utilizzo è comunque disponibile nella scheda Tendenze.",
|
||||
"loadFailedTitle": "Impossibile caricare il tuo utilizzo",
|
||||
"loadFailedNetwork": "Impossibile raggiungere il server. Controlla la connessione di rete e riprova.",
|
||||
"loadFailedServer": "Il servizio di fatturazione ha restituito un errore. Riprova tra un momento.",
|
||||
"refreshFailed": "Impossibile aggiornare l'utilizzo. I numeri mostrati sono quelli dell'ultimo caricamento.",
|
||||
"byStream": "Per stream",
|
||||
"colStream": "Stream",
|
||||
"byUser": "Per utente",
|
||||
"colUser": "Utente",
|
||||
"byAlert": "Per avviso",
|
||||
"colAlert": "Avviso",
|
||||
"byDashboard": "Per dashboard",
|
||||
"colDashboard": "Dashboard",
|
||||
"byScheduledPipeline": "Per pipeline pianificata",
|
||||
"colScheduledPipeline": "Pipeline pianificata",
|
||||
"byPipeline": "Per pipeline",
|
||||
"colPipeline": "Pipeline",
|
||||
"byDestination": "Per destinazione",
|
||||
"colDestination": "Destinazione",
|
||||
"colRetentionPeriod": "Periodo di conservazione",
|
||||
"colCurrentSize": "Dimensione attuale",
|
||||
"drilldownFailed": "Impossibile caricare questa ripartizione.",
|
||||
"retry": "Riprova",
|
||||
"unknownPipeline": "Sconosciuta",
|
||||
"rangeCurrentCycle": "Ciclo attuale",
|
||||
"assumptions": "Ipotesi",
|
||||
"avgStreamSize": "Dati medi per stream",
|
||||
"ingestPerMonth": "Dati ingeriti al mese",
|
||||
"searchesPerMonth": "Ricerche al mese",
|
||||
"scanPerSearch": "Dati analizzati per ricerca",
|
||||
"pipelineCount": "Pipeline in esecuzione sugli stream",
|
||||
"retentionDays": "Dati conservati per",
|
||||
"aiCreditsPerMonth": "Crediti AI al mese",
|
||||
"browserChecks": "Controlli browser",
|
||||
"stepsPerCheck": "Passaggi per controllo",
|
||||
"checkRegions": "Regioni",
|
||||
"checkDevices": "Dispositivi",
|
||||
"checkInterval": "Esecuzione ogni",
|
||||
"apiChecks": "Controlli API",
|
||||
"browserRunsNote": "Circa {runs} esecuzioni di passaggi al mese.",
|
||||
"apiRunsNote": "Circa {runs} esecuzioni al mese.",
|
||||
"qtyStepRuns": "{count} esecuzioni di passaggi",
|
||||
"qtyRuns": "{count} esecuzioni",
|
||||
"estimatedTotal": "Totale stimato",
|
||||
"perMonth": "al mese",
|
||||
"estimateDisclaimer": "Questa è una stima al prezzo di listino. Non include eventuali sconti sul tuo account.",
|
||||
"cycleDay": "Giorno {day} di {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Sconto",
|
||||
"costSoFar": "Costo finora",
|
||||
"estimatedCostSoFar": "Costo stimato finora",
|
||||
"projectedTotal": "Totale previsto",
|
||||
"tooEarlyToProject": "Troppo presto nel ciclo per fare una previsione",
|
||||
"colStorageShare": "% dello spazio di archiviazione",
|
||||
"orgDefaultRetention": "Predefinito organizzazione",
|
||||
"retentionDaysValue": "{days} giorni",
|
||||
"topMeter": "Metrica principale",
|
||||
"topMeterShare": "{cost} · {percent}% della fattura",
|
||||
"billedIngestion": "Dati ingeriti",
|
||||
"billedQuery": "Dati analizzati dalle ricerche",
|
||||
"billedPipeline": "Dati elaborati dalle pipeline",
|
||||
"billedRemotePipeline": "Dati inviati a destinazioni remote",
|
||||
"billedRetention": "Dati archiviati nel tempo",
|
||||
"billedAi": "Crediti AI utilizzati",
|
||||
"billedSynthetics": "Esecuzioni di controlli sintetici",
|
||||
"meterShare": "Quota della fattura",
|
||||
"everythingElse": "Tutto il resto",
|
||||
"otherBand": "Altro",
|
||||
"colTotal": "Totale",
|
||||
"groupBy": "Raggruppa per",
|
||||
"groupNotRecorded": "Nessun utilizzo per {group} registrato finora.",
|
||||
"segmentTitle": "{meter}: {billed} fatturati, {share} della fattura",
|
||||
"colEstCost": "Costo stimato",
|
||||
"shareOfMeter": "Quota di {meter}",
|
||||
"costByMeter": "Costo per contatore",
|
||||
"meterByGroup": "{meter} per {group}",
|
||||
"billedDay": "Fatturato, giorno {day} di {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} fatturati ({gross} lordi, {discount} di sconto), {share} della fattura",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Fatturato tramite {payer}. I costi e i budget sono nella pagina Utilizzo di tale organizzazione.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, fatturato come {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "Lordo",
|
||||
"colBilled": "Fatturato",
|
||||
"allMeters": "Tutti i contatori",
|
||||
"noDailyRetention": "La conservazione non ha una cronologia giornaliera. La tabella mostra invece lo spazio di archiviazione di ciascuno stream.",
|
||||
"grossLabel": "Lordo",
|
||||
"cycleCloses": "Il ciclo si chiude il {date}",
|
||||
"dailyCost": "Costo giornaliero",
|
||||
"breakdownSummary": "{cost} fatturato · {volume} · {share} della fattura",
|
||||
"scenarioFrom": "Parti da",
|
||||
"scenarioCurrent": "Il mio utilizzo attuale",
|
||||
"scenarioTwice": "Crescita 2×",
|
||||
"scenarioFiveTimes": "Crescita 5×",
|
||||
"resetEstimate": "Reimposta",
|
||||
"expectedUsage": "Utilizzo mensile previsto",
|
||||
"expectedUsageHint": "Precompilato in base al ciclo finora",
|
||||
"loadingHistory": "Lettura della cronologia di utilizzo…",
|
||||
"yourEstimate": "La tua stima",
|
||||
"assumptionsFrom": "In base agli ultimi 30 giorni",
|
||||
"scanNote": "Analizza circa {volume} al mese.",
|
||||
"qtyScanned": "{volume} analizzati",
|
||||
"qtyProcessed": "{volume} elaborati",
|
||||
"qtyKeptFor": "{volume} per {days} giorni",
|
||||
"qtyCredits": "{count} crediti",
|
||||
"rateIncluded": "Incluso nel tuo piano",
|
||||
"rateTooltip": "{rate}. La fatturazione si basa su {basis}.",
|
||||
"rateIncludedTooltip": "La voce {basis} è inclusa nel tuo piano, quindi non aggiunge nulla a questa stima.",
|
||||
"byIncident": "Per incidente",
|
||||
"colIncident": "Incidente",
|
||||
"colCredits": "Crediti",
|
||||
"byCheck": "Per controllo",
|
||||
"colCheck": "Controllo",
|
||||
"colSteps": "Passaggi",
|
||||
"unknownCheck": "Controllo eliminato",
|
||||
"noBreakdownYet": "Nessun utilizzo in questo ciclo, quindi non c'è ancora nulla da suddividere.",
|
||||
"rangeLastCycle": "Ultimo ciclo",
|
||||
"rangeLast90Days": "Ultimi 90 giorni",
|
||||
"runRate": "Tasso di consumo",
|
||||
"dailyAverage": "Media giornaliera",
|
||||
"peakDay": "Giorno di picco",
|
||||
"runRateWindow": "Ultimi {days} giorni",
|
||||
"acrossDays": "Su {days} giorni",
|
||||
"atPaceProjected": "{cost} al ritmo degli ultimi {days} giorni",
|
||||
"atPace": "Al ritmo di {days} giorni",
|
||||
"topMeterOfCost": "{cost} · {percent}% del costo",
|
||||
"perDay": "{cost} al giorno",
|
||||
"spendToDate": "Spesa fino a oggi",
|
||||
"costOverTime": "Costo nel tempo",
|
||||
"volumeOverTime": "Volume nel tempo",
|
||||
"volumeSubtitle": "Ingestione, ricerca e pipeline, al giorno",
|
||||
"usageByMeter": "Utilizzo per contatore",
|
||||
"dailyVolume": "Volume giornaliero",
|
||||
"lastDaysRange": "Ultimi {days} giorni: da {start} a {end}",
|
||||
"enableVolumeSubtitle": "Attiva la reportistica di utilizzo per vedere quanto consuma ciascun contatore, giorno per giorno.",
|
||||
"measureCost": "Costo",
|
||||
"measureVolume": "Volume",
|
||||
"estimatedAtCyclePrice": "Stimato dall'utilizzo, al prezzo di ciascun ciclo. Esclude la conservazione dei dati.",
|
||||
"showOnlyMeter": "Mostra solo {meter}",
|
||||
"showAllMeters": "Mostra tutti i contatori",
|
||||
"noUsageInRange": "Nessun utilizzo in questo intervallo",
|
||||
"trendsLoadFailed": "Impossibile caricare l'utilizzo per questo intervallo.",
|
||||
"rangeDates": "Da {start} a {end}",
|
||||
"forecast": "Previsione",
|
||||
"dayTotal": "Totale giornaliero",
|
||||
"costByOrg": "Costo per organizzazione",
|
||||
"orgFilter": "Organizzazione",
|
||||
"organizationsTitle": "Organizzazioni",
|
||||
"orgCountThisCycle": "{count} organizzazioni · questo ciclo",
|
||||
"overallBudget": "Budget complessivo",
|
||||
"overallBudgetLabel": "Budget complessivo per questo ciclo di fatturazione",
|
||||
"appliesToGroup": "La fattura dell'intero gruppo",
|
||||
"orgBudgetsTotal": "I budget delle organizzazioni ammontano a {amount}",
|
||||
"orgBudgetsOver": "I budget delle organizzazioni ammontano a {amount}, {over} in più rispetto al budget complessivo",
|
||||
"needsAttention": "Richiede attenzione",
|
||||
"attentionHint": "{over} oltre il budget, {near} vicini alla soglia di avviso",
|
||||
"colSpendThisCycle": "Spesa di questo ciclo",
|
||||
"colBudget": "Budget",
|
||||
"colAlerts": "Avvisi",
|
||||
"statusNone": "Nessun budget",
|
||||
"statusOk": "In linea",
|
||||
"statusWarn": "Vicino al limite",
|
||||
"statusOver": "Oltre il budget",
|
||||
"budgetFor": "Budget per {org}",
|
||||
"warnPercentFor": "Percentuale di avviso per {org}",
|
||||
"alertsFor": "Avvisi email per {org}",
|
||||
"groupAlertsOnHint": "Gli amministratori ricevono email finché la spesa del gruppo resta sopra la soglia di avviso in questo ciclo. Metti in pausa per interromperle. Ogni riga dell'organizzazione ha il proprio interruttore.",
|
||||
"unsavedChanges": "Non salvato: {parts}",
|
||||
"unsavedOrgs": "{count} organizzazione/i",
|
||||
"unsavedOverall": "impostazioni generali",
|
||||
"allChangesSaved": "Tutte le modifiche salvate",
|
||||
"discard": "Scarta",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"orgCostsFailed": "Impossibile caricare i costi per organizzazione. Aggiorna per riprovare.",
|
||||
"clickForOrg": "Clicca per vedere i meter di questa organizzazione",
|
||||
"clickForBreakdown": "Clicca per vedere cosa determina questo meter",
|
||||
"allOrgs": "Tutte le organizzazioni",
|
||||
"colOrganization": "Organizzazione",
|
||||
"shareOfOrg": "Quota di {org}",
|
||||
"thisOrg": "{org} (questa organizzazione)",
|
||||
"formerMemberIngested": "Ex membro · {volume} ingeriti",
|
||||
"ingestedVolume": "{volume} inseriti",
|
||||
"notSplitByOrg": "Addebitato all'intero gruppo, non suddiviso per organizzazione",
|
||||
"orgSummary": "{cost} addebitati · {share} della fattura",
|
||||
"orgCostEstimated": "Questa fattura suddivisa in base alla quota di utilizzo di ciascuna organizzazione",
|
||||
"byOrg": "Per organizzazione",
|
||||
"historyStartsOn": "La cronologia di utilizzo inizia il {date}, l'inizio del tuo ciclo di fatturazione più vecchio di cui abbiamo dati di utilizzo. I giorni precedenti non hanno utilizzo da mostrare.",
|
||||
"budgetTab": "Budget",
|
||||
"budgetKpi": "Budget",
|
||||
"budgetNotSet": "Non impostato",
|
||||
"budgetThisCycle": "Questo ciclo di fatturazione",
|
||||
"spentOfBudget": "{percent}% del budget · giorno {day} di {total}",
|
||||
"overBudgetBy": "{amount} oltre il budget",
|
||||
"toSpare": "{amount} disponibili",
|
||||
"budgetReached": "Budget raggiunto",
|
||||
"reachedOnDay": "Giorno {day}",
|
||||
"notThisCycle": "Non in questo ciclo",
|
||||
"atPacePerDay": "A {cost} al giorno",
|
||||
"atCurrentPace": "Al ritmo attuale",
|
||||
"spendAgainstBudget": "Spesa rispetto al budget",
|
||||
"ofBudget": "di {budget} · {percent}%",
|
||||
"leftInBudget": "{amount} rimanenti",
|
||||
"overBy": "{amount} in eccesso",
|
||||
"warnAtPercent": "Avviso al {percent}%",
|
||||
"shareOfBudget": "{percent}% del budget",
|
||||
"setBudgetFirst": "Imposta un budget per confrontare la spesa di questo ciclo.",
|
||||
"budgetSettings": "Impostazioni budget",
|
||||
"budgetAmount": "Budget per questo ciclo di fatturazione",
|
||||
"budgetAmountHelp": "Al ritmo attuale questo ciclo termina intorno a {amount}",
|
||||
"warnAt": "Avvisa a",
|
||||
"warnAmount": "Importo di avviso in dollari",
|
||||
"warnPercent": "Importo di avviso come percentuale del budget",
|
||||
"warnHelp": "Avvisa quando la spesa supera {amount}",
|
||||
"emailAlerts": "Avvisi email",
|
||||
"alertsOnHint": "Gli amministratori ricevono email finché la spesa resta sopra l'importo di avviso in questo ciclo. Metti in pausa per interromperle.",
|
||||
"alertsPausedHint": "Nessuna email finché non lo riattivi.",
|
||||
"saveBudget": "Salva impostazioni",
|
||||
"budgetSaved": "Budget salvato",
|
||||
"budgetRemoved": "Budget rimosso",
|
||||
"budgetSaveFailed": "Impossibile salvare il budget",
|
||||
"budgetLoadFailedTitle": "Impossibile caricare i tuoi budget",
|
||||
"budgetLoadFailed": "La modifica attende il caricamento, così il salvataggio non può sovrascrivere i budget già impostati. Riprova tra un momento."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Nessun token di Marketplace trovato",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "プランを比較",
|
||||
"contactSupport": "サポートに問い合わせる"
|
||||
},
|
||||
"previousCycle": "前のサイクル",
|
||||
"currentCycle": "現在のサイクル",
|
||||
"period30Days": "30日",
|
||||
"period60Days": "60日",
|
||||
"period3Months": "3か月",
|
||||
"period6Months": "6か月",
|
||||
"pricingErrorMessage": "料金の詳細を読み込めませんでした。",
|
||||
"awsManagedMessage": "請求はAWSアカウントを通じて処理されます",
|
||||
"azureManagedMessage": "請求はAzureアカウントを通じて処理されます",
|
||||
"contractManagedMessage": "請求は契約に基づいて処理されます — 変更についてはアカウントマネージャーにお問い合わせください",
|
||||
"upgradeToPlanMessage": "OpenObserveのサービスを引き続きご利用いただくには、プランをアップグレードしてください。",
|
||||
"creditsUsedLabel": "使用済みクレジット",
|
||||
"errorTracking": "エラートラッキング",
|
||||
"rumSession": "RUMセッション",
|
||||
"licenseExpiryContactAdmin": "管理者に連絡してライセンスを更新してください。",
|
||||
"subscribed": "購読済み",
|
||||
"invoiceHistory": "請求書履歴",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "ステータス",
|
||||
"action": "アクション",
|
||||
"manageSubscription": "サブスクリプション管理",
|
||||
"messageDataNotFound": "データが見つかりません",
|
||||
"invoiceStartDate": "請求開始日",
|
||||
"invoiceEndDate": "請求書終了日",
|
||||
"downloadInvoice": "請求書をダウンロード",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "大量生産が必要ですか?スケールについて話そう!カスタマイズされたソリューションについてはお問い合わせください。",
|
||||
"contactSupport": "サポートに連絡",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "毎日の使用量",
|
||||
"enableTitle": "日次使用量を確認する",
|
||||
"enableSubtitle": "使用状況レポートを有効にすると、取り込み量とクエリ量を日ごとにグラフ化できます。上記の合計はどちらの場合でも利用可能です。",
|
||||
"enableSubtitle": "メーターごとの日次コストとボリュームをグラフ化するには、使用状況レポートをオンにしてください。概要の請求情報はどちらの場合も引き続き確認できます。",
|
||||
"enableButton": "使用状況レポートを有効にする",
|
||||
"enablePending": "使用状況レポートが有効になりました。新しい使用量が記録されると、日次グラフがすぐに入力され始めます。",
|
||||
"enableConfirmTitle": "使用状況レポートを有効にしますか?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "AIクレジットを追加するには、アカウントマネージャーにお問い合わせください",
|
||||
"aiPayerSubscribeMessage": "この組織の請求は {payer} が管理しています。サブスクライブするには {payer} の管理者に依頼してください。以降の使用量はその請求書に請求されます。",
|
||||
"aiFreeUsedLabel": "無料クレジット使用量",
|
||||
"aiBillableCredits": "課金対象の AI クレジット",
|
||||
"aiFreeOneTime": "1 回限りの割り当てです。",
|
||||
"aiUsedByFeature": "アシスタントとクエリ生成: {chat} · インシデント分析: {incident} · 再分析: {reanalysis}",
|
||||
"aiPaygMessage": "無料クレジットを使い切りました — 使用量はサブスクリプションに請求されます",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "親が管理するプラン",
|
||||
"chipNoInvoices": "個別の請求書は不要",
|
||||
"viewOrgGroup": "組織グループを表示",
|
||||
"currentOrgTitle": "現在の組織",
|
||||
"memberOrgsTitle": "メンバー組織",
|
||||
"searchMemberOrg": "組織を検索",
|
||||
"noMemberMatch": "一致する組織がありません",
|
||||
"remove": "削除",
|
||||
"removeTitle": "{name} を請求グループから削除しますか?",
|
||||
"removeBody": "この組織は無料組織になり、自身の利用分を支払います。無料プランの制限が適用されます。ユーザー、データ、および過去の利用分には影響しません。",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "アラートステータス",
|
||||
"contactAdministratorToResolve": "この問題を解決するには、管理者にお問い合わせください。",
|
||||
"contactAdministratorUsageReporting": "使用状況レポートが復元されるように、管理者にお問い合わせください。",
|
||||
"credits": "クレジット",
|
||||
"dataRetention": "データ保持期間",
|
||||
"event": "イベント",
|
||||
"ingestion": "取り込み",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "試用期間が期限切れになりました。",
|
||||
"trialDaysRemaining": "{count} 日(試用アカウントの残り日数) | {count} 日(試用アカウントの残り日数)",
|
||||
"usageReportingFailedForDays": "使用状況のレポートが {count} 日間失敗しました。使用状況が正常にレポートされるまで、RBAC および SSO 機能は無効になっています。 | 使用状況のレポートが {count} 日間失敗しました。使用状況が正常にレポートされるまで、RBAC および SSO 機能は無効になっています。",
|
||||
"usageReportingFailingForHours": "使用状況のレポートが {count} 時間失敗し続けています。まもなく一部の機能が無効になります。 | 使用状況のレポートが {count} 時間失敗し続けています。まもなく一部の機能が無効になります。"
|
||||
"usageReportingFailingForHours": "使用状況のレポートが {count} 時間失敗し続けています。まもなく一部の機能が無効になります。 | 使用状況のレポートが {count} 時間失敗し続けています。まもなく一部の機能が無効になります。",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB時間",
|
||||
"unitCredit": "クレジット",
|
||||
"unitRun": "実行",
|
||||
"ratePerUnit": "{unit}あたり{rate}",
|
||||
"cycleRange": "請求サイクル: {start} ~ {end}",
|
||||
"syntheticsBrowser": "合成モニタリング(ブラウザ)",
|
||||
"syntheticsProtocol": "合成モニタリング(プロトコル)",
|
||||
"overviewTab": "概要",
|
||||
"trendsTab": "トレンド",
|
||||
"estimationTab": "料金見積もり",
|
||||
"costBreakdown": "コスト内訳",
|
||||
"colMeter": "メーター",
|
||||
"colVolume": "ボリューム",
|
||||
"colCost": "コスト",
|
||||
"totalCost": "合計コスト",
|
||||
"missingMeters": "次のメーターの料金を計算できませんでした: {meters}。該当する行のコストはダッシュで表示されます。",
|
||||
"noPricingTitle": "この組織ではコストを利用できません",
|
||||
"noPricingSubtitle": "この組織には従量課金の請求アカウントがないため、表示できる料金がありません。使用量ボリュームはトレンドタブで引き続き確認できます。",
|
||||
"loadFailedTitle": "使用状況を読み込めませんでした",
|
||||
"loadFailedNetwork": "サーバーに接続できませんでした。ネットワーク接続を確認して、もう一度お試しください。",
|
||||
"loadFailedServer": "請求サービスでエラーが発生しました。しばらくしてからもう一度お試しください。",
|
||||
"refreshFailed": "使用状況を更新できませんでした。表示されている数値は最後に読み込んだ時点のものです。",
|
||||
"byStream": "ストリーム別",
|
||||
"colStream": "ストリーム",
|
||||
"byUser": "ユーザー別",
|
||||
"colUser": "ユーザー",
|
||||
"byAlert": "アラート別",
|
||||
"colAlert": "アラート",
|
||||
"byDashboard": "ダッシュボード別",
|
||||
"colDashboard": "ダッシュボード",
|
||||
"byScheduledPipeline": "スケジュール済みパイプライン別",
|
||||
"colScheduledPipeline": "スケジュール済みパイプライン",
|
||||
"byPipeline": "パイプライン別",
|
||||
"colPipeline": "パイプライン",
|
||||
"byDestination": "送信先別",
|
||||
"colDestination": "送信先",
|
||||
"colRetentionPeriod": "保持期間",
|
||||
"colCurrentSize": "現在のサイズ",
|
||||
"drilldownFailed": "この内訳を読み込めませんでした。",
|
||||
"retry": "再試行",
|
||||
"unknownPipeline": "不明",
|
||||
"rangeCurrentCycle": "現在のサイクル",
|
||||
"assumptions": "前提条件",
|
||||
"avgStreamSize": "ストリームあたりの平均データ量",
|
||||
"ingestPerMonth": "月間の取り込みデータ量",
|
||||
"searchesPerMonth": "月間の検索回数",
|
||||
"scanPerSearch": "検索ごとのスキャンデータ量",
|
||||
"pipelineCount": "ストリームで実行中のパイプライン",
|
||||
"retentionDays": "データ保持期間",
|
||||
"aiCreditsPerMonth": "月間のAIクレジット",
|
||||
"browserChecks": "ブラウザチェック",
|
||||
"stepsPerCheck": "チェックあたりのステップ数",
|
||||
"checkRegions": "リージョン",
|
||||
"checkDevices": "デバイス",
|
||||
"checkInterval": "実行間隔",
|
||||
"apiChecks": "APIチェック",
|
||||
"browserRunsNote": "月あたり約{runs}回のステップ実行です。",
|
||||
"apiRunsNote": "月あたり約{runs}回実行されます。",
|
||||
"qtyStepRuns": "{count}回のステップ実行",
|
||||
"qtyRuns": "{count}回の実行",
|
||||
"estimatedTotal": "推定合計",
|
||||
"perMonth": "月あたり",
|
||||
"estimateDisclaimer": "これは定価での推定値です。アカウントの割引は含まれていません。",
|
||||
"cycleDay": "{total}日中{day}日目",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "割引",
|
||||
"costSoFar": "現時点のコスト",
|
||||
"estimatedCostSoFar": "これまでの推定コスト",
|
||||
"projectedTotal": "予測合計",
|
||||
"tooEarlyToProject": "サイクル初期のため予測できません",
|
||||
"colStorageShare": "ストレージの割合",
|
||||
"orgDefaultRetention": "組織のデフォルト",
|
||||
"retentionDaysValue": "{days}日",
|
||||
"topMeter": "トップメーター",
|
||||
"topMeterShare": "{cost} · 請求額の{percent}%",
|
||||
"billedIngestion": "取り込みデータ",
|
||||
"billedQuery": "検索でスキャンされたデータ",
|
||||
"billedPipeline": "パイプラインで処理されたデータ",
|
||||
"billedRemotePipeline": "リモート宛先に送信されたデータ",
|
||||
"billedRetention": "経時的に保存されたデータ",
|
||||
"billedAi": "使用済みAIクレジット",
|
||||
"billedSynthetics": "合成チェック実行回数",
|
||||
"meterShare": "請求額の割合",
|
||||
"everythingElse": "その他すべて",
|
||||
"otherBand": "その他",
|
||||
"colTotal": "合計",
|
||||
"groupBy": "グループ化",
|
||||
"groupNotRecorded": "{group}の使用量はまだ記録されていません。",
|
||||
"segmentTitle": "{meter}: 請求額 {billed}、請求額の {share}",
|
||||
"colEstCost": "推定コスト",
|
||||
"shareOfMeter": "{meter}の割合",
|
||||
"costByMeter": "メーター別コスト",
|
||||
"meterByGroup": "{meter}({group}別)",
|
||||
"billedDay": "請求額、{day}日目(全{total}日)",
|
||||
"segmentTitleDiscounted": "{meter}: 請求額 {billed}(総額 {gross}、割引 {discount})、請求額の {share}",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "{payer} を通じて請求されます。コストと予算はその組織の使用状況ページにあります。",
|
||||
"volumeAtRateBilled": "{volume} · {rate}、{amount} {unit} として請求",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "総額",
|
||||
"colBilled": "請求額",
|
||||
"allMeters": "すべてのメーター",
|
||||
"noDailyRetention": "保持には日次履歴がありません。代わりに、テーブルには各ストリームのストレージが表示されます。",
|
||||
"grossLabel": "総額",
|
||||
"cycleCloses": "サイクル終了: {date}",
|
||||
"dailyCost": "日次コスト",
|
||||
"breakdownSummary": "{cost} 請求 · {volume} · 請求額の {share}",
|
||||
"scenarioFrom": "開始時点",
|
||||
"scenarioCurrent": "現在の使用量",
|
||||
"scenarioTwice": "2倍の成長",
|
||||
"scenarioFiveTimes": "5倍の成長",
|
||||
"resetEstimate": "リセット",
|
||||
"expectedUsage": "予想月間使用量",
|
||||
"expectedUsageHint": "今サイクルのこれまでの使用量から自動入力済み",
|
||||
"loadingHistory": "使用履歴を読み込んでいます…",
|
||||
"yourEstimate": "あなたの見積もり",
|
||||
"assumptionsFrom": "過去30日間に基づく",
|
||||
"scanNote": "月間約{volume}をスキャンします。",
|
||||
"qtyScanned": "{volume} スキャン済み",
|
||||
"qtyProcessed": "{volume} 処理済み",
|
||||
"qtyKeptFor": "{volume} を {days} 日間",
|
||||
"qtyCredits": "{count} クレジット",
|
||||
"rateIncluded": "プランに含まれています",
|
||||
"rateTooltip": "{rate}。{basis} に基づいて請求されます。",
|
||||
"rateIncludedTooltip": "{basis} はプランに含まれているため、この見積もりには加算されません。",
|
||||
"byIncident": "インシデント別",
|
||||
"colIncident": "インシデント",
|
||||
"colCredits": "クレジット",
|
||||
"byCheck": "チェック別",
|
||||
"colCheck": "チェック",
|
||||
"colSteps": "ステップ",
|
||||
"unknownCheck": "削除されたチェック",
|
||||
"noBreakdownYet": "このサイクルは使用量がないため、まだ内訳はありません。",
|
||||
"rangeLastCycle": "前回のサイクル",
|
||||
"rangeLast90Days": "過去90日間",
|
||||
"runRate": "ランレート",
|
||||
"dailyAverage": "1日平均",
|
||||
"peakDay": "ピーク日",
|
||||
"runRateWindow": "過去 {days} 日間",
|
||||
"acrossDays": "{days} 日間全体",
|
||||
"atPaceProjected": "直近 {days} 日間のペースで {cost}",
|
||||
"atPace": "{days} 日間のペースで",
|
||||
"topMeterOfCost": "{cost} · コストの {percent}%",
|
||||
"perDay": "{cost} / 日",
|
||||
"spendToDate": "現在までの支出",
|
||||
"costOverTime": "コストの推移",
|
||||
"volumeOverTime": "ボリュームの推移",
|
||||
"volumeSubtitle": "取り込み、検索、パイプラインの1日あたりの量",
|
||||
"usageByMeter": "メーター別の使用量",
|
||||
"dailyVolume": "日別ボリューム",
|
||||
"lastDaysRange": "過去 {days} 日間: {start} から {end}",
|
||||
"enableVolumeSubtitle": "使用量レポートを有効にすると、各メーターの使用量を日ごとに確認できます。",
|
||||
"measureCost": "コスト",
|
||||
"measureVolume": "ボリューム",
|
||||
"estimatedAtCyclePrice": "使用量から各サイクルの料金で推定。データ保持は含みません。",
|
||||
"showOnlyMeter": "{meter} のみ表示",
|
||||
"showAllMeters": "すべてのメーターを表示",
|
||||
"noUsageInRange": "この範囲に使用量はありません",
|
||||
"trendsLoadFailed": "この範囲の使用量を読み込めませんでした。",
|
||||
"rangeDates": "{start} ~ {end}",
|
||||
"forecast": "予測",
|
||||
"dayTotal": "1日の合計",
|
||||
"costByOrg": "組織別コスト",
|
||||
"orgFilter": "組織",
|
||||
"organizationsTitle": "組織",
|
||||
"orgCountThisCycle": "{count} 組織 · このサイクル",
|
||||
"overallBudget": "全体予算",
|
||||
"overallBudgetLabel": "この請求サイクルの全体予算",
|
||||
"appliesToGroup": "グループ全体の請求",
|
||||
"orgBudgetsTotal": "組織予算の合計は {amount}",
|
||||
"orgBudgetsOver": "組織予算の合計は {amount} で、全体予算を {over} 上回っています",
|
||||
"needsAttention": "要注意",
|
||||
"attentionHint": "予算超過 {over} 件、警告ライン付近 {near} 件",
|
||||
"colSpendThisCycle": "今期の支出",
|
||||
"colBudget": "予算",
|
||||
"colAlerts": "アラート",
|
||||
"statusNone": "予算未設定",
|
||||
"statusOk": "順調",
|
||||
"statusWarn": "上限に接近",
|
||||
"statusOver": "予算超過",
|
||||
"budgetFor": "{org} の予算",
|
||||
"warnPercentFor": "{org} の警告割合",
|
||||
"alertsFor": "{org} のメールアラート",
|
||||
"groupAlertsOnHint": "グループの支出が今回のサイクルで警告額を超えている間、管理者にメールが届きます。メールを止めるには一時停止してください。組織の各行には個別のスイッチがあります。",
|
||||
"unsavedChanges": "未保存: {parts}",
|
||||
"unsavedOrgs": "{count} 組織",
|
||||
"unsavedOverall": "全体設定",
|
||||
"allChangesSaved": "すべての変更を保存しました",
|
||||
"discard": "破棄",
|
||||
"saveChanges": "変更を保存",
|
||||
"orgCostsFailed": "組織別のコストを読み込めませんでした。更新して再試行してください。",
|
||||
"clickForOrg": "クリックしてこの組織のメーターを表示",
|
||||
"clickForBreakdown": "クリックしてこのメーターの要因を表示",
|
||||
"allOrgs": "すべての組織",
|
||||
"colOrganization": "組織",
|
||||
"shareOfOrg": "{org} のシェア",
|
||||
"thisOrg": "{org}(この組織)",
|
||||
"formerMemberIngested": "元メンバー · {volume} 取り込み",
|
||||
"ingestedVolume": "{volume} 取り込み済み",
|
||||
"notSplitByOrg": "グループ全体に請求され、組織別には分割されません",
|
||||
"orgSummary": "{cost} 請求済み · 請求額の {share}",
|
||||
"orgCostEstimated": "この請求は各組織の使用量シェアで分割されます",
|
||||
"byOrg": "組織別",
|
||||
"historyStartsOn": "使用履歴は {date} から始まります。これは使用状況がある最も古い請求サイクルの開始日です。それ以前の日付には表示する使用状況がありません。",
|
||||
"budgetTab": "予算",
|
||||
"budgetKpi": "予算",
|
||||
"budgetNotSet": "未設定",
|
||||
"budgetThisCycle": "今回の請求サイクル",
|
||||
"spentOfBudget": "予算の {percent}% · {total} 日中 {day} 日目",
|
||||
"overBudgetBy": "{amount} 予算超過",
|
||||
"toSpare": "残り {amount}",
|
||||
"budgetReached": "予算到達",
|
||||
"reachedOnDay": "{day} 日目",
|
||||
"notThisCycle": "今回は未到達",
|
||||
"atPacePerDay": "1 日あたり {cost}",
|
||||
"atCurrentPace": "現在のペースで",
|
||||
"spendAgainstBudget": "予算に対する支出",
|
||||
"ofBudget": "{budget} のうち · {percent}%",
|
||||
"leftInBudget": "残り {amount}",
|
||||
"overBy": "{amount} 超過",
|
||||
"warnAtPercent": "{percent}% で警告",
|
||||
"shareOfBudget": "予算の {percent}%",
|
||||
"setBudgetFirst": "予算を設定すると、今回のサイクルの支出を予算に対して追跡できます。",
|
||||
"budgetSettings": "予算設定",
|
||||
"budgetAmount": "この請求サイクルの予算",
|
||||
"budgetAmountHelp": "現在のペースでは、このサイクルは{amount}付近で終了します",
|
||||
"warnAt": "警告しきい値",
|
||||
"warnAmount": "警告金額(ドル)",
|
||||
"warnPercent": "予算に対する警告金額の割合",
|
||||
"warnHelp": "支出が{amount}を超えたらアラート",
|
||||
"emailAlerts": "メールアラート",
|
||||
"alertsOnHint": "このサイクル中、支出が警告金額を超えている間、管理者にメールが送信されます。停止するには一時停止してください。",
|
||||
"alertsPausedHint": "これを再度オンにするまでメールは送信されません。",
|
||||
"saveBudget": "設定を保存",
|
||||
"budgetSaved": "予算を保存しました",
|
||||
"budgetRemoved": "予算を削除しました",
|
||||
"budgetSaveFailed": "予算を保存できませんでした",
|
||||
"budgetLoadFailedTitle": "予算を読み込めませんでした",
|
||||
"budgetLoadFailed": "読み込まれるまで編集はできません。これにより、保存時に既存の予算が上書きされるのを防ぎます。しばらくしてからもう一度お試しください。"
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "マーケットプレイストークンが見つかりません",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "플랜 비교",
|
||||
"contactSupport": "지원팀에 문의"
|
||||
},
|
||||
"previousCycle": "이전 주기",
|
||||
"currentCycle": "현재 주기",
|
||||
"period30Days": "30일",
|
||||
"period60Days": "60일",
|
||||
"period3Months": "3개월",
|
||||
"period6Months": "6개월",
|
||||
"pricingErrorMessage": "요금 세부 정보를 불러오지 못했습니다.",
|
||||
"awsManagedMessage": "청구는 AWS 계정을 통해 처리됩니다.",
|
||||
"azureManagedMessage": "청구는 Azure 계정을 통해 처리됩니다.",
|
||||
"contractManagedMessage": "청구는 계약을 통해 처리됩니다 — 변경 사항은 계정 관리자에게 문의하세요.",
|
||||
"upgradeToPlanMessage": "OpenObserve 서비스를 계속 이용하려면 요금제를 업그레이드하세요.",
|
||||
"creditsUsedLabel": "사용된 크레딧",
|
||||
"errorTracking": "오류 추적",
|
||||
"rumSession": "RUM 세션",
|
||||
"licenseExpiryContactAdmin": "관리자에게 문의하여 라이선스를 업데이트하세요.",
|
||||
"subscribed": "구독됨",
|
||||
"invoiceHistory": "인보이스 내역",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "상태",
|
||||
"action": "액션",
|
||||
"manageSubscription": "구독 관리",
|
||||
"messageDataNotFound": "데이터를 찾을 수 없음",
|
||||
"invoiceStartDate": "인보이스 시작 날짜",
|
||||
"invoiceEndDate": "인보이스 종료 날짜",
|
||||
"downloadInvoice": "인보이스 다운로드",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "대량 구매가 필요하신가요?스케일에 대해 얘기해 볼까요!맞춤형 솔루션을 원하시면 저희에게 문의하세요.",
|
||||
"contactSupport": "지원팀에 문의",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "일일 사용량",
|
||||
"enableTitle": "일일 사용량 보기",
|
||||
"enableSubtitle": "사용량 보고를 켜서 일일 수집 및 쿼리 양을 차트로 확인하세요. 위의 총계는 어느 쪽이든 계속 사용할 수 있습니다.",
|
||||
"enableSubtitle": "각 미터의 일별 비용과 사용량을 차트로 확인하려면 사용량 보고를 켜세요. 개요의 청구서는 어느 경우든 계속 이용할 수 있습니다.",
|
||||
"enableButton": "사용량 보고 활성화",
|
||||
"enablePending": "사용량 보고가 활성화되었습니다. 새로운 사용량이 기록됨에 따라 일일 차트가 곧 채워지기 시작합니다.",
|
||||
"enableConfirmTitle": "사용량 보고를 활성화하시겠습니까?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "추가 AI 크레딧을 원하시면 계정 관리자에게 문의하세요.",
|
||||
"aiPayerSubscribeMessage": "이 조직의 요금 청구는 {payer}에서 관리합니다. {payer}의 관리자에게 구독을 요청하세요. 추가 사용량은 해당 청구서로 청구됩니다.",
|
||||
"aiFreeUsedLabel": "사용한 무료 크레딧",
|
||||
"aiBillableCredits": "청구 가능한 AI 크레딧",
|
||||
"aiFreeOneTime": "일회성 허용량입니다.",
|
||||
"aiUsedByFeature": "어시스턴트 및 쿼리 생성: {chat} · 인시던트 분석: {incident} · 재분석: {reanalysis}",
|
||||
"aiPaygMessage": "무료 크레딧 소진 — 사용량이 구독에 청구됨",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "부모가 관리하는 플랜",
|
||||
"chipNoInvoices": "별도 청구서 없음",
|
||||
"viewOrgGroup": "조직 그룹 보기",
|
||||
"currentOrgTitle": "현재 조직",
|
||||
"memberOrgsTitle": "구성원 조직",
|
||||
"searchMemberOrg": "조직 검색",
|
||||
"noMemberMatch": "일치하는 조직 없음",
|
||||
"remove": "제거",
|
||||
"removeTitle": "청구 그룹에서 {name}을(를) 제거하시겠습니까?",
|
||||
"removeBody": "이 조직은 무료 조직이 되어 자체 사용량을 결제합니다. 무료 플랜 한도가 적용됩니다. 해당 조직의 사용자, 데이터, 과거 사용량에는 영향을 주지 않습니다.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "알림 상태",
|
||||
"contactAdministratorToResolve": "이 문제를 해결하려면 관리자에게 문의하세요.",
|
||||
"contactAdministratorUsageReporting": "사용량 보고가 복원되도록 관리자에게 문의하세요.",
|
||||
"credits": "크레딧",
|
||||
"dataRetention": "데이터 보존",
|
||||
"event": "이벤트",
|
||||
"ingestion": "수집",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "평가판 기간이 만료되었습니다.",
|
||||
"trialDaysRemaining": "평가판 계정에 {count}일 남았습니다 | 평가판 계정에 {count}일 남았습니다",
|
||||
"usageReportingFailedForDays": "사용량 보고가 {count}일 동안 실패했습니다. 사용량이 성공적으로 보고될 때까지 RBAC 및 SSO 기능이 비활성화됩니다. | 사용량 보고가 {count}일 동안 실패했습니다. 사용량이 성공적으로 보고될 때까지 RBAC 및 SSO 기능이 비활성화됩니다.",
|
||||
"usageReportingFailingForHours": "사용량 보고가 {count}시간 동안 실패했습니다. 곧 일부 기능이 비활성화됩니다. | 사용량 보고가 {count}시간 동안 실패했습니다. 곧 일부 기능이 비활성화됩니다."
|
||||
"usageReportingFailingForHours": "사용량 보고가 {count}시간 동안 실패했습니다. 곧 일부 기능이 비활성화됩니다. | 사용량 보고가 {count}시간 동안 실패했습니다. 곧 일부 기능이 비활성화됩니다.",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-시간",
|
||||
"unitCredit": "크레딧",
|
||||
"unitRun": "실행",
|
||||
"ratePerUnit": "{unit}당 {rate}",
|
||||
"cycleRange": "청구 주기: {start} ~ {end}",
|
||||
"syntheticsBrowser": "Synthetics (브라우저)",
|
||||
"syntheticsProtocol": "Synthetics (프로토콜)",
|
||||
"overviewTab": "개요",
|
||||
"trendsTab": "추세",
|
||||
"estimationTab": "가격 추정",
|
||||
"costBreakdown": "비용 내역",
|
||||
"colMeter": "미터",
|
||||
"colVolume": "사용량",
|
||||
"colCost": "비용",
|
||||
"totalCost": "총 비용",
|
||||
"missingMeters": "다음 미터의 가격을 산정할 수 없습니다: {meters}. 해당 행에는 비용 대신 대시(-)가 표시됩니다.",
|
||||
"noPricingTitle": "이 조직에서는 비용을 확인할 수 없습니다",
|
||||
"noPricingSubtitle": "이 조직에는 사용량 기반 청구 계정이 없어 표시할 가격이 없습니다. 사용량은 추세 탭에서 계속 확인할 수 있습니다.",
|
||||
"loadFailedTitle": "사용량을 불러올 수 없습니다",
|
||||
"loadFailedNetwork": "서버에 연결할 수 없습니다. 네트워크 연결을 확인한 후 다시 시도하세요.",
|
||||
"loadFailedServer": "요금 청구 서비스에서 오류가 발생했습니다. 잠시 후 다시 시도하세요.",
|
||||
"refreshFailed": "사용량을 새로 고치지 못했습니다. 표시된 수치는 마지막으로 불러온 값입니다.",
|
||||
"byStream": "스트림별",
|
||||
"colStream": "스트림",
|
||||
"byUser": "사용자별",
|
||||
"colUser": "사용자",
|
||||
"byAlert": "알림별",
|
||||
"colAlert": "알림",
|
||||
"byDashboard": "대시보드별",
|
||||
"colDashboard": "대시보드",
|
||||
"byScheduledPipeline": "예약된 파이프라인별",
|
||||
"colScheduledPipeline": "예약된 파이프라인",
|
||||
"byPipeline": "파이프라인별",
|
||||
"colPipeline": "파이프라인",
|
||||
"byDestination": "대상별",
|
||||
"colDestination": "대상",
|
||||
"colRetentionPeriod": "보존 기간",
|
||||
"colCurrentSize": "현재 크기",
|
||||
"drilldownFailed": "이 내역을 불러올 수 없습니다.",
|
||||
"retry": "재시도",
|
||||
"unknownPipeline": "알 수 없음",
|
||||
"rangeCurrentCycle": "현재 주기",
|
||||
"assumptions": "가정",
|
||||
"avgStreamSize": "스트림당 평균 데이터량",
|
||||
"ingestPerMonth": "월별 수집 데이터",
|
||||
"searchesPerMonth": "월별 검색 수",
|
||||
"scanPerSearch": "검색당 스캔된 데이터",
|
||||
"pipelineCount": "스트림에서 실행 중인 파이프라인",
|
||||
"retentionDays": "데이터 보관 기간",
|
||||
"aiCreditsPerMonth": "월별 AI 크레딧",
|
||||
"browserChecks": "브라우저 검사",
|
||||
"stepsPerCheck": "검사당 단계",
|
||||
"checkRegions": "리전",
|
||||
"checkDevices": "디바이스",
|
||||
"checkInterval": "실행 주기",
|
||||
"apiChecks": "API 검사",
|
||||
"browserRunsNote": "월 약 {runs}회 단계 실행입니다.",
|
||||
"apiRunsNote": "월 약 {runs}회 실행입니다.",
|
||||
"qtyStepRuns": "{count}회 단계 실행",
|
||||
"qtyRuns": "{count}회 실행",
|
||||
"estimatedTotal": "예상 합계",
|
||||
"perMonth": "월당",
|
||||
"estimateDisclaimer": "정가 기준 예상치입니다. 계정의 할인은 포함되지 않습니다.",
|
||||
"cycleDay": "{day}일 / {total}일",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "할인",
|
||||
"costSoFar": "현재까지 비용",
|
||||
"estimatedCostSoFar": "현재까지 예상 비용",
|
||||
"projectedTotal": "예상 총액",
|
||||
"tooEarlyToProject": "주기 초반이라 예측하기에는 이릅니다",
|
||||
"colStorageShare": "스토리지 비중",
|
||||
"orgDefaultRetention": "조직 기본값",
|
||||
"retentionDaysValue": "{days}일",
|
||||
"topMeter": "상위 미터",
|
||||
"topMeterShare": "{cost} · 청구서의 {percent}%",
|
||||
"billedIngestion": "수집된 데이터",
|
||||
"billedQuery": "검색으로 스캔된 데이터",
|
||||
"billedPipeline": "파이프라인에서 처리된 데이터",
|
||||
"billedRemotePipeline": "원격 대상으로 전송된 데이터",
|
||||
"billedRetention": "시간 경과에 따라 저장된 데이터",
|
||||
"billedAi": "사용된 AI 크레딧",
|
||||
"billedSynthetics": "Synthetics 검사 실행",
|
||||
"meterShare": "청구서 비중",
|
||||
"everythingElse": "나머지 전체",
|
||||
"otherBand": "기타",
|
||||
"colTotal": "합계",
|
||||
"groupBy": "그룹 기준",
|
||||
"groupNotRecorded": "아직 기록된 {group} 사용량이 없습니다.",
|
||||
"segmentTitle": "{meter}: {billed} 청구, 청구서 비중 {share}",
|
||||
"colEstCost": "예상 비용",
|
||||
"shareOfMeter": "{meter} 비중",
|
||||
"costByMeter": "미터별 비용",
|
||||
"meterByGroup": "{group}별 {meter}",
|
||||
"billedDay": "청구됨, {total}일 중 {day}일",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} 청구(총액 {gross}, 할인 {discount}), 청구서 비중 {share}",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "{payer}을(를) 통해 청구됩니다. 비용과 예산은 해당 조직의 사용량 페이지에 있습니다.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, {amount} {unit}(으)로 청구",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "총액",
|
||||
"colBilled": "청구액",
|
||||
"allMeters": "모든 미터",
|
||||
"noDailyRetention": "보존 기간에는 일별 이력이 없습니다. 대신 표에는 각 스트림의 스토리지가 표시됩니다.",
|
||||
"grossLabel": "총액",
|
||||
"cycleCloses": "주기 종료 {date}",
|
||||
"dailyCost": "일일 비용",
|
||||
"breakdownSummary": "{cost} 청구 · {volume} · 청구서 비중 {share}",
|
||||
"scenarioFrom": "시작 기준",
|
||||
"scenarioCurrent": "내 현재 사용량",
|
||||
"scenarioTwice": "2배 성장",
|
||||
"scenarioFiveTimes": "5배 성장",
|
||||
"resetEstimate": "초기화",
|
||||
"expectedUsage": "예상 월간 사용량",
|
||||
"expectedUsageHint": "이번 주기 현재까지 기준으로 미리 입력됨",
|
||||
"loadingHistory": "사용 내역을 불러오는 중…",
|
||||
"yourEstimate": "내 예상치",
|
||||
"assumptionsFrom": "최근 30일 기준",
|
||||
"scanNote": "월간 스캔량은 약 {volume}입니다.",
|
||||
"qtyScanned": "{volume} 스캔됨",
|
||||
"qtyProcessed": "{volume} 처리됨",
|
||||
"qtyKeptFor": "{volume} · {days}일",
|
||||
"qtyCredits": "{count} 크레딧",
|
||||
"rateIncluded": "플랜에 포함됨",
|
||||
"rateTooltip": "{rate}. {basis} 기준으로 청구됩니다.",
|
||||
"rateIncludedTooltip": "{basis}은(는) 플랜에 포함되어 있으므로 이 추정치에 아무것도 추가되지 않습니다.",
|
||||
"byIncident": "인시던트별",
|
||||
"colIncident": "인시던트",
|
||||
"colCredits": "크레딧",
|
||||
"byCheck": "체크별",
|
||||
"colCheck": "체크",
|
||||
"colSteps": "단계",
|
||||
"unknownCheck": "삭제된 체크",
|
||||
"noBreakdownYet": "이번 주기에는 사용량이 없어 아직 세분화할 항목이 없습니다.",
|
||||
"rangeLastCycle": "지난 주기",
|
||||
"rangeLast90Days": "최근 90일",
|
||||
"runRate": "사용 속도",
|
||||
"dailyAverage": "일일 평균",
|
||||
"peakDay": "최고 사용일",
|
||||
"runRateWindow": "최근 {days}일",
|
||||
"acrossDays": "{days}일 전체",
|
||||
"atPaceProjected": "최근 {days}일 추세 기준 {cost}",
|
||||
"atPace": "{days}일 추세 기준",
|
||||
"topMeterOfCost": "{cost} · 비용의 {percent}%",
|
||||
"perDay": "{cost}/일",
|
||||
"spendToDate": "현재까지 지출",
|
||||
"costOverTime": "시간별 비용",
|
||||
"volumeOverTime": "시간별 볼륨",
|
||||
"volumeSubtitle": "수집, 검색, 파이프라인 일별",
|
||||
"usageByMeter": "미터별 사용량",
|
||||
"dailyVolume": "일별 볼륨",
|
||||
"lastDaysRange": "최근 {days}일: {start} ~ {end}",
|
||||
"enableVolumeSubtitle": "사용량 보고를 켜면 각 미터의 사용량을 일별로 확인할 수 있습니다.",
|
||||
"measureCost": "비용",
|
||||
"measureVolume": "볼륨",
|
||||
"estimatedAtCyclePrice": "사용량을 기준으로 각 주기의 가격으로 산정한 예상치입니다. 데이터 보존은 제외됩니다.",
|
||||
"showOnlyMeter": "{meter}만 표시",
|
||||
"showAllMeters": "모든 과금 항목 표시",
|
||||
"noUsageInRange": "이 범위에 사용량이 없습니다",
|
||||
"trendsLoadFailed": "이 범위의 사용량을 불러오지 못했습니다.",
|
||||
"rangeDates": "{start} ~ {end}",
|
||||
"forecast": "예측",
|
||||
"dayTotal": "일일 합계",
|
||||
"costByOrg": "조직별 비용",
|
||||
"orgFilter": "조직",
|
||||
"organizationsTitle": "조직",
|
||||
"orgCountThisCycle": "{count}개 조직 · 이번 주기",
|
||||
"overallBudget": "전체 예산",
|
||||
"overallBudgetLabel": "이번 결제 주기의 전체 예산",
|
||||
"appliesToGroup": "그룹 전체의 청구서",
|
||||
"orgBudgetsTotal": "조직 예산 합계는 {amount}",
|
||||
"orgBudgetsOver": "조직 예산 합계는 {amount}이며, 전체 예산보다 {over} 많습니다",
|
||||
"needsAttention": "주의 필요",
|
||||
"attentionHint": "{over} 예산 초과, {near} 경고 기준선 근접",
|
||||
"colSpendThisCycle": "이번 주기 지출",
|
||||
"colBudget": "예산",
|
||||
"colAlerts": "알림",
|
||||
"statusNone": "예산 없음",
|
||||
"statusOk": "정상",
|
||||
"statusWarn": "한도 근접",
|
||||
"statusOver": "예산 초과",
|
||||
"budgetFor": "{org} 예산",
|
||||
"warnPercentFor": "{org} 경고 비율",
|
||||
"alertsFor": "{org} 이메일 알림",
|
||||
"groupAlertsOnHint": "이번 주기 동안 그룹 지출이 경고 금액을 초과하는 한 관리자에게 이메일이 발송됩니다. 이메일을 중지하려면 일시 중지하세요. 각 조직 행에는 별도의 스위치가 있습니다.",
|
||||
"unsavedChanges": "저장되지 않음: {parts}",
|
||||
"unsavedOrgs": "{count}개 조직",
|
||||
"unsavedOverall": "전체 설정",
|
||||
"allChangesSaved": "모든 변경 사항 저장됨",
|
||||
"discard": "버리기",
|
||||
"saveChanges": "변경 사항 저장",
|
||||
"orgCostsFailed": "조직별 비용을 불러올 수 없습니다. 새로고침하여 다시 시도하세요.",
|
||||
"clickForOrg": "이 조직의 사용량 항목을 보려면 클릭하세요",
|
||||
"clickForBreakdown": "이 사용량 항목을 구성하는 요소를 보려면 클릭하세요",
|
||||
"allOrgs": "모든 조직",
|
||||
"colOrganization": "조직",
|
||||
"shareOfOrg": "{org} 내 비중",
|
||||
"thisOrg": "{org}(이 조직)",
|
||||
"formerMemberIngested": "이전 구성원 · {volume} 수집됨",
|
||||
"ingestedVolume": "{volume} 수집됨",
|
||||
"notSplitByOrg": "조직별로 분할되지 않고 전체 그룹에 청구됨",
|
||||
"orgSummary": "{cost} 청구됨 · 전체 청구서의 {share}",
|
||||
"orgCostEstimated": "이 청구서는 각 조직의 사용량 비중에 따라 분할됩니다",
|
||||
"byOrg": "조직별",
|
||||
"historyStartsOn": "사용량 기록은 사용량 데이터가 있는 가장 오래된 청구 주기의 시작일인 {date}부터 시작됩니다. 그 이전 날짜에는 표시할 사용량이 없습니다.",
|
||||
"budgetTab": "예산",
|
||||
"budgetKpi": "예산",
|
||||
"budgetNotSet": "설정 안 됨",
|
||||
"budgetThisCycle": "이번 청구 주기",
|
||||
"spentOfBudget": "예산의 {percent}% · {total}일 중 {day}일째",
|
||||
"overBudgetBy": "예산보다 {amount} 초과",
|
||||
"toSpare": "{amount} 여유",
|
||||
"budgetReached": "예산 도달",
|
||||
"reachedOnDay": "{day}일차",
|
||||
"notThisCycle": "이번 주기 아님",
|
||||
"atPacePerDay": "일일 {cost} 기준",
|
||||
"atCurrentPace": "현재 속도 기준",
|
||||
"spendAgainstBudget": "예산 대비 지출",
|
||||
"ofBudget": "{budget} 중 · {percent}%",
|
||||
"leftInBudget": "{amount} 남음",
|
||||
"overBy": "{amount} 초과",
|
||||
"warnAtPercent": "{percent}%에서 경고",
|
||||
"shareOfBudget": "예산의 {percent}%",
|
||||
"setBudgetFirst": "이번 주기 지출을 추적하려면 먼저 예산을 설정하세요.",
|
||||
"budgetSettings": "예산 설정",
|
||||
"budgetAmount": "이번 청구 주기 예산",
|
||||
"budgetAmountHelp": "현재 속도라면 이번 주기 종료 시 {amount}에 가까울 것으로 예상됩니다.",
|
||||
"warnAt": "경고 기준",
|
||||
"warnAmount": "경고 금액(달러)",
|
||||
"warnPercent": "예산 대비 경고 금액 비율",
|
||||
"warnHelp": "지출이 {amount}를 넘으면 알림",
|
||||
"emailAlerts": "이메일 알림",
|
||||
"alertsOnHint": "이번 주기에서 지출이 경고 금액을 초과하는 동안 관리자에게 이메일이 계속 발송됩니다. 중지하려면 일시중지하세요.",
|
||||
"alertsPausedHint": "다시 켤 때까지 이메일이 발송되지 않습니다.",
|
||||
"saveBudget": "설정 저장",
|
||||
"budgetSaved": "예산이 저장되었습니다",
|
||||
"budgetRemoved": "예산이 삭제되었습니다",
|
||||
"budgetSaveFailed": "예산을 저장하지 못했습니다",
|
||||
"budgetLoadFailedTitle": "예산을 불러오지 못했습니다",
|
||||
"budgetLoadFailed": "예산이 로드될 때까지 편집이 대기되므로, 저장으로 이미 설정된 예산을 덮어쓸 수 없습니다. 잠시 후 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "마켓플레이스 토큰을 찾을 수 없습니다.",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Plannen vergelijken",
|
||||
"contactSupport": "Contact opnemen met support"
|
||||
},
|
||||
"previousCycle": "Vorige cyclus",
|
||||
"currentCycle": "Huidige cyclus",
|
||||
"period30Days": "30 dagen",
|
||||
"period60Days": "60 dagen",
|
||||
"period3Months": "3 maanden",
|
||||
"period6Months": "6 maanden",
|
||||
"pricingErrorMessage": "Prijsdetails zijn niet geladen.",
|
||||
"awsManagedMessage": "Facturering verloopt via uw AWS-account",
|
||||
"azureManagedMessage": "Facturering verloopt via uw Azure-account",
|
||||
"contractManagedMessage": "Facturering verloopt via uw contract — neem contact op met uw accountmanager voor wijzigingen",
|
||||
"upgradeToPlanMessage": "Upgrade naar een abonnement om te blijven genieten van de diensten van OpenObserve.",
|
||||
"creditsUsedLabel": "credits gebruikt",
|
||||
"errorTracking": "Foutopsporing",
|
||||
"rumSession": "RUM-sessie",
|
||||
"licenseExpiryContactAdmin": "Update uw licentie door contact op te nemen met uw beheerder.",
|
||||
"subscribed": "Geabonneerd",
|
||||
"invoiceHistory": "Factuurgeschiedenis",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Status",
|
||||
"action": "Actie",
|
||||
"manageSubscription": "Abonnement beheren",
|
||||
"messageDataNotFound": "Gegevens niet gevonden",
|
||||
"invoiceStartDate": "Startdatum van de factuur",
|
||||
"invoiceEndDate": "Einddatum van de factuur",
|
||||
"downloadInvoice": "Factuur downloaden",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Heeft u grote hoeveelheden nodig? Laten we het over schaal hebben! Neem contact met ons op voor oplossingen op maat.",
|
||||
"contactSupport": "Contact opnemen met de klantenservice",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Dagelijks gebruik",
|
||||
"enableTitle": "Bekijk uw dagelijkse gebruik",
|
||||
"enableSubtitle": "Schakel gebruiksrapportage in om uw opname- en queryvolume dagelijks in kaart te brengen. Uw totalen hierboven blijven in beide gevallen beschikbaar.",
|
||||
"enableSubtitle": "Schakel gebruiksrapportage in om je dagelijkse kosten en volume per meter in een grafiek te bekijken. Je factuur op Overzicht blijft hoe dan ook beschikbaar.",
|
||||
"enableButton": "Gebruiksrapportage inschakelen",
|
||||
"enablePending": "Gebruiksrapportage ingeschakeld. Uw dagelijkse grafiek begint zich binnenkort te vullen zodra nieuw gebruik wordt geregistreerd.",
|
||||
"enableConfirmTitle": "Gebruiksrapportage inschakelen?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Neem contact op met uw accountmanager om meer AI-tegoed toe te voegen",
|
||||
"aiPayerSubscribeMessage": "De facturatie voor deze organisatie wordt beheerd door {payer}. Vraag een beheerder van {payer} om een abonnement te nemen; verder gebruik wordt gefactureerd op de factuur ervan.",
|
||||
"aiFreeUsedLabel": "gratis credits gebruikt",
|
||||
"aiBillableCredits": "Factureerbare AI-credits",
|
||||
"aiFreeOneTime": "Een eenmalige toewijzing.",
|
||||
"aiUsedByFeature": "Assistent en querygeneratie: {chat} · Incidentanalyse: {incident} · Heranalyse: {reanalysis}",
|
||||
"aiPaygMessage": "Gratis credits uitgeput — gebruik wordt gefactureerd op het abonnement",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Plan beheerd door de ouder",
|
||||
"chipNoInvoices": "Geen afzonderlijke facturen",
|
||||
"viewOrgGroup": "Organisatiegroep bekijken",
|
||||
"currentOrgTitle": "Huidige Organisatie",
|
||||
"memberOrgsTitle": "Lidorganisaties",
|
||||
"searchMemberOrg": "Organisatie zoeken",
|
||||
"noMemberMatch": "Geen overeenkomende organisaties",
|
||||
"remove": "Verwijderen",
|
||||
"removeTitle": "{name} uit je facturatiegroep verwijderen?",
|
||||
"removeBody": "Deze organisatie wordt een gratis organisatie en betaalt voor zijn eigen verbruik. De limieten van het gratis plan zijn van toepassing. De gebruikers, gegevens en het eerdere verbruik blijven ongewijzigd.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Alarmstatus",
|
||||
"contactAdministratorToResolve": "Neem contact op met uw beheerder om dit probleem op te lossen.",
|
||||
"contactAdministratorUsageReporting": "Neem contact op met uw beheerder om ervoor te zorgen dat gebruiksrapportage wordt hersteld.",
|
||||
"credits": "Tegoed",
|
||||
"dataRetention": "Gegevensbewaring",
|
||||
"event": "Gebeurtenis",
|
||||
"ingestion": "Ingestie",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Uw proefperiode is verlopen.",
|
||||
"trialDaysRemaining": "{count} dag resterend in uw proefaccount | {count} dagen resterend in uw proefaccount",
|
||||
"usageReportingFailedForDays": "Gebruiksrapportage is mislukt voor {count} dag, RBAC- en SSO-functies zijn uitgeschakeld totdat het gebruik met succes is gerapporteerd | Gebruiksrapportage is mislukt voor {count} dagen, RBAC- en SSO-functies zijn uitgeschakeld totdat het gebruik met succes is gerapporteerd",
|
||||
"usageReportingFailingForHours": "Gebruiksrapportage mislukt al {count} uur, sommige functies worden binnenkort uitgeschakeld | Gebruiksrapportage mislukt al {count} uren, sommige functies worden binnenkort uitgeschakeld"
|
||||
"usageReportingFailingForHours": "Gebruiksrapportage mislukt al {count} uur, sommige functies worden binnenkort uitgeschakeld | Gebruiksrapportage mislukt al {count} uren, sommige functies worden binnenkort uitgeschakeld",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-uur",
|
||||
"unitCredit": "Tegoed",
|
||||
"unitRun": "Uitvoering",
|
||||
"ratePerUnit": "{rate} per {unit}",
|
||||
"cycleRange": "Factureringscyclus: {start} tot {end}",
|
||||
"syntheticsBrowser": "Synthetics (browser)",
|
||||
"syntheticsProtocol": "Synthetics (protocol)",
|
||||
"overviewTab": "Overzicht",
|
||||
"trendsTab": "Trends",
|
||||
"estimationTab": "Prijsraming",
|
||||
"costBreakdown": "Kostenverdeling",
|
||||
"colMeter": "Meter",
|
||||
"colVolume": "Volume",
|
||||
"colCost": "Kosten",
|
||||
"totalCost": "Totale kosten",
|
||||
"missingMeters": "We konden deze meters niet prijzen: {meters}. Hun rijen tonen een streepje in plaats van kosten.",
|
||||
"noPricingTitle": "Kosten zijn niet beschikbaar voor deze organisatie",
|
||||
"noPricingSubtitle": "Deze organisatie heeft geen account voor gemeten facturering, dus we hebben geen prijzen om te tonen. Gebruiksvolume is nog wel beschikbaar op het tabblad Trends.",
|
||||
"loadFailedTitle": "Je gebruik kon niet worden geladen",
|
||||
"loadFailedNetwork": "De server kon niet worden bereikt. Controleer je netwerkverbinding en probeer het opnieuw.",
|
||||
"loadFailedServer": "De factureringsdienst gaf een fout terug. Probeer het zo meteen opnieuw.",
|
||||
"refreshFailed": "Gebruik kon niet worden vernieuwd. De getoonde cijfers zijn van de laatste keer laden.",
|
||||
"byStream": "Per stream",
|
||||
"colStream": "Stream",
|
||||
"byUser": "Per gebruiker",
|
||||
"colUser": "Gebruiker",
|
||||
"byAlert": "Per alarm",
|
||||
"colAlert": "Alarm",
|
||||
"byDashboard": "Per dashboard",
|
||||
"colDashboard": "Dashboard",
|
||||
"byScheduledPipeline": "Per geplande pijpleiding",
|
||||
"colScheduledPipeline": "Geplande pijpleiding",
|
||||
"byPipeline": "Per pijpleiding",
|
||||
"colPipeline": "Pijpleiding",
|
||||
"byDestination": "Per bestemming",
|
||||
"colDestination": "Bestemming",
|
||||
"colRetentionPeriod": "Bewaarperiode",
|
||||
"colCurrentSize": "Huidige grootte",
|
||||
"drilldownFailed": "Deze verdeling kon niet worden geladen.",
|
||||
"retry": "Opnieuw proberen",
|
||||
"unknownPipeline": "Onbekend",
|
||||
"rangeCurrentCycle": "Huidige cyclus",
|
||||
"assumptions": "Aannames",
|
||||
"avgStreamSize": "Gemiddelde hoeveelheid gegevens per stream",
|
||||
"ingestPerMonth": "Geïngesteerde gegevens per maand",
|
||||
"searchesPerMonth": "Zoekopdrachten per maand",
|
||||
"scanPerSearch": "Gescande gegevens per zoekopdracht",
|
||||
"pipelineCount": "Pijpleidingen die op streams draaien",
|
||||
"retentionDays": "Bewaartermijn van gegevens",
|
||||
"aiCreditsPerMonth": "AI-credits per maand",
|
||||
"browserChecks": "Browsercontroles",
|
||||
"stepsPerCheck": "Stappen per controle",
|
||||
"checkRegions": "Regio's",
|
||||
"checkDevices": "Apparaten",
|
||||
"checkInterval": "Draait elke",
|
||||
"apiChecks": "API-controles",
|
||||
"browserRunsNote": "Ongeveer {runs} stapuitvoeringen per maand.",
|
||||
"apiRunsNote": "Ongeveer {runs} uitvoeringen per maand.",
|
||||
"qtyStepRuns": "{count} stapuitvoeringen",
|
||||
"qtyRuns": "{count} uitvoeringen",
|
||||
"estimatedTotal": "Geschat totaal",
|
||||
"perMonth": "per maand",
|
||||
"estimateDisclaimer": "Dit is een schatting tegen lijstprijs. Kortingen op je account zijn niet inbegrepen.",
|
||||
"cycleDay": "Dag {day} van {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Korting",
|
||||
"costSoFar": "Kosten tot nu toe",
|
||||
"estimatedCostSoFar": "Geschatte kosten tot nu toe",
|
||||
"projectedTotal": "Verwacht totaal",
|
||||
"tooEarlyToProject": "Te vroeg in de cyclus om een projectie te maken",
|
||||
"colStorageShare": "% van opslag",
|
||||
"orgDefaultRetention": "Org-standaard",
|
||||
"retentionDaysValue": "{days} dagen",
|
||||
"topMeter": "Grootste meter",
|
||||
"topMeterShare": "{cost} · {percent}% van de rekening",
|
||||
"billedIngestion": "Geïngesteerde gegevens",
|
||||
"billedQuery": "Door zoekopdrachten gescande gegevens",
|
||||
"billedPipeline": "Door pijpleidingen verwerkte gegevens",
|
||||
"billedRemotePipeline": "Gegevens verzonden naar externe bestemmingen",
|
||||
"billedRetention": "Opgeslagen gegevens in de tijd",
|
||||
"billedAi": "Gebruikte AI-credits",
|
||||
"billedSynthetics": "Synthetische checkruns",
|
||||
"meterShare": "Aandeel in de factuur",
|
||||
"everythingElse": "Al het overige",
|
||||
"otherBand": "Overig",
|
||||
"colTotal": "Totaal",
|
||||
"groupBy": "Groeperen op",
|
||||
"groupNotRecorded": "Nog geen gebruik van {group} geregistreerd.",
|
||||
"segmentTitle": "{meter}: {billed} gefactureerd, {share} van de factuur",
|
||||
"colEstCost": "Gesch. kosten",
|
||||
"shareOfMeter": "Aandeel in {meter}",
|
||||
"costByMeter": "Kosten per meter",
|
||||
"meterByGroup": "{meter} per {group}",
|
||||
"billedDay": "Gefactureerd, dag {day} van {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} gefactureerd ({gross} bruto, {discount} korting), {share} van de factuur",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Gefactureerd via {payer}. Kosten en budgetten staan op de gebruikspagina van die organisatie.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, gefactureerd als {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "Bruto",
|
||||
"colBilled": "Gefactureerd",
|
||||
"allMeters": "Alle meters",
|
||||
"noDailyRetention": "Retentie heeft geen dagelijkse historie. De tabel toont in plaats daarvan de opslag per stream.",
|
||||
"grossLabel": "Bruto",
|
||||
"cycleCloses": "Cyclus sluit op {date}",
|
||||
"dailyCost": "Dagelijkse kosten",
|
||||
"breakdownSummary": "{cost} gefactureerd · {volume} · {share} van de factuur",
|
||||
"scenarioFrom": "Begin vanaf",
|
||||
"scenarioCurrent": "Mijn huidige gebruik",
|
||||
"scenarioTwice": "2× groei",
|
||||
"scenarioFiveTimes": "5× groei",
|
||||
"resetEstimate": "Resetten",
|
||||
"expectedUsage": "Verwacht maandelijks gebruik",
|
||||
"expectedUsageHint": "Vooraf ingevuld op basis van deze cyclus tot nu toe",
|
||||
"loadingHistory": "Je gebruiksgeschiedenis wordt gelezen…",
|
||||
"yourEstimate": "Je schatting",
|
||||
"assumptionsFrom": "Op basis van je laatste 30 dagen",
|
||||
"scanNote": "Scant ongeveer {volume} per maand.",
|
||||
"qtyScanned": "{volume} gescand",
|
||||
"qtyProcessed": "{volume} verwerkt",
|
||||
"qtyKeptFor": "{volume} voor {days} dagen",
|
||||
"qtyCredits": "{count} credits",
|
||||
"rateIncluded": "Inbegrepen in je abonnement",
|
||||
"rateTooltip": "{rate}. Je wordt gefactureerd op basis van {basis}.",
|
||||
"rateIncludedTooltip": "{basis} is inbegrepen in je abonnement en draagt dus niets bij aan deze schatting.",
|
||||
"byIncident": "Per incident",
|
||||
"colIncident": "Incident",
|
||||
"colCredits": "Credits",
|
||||
"byCheck": "Per check",
|
||||
"colCheck": "Check",
|
||||
"colSteps": "Stappen",
|
||||
"unknownCheck": "Verwijderde check",
|
||||
"noBreakdownYet": "Geen gebruik in deze cyclus, dus er valt nog niets uit te splitsen.",
|
||||
"rangeLastCycle": "Vorige cyclus",
|
||||
"rangeLast90Days": "Afgelopen 90 dagen",
|
||||
"runRate": "Run Rate",
|
||||
"dailyAverage": "Dagelijks gemiddelde",
|
||||
"peakDay": "Piekdag",
|
||||
"runRateWindow": "Afgelopen {days} dagen",
|
||||
"acrossDays": "Over {days} dagen",
|
||||
"atPaceProjected": "{cost} bij het tempo van de laatste {days} dagen",
|
||||
"atPace": "Op {days}-dagtempo",
|
||||
"topMeterOfCost": "{cost} · {percent}% van de kosten",
|
||||
"perDay": "{cost} per dag",
|
||||
"spendToDate": "Besteed tot nu toe",
|
||||
"costOverTime": "Kosten in de tijd",
|
||||
"volumeOverTime": "Volume in de tijd",
|
||||
"volumeSubtitle": "Ingestie, zoeken en pijpleidingen, per dag",
|
||||
"usageByMeter": "Gebruik per meter",
|
||||
"dailyVolume": "Dagelijks volume",
|
||||
"lastDaysRange": "Laatste {days} dagen: {start} tot {end}",
|
||||
"enableVolumeSubtitle": "Schakel gebruiksrapportage in om te zien hoeveel elke meter dagelijks gebruikt.",
|
||||
"measureCost": "Kosten",
|
||||
"measureVolume": "Volume",
|
||||
"estimatedAtCyclePrice": "Geschat op basis van gebruik, tegen de prijs van elke cyclus. Exclusief gegevensbewaring.",
|
||||
"showOnlyMeter": "Alleen {meter} tonen",
|
||||
"showAllMeters": "Alle meters tonen",
|
||||
"noUsageInRange": "Geen gebruik in dit bereik",
|
||||
"trendsLoadFailed": "Kan het gebruik voor dit bereik niet laden.",
|
||||
"rangeDates": "{start} tot {end}",
|
||||
"forecast": "Prognose",
|
||||
"dayTotal": "Dagtotaal",
|
||||
"costByOrg": "Kosten per organisatie",
|
||||
"orgFilter": "Organisatie",
|
||||
"organizationsTitle": "Organisaties",
|
||||
"orgCountThisCycle": "{count} organisaties · deze cyclus",
|
||||
"overallBudget": "Totaalbudget",
|
||||
"overallBudgetLabel": "Totaalbudget voor deze factureringscyclus",
|
||||
"appliesToGroup": "De rekening van de hele groep",
|
||||
"orgBudgetsTotal": "Budgetten per organisatie komen op {amount}",
|
||||
"orgBudgetsOver": "Budgetten per organisatie komen op {amount}, {over} meer dan het totaalbudget",
|
||||
"needsAttention": "Vereist aandacht",
|
||||
"attentionHint": "{over} boven budget, {near} bij de waarschuwingsgrens",
|
||||
"colSpendThisCycle": "Besteding deze cyclus",
|
||||
"colBudget": "Budget",
|
||||
"colAlerts": "Alarmen",
|
||||
"statusNone": "Geen budget",
|
||||
"statusOk": "Op schema",
|
||||
"statusWarn": "Bijna aan limiet",
|
||||
"statusOver": "Boven budget",
|
||||
"budgetFor": "Budget voor {org}",
|
||||
"warnPercentFor": "Waarschuwingspercentage voor {org}",
|
||||
"alertsFor": "E-mailalarmen voor {org}",
|
||||
"groupAlertsOnHint": "Beheerders ontvangen e-mails zolang de besteding van de groep deze cyclus boven het waarschuwingsbedrag blijft. Pauzeer om ze te stoppen. Elke organisatierij heeft een eigen schakelaar.",
|
||||
"unsavedChanges": "Niet opgeslagen: {parts}",
|
||||
"unsavedOrgs": "{count} organisatie(s)",
|
||||
"unsavedOverall": "algemene instellingen",
|
||||
"allChangesSaved": "Alle wijzigingen opgeslagen",
|
||||
"discard": "Verwerpen",
|
||||
"saveChanges": "Wijzigingen opslaan",
|
||||
"orgCostsFailed": "Kosten per organisatie konden niet worden geladen. Vernieuw om het opnieuw te proberen.",
|
||||
"clickForOrg": "Klik om de meters van deze organisatie te zien",
|
||||
"clickForBreakdown": "Klik om te zien wat deze meter bepaalt",
|
||||
"allOrgs": "Alle organisaties",
|
||||
"colOrganization": "Organisatie",
|
||||
"shareOfOrg": "Aandeel van {org}",
|
||||
"thisOrg": "{org} (deze organisatie)",
|
||||
"formerMemberIngested": "Voormalig lid · {volume} geïngesteerd",
|
||||
"ingestedVolume": "{volume} geïngesteerd",
|
||||
"notSplitByOrg": "Gefactureerd aan de hele groep, niet verdeeld per organisatie",
|
||||
"orgSummary": "{cost} gefactureerd · {share} van de factuur",
|
||||
"orgCostEstimated": "Deze factuur verdeeld op basis van het gebruikaandeel per organisatie",
|
||||
"byOrg": "Per organisatie",
|
||||
"historyStartsOn": "De gebruiksgeschiedenis begint op {date}, het begin van je oudste factureringscyclus waarvoor we gebruiksgegevens hebben. Eerdere dagen hebben geen gebruik om te tonen.",
|
||||
"budgetTab": "Budget",
|
||||
"budgetKpi": "Budget",
|
||||
"budgetNotSet": "Niet ingesteld",
|
||||
"budgetThisCycle": "Deze factureringscyclus",
|
||||
"spentOfBudget": "{percent}% van het budget · dag {day} van {total}",
|
||||
"overBudgetBy": "{amount} boven budget",
|
||||
"toSpare": "{amount} beschikbaar",
|
||||
"budgetReached": "Budget bereikt",
|
||||
"reachedOnDay": "Dag {day}",
|
||||
"notThisCycle": "Niet deze cyclus",
|
||||
"atPacePerDay": "Bij {cost} per dag",
|
||||
"atCurrentPace": "Bij het huidige tempo",
|
||||
"spendAgainstBudget": "Besteding ten opzichte van budget",
|
||||
"ofBudget": "van {budget} · {percent}%",
|
||||
"leftInBudget": "{amount} over",
|
||||
"overBy": "{amount} te veel",
|
||||
"warnAtPercent": "Waarschuwing bij {percent}%",
|
||||
"shareOfBudget": "{percent}% van het budget",
|
||||
"setBudgetFirst": "Stel een budget in om de besteding van deze cyclus ertegen af te zetten.",
|
||||
"budgetSettings": "Budgetinstellingen",
|
||||
"budgetAmount": "Budget voor deze factureringscyclus",
|
||||
"budgetAmountHelp": "Bij het huidige tempo eindigt deze cyclus rond {amount}",
|
||||
"warnAt": "Waarschuwen bij",
|
||||
"warnAmount": "Waarschuwingsbedrag in dollars",
|
||||
"warnPercent": "Waarschuwingsbedrag als percentage van het budget",
|
||||
"warnHelp": "Alarm wanneer de uitgaven {amount} overschrijden",
|
||||
"emailAlerts": "E-mailalarmen",
|
||||
"alertsOnHint": "Beheerders ontvangen e-mails zolang de uitgaven deze cyclus boven het waarschuwingsbedrag blijven. Pauzeer om ze te stoppen.",
|
||||
"alertsPausedHint": "Geen e-mails tot je dit weer inschakelt.",
|
||||
"saveBudget": "Instellingen opslaan",
|
||||
"budgetSaved": "Budget opgeslagen",
|
||||
"budgetRemoved": "Budget verwijderd",
|
||||
"budgetSaveFailed": "Het budget kon niet worden opgeslagen",
|
||||
"budgetLoadFailedTitle": "Je budgetten konden niet worden geladen",
|
||||
"budgetLoadFailed": "Bewerken is pas mogelijk nadat ze zijn geladen, zodat opslaan geen bestaande budgetten kan overschrijven. Probeer het zo meteen opnieuw."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Geen Marketplace-token gevonden",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Porównaj plany",
|
||||
"contactSupport": "Skontaktuj się z pomocą techniczną"
|
||||
},
|
||||
"previousCycle": "Poprzedni cykl",
|
||||
"currentCycle": "Bieżący cykl",
|
||||
"period30Days": "30 dni",
|
||||
"period60Days": "60 dni",
|
||||
"period3Months": "3 miesiące",
|
||||
"period6Months": "6 miesięcy",
|
||||
"pricingErrorMessage": "Szczegóły cennika nie zostały załadowane.",
|
||||
"awsManagedMessage": "Rozliczenia są obsługiwane przez Twoje konto AWS",
|
||||
"azureManagedMessage": "Rozliczenia są obsługiwane przez Twoje konto Azure",
|
||||
"contractManagedMessage": "Rozliczenia są obsługiwane przez Twoją umowę — skontaktuj się z opiekunem konta w celu wprowadzenia zmian.",
|
||||
"upgradeToPlanMessage": "Przejdź na wyższy plan, aby nadal korzystać z usług OpenObserve.",
|
||||
"creditsUsedLabel": "wykorzystane kredyty",
|
||||
"errorTracking": "Śledzenie błędów",
|
||||
"rumSession": "Sesja RUM",
|
||||
"licenseExpiryContactAdmin": "Zaktualizuj licencję, kontaktując się z administratorem.",
|
||||
"subscribed": "Subskrybowany",
|
||||
"invoiceHistory": "Historia faktur",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Status",
|
||||
"action": "Akcja",
|
||||
"manageSubscription": "Zarządzaj subskrypcją",
|
||||
"messageDataNotFound": "Nie znaleziono danych",
|
||||
"invoiceStartDate": "Data rozpoczęcia faktury",
|
||||
"invoiceEndDate": "Data zakończenia faktury",
|
||||
"downloadInvoice": "Pobierz fakturę",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Potrzebujesz dużej pojemności? Porozmawiajmy o skali! Skontaktuj się z nami po rozwiązania dostosowane do Twoich potrzeb.",
|
||||
"contactSupport": "Kontakt z pomocą techniczną",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Dzienne użycie",
|
||||
"enableTitle": "Zobacz swoje dzienne użycie",
|
||||
"enableSubtitle": "Włącz raportowanie użycia, aby codziennie rejestrować wolumen pozyskiwania danych i zapytań. Łączne wartości powyżej pozostaną dostępne niezależnie od tego.",
|
||||
"enableSubtitle": "Włącz raportowanie zużycia, aby wyświetlić na wykresie dzienny koszt i wolumen dla każdego miernika. Rachunek na karcie Przegląd pozostaje dostępny w obu przypadkach.",
|
||||
"enableButton": "Włącz raportowanie użycia",
|
||||
"enablePending": "Raportowanie użycia włączone. Wykres dzienny zacznie się wypełniać wkrótce po zarejestrowaniu nowego użycia.",
|
||||
"enableConfirmTitle": "Włączyć raportowanie użycia?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Skontaktuj się z menedżerem konta, aby dodać więcej kredytów AI",
|
||||
"aiPayerSubscribeMessage": "Rozliczeniami tej organizacji zarządza {payer}. Poproś administratora {payer} o subskrypcję; dalsze użycie zostanie rozliczone na jej fakturze.",
|
||||
"aiFreeUsedLabel": "wykorzystane darmowe kredyty",
|
||||
"aiBillableCredits": "Płatne kredyty AI",
|
||||
"aiFreeOneTime": "Jednorazowy limit.",
|
||||
"aiUsedByFeature": "Asystent i generowanie zapytań: {chat} · Analiza incydentów: {incident} · Ponowna analiza: {reanalysis}",
|
||||
"aiPaygMessage": "Bezpłatne kredyty wyczerpane — użycie fakturowane na subskrypcję",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Plan zarządzany przez rodzica",
|
||||
"chipNoInvoices": "Brak oddzielnych faktur",
|
||||
"viewOrgGroup": "Wyświetl grupę organizacji",
|
||||
"currentOrgTitle": "Bieżąca organizacja",
|
||||
"memberOrgsTitle": "Organizacje członkowskie",
|
||||
"searchMemberOrg": "Szukaj organizacji",
|
||||
"noMemberMatch": "Brak pasujących organizacji",
|
||||
"remove": "Usuń",
|
||||
"removeTitle": "Usunąć {name} z grupy rozliczeniowej?",
|
||||
"removeBody": "Ta organizacja staje się organizacją darmową i sama płaci za swoje zużycie. Obowiązują limity planu darmowego. Jej użytkownicy, dane i wcześniejsze zużycie nie zostaną zmienione.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Status alertów",
|
||||
"contactAdministratorToResolve": "Skontaktuj się z administratorem, aby rozwiązać ten problem.",
|
||||
"contactAdministratorUsageReporting": "Skontaktuj się z administratorem, aby przywrócić raportowanie użycia.",
|
||||
"credits": "Kredyty",
|
||||
"dataRetention": "Przechowywanie danych",
|
||||
"event": "Zdarzenie",
|
||||
"ingestion": "Pozyskiwanie danych",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Twój okres próbny wygasł.",
|
||||
"trialDaysRemaining": "Pozostał {count} dzień w Twoim koncie próbnym | Pozostało {count} dni w Twoim koncie próbnym",
|
||||
"usageReportingFailedForDays": "Raportowanie użycia nie powiodło się przez {count} dzień, funkcje RBAC i SSO są wyłączone do czasu pomyślnego raportowania użycia | Raportowanie użycia nie powiodło się przez {count} dni, funkcje RBAC i SSO są wyłączone do czasu pomyślnego raportowania użycia",
|
||||
"usageReportingFailingForHours": "Raportowanie użycia nie działa od {count} godziny, niektóre funkcje zostaną wkrótce wyłączone | Raportowanie użycia nie działa od {count} godzin, niektóre funkcje zostaną wkrótce wyłączone"
|
||||
"usageReportingFailingForHours": "Raportowanie użycia nie działa od {count} godziny, niektóre funkcje zostaną wkrótce wyłączone | Raportowanie użycia nie działa od {count} godzin, niektóre funkcje zostaną wkrótce wyłączone",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-godzina",
|
||||
"unitCredit": "Kredyt",
|
||||
"unitRun": "Uruchomienie",
|
||||
"ratePerUnit": "{rate} za {unit}",
|
||||
"cycleRange": "Cykl rozliczeniowy: od {start} do {end}",
|
||||
"syntheticsBrowser": "Syntetyki (przeglądarka)",
|
||||
"syntheticsProtocol": "Syntetyki (protokół)",
|
||||
"overviewTab": "Przegląd",
|
||||
"trendsTab": "Trendy",
|
||||
"estimationTab": "Szacowanie ceny",
|
||||
"costBreakdown": "Podział kosztów",
|
||||
"colMeter": "Miernik",
|
||||
"colVolume": "Wolumen",
|
||||
"colCost": "Koszt",
|
||||
"totalCost": "Koszt całkowity",
|
||||
"missingMeters": "Nie udało się wycenić tych mierników: {meters}. W ich wierszach zamiast kosztu wyświetlana jest kreska.",
|
||||
"noPricingTitle": "Koszt nie jest dostępny dla tej organizacji",
|
||||
"noPricingSubtitle": "Ta organizacja nie ma konta rozliczeniowego z opłatami za zużycie, więc nie mamy cen do wyświetlenia. Wolumen zużycia jest nadal dostępny na karcie Trendy.",
|
||||
"loadFailedTitle": "Nie udało się wczytać Twojego zużycia",
|
||||
"loadFailedNetwork": "Nie można połączyć się z serwerem. Sprawdź połączenie sieciowe i spróbuj ponownie.",
|
||||
"loadFailedServer": "Usługa rozliczeniowa zwróciła błąd. Spróbuj ponownie za chwilę.",
|
||||
"refreshFailed": "Nie udało się odświeżyć zużycia. Wyświetlane liczby pochodzą z ostatniego wczytania.",
|
||||
"byStream": "Według strumienia",
|
||||
"colStream": "Strumień",
|
||||
"byUser": "Według użytkownika",
|
||||
"colUser": "Użytkownik",
|
||||
"byAlert": "Według alertu",
|
||||
"colAlert": "Alert",
|
||||
"byDashboard": "Według pulpitu",
|
||||
"colDashboard": "Pulpit",
|
||||
"byScheduledPipeline": "Według potoku zaplanowanego",
|
||||
"colScheduledPipeline": "Potok zaplanowany",
|
||||
"byPipeline": "Według potoku",
|
||||
"colPipeline": "Potok",
|
||||
"byDestination": "Według miejsca docelowego",
|
||||
"colDestination": "Miejsce docelowe",
|
||||
"colRetentionPeriod": "Okres przechowywania",
|
||||
"colCurrentSize": "Bieżący rozmiar",
|
||||
"drilldownFailed": "Nie udało się wczytać tego podziału.",
|
||||
"retry": "Ponów",
|
||||
"unknownPipeline": "Nieznany",
|
||||
"rangeCurrentCycle": "Bieżący cykl",
|
||||
"assumptions": "Założenia",
|
||||
"avgStreamSize": "Średnia ilość danych na strumień",
|
||||
"ingestPerMonth": "Dane pozyskane miesięcznie",
|
||||
"searchesPerMonth": "Wyszukiwania miesięcznie",
|
||||
"scanPerSearch": "Dane skanowane na wyszukiwanie",
|
||||
"pipelineCount": "Potoki działające na strumieniach",
|
||||
"retentionDays": "Dane przechowywane przez",
|
||||
"aiCreditsPerMonth": "Kredyty AI miesięcznie",
|
||||
"browserChecks": "Testy przeglądarkowe",
|
||||
"stepsPerCheck": "Kroki na test",
|
||||
"checkRegions": "Regiony",
|
||||
"checkDevices": "Urządzenia",
|
||||
"checkInterval": "Uruchamiane co",
|
||||
"apiChecks": "Testy API",
|
||||
"browserRunsNote": "Około {runs} uruchomień kroków miesięcznie.",
|
||||
"apiRunsNote": "Około {runs} uruchomień miesięcznie.",
|
||||
"qtyStepRuns": "{count} uruchomień kroków",
|
||||
"qtyRuns": "{count} uruchomień",
|
||||
"estimatedTotal": "Szacowana suma",
|
||||
"perMonth": "miesięcznie",
|
||||
"estimateDisclaimer": "To szacunkowa kwota według ceny katalogowej. Nie uwzględnia żadnych rabatów na Twoim koncie.",
|
||||
"cycleDay": "Dzień {day} z {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Rabat",
|
||||
"costSoFar": "Dotychczasowy koszt",
|
||||
"estimatedCostSoFar": "Szacowany koszt do tej pory",
|
||||
"projectedTotal": "Prognozowana suma",
|
||||
"tooEarlyToProject": "Zbyt wcześnie w cyklu, aby prognozować",
|
||||
"colStorageShare": "% pamięci masowej",
|
||||
"orgDefaultRetention": "Domyślne organizacji",
|
||||
"retentionDaysValue": "{days} dni",
|
||||
"topMeter": "Największy licznik",
|
||||
"topMeterShare": "{cost} · {percent}% rachunku",
|
||||
"billedIngestion": "Pozyskane dane",
|
||||
"billedQuery": "Dane skanowane przez wyszukiwania",
|
||||
"billedPipeline": "Dane przetworzone przez potoki",
|
||||
"billedRemotePipeline": "Dane wysłane do zdalnych miejsc docelowych",
|
||||
"billedRetention": "Dane przechowywane w czasie",
|
||||
"billedAi": "Wykorzystane kredyty AI",
|
||||
"billedSynthetics": "Uruchomienia testów syntetycznych",
|
||||
"meterShare": "Udział w rachunku",
|
||||
"everythingElse": "Wszystko inne",
|
||||
"otherBand": "Inne",
|
||||
"colTotal": "Razem",
|
||||
"groupBy": "Grupuj według",
|
||||
"groupNotRecorded": "Nie zarejestrowano jeszcze użycia: {group}.",
|
||||
"segmentTitle": "{meter}: {billed} rozliczono, {share} rachunku",
|
||||
"colEstCost": "Szac. koszt",
|
||||
"shareOfMeter": "Udział w {meter}",
|
||||
"costByMeter": "Koszt według miernika",
|
||||
"meterByGroup": "{meter} według {group}",
|
||||
"billedDay": "Rozliczono, dzień {day} z {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} rozliczono ({gross} brutto, {discount} rabatu), {share} rachunku",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Rozliczane przez {payer}. Koszty i budżety znajdują się na stronie Użycia tej organizacji.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, rozliczane jako {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "Brutto",
|
||||
"colBilled": "Rozliczono",
|
||||
"allMeters": "Wszystkie mierniki",
|
||||
"noDailyRetention": "Retencja nie ma historii dziennej. Tabela pokazuje zamiast tego pamięć masową każdego strumienia.",
|
||||
"grossLabel": "Brutto",
|
||||
"cycleCloses": "Cykl zamyka się {date}",
|
||||
"dailyCost": "Dzienny koszt",
|
||||
"breakdownSummary": "{cost} rozliczono · {volume} · {share} rachunku",
|
||||
"scenarioFrom": "Zacznij od",
|
||||
"scenarioCurrent": "Moje bieżące użycie",
|
||||
"scenarioTwice": "Wzrost 2×",
|
||||
"scenarioFiveTimes": "Wzrost 5×",
|
||||
"resetEstimate": "Resetuj",
|
||||
"expectedUsage": "Oczekiwane miesięczne użycie",
|
||||
"expectedUsageHint": "Wstępnie wypełnione na podstawie dotychczasowego cyklu",
|
||||
"loadingHistory": "Wczytywanie historii użycia…",
|
||||
"yourEstimate": "Twoje szacunki",
|
||||
"assumptionsFrom": "Z ostatnich 30 dni",
|
||||
"scanNote": "Skanuje około {volume} miesięcznie.",
|
||||
"qtyScanned": "{volume} przeskanowano",
|
||||
"qtyProcessed": "{volume} przetworzono",
|
||||
"qtyKeptFor": "{volume} przez {days} dni",
|
||||
"qtyCredits": "{count} kredytów",
|
||||
"rateIncluded": "Wliczone w Twój plan",
|
||||
"rateTooltip": "{rate}. Rozliczenie następuje na podstawie: {basis}.",
|
||||
"rateIncludedTooltip": "{basis} jest wliczone w Twój plan, więc nie zwiększa tego szacunku.",
|
||||
"byIncident": "Według incydentów",
|
||||
"colIncident": "Incydent",
|
||||
"colCredits": "Kredyty",
|
||||
"byCheck": "Według testów",
|
||||
"colCheck": "Test",
|
||||
"colSteps": "Kroki",
|
||||
"unknownCheck": "Usunięty test",
|
||||
"noBreakdownYet": "Brak zużycia w tym cyklu, więc nie ma jeszcze czego rozbijać.",
|
||||
"rangeLastCycle": "Ostatni cykl",
|
||||
"rangeLast90Days": "Ostatnie 90 dni",
|
||||
"runRate": "Tempo bieżące",
|
||||
"dailyAverage": "Średnia dzienna",
|
||||
"peakDay": "Dzień szczytowy",
|
||||
"runRateWindow": "Ostatnie {days} dni",
|
||||
"acrossDays": "W ciągu {days} dni",
|
||||
"atPaceProjected": "{cost} przy tempie z ostatnich {days} dni",
|
||||
"atPace": "Przy tempie z {days} dni",
|
||||
"topMeterOfCost": "{cost} · {percent}% kosztów",
|
||||
"perDay": "{cost} dziennie",
|
||||
"spendToDate": "Wydatki do tej pory",
|
||||
"costOverTime": "Koszty w czasie",
|
||||
"volumeOverTime": "Wolumen w czasie",
|
||||
"volumeSubtitle": "Pozyskiwanie danych, wyszukiwanie i potoki, dziennie",
|
||||
"usageByMeter": "Użycie według licznika",
|
||||
"dailyVolume": "Dzienny wolumen",
|
||||
"lastDaysRange": "Ostatnie {days} dni: {start} do {end}",
|
||||
"enableVolumeSubtitle": "Włącz raportowanie użycia, aby zobaczyć, ile zużywa każdy licznik, dzień po dniu.",
|
||||
"measureCost": "Koszt",
|
||||
"measureVolume": "Wolumen",
|
||||
"estimatedAtCyclePrice": "Szacowane na podstawie użycia, według ceny każdego cyklu. Nie obejmuje retencji danych.",
|
||||
"showOnlyMeter": "Pokaż tylko {meter}",
|
||||
"showAllMeters": "Pokaż wszystkie liczniki",
|
||||
"noUsageInRange": "Brak zużycia w tym zakresie",
|
||||
"trendsLoadFailed": "Nie udało się wczytać zużycia dla tego zakresu.",
|
||||
"rangeDates": "{start} – {end}",
|
||||
"forecast": "Prognoza",
|
||||
"dayTotal": "Suma dnia",
|
||||
"costByOrg": "Koszty według organizacji",
|
||||
"orgFilter": "Organizacja",
|
||||
"organizationsTitle": "Organizacje",
|
||||
"orgCountThisCycle": "{count} organizacji · w tym cyklu",
|
||||
"overallBudget": "Budżet ogólny",
|
||||
"overallBudgetLabel": "Budżet ogólny dla tego cyklu rozliczeniowego",
|
||||
"appliesToGroup": "Cały rachunek grupy",
|
||||
"orgBudgetsTotal": "Budżety organizacji sumują się do {amount}",
|
||||
"orgBudgetsOver": "Budżety organizacji sumują się do {amount}, o {over} więcej niż budżet ogólny",
|
||||
"needsAttention": "Wymaga uwagi",
|
||||
"attentionHint": "{over} powyżej budżetu, {near} blisko progu ostrzeżenia",
|
||||
"colSpendThisCycle": "Wydatki w tym cyklu",
|
||||
"colBudget": "Budżet",
|
||||
"colAlerts": "Alerty",
|
||||
"statusNone": "Brak budżetu",
|
||||
"statusOk": "Zgodnie z planem",
|
||||
"statusWarn": "Blisko limitu",
|
||||
"statusOver": "Powyżej budżetu",
|
||||
"budgetFor": "Budżet dla {org}",
|
||||
"warnPercentFor": "Procent ostrzeżenia dla {org}",
|
||||
"alertsFor": "Alerty e-mail dla {org}",
|
||||
"groupAlertsOnHint": "Administratorzy otrzymują e-maile, dopóki wydatki grupy w tym cyklu pozostają powyżej kwoty ostrzeżenia. Wstrzymaj, aby je zatrzymać. Każdy wiersz organizacji ma własny przełącznik.",
|
||||
"unsavedChanges": "Niezapisane: {parts}",
|
||||
"unsavedOrgs": "{count} organizacji",
|
||||
"unsavedOverall": "ustawienia ogólne",
|
||||
"allChangesSaved": "Wszystkie zmiany zapisane",
|
||||
"discard": "Odrzuć",
|
||||
"saveChanges": "Zapisz zmiany",
|
||||
"orgCostsFailed": "Nie udało się wczytać kosztów według organizacji. Odśwież, aby spróbować ponownie.",
|
||||
"clickForOrg": "Kliknij, aby zobaczyć liczniki tej organizacji",
|
||||
"clickForBreakdown": "Kliknij, aby zobaczyć, co wpływa na ten licznik",
|
||||
"allOrgs": "Wszystkie organizacje",
|
||||
"colOrganization": "Organizacja",
|
||||
"shareOfOrg": "Udział w {org}",
|
||||
"thisOrg": "{org} (ta organizacja)",
|
||||
"formerMemberIngested": "Były członek · pozyskane {volume}",
|
||||
"ingestedVolume": "Pozyskano {volume}",
|
||||
"notSplitByOrg": "Rozliczane dla całej grupy, bez podziału na organizacje",
|
||||
"orgSummary": "{cost} rozliczone · {share} rachunku",
|
||||
"orgCostEstimated": "Ten rachunek podzielony według udziału każdej organizacji w zużyciu",
|
||||
"byOrg": "Według organizacji",
|
||||
"historyStartsOn": "Historia zużycia zaczyna się {date}, w dniu rozpoczęcia najstarszego cyklu rozliczeniowego, dla którego mamy dane o zużyciu. Wcześniejsze dni nie mają zużycia do pokazania.",
|
||||
"budgetTab": "Budżet",
|
||||
"budgetKpi": "Budżet",
|
||||
"budgetNotSet": "Nie ustawiono",
|
||||
"budgetThisCycle": "Ten cykl rozliczeniowy",
|
||||
"spentOfBudget": "{percent}% budżetu · dzień {day} z {total}",
|
||||
"overBudgetBy": "{amount} powyżej budżetu",
|
||||
"toSpare": "{amount} w zapasie",
|
||||
"budgetReached": "Osiągnięto budżet",
|
||||
"reachedOnDay": "Dzień {day}",
|
||||
"notThisCycle": "Nie w tym cyklu",
|
||||
"atPacePerDay": "Po {cost} dziennie",
|
||||
"atCurrentPace": "W obecnym tempie",
|
||||
"spendAgainstBudget": "Wydatki względem budżetu",
|
||||
"ofBudget": "z {budget} · {percent}%",
|
||||
"leftInBudget": "Pozostało {amount}",
|
||||
"overBy": "{amount} ponad",
|
||||
"warnAtPercent": "Ostrzeżenie przy {percent}%",
|
||||
"shareOfBudget": "{percent}% budżetu",
|
||||
"setBudgetFirst": "Ustaw budżet, aby śledzić względem niego wydatki w tym cyklu.",
|
||||
"budgetSettings": "Ustawienia budżetu",
|
||||
"budgetAmount": "Budżet na ten cykl rozliczeniowy",
|
||||
"budgetAmountHelp": "Przy dzisiejszym tempie ten cykl zakończy się w okolicach {amount}",
|
||||
"warnAt": "Ostrzeżenie przy",
|
||||
"warnAmount": "Kwota ostrzeżenia w dolarach",
|
||||
"warnPercent": "Kwota ostrzeżenia jako procent budżetu",
|
||||
"warnHelp": "Alert, gdy wydatki przekroczą {amount}",
|
||||
"emailAlerts": "Alerty e-mail",
|
||||
"alertsOnHint": "Administratorzy otrzymują e-maile, dopóki w tym cyklu wydatki pozostają powyżej kwoty ostrzeżenia. Wstrzymaj, aby je zatrzymać.",
|
||||
"alertsPausedHint": "Brak e-maili, dopóki tego ponownie nie włączysz.",
|
||||
"saveBudget": "Zapisz ustawienia",
|
||||
"budgetSaved": "Budżet zapisany",
|
||||
"budgetRemoved": "Budżet usunięty",
|
||||
"budgetSaveFailed": "Nie udało się zapisać budżetu",
|
||||
"budgetLoadFailedTitle": "Nie udało się wczytać budżetów",
|
||||
"budgetLoadFailed": "Edycja jest wstrzymana do czasu ich wczytania, aby zapis nie nadpisał już ustawionych budżetów. Spróbuj ponownie za chwilę."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Nie znaleziono tokenu Marketplace",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Comparar planos",
|
||||
"contactSupport": "Contatar o suporte"
|
||||
},
|
||||
"previousCycle": "Ciclo Anterior",
|
||||
"currentCycle": "Ciclo Atual",
|
||||
"period30Days": "30 dias",
|
||||
"period60Days": "60 dias",
|
||||
"period3Months": "3 meses",
|
||||
"period6Months": "6 meses",
|
||||
"pricingErrorMessage": "Os detalhes de preços não foram carregados.",
|
||||
"awsManagedMessage": "A cobrança é gerenciada pela sua conta AWS",
|
||||
"azureManagedMessage": "A cobrança é gerenciada pela sua conta Azure",
|
||||
"contractManagedMessage": "A cobrança é gerenciada pelo seu contrato — entre em contato com seu gerente de conta para alterações",
|
||||
"upgradeToPlanMessage": "Atualize para um plano para continuar aproveitando os serviços do OpenObserve.",
|
||||
"creditsUsedLabel": "créditos usados",
|
||||
"errorTracking": "Rastreamento de Erros",
|
||||
"rumSession": "Sessão RUM",
|
||||
"licenseExpiryContactAdmin": "Atualize sua licença entrando em contato com seu administrador.",
|
||||
"subscribed": "Subscrito",
|
||||
"invoiceHistory": "Histórico de faturas",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Status",
|
||||
"action": "Ação",
|
||||
"manageSubscription": "Gerenciar assinatura",
|
||||
"messageDataNotFound": "Dados não encontrados",
|
||||
"invoiceStartDate": "Data de início da fatura",
|
||||
"invoiceEndDate": "Data de término da fatura",
|
||||
"downloadInvoice": "Baixar fatura",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Necessidades de alto volume? Vamos falar de escala! Entre em contato conosco para obter soluções personalizadas.",
|
||||
"contactSupport": "Contate o suporte",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Uso diário",
|
||||
"enableTitle": "Veja seu uso diário",
|
||||
"enableSubtitle": "Ative o relatório de uso para gráfico seu volume de ingestão e consulta dia a dia. Seus totais acima permanecem disponíveis de qualquer forma.",
|
||||
"enableSubtitle": "Ative o relatório de uso para exibir em gráfico seu custo e volume diários de cada medidor. Sua fatura na Visão geral continua disponível de qualquer forma.",
|
||||
"enableButton": "Ativar relatório de uso",
|
||||
"enablePending": "Relatório de uso ativado. Seu gráfico diário começará a ser preenchido em breve à medida que novos usos forem registrados.",
|
||||
"enableConfirmTitle": "Ativar relatório de uso?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Entre em contato com seu gerente de conta para adicionar mais créditos de IA",
|
||||
"aiPayerSubscribeMessage": "O faturamento desta organização é gerenciado por {payer}. Peça a um administrador de {payer} para assinar; o uso adicional é cobrado na fatura dela.",
|
||||
"aiFreeUsedLabel": "créditos gratuitos usados",
|
||||
"aiBillableCredits": "Créditos de IA faturáveis",
|
||||
"aiFreeOneTime": "Uma cota única.",
|
||||
"aiUsedByFeature": "Assistente e geração de consultas: {chat} · Análise de incidentes: {incident} · Reanálise: {reanalysis}",
|
||||
"aiPaygMessage": "Créditos gratuitos esgotados — uso cobrado na assinatura",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Plano gerenciado pelos pais",
|
||||
"chipNoInvoices": "Sem faturas separadas",
|
||||
"viewOrgGroup": "Exibir grupo organizacional",
|
||||
"currentOrgTitle": "Organização Atual",
|
||||
"memberOrgsTitle": "Organizações Membros",
|
||||
"searchMemberOrg": "Pesquisar Organização",
|
||||
"noMemberMatch": "Nenhuma Organização Correspondente",
|
||||
"remove": "Remover",
|
||||
"removeTitle": "Remover {name} do seu grupo de faturamento?",
|
||||
"removeBody": "Esta organização passa a ser uma organização gratuita e paga pelo próprio uso. Aplicam-se os limites do plano gratuito. Seus usuários, dados e uso anterior não são afetados.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Status do alerta",
|
||||
"contactAdministratorToResolve": "Entre em contato com o administrador para resolver este problema.",
|
||||
"contactAdministratorUsageReporting": "Entre em contato com o administrador para garantir que o relatório de uso seja restaurado.",
|
||||
"credits": "Créditos",
|
||||
"dataRetention": "Retenção de dados",
|
||||
"event": "Evento",
|
||||
"ingestion": "Ingestão",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Seu período de avaliação expirou.",
|
||||
"trialDaysRemaining": "{count} Dia restante em sua conta de avaliação | {count} Dias restantes em sua conta de avaliação",
|
||||
"usageReportingFailedForDays": "O relatório de uso falhou por {count} dia, os recursos de RBAC e SSO estão desabilitados até que o uso seja relatado com sucesso | O relatório de uso falhou por {count} dias, os recursos de RBAC e SSO estão desabilitados até que o uso seja relatado com sucesso",
|
||||
"usageReportingFailingForHours": "O relatório de uso está falhando há {count} hora, alguns recursos serão desabilitados em breve | O relatório de uso está falhando há {count} horas, alguns recursos serão desabilitados em breve"
|
||||
"usageReportingFailingForHours": "O relatório de uso está falhando há {count} hora, alguns recursos serão desabilitados em breve | O relatório de uso está falhando há {count} horas, alguns recursos serão desabilitados em breve",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-hora",
|
||||
"unitCredit": "Crédito",
|
||||
"unitRun": "Execução",
|
||||
"ratePerUnit": "{rate} por {unit}",
|
||||
"cycleRange": "Ciclo de faturamento: {start} a {end}",
|
||||
"syntheticsBrowser": "Sintéticos (Navegador)",
|
||||
"syntheticsProtocol": "Sintéticos (Protocolo)",
|
||||
"overviewTab": "Visão geral",
|
||||
"trendsTab": "Tendências",
|
||||
"estimationTab": "Estimativa de preços",
|
||||
"costBreakdown": "Detalhamento de custos",
|
||||
"colMeter": "Medidor",
|
||||
"colVolume": "Volume",
|
||||
"colCost": "Custo",
|
||||
"totalCost": "Custo total",
|
||||
"missingMeters": "Não foi possível calcular o preço destes medidores: {meters}. As linhas deles mostram um traço em vez de um custo.",
|
||||
"noPricingTitle": "O custo não está disponível para esta organização",
|
||||
"noPricingSubtitle": "Esta organização não tem uma conta de faturamento com medição, portanto não temos preços para mostrar. O volume de uso ainda está disponível na aba Tendências.",
|
||||
"loadFailedTitle": "Não foi possível carregar seu uso",
|
||||
"loadFailedNetwork": "Não foi possível acessar o servidor. Verifique sua conexão de rede e tente novamente.",
|
||||
"loadFailedServer": "O serviço de faturamento retornou um erro. Tente novamente em instantes.",
|
||||
"refreshFailed": "Não foi possível atualizar o uso. Os números exibidos são da última carga.",
|
||||
"byStream": "Por stream",
|
||||
"colStream": "Stream",
|
||||
"byUser": "Por usuário",
|
||||
"colUser": "Usuário",
|
||||
"byAlert": "Por alerta",
|
||||
"colAlert": "Alerta",
|
||||
"byDashboard": "Por painel",
|
||||
"colDashboard": "Painel",
|
||||
"byScheduledPipeline": "Por oleoduto agendado",
|
||||
"colScheduledPipeline": "Oleoduto agendado",
|
||||
"byPipeline": "Por oleoduto",
|
||||
"colPipeline": "Oleoduto",
|
||||
"byDestination": "Por destino",
|
||||
"colDestination": "Destino",
|
||||
"colRetentionPeriod": "Período de retenção",
|
||||
"colCurrentSize": "Tamanho atual",
|
||||
"drilldownFailed": "Não foi possível carregar este detalhamento.",
|
||||
"retry": "Tentar novamente",
|
||||
"unknownPipeline": "Desconhecido",
|
||||
"rangeCurrentCycle": "Ciclo atual",
|
||||
"assumptions": "Premissas",
|
||||
"avgStreamSize": "Dados médios por stream",
|
||||
"ingestPerMonth": "Dados ingeridos por mês",
|
||||
"searchesPerMonth": "Pesquisas por mês",
|
||||
"scanPerSearch": "Dados verificados por pesquisa",
|
||||
"pipelineCount": "Oleodutos em execução nos streams",
|
||||
"retentionDays": "Dados mantidos por",
|
||||
"aiCreditsPerMonth": "Créditos de IA por mês",
|
||||
"browserChecks": "Verificações de navegador",
|
||||
"stepsPerCheck": "Etapas por verificação",
|
||||
"checkRegions": "Regiões",
|
||||
"checkDevices": "Dispositivos",
|
||||
"checkInterval": "Executa a cada",
|
||||
"apiChecks": "Verificações de API",
|
||||
"browserRunsNote": "Cerca de {runs} execuções de etapas por mês.",
|
||||
"apiRunsNote": "Cerca de {runs} execuções por mês.",
|
||||
"qtyStepRuns": "{count} execuções de etapas",
|
||||
"qtyRuns": "{count} execuções",
|
||||
"estimatedTotal": "Total estimado",
|
||||
"perMonth": "por mês",
|
||||
"estimateDisclaimer": "Esta é uma estimativa pelo preço de tabela. Não inclui nenhum desconto na sua conta.",
|
||||
"cycleDay": "Dia {day} de {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Desconto",
|
||||
"costSoFar": "Custo até agora",
|
||||
"estimatedCostSoFar": "Custo estimado até agora",
|
||||
"projectedTotal": "Total projetado",
|
||||
"tooEarlyToProject": "Muito cedo no ciclo para projetar",
|
||||
"colStorageShare": "% do armazenamento",
|
||||
"orgDefaultRetention": "Padrão da organização",
|
||||
"retentionDaysValue": "{days} dias",
|
||||
"topMeter": "Medidor principal",
|
||||
"topMeterShare": "{cost} · {percent}% da fatura",
|
||||
"billedIngestion": "Dados ingeridos",
|
||||
"billedQuery": "Dados varridos por pesquisas",
|
||||
"billedPipeline": "Dados processados por oleodutos",
|
||||
"billedRemotePipeline": "Dados enviados para destinos remotos",
|
||||
"billedRetention": "Dados armazenados ao longo do tempo",
|
||||
"billedAi": "Créditos de IA usados",
|
||||
"billedSynthetics": "Execuções de verificações sintéticas",
|
||||
"meterShare": "Participação na fatura",
|
||||
"everythingElse": "Todo o resto",
|
||||
"otherBand": "Outros",
|
||||
"colTotal": "Total",
|
||||
"groupBy": "Agrupar por",
|
||||
"groupNotRecorded": "Ainda não há uso de {group} registrado.",
|
||||
"segmentTitle": "{meter}: {billed} faturado, {share} da fatura",
|
||||
"colEstCost": "Custo estimado",
|
||||
"shareOfMeter": "Participação em {meter}",
|
||||
"costByMeter": "Custo por medidor",
|
||||
"meterByGroup": "{meter} por {group}",
|
||||
"billedDay": "Faturado, dia {day} de {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} faturado ({gross} bruto, desconto de {discount}), {share} da fatura",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Faturado por {payer}. Os custos e orçamentos ficam na página Uso dessa organização.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, faturado como {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "Bruto",
|
||||
"colBilled": "Faturado",
|
||||
"allMeters": "Todos os medidores",
|
||||
"noDailyRetention": "A retenção não tem histórico diário. Em vez disso, a tabela mostra o armazenamento de cada stream.",
|
||||
"grossLabel": "Bruto",
|
||||
"cycleCloses": "O ciclo fecha em {date}",
|
||||
"dailyCost": "Custo diário",
|
||||
"breakdownSummary": "{cost} faturado · {volume} · {share} da fatura",
|
||||
"scenarioFrom": "Ponto de partida",
|
||||
"scenarioCurrent": "Meu uso atual",
|
||||
"scenarioTwice": "Crescimento de 2×",
|
||||
"scenarioFiveTimes": "Crescimento de 5×",
|
||||
"resetEstimate": "Redefinir",
|
||||
"expectedUsage": "Uso mensal esperado",
|
||||
"expectedUsageHint": "Pré-preenchido com base neste ciclo até agora",
|
||||
"loadingHistory": "Lendo seu histórico de uso…",
|
||||
"yourEstimate": "Sua estimativa",
|
||||
"assumptionsFrom": "Com base nos seus últimos 30 dias",
|
||||
"scanNote": "Verifica cerca de {volume} por mês.",
|
||||
"qtyScanned": "{volume} verificados",
|
||||
"qtyProcessed": "{volume} processados",
|
||||
"qtyKeptFor": "{volume} por {days} dias",
|
||||
"qtyCredits": "{count} créditos",
|
||||
"rateIncluded": "Incluído no seu plano",
|
||||
"rateTooltip": "{rate}. A cobrança é feita com base em {basis}.",
|
||||
"rateIncludedTooltip": "{basis} faz parte do seu plano, então não acrescenta nada a esta estimativa.",
|
||||
"byIncident": "Por incidente",
|
||||
"colIncident": "Incidente",
|
||||
"colCredits": "Créditos",
|
||||
"byCheck": "Por verificação",
|
||||
"colCheck": "Verificação",
|
||||
"colSteps": "Etapas",
|
||||
"unknownCheck": "Verificação excluída",
|
||||
"noBreakdownYet": "Nenhum uso neste ciclo, então ainda não há nada para detalhar.",
|
||||
"rangeLastCycle": "Ciclo anterior",
|
||||
"rangeLast90Days": "Últimos 90 dias",
|
||||
"runRate": "Ritmo de gastos",
|
||||
"dailyAverage": "Média diária",
|
||||
"peakDay": "Dia de pico",
|
||||
"runRateWindow": "Últimos {days} dias",
|
||||
"acrossDays": "Ao longo de {days} dias",
|
||||
"atPaceProjected": "{cost} no ritmo dos últimos {days} dias",
|
||||
"atPace": "No ritmo de {days} dias",
|
||||
"topMeterOfCost": "{cost} · {percent}% do custo",
|
||||
"perDay": "{cost} por dia",
|
||||
"spendToDate": "Gasto até o momento",
|
||||
"costOverTime": "Custo ao longo do tempo",
|
||||
"volumeOverTime": "Volume ao longo do tempo",
|
||||
"volumeSubtitle": "Ingestão, pesquisa e oleodutos, por dia",
|
||||
"usageByMeter": "Uso por medidor",
|
||||
"dailyVolume": "Volume diário",
|
||||
"lastDaysRange": "Últimos {days} dias: {start} a {end}",
|
||||
"enableVolumeSubtitle": "Ative o relatório de uso para ver quanto cada medidor consome, dia a dia.",
|
||||
"measureCost": "Custo",
|
||||
"measureVolume": "Volume",
|
||||
"estimatedAtCyclePrice": "Estimado com base no uso, ao preço de cada ciclo. Não inclui Retenção de dados.",
|
||||
"showOnlyMeter": "Mostrar apenas {meter}",
|
||||
"showAllMeters": "Mostrar todos os medidores de uso",
|
||||
"noUsageInRange": "Nenhum uso neste intervalo",
|
||||
"trendsLoadFailed": "Não foi possível carregar o uso para este intervalo.",
|
||||
"rangeDates": "{start} a {end}",
|
||||
"forecast": "Previsão",
|
||||
"dayTotal": "Total do dia",
|
||||
"costByOrg": "Custo por organização",
|
||||
"orgFilter": "Organização",
|
||||
"organizationsTitle": "Organizações",
|
||||
"orgCountThisCycle": "{count} organizações · este ciclo",
|
||||
"overallBudget": "Orçamento geral",
|
||||
"overallBudgetLabel": "Orçamento geral para este ciclo de faturamento",
|
||||
"appliesToGroup": "A fatura de todo o grupo",
|
||||
"orgBudgetsTotal": "Os orçamentos das organizações somam {amount}",
|
||||
"orgBudgetsOver": "Os orçamentos das organizações somam {amount}, {over} a mais que o orçamento geral",
|
||||
"needsAttention": "Requer atenção",
|
||||
"attentionHint": "{over} acima do orçamento, {near} perto do limite de aviso",
|
||||
"colSpendThisCycle": "Gasto neste ciclo",
|
||||
"colBudget": "Orçamento",
|
||||
"colAlerts": "Alertas",
|
||||
"statusNone": "Sem orçamento",
|
||||
"statusOk": "Dentro do previsto",
|
||||
"statusWarn": "Perto do limite",
|
||||
"statusOver": "Acima do orçamento",
|
||||
"budgetFor": "Orçamento para {org}",
|
||||
"warnPercentFor": "Percentual de aviso para {org}",
|
||||
"alertsFor": "Alertas por e-mail para {org}",
|
||||
"groupAlertsOnHint": "Os administradores recebem e-mails enquanto o gasto do grupo permanecer acima do valor de aviso neste ciclo. Pause para interrompê-los. Cada linha de organização tem seu próprio interruptor.",
|
||||
"unsavedChanges": "Alterações não salvas: {parts}",
|
||||
"unsavedOrgs": "{count} organização(ões)",
|
||||
"unsavedOverall": "configurações gerais",
|
||||
"allChangesSaved": "Todas as alterações salvas",
|
||||
"discard": "Descartar",
|
||||
"saveChanges": "Salvar alterações",
|
||||
"orgCostsFailed": "Não foi possível carregar o custo por organização. Atualize para tentar novamente.",
|
||||
"clickForOrg": "Clique para ver os medidores desta organização",
|
||||
"clickForBreakdown": "Clique para ver o que compõe este medidor",
|
||||
"allOrgs": "Todas as organizações",
|
||||
"colOrganization": "Organização",
|
||||
"shareOfOrg": "Participação de {org}",
|
||||
"thisOrg": "{org} (esta organização)",
|
||||
"formerMemberIngested": "Ex-membro · {volume} ingerido",
|
||||
"ingestedVolume": "{volume} ingerido",
|
||||
"notSplitByOrg": "Cobrado do grupo inteiro, não dividido por organização",
|
||||
"orgSummary": "{cost} faturado · {share} da fatura",
|
||||
"orgCostEstimated": "Esta fatura é dividida pela participação de uso de cada organização",
|
||||
"byOrg": "Por organização",
|
||||
"historyStartsOn": "O histórico de uso começa em {date}, o início do seu ciclo de faturamento mais antigo para o qual temos uso. Dias anteriores não têm uso para mostrar.",
|
||||
"budgetTab": "Orçamento",
|
||||
"budgetKpi": "Orçamento",
|
||||
"budgetNotSet": "Não definido",
|
||||
"budgetThisCycle": "Este ciclo de faturamento",
|
||||
"spentOfBudget": "{percent}% do orçamento · dia {day} de {total}",
|
||||
"overBudgetBy": "{amount} acima do orçamento",
|
||||
"toSpare": "{amount} de folga",
|
||||
"budgetReached": "Orçamento atingido",
|
||||
"reachedOnDay": "Dia {day}",
|
||||
"notThisCycle": "Não neste ciclo",
|
||||
"atPacePerDay": "A {cost} por dia",
|
||||
"atCurrentPace": "No ritmo atual",
|
||||
"spendAgainstBudget": "Gasto em relação ao orçamento",
|
||||
"ofBudget": "de {budget} · {percent}%",
|
||||
"leftInBudget": "{amount} restante",
|
||||
"overBy": "{amount} acima",
|
||||
"warnAtPercent": "Avisar em {percent}%",
|
||||
"shareOfBudget": "{percent}% do orçamento",
|
||||
"setBudgetFirst": "Defina um orçamento para acompanhar os gastos deste ciclo em relação a ele.",
|
||||
"budgetSettings": "Configurações de orçamento",
|
||||
"budgetAmount": "Orçamento para este ciclo de faturamento",
|
||||
"budgetAmountHelp": "No ritmo de hoje, este ciclo termina perto de {amount}",
|
||||
"warnAt": "Avisar em",
|
||||
"warnAmount": "Valor do aviso em dólares",
|
||||
"warnPercent": "Valor do aviso como percentual do orçamento",
|
||||
"warnHelp": "Alertar quando o gasto ultrapassar {amount}",
|
||||
"emailAlerts": "Alertas por e-mail",
|
||||
"alertsOnHint": "Os administradores recebem e-mails enquanto o gasto permanecer acima do valor de aviso neste ciclo. Pausar para interrompê-los.",
|
||||
"alertsPausedHint": "Nenhum e-mail até que você reative isso.",
|
||||
"saveBudget": "Salvar configurações",
|
||||
"budgetSaved": "Orçamento salvo",
|
||||
"budgetRemoved": "Orçamento removido",
|
||||
"budgetSaveFailed": "Não foi possível salvar o orçamento",
|
||||
"budgetLoadFailedTitle": "Não foi possível carregar seus orçamentos",
|
||||
"budgetLoadFailed": "A edição fica bloqueada até que eles sejam carregados, para que o salvamento não substitua orçamentos já definidos. Tente novamente em instantes."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Nenhum Token de Marketplace Encontrado",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Сравнить планы",
|
||||
"contactSupport": "Связаться с поддержкой"
|
||||
},
|
||||
"previousCycle": "Предыдущий цикл",
|
||||
"currentCycle": "Текущий цикл",
|
||||
"period30Days": "30 дней",
|
||||
"period60Days": "60 дней",
|
||||
"period3Months": "3 месяца",
|
||||
"period6Months": "6 месяцев",
|
||||
"pricingErrorMessage": "Не удалось загрузить сведения о тарифах.",
|
||||
"awsManagedMessage": "Оплата обрабатывается через ваш аккаунт AWS",
|
||||
"azureManagedMessage": "Оплата обрабатывается через ваш аккаунт Azure",
|
||||
"contractManagedMessage": "Оплата обрабатывается по вашему контракту — обратитесь к вашему менеджеру по работе с клиентами для изменений",
|
||||
"upgradeToPlanMessage": "Перейдите на более высокий тарифный план, чтобы продолжить пользоваться услугами OpenObserve.",
|
||||
"creditsUsedLabel": "Использовано кредитов",
|
||||
"errorTracking": "Отслеживание ошибок",
|
||||
"rumSession": "RUM-сессия",
|
||||
"licenseExpiryContactAdmin": "Пожалуйста, обновите свою лицензию, обратившись к вашему администратору.",
|
||||
"subscribed": "Подписано",
|
||||
"invoiceHistory": "История счетов",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Статус",
|
||||
"action": "Действие",
|
||||
"manageSubscription": "Управление подпиской",
|
||||
"messageDataNotFound": "Данные не найдены",
|
||||
"invoiceStartDate": "Дата начала счета",
|
||||
"invoiceEndDate": "Дата окончания счета",
|
||||
"downloadInvoice": "Скачать счет",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Нужен большой объем? Давайте обсудим масштабирование! Свяжитесь с нами для индивидуальных решений.",
|
||||
"contactSupport": "Связаться с поддержкой",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Ежедневное использование",
|
||||
"enableTitle": "Посмотреть ежедневное использование",
|
||||
"enableSubtitle": "Включите отчет об использовании, чтобы отслеживать объем ввода и запросов день за днем. Ваши общие показатели выше остаются доступными в любом случае.",
|
||||
"enableSubtitle": "Включите отчётность об использовании, чтобы строить графики ежедневных затрат и объёма по каждому счётчику. Счёт на вкладке «Обзор» остаётся доступным в любом случае.",
|
||||
"enableButton": "Включить отчет об использовании",
|
||||
"enablePending": "Отчет об использовании включен. Ваш ежедневный график начнет заполняться в ближайшее время по мере записи нового использования.",
|
||||
"enableConfirmTitle": "Включить отчет об использовании?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Свяжитесь с вашим менеджером по работе с клиентами, чтобы добавить больше AI кредитов",
|
||||
"aiPayerSubscribeMessage": "Биллингом этой организации управляет {payer}. Попросите администратора {payer} оформить подписку; дальнейшее использование будет включено в его счёт.",
|
||||
"aiFreeUsedLabel": "использовано бесплатных кредитов",
|
||||
"aiBillableCredits": "Оплачиваемые кредиты ИИ",
|
||||
"aiFreeOneTime": "Единовременный лимит.",
|
||||
"aiUsedByFeature": "Ассистент и генерация запросов: {chat} · Анализ инцидентов: {incident} · Повторный анализ: {reanalysis}",
|
||||
"aiPaygMessage": "Бесплатные кредиты исчерпаны — использование выставляется по подписке",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "План управляется родительской организацией",
|
||||
"chipNoInvoices": "Нет отдельных счетов",
|
||||
"viewOrgGroup": "Просмотреть группу организаций",
|
||||
"currentOrgTitle": "Текущая организация",
|
||||
"memberOrgsTitle": "Организации-участники",
|
||||
"searchMemberOrg": "Поиск организации",
|
||||
"noMemberMatch": "Нет подходящих организаций",
|
||||
"remove": "Удалить",
|
||||
"removeTitle": "Удалить {name} из вашей группы биллинга?",
|
||||
"removeBody": "Эта организация становится бесплатной и оплачивает своё использование. Применяются ограничения бесплатного плана. Её пользователи, данные и прошлое использование не затрагиваются.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Статус оповещений",
|
||||
"contactAdministratorToResolve": "Обратитесь к администратору, чтобы решить эту проблему.",
|
||||
"contactAdministratorUsageReporting": "Обратитесь к администратору для восстановления отчетности об использовании.",
|
||||
"credits": "Кредиты",
|
||||
"dataRetention": "Хранение данных",
|
||||
"event": "Событие",
|
||||
"ingestion": "Прием",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Срок действия вашего пробного периода истек.",
|
||||
"trialDaysRemaining": "{count} день остался в вашей пробной учетной записи | {count} дней осталось в вашей пробной учетной записи",
|
||||
"usageReportingFailedForDays": "Не удается передавать данные об использовании уже {count} день, функции RBAC и SSO отключены до успешной передачи данных | Не удается передавать данные об использовании уже {count} дней, функции RBAC и SSO отключены до успешной передачи данных",
|
||||
"usageReportingFailingForHours": "Передача данных об использовании не удается уже {count} час, некоторые функции скоро будут отключены | Передача данных об использовании не удается уже {count} часов, некоторые функции скоро будут отключены"
|
||||
"usageReportingFailingForHours": "Передача данных об использовании не удается уже {count} час, некоторые функции скоро будут отключены | Передача данных об использовании не удается уже {count} часов, некоторые функции скоро будут отключены",
|
||||
"usageV2": {
|
||||
"unitGb": "ГБ",
|
||||
"unitGbHour": "ГБ-час",
|
||||
"unitCredit": "Кредит",
|
||||
"unitRun": "Запуск",
|
||||
"ratePerUnit": "{rate} за {unit}",
|
||||
"cycleRange": "Расчётный период: с {start} по {end}",
|
||||
"syntheticsBrowser": "Синтетика (браузер)",
|
||||
"syntheticsProtocol": "Синтетика (протокол)",
|
||||
"overviewTab": "Обзор",
|
||||
"trendsTab": "Тренды",
|
||||
"estimationTab": "Оценка стоимости",
|
||||
"costBreakdown": "Разбивка затрат",
|
||||
"colMeter": "Счётчик",
|
||||
"colVolume": "Объём",
|
||||
"colCost": "Стоимость",
|
||||
"totalCost": "Общая стоимость",
|
||||
"missingMeters": "Не удалось рассчитать стоимость для этих счётчиков: {meters}. В их строках вместо стоимости отображается прочерк.",
|
||||
"noPricingTitle": "Стоимость недоступна для этой организации",
|
||||
"noPricingSubtitle": "У этой организации нет аккаунта с оплатой по использованию, поэтому цены недоступны. Объём использования по-прежнему можно посмотреть на вкладке «Тренды».",
|
||||
"loadFailedTitle": "Не удалось загрузить данные об использовании",
|
||||
"loadFailedNetwork": "Не удалось связаться с сервером. Проверьте сетевое подключение и повторите попытку.",
|
||||
"loadFailedServer": "Сервис биллинга вернул ошибку. Повторите попытку чуть позже.",
|
||||
"refreshFailed": "Не удалось обновить данные об использовании. Показаны числа из последней загрузки.",
|
||||
"byStream": "По потокам",
|
||||
"colStream": "Поток",
|
||||
"byUser": "По пользователям",
|
||||
"colUser": "Пользователь",
|
||||
"byAlert": "По оповещениям",
|
||||
"colAlert": "Оповещение",
|
||||
"byDashboard": "По дашбордам",
|
||||
"colDashboard": "Дашборд",
|
||||
"byScheduledPipeline": "По запланированным конвейерам",
|
||||
"colScheduledPipeline": "Запланированный конвейер",
|
||||
"byPipeline": "По конвейерам",
|
||||
"colPipeline": "Конвейер",
|
||||
"byDestination": "По назначениям",
|
||||
"colDestination": "Назначение",
|
||||
"colRetentionPeriod": "Период хранения",
|
||||
"colCurrentSize": "Текущий размер",
|
||||
"drilldownFailed": "Не удалось загрузить эту разбивку.",
|
||||
"retry": "Повторить",
|
||||
"unknownPipeline": "Неизвестно",
|
||||
"rangeCurrentCycle": "Текущий цикл",
|
||||
"assumptions": "Допущения",
|
||||
"avgStreamSize": "Средний объём данных на поток",
|
||||
"ingestPerMonth": "Объём принимаемых данных в месяц",
|
||||
"searchesPerMonth": "Поисков в месяц",
|
||||
"scanPerSearch": "Объём данных, сканируемых при поиске",
|
||||
"pipelineCount": "Конвейеры, работающие с потоками",
|
||||
"retentionDays": "Срок хранения данных",
|
||||
"aiCreditsPerMonth": "Кредиты ИИ в месяц",
|
||||
"browserChecks": "Браузерные проверки",
|
||||
"stepsPerCheck": "Шагов на проверку",
|
||||
"checkRegions": "Регионы",
|
||||
"checkDevices": "Устройства",
|
||||
"checkInterval": "Запуск каждые",
|
||||
"apiChecks": "API-проверки",
|
||||
"browserRunsNote": "Около {runs} запусков шагов в месяц.",
|
||||
"apiRunsNote": "Около {runs} запусков в месяц.",
|
||||
"qtyStepRuns": "{count} запусков шагов",
|
||||
"qtyRuns": "{count} запусков",
|
||||
"estimatedTotal": "Расчётный итог",
|
||||
"perMonth": "в месяц",
|
||||
"estimateDisclaimer": "Это расчёт по прайс-листу. Он не учитывает скидки на вашем аккаунте.",
|
||||
"cycleDay": "День {day} из {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Скидка",
|
||||
"costSoFar": "Стоимость на данный момент",
|
||||
"estimatedCostSoFar": "Оценочная стоимость на данный момент",
|
||||
"projectedTotal": "Прогнозируемый итог",
|
||||
"tooEarlyToProject": "Слишком рано для прогноза в этом цикле",
|
||||
"colStorageShare": "% хранилища",
|
||||
"orgDefaultRetention": "По умолчанию в организации",
|
||||
"retentionDaysValue": "{days} дней",
|
||||
"topMeter": "Основной счётчик",
|
||||
"topMeterShare": "{cost} · {percent}% от счёта",
|
||||
"billedIngestion": "Принятые данные",
|
||||
"billedQuery": "Данные, просканированные поисками",
|
||||
"billedPipeline": "Данные, обработанные конвейерами",
|
||||
"billedRemotePipeline": "Данные, отправленные на удалённые назначения",
|
||||
"billedRetention": "Данные, хранимые за период",
|
||||
"billedAi": "Использовано кредитов ИИ",
|
||||
"billedSynthetics": "Запуски синтетических проверок",
|
||||
"meterShare": "Доля в счёте",
|
||||
"everythingElse": "Всё остальное",
|
||||
"otherBand": "Другое",
|
||||
"colTotal": "Итого",
|
||||
"groupBy": "Группировать по",
|
||||
"groupNotRecorded": "Использование по {group} пока не зафиксировано.",
|
||||
"segmentTitle": "{meter}: {billed} начислено, {share} в счёте",
|
||||
"colEstCost": "Оцен. стоимость",
|
||||
"shareOfMeter": "Доля в {meter}",
|
||||
"costByMeter": "Стоимость по счётчикам",
|
||||
"meterByGroup": "{meter} по {group}",
|
||||
"billedDay": "Начислено, день {day} из {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} начислено ({gross} до скидки, скидка {discount}), {share} в счёте",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Счета выставляются через {payer}. Затраты и бюджеты — на странице использования этой организации.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, тарифицируется как {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "До скидки",
|
||||
"colBilled": "Начислено",
|
||||
"allMeters": "Все счётчики",
|
||||
"noDailyRetention": "Для хранения нет истории по дням. Вместо этого в таблице показан объём хранения каждого потока.",
|
||||
"grossLabel": "До скидки",
|
||||
"cycleCloses": "Цикл закрывается {date}",
|
||||
"dailyCost": "Ежедневная стоимость",
|
||||
"breakdownSummary": "{cost} начислено · {volume} · {share} в счёте",
|
||||
"scenarioFrom": "Начать с",
|
||||
"scenarioCurrent": "Текущее использование",
|
||||
"scenarioTwice": "Рост ×2",
|
||||
"scenarioFiveTimes": "Рост ×5",
|
||||
"resetEstimate": "Сбросить",
|
||||
"expectedUsage": "Ожидаемое месячное использование",
|
||||
"expectedUsageHint": "Заполнено по текущему циклу",
|
||||
"loadingHistory": "Чтение истории использования…",
|
||||
"yourEstimate": "Ваша оценка",
|
||||
"assumptionsFrom": "По вашим последним 30 дням",
|
||||
"scanNote": "Сканирует около {volume} в месяц.",
|
||||
"qtyScanned": "{volume} просканировано",
|
||||
"qtyProcessed": "{volume} обработано",
|
||||
"qtyKeptFor": "{volume} за {days} дней",
|
||||
"qtyCredits": "{count} кредитов",
|
||||
"rateIncluded": "Включено в ваш план",
|
||||
"rateTooltip": "{rate}. Оплата рассчитывается по {basis}.",
|
||||
"rateIncludedTooltip": "{basis} включено в ваш план, поэтому на эту оценку это не влияет.",
|
||||
"byIncident": "По инцидентам",
|
||||
"colIncident": "Инцидент",
|
||||
"colCredits": "Кредиты",
|
||||
"byCheck": "По проверкам",
|
||||
"colCheck": "Проверка",
|
||||
"colSteps": "Шаги",
|
||||
"unknownCheck": "Удалённая проверка",
|
||||
"noBreakdownYet": "В этом цикле использования нет, поэтому пока нечего детализировать.",
|
||||
"rangeLastCycle": "Последний цикл",
|
||||
"rangeLast90Days": "Последние 90 дней",
|
||||
"runRate": "Темп расходов",
|
||||
"dailyAverage": "Среднее в день",
|
||||
"peakDay": "Пиковый день",
|
||||
"runRateWindow": "Последние {days} дней",
|
||||
"acrossDays": "За {days} дней",
|
||||
"atPaceProjected": "{cost} при темпе за последние {days} дн.",
|
||||
"atPace": "При темпе за {days} дн.",
|
||||
"topMeterOfCost": "{cost} · {percent}% затрат",
|
||||
"perDay": "{cost} в день",
|
||||
"spendToDate": "Расходы на текущий момент",
|
||||
"costOverTime": "Затраты по времени",
|
||||
"volumeOverTime": "Объём по времени",
|
||||
"volumeSubtitle": "Прием данных, поиск и конвейеры, по дням",
|
||||
"usageByMeter": "Использование по счётчикам",
|
||||
"dailyVolume": "Дневной объём",
|
||||
"lastDaysRange": "Последние {days} дн.: {start} — {end}",
|
||||
"enableVolumeSubtitle": "Включите отчёты об использовании, чтобы видеть расход по каждому счётчику по дням.",
|
||||
"measureCost": "Затраты",
|
||||
"measureVolume": "Объём",
|
||||
"estimatedAtCyclePrice": "Оценено по использованию, по цене каждого цикла. Не включает хранение данных.",
|
||||
"showOnlyMeter": "Показать только {meter}",
|
||||
"showAllMeters": "Показать все счётчики",
|
||||
"noUsageInRange": "В этом диапазоне нет использования",
|
||||
"trendsLoadFailed": "Не удалось загрузить данные об использовании за этот диапазон.",
|
||||
"rangeDates": "с {start} по {end}",
|
||||
"forecast": "Прогноз",
|
||||
"dayTotal": "Итого за день",
|
||||
"costByOrg": "Затраты по организациям",
|
||||
"orgFilter": "Организация",
|
||||
"organizationsTitle": "Организации",
|
||||
"orgCountThisCycle": "{count} организаций · этот цикл",
|
||||
"overallBudget": "Общий бюджет",
|
||||
"overallBudgetLabel": "Общий бюджет на этот цикл биллинга",
|
||||
"appliesToGroup": "Счёт всей группы",
|
||||
"orgBudgetsTotal": "Бюджеты организаций в сумме составляют {amount}",
|
||||
"orgBudgetsOver": "Бюджеты организаций в сумме составляют {amount}, что на {over} больше общего бюджета",
|
||||
"needsAttention": "Требует внимания",
|
||||
"attentionHint": "{over} сверх бюджета, {near} близко к порогу предупреждения",
|
||||
"colSpendThisCycle": "Расходы за этот цикл",
|
||||
"colBudget": "Бюджет",
|
||||
"colAlerts": "Оповещения",
|
||||
"statusNone": "Бюджет не задан",
|
||||
"statusOk": "В пределах плана",
|
||||
"statusWarn": "Близко к лимиту",
|
||||
"statusOver": "Бюджет превышен",
|
||||
"budgetFor": "Бюджет для {org}",
|
||||
"warnPercentFor": "Процент предупреждения для {org}",
|
||||
"alertsFor": "Оповещения по эл. почте для {org}",
|
||||
"groupAlertsOnHint": "Администраторы получают письма, пока расходы группы за этот цикл остаются выше пороговой суммы. Чтобы остановить их, приостановите отправку. У каждой строки организации есть свой переключатель.",
|
||||
"unsavedChanges": "Не сохранено: {parts}",
|
||||
"unsavedOrgs": "{count} орг.",
|
||||
"unsavedOverall": "общие настройки",
|
||||
"allChangesSaved": "Все изменения сохранены",
|
||||
"discard": "Сбросить",
|
||||
"saveChanges": "Сохранить изменения",
|
||||
"orgCostsFailed": "Не удалось загрузить затраты по организациям. Обновите страницу и повторите попытку.",
|
||||
"clickForOrg": "Нажмите, чтобы посмотреть счётчики этой организации",
|
||||
"clickForBreakdown": "Нажмите, чтобы увидеть, что влияет на этот счётчик",
|
||||
"allOrgs": "Все организации",
|
||||
"colOrganization": "Организация",
|
||||
"shareOfOrg": "Доля {org}",
|
||||
"thisOrg": "{org} (эта организация)",
|
||||
"formerMemberIngested": "Бывший участник · принято {volume}",
|
||||
"ingestedVolume": "Принято {volume}",
|
||||
"notSplitByOrg": "Оплачивается всей группой, без разбивки по организациям",
|
||||
"orgSummary": "Начислено {cost} · {share} от счёта",
|
||||
"orgCostEstimated": "Этот счёт распределён по доле каждой организации в использовании",
|
||||
"byOrg": "По организациям",
|
||||
"historyStartsOn": "История использования начинается с {date} — начала вашего самого старого расчётного периода, за который у нас есть данные об использовании. За более ранние дни данных об использовании нет.",
|
||||
"budgetTab": "Бюджет",
|
||||
"budgetKpi": "Бюджет",
|
||||
"budgetNotSet": "Не задан",
|
||||
"budgetThisCycle": "Этот расчётный период",
|
||||
"spentOfBudget": "{percent}% бюджета · день {day} из {total}",
|
||||
"overBudgetBy": "На {amount} сверх бюджета",
|
||||
"toSpare": "{amount} в запасе",
|
||||
"budgetReached": "Бюджет достигнут",
|
||||
"reachedOnDay": "День {day}",
|
||||
"notThisCycle": "Не в этом цикле",
|
||||
"atPacePerDay": "При {cost} в день",
|
||||
"atCurrentPace": "При текущем темпе",
|
||||
"spendAgainstBudget": "Расходы относительно бюджета",
|
||||
"ofBudget": "из {budget} · {percent}%",
|
||||
"leftInBudget": "Осталось {amount}",
|
||||
"overBy": "{amount} сверх",
|
||||
"warnAtPercent": "Предупреждение при {percent}%",
|
||||
"shareOfBudget": "{percent}% бюджета",
|
||||
"setBudgetFirst": "Задайте бюджет, чтобы отслеживать расходы за этот цикл относительно него.",
|
||||
"budgetSettings": "Настройки бюджета",
|
||||
"budgetAmount": "Бюджет на этот расчетный период",
|
||||
"budgetAmountHelp": "При текущем темпе цикл завершится примерно на {amount}",
|
||||
"warnAt": "Предупреждать при",
|
||||
"warnAmount": "Сумма предупреждения в долларах",
|
||||
"warnPercent": "Сумма предупреждения в процентах от бюджета",
|
||||
"warnHelp": "Оповещать, когда расходы превысят {amount}",
|
||||
"emailAlerts": "Оповещения по электронной почте",
|
||||
"alertsOnHint": "Администраторы получают письма, пока расходы в этом цикле остаются выше суммы предупреждения. Приостановите, чтобы остановить их.",
|
||||
"alertsPausedHint": "Письма не будут приходить, пока вы не включите это снова.",
|
||||
"saveBudget": "Сохранить настройки",
|
||||
"budgetSaved": "Бюджет сохранён",
|
||||
"budgetRemoved": "Бюджет удалён",
|
||||
"budgetSaveFailed": "Не удалось сохранить бюджет",
|
||||
"budgetLoadFailedTitle": "Не удалось загрузить ваши бюджеты",
|
||||
"budgetLoadFailed": "Редактирование станет доступно после загрузки, чтобы сохранение не перезаписало уже заданные бюджеты. Повторите попытку через мгновение."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Токен Marketplace не найден",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "Planları karşılaştır",
|
||||
"contactSupport": "Destekle iletişime geç"
|
||||
},
|
||||
"previousCycle": "Önceki Döngü",
|
||||
"currentCycle": "Geçerli Döngü",
|
||||
"period30Days": "30 Gün",
|
||||
"period60Days": "60 Gün",
|
||||
"period3Months": "3 Ay",
|
||||
"period6Months": "6 Ay",
|
||||
"pricingErrorMessage": "Fiyatlandırma ayrıntıları yüklenemedi.",
|
||||
"awsManagedMessage": "Faturalandırma AWS hesabınız üzerinden yapılır.",
|
||||
"azureManagedMessage": "Faturalandırma Azure hesabınız üzerinden yapılır.",
|
||||
"contractManagedMessage": "Faturalandırma sözleşmeniz üzerinden yapılır — değişiklikler için hesap yöneticinizle iletişime geçin.",
|
||||
"upgradeToPlanMessage": "OpenObserve hizmetlerinden yararlanmaya devam etmek için bir plana yükseltin.",
|
||||
"creditsUsedLabel": "kullanılan krediler",
|
||||
"errorTracking": "Hata Takibi",
|
||||
"rumSession": "RUM Oturumu",
|
||||
"licenseExpiryContactAdmin": "Lütfen yöneticinizle iletişime geçerek lisansınızı güncelleyin.",
|
||||
"subscribed": "Abone",
|
||||
"invoiceHistory": "Fatura Geçmişi",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Durum",
|
||||
"action": "Eylem",
|
||||
"manageSubscription": "Aboneliği Yönet",
|
||||
"messageDataNotFound": "Veri bulunamadı",
|
||||
"invoiceStartDate": "Fatura Başlangıç Tarihi",
|
||||
"invoiceEndDate": "Fatura Bitiş Tarihi",
|
||||
"downloadInvoice": "Faturayı İndir",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Yüksek hacimli ihtiyaçlar? Ölçekten bahsedelim! Özel çözümler için bizimle iletişime geçin.",
|
||||
"contactSupport": "Destek ile iletişime geçin",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Günlük kullanım",
|
||||
"enableTitle": "Günlük kullanımınızı görün",
|
||||
"enableSubtitle": "Günlük veri alım ve sorgu hacminizi grafiklemek için kullanım raporlamayı açın. Üstteki toplamlarınız her iki durumda da kullanılabilir.",
|
||||
"enableSubtitle": "Her sayaç için günlük maliyetinizi ve hacminizi grafiklerle görmek üzere kullanım raporlamasını açın. Genel Bakış'taki faturanız her durumda kullanılabilir olarak kalır.",
|
||||
"enableButton": "Kullanım raporlamayı etkinleştir",
|
||||
"enablePending": "Kullanım raporlaması etkinleştirildi. Günlük grafiğiniz, yeni kullanım kaydedildikçe kısa süre içinde dolmaya başlayacaktır.",
|
||||
"enableConfirmTitle": "Kullanım raporlaması etkinleştirilsin mi?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Daha fazla AI kredisi eklemek için hesap yöneticinizle iletişime geçin",
|
||||
"aiPayerSubscribeMessage": "Bu kuruluşun faturalandırması {payer} tarafından yönetilir. Abone olmak için {payer} yöneticisinden isteyin; daha fazla kullanım onun faturasına yansıtılır.",
|
||||
"aiFreeUsedLabel": "kullanılan ücretsiz krediler",
|
||||
"aiBillableCredits": "Faturalandırılabilir yapay zeka kredileri",
|
||||
"aiFreeOneTime": "Tek seferlik bir hak.",
|
||||
"aiUsedByFeature": "Asistan ve sorgu oluşturma: {chat} · Olay analizi: {incident} · Yeniden analiz: {reanalysis}",
|
||||
"aiPaygMessage": "Ücretsiz krediler tükendi — kullanım aboneliğe faturalandırılır",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Ebeveyn Tarafından Yönetilen Plan",
|
||||
"chipNoInvoices": "Ayrı Fatura Yok",
|
||||
"viewOrgGroup": "Organizasyon Grubunu Görüntüle",
|
||||
"currentOrgTitle": "Mevcut Kuruluş",
|
||||
"memberOrgsTitle": "Üye Organizasyonlar",
|
||||
"searchMemberOrg": "Organizasyon Ara",
|
||||
"noMemberMatch": "Eşleşen Organizasyon Yok",
|
||||
"remove": "Kaldır",
|
||||
"removeTitle": "{name} faturalandırma grubunuzdan kaldırılsın mı?",
|
||||
"removeBody": "Bu organizasyon ücretsiz bir organizasyon haline gelir ve kendi kullanımı için ödeme yapar. Ücretsiz plan sınırları geçerlidir. Kullanıcıları, verileri ve geçmiş kullanımı etkilenmez.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Uyarı Durumu",
|
||||
"contactAdministratorToResolve": "Bu sorunu çözmek için lütfen yöneticinizle iletişime geçin.",
|
||||
"contactAdministratorUsageReporting": "Kullanım raporlamasının geri yüklendiğinden emin olmak için lütfen yöneticinizle iletişime geçin.",
|
||||
"credits": "Krediler",
|
||||
"dataRetention": "Veri Saklama",
|
||||
"event": "Olay",
|
||||
"ingestion": "Veri Alımı",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Deneme sürenizin süresi doldu.",
|
||||
"trialDaysRemaining": "Deneme hesabınızda {count} gün kaldı | Deneme hesabınızda {count} gün kaldı",
|
||||
"usageReportingFailedForDays": "Kullanım raporlaması {count} gündür başarısız durumda, kullanım başarıyla raporlanana kadar RBAC ve SSO özellikleri devre dışıdır | Kullanım raporlaması {count} gündür başarısız durumda, kullanım başarıyla raporlanana kadar RBAC ve SSO özellikleri devre dışıdır",
|
||||
"usageReportingFailingForHours": "Kullanım raporlaması {count} saattir başarısız oluyor, bazı özellikler yakında devre dışı bırakılacak | Kullanım raporlaması {count} saattir başarısız oluyor, bazı özellikler yakında devre dışı bırakılacak"
|
||||
"usageReportingFailingForHours": "Kullanım raporlaması {count} saattir başarısız oluyor, bazı özellikler yakında devre dışı bırakılacak | Kullanım raporlaması {count} saattir başarısız oluyor, bazı özellikler yakında devre dışı bırakılacak",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-saat",
|
||||
"unitCredit": "Kredi",
|
||||
"unitRun": "Çalıştırma",
|
||||
"ratePerUnit": "Her {unit} için {rate}",
|
||||
"cycleRange": "Faturalandırma döngüsü: {start} – {end}",
|
||||
"syntheticsBrowser": "Sentetikler (Tarayıcı)",
|
||||
"syntheticsProtocol": "Sentetikler (Protokol)",
|
||||
"overviewTab": "Genel Bakış",
|
||||
"trendsTab": "Eğilimler",
|
||||
"estimationTab": "Fiyat Tahmini",
|
||||
"costBreakdown": "Maliyet Dökümü",
|
||||
"colMeter": "Sayaç",
|
||||
"colVolume": "Hacim",
|
||||
"colCost": "Maliyet",
|
||||
"totalCost": "Toplam Maliyet",
|
||||
"missingMeters": "Bu sayaçları fiyatlandıramadık: {meters}. Satırlarında maliyet yerine tire gösteriliyor.",
|
||||
"noPricingTitle": "Bu kuruluş için maliyet kullanılamıyor",
|
||||
"noPricingSubtitle": "Bu kuruluşun ölçümlü faturalandırma hesabı yok, bu nedenle gösterecek fiyatımız yok. Kullanım hacmi Eğilimler sekmesinde hâlâ kullanılabilir.",
|
||||
"loadFailedTitle": "Kullanımınız yüklenemedi",
|
||||
"loadFailedNetwork": "Sunucuya ulaşılamadı. Ağ bağlantınızı kontrol edip tekrar deneyin.",
|
||||
"loadFailedServer": "Faturalandırma hizmeti bir hata döndürdü. Biraz sonra tekrar deneyin.",
|
||||
"refreshFailed": "Kullanım yenilenemedi. Gösterilen sayılar son yüklemeden geliyor.",
|
||||
"byStream": "Akışa Göre",
|
||||
"colStream": "Akış",
|
||||
"byUser": "Kullanıcıya Göre",
|
||||
"colUser": "Kullanıcı",
|
||||
"byAlert": "Uyarıya Göre",
|
||||
"colAlert": "Uyarı",
|
||||
"byDashboard": "Gösterge Panosuna Göre",
|
||||
"colDashboard": "Gösterge Panosu",
|
||||
"byScheduledPipeline": "Zamanlanmış Boru Hattına Göre",
|
||||
"colScheduledPipeline": "Zamanlanmış Boru Hattı",
|
||||
"byPipeline": "Boru Hattına Göre",
|
||||
"colPipeline": "Boru Hattı",
|
||||
"byDestination": "Hedefe Göre",
|
||||
"colDestination": "Hedef",
|
||||
"colRetentionPeriod": "Saklama Süresi",
|
||||
"colCurrentSize": "Mevcut Boyut",
|
||||
"drilldownFailed": "Bu döküm yüklenemedi.",
|
||||
"retry": "Yeniden Dene",
|
||||
"unknownPipeline": "Bilinmiyor",
|
||||
"rangeCurrentCycle": "Mevcut Döngü",
|
||||
"assumptions": "Varsayımlar",
|
||||
"avgStreamSize": "Akış başına ortalama veri",
|
||||
"ingestPerMonth": "Aylık alınan veri",
|
||||
"searchesPerMonth": "Aylık arama sayısı",
|
||||
"scanPerSearch": "Arama başına taranan veri",
|
||||
"pipelineCount": "Akışlarda çalışan boru hatları",
|
||||
"retentionDays": "Verinin saklanma süresi",
|
||||
"aiCreditsPerMonth": "Aylık yapay zeka kredisi",
|
||||
"browserChecks": "Tarayıcı kontrolleri",
|
||||
"stepsPerCheck": "Kontrol başına adım",
|
||||
"checkRegions": "Bölgeler",
|
||||
"checkDevices": "Cihazlar",
|
||||
"checkInterval": "Çalışma sıklığı",
|
||||
"apiChecks": "API kontrolleri",
|
||||
"browserRunsNote": "Ayda yaklaşık {runs} adım çalıştırması.",
|
||||
"apiRunsNote": "Ayda yaklaşık {runs} çalıştırma.",
|
||||
"qtyStepRuns": "{count} adım çalıştırması",
|
||||
"qtyRuns": "{count} çalıştırma",
|
||||
"estimatedTotal": "Tahmini toplam",
|
||||
"perMonth": "aylık",
|
||||
"estimateDisclaimer": "Bu, liste fiyatı üzerinden bir tahmindir. Hesabınızdaki hiçbir indirimi içermez.",
|
||||
"cycleDay": "{total} günün {day}. günü",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "İndirim",
|
||||
"costSoFar": "Şu Ana Kadarki Maliyet",
|
||||
"estimatedCostSoFar": "Şu Ana Kadarki Tahmini Maliyet",
|
||||
"projectedTotal": "Öngörülen Toplam",
|
||||
"tooEarlyToProject": "Öngörü için döngüde çok erken",
|
||||
"colStorageShare": "Depolama %'si",
|
||||
"orgDefaultRetention": "Kuruluş varsayılanı",
|
||||
"retentionDaysValue": "{days} gün",
|
||||
"topMeter": "En Yüksek Ölçüm",
|
||||
"topMeterShare": "{cost} · faturanın %{percent}'i",
|
||||
"billedIngestion": "Alınan veri",
|
||||
"billedQuery": "Aramalarla taranan veri",
|
||||
"billedPipeline": "Boru hatlarıyla işlenen veri",
|
||||
"billedRemotePipeline": "Uzak hedeflere gönderilen veri",
|
||||
"billedRetention": "Zaman içinde depolanan veri",
|
||||
"billedAi": "Kullanılan yapay zeka kredisi",
|
||||
"billedSynthetics": "Sentetik kontrol çalıştırmaları",
|
||||
"meterShare": "Fatura Payı",
|
||||
"everythingElse": "Diğerleri",
|
||||
"otherBand": "Diğer",
|
||||
"colTotal": "Toplam",
|
||||
"groupBy": "Gruplama",
|
||||
"groupNotRecorded": "Henüz {group} kullanımı kaydedilmedi.",
|
||||
"segmentTitle": "{meter}: {billed} faturalandırıldı, fatura payı {share}",
|
||||
"colEstCost": "Tahmini Maliyet",
|
||||
"shareOfMeter": "{meter} payı",
|
||||
"costByMeter": "Ölçüme Göre Maliyet",
|
||||
"meterByGroup": "{group} bazında {meter}",
|
||||
"billedDay": "Faturalandırıldı, {total} günün {day}. günü",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} faturalandırıldı ({gross} brüt, {discount} indirim), fatura payı {share}",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Faturalandırma {payer} üzerinden yapılır. Maliyetler ve bütçeler o kuruluşun Kullanım sayfasındadır.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, {amount} {unit} olarak faturalandırılır",
|
||||
"percentInParens": "(%{percent})",
|
||||
"colGross": "Brüt",
|
||||
"colBilled": "Faturalandırılan",
|
||||
"allMeters": "Tüm Ölçümler",
|
||||
"noDailyRetention": "Saklama süresinin günlük geçmişi yok. Tablo bunun yerine her akışın depolamasını gösterir.",
|
||||
"grossLabel": "Brüt",
|
||||
"cycleCloses": "Dönem {date} tarihinde kapanıyor",
|
||||
"dailyCost": "Günlük Maliyet",
|
||||
"breakdownSummary": "{cost} faturalandırıldı · {volume} · fatura payı {share}",
|
||||
"scenarioFrom": "Başlangıç",
|
||||
"scenarioCurrent": "Mevcut Kullanımım",
|
||||
"scenarioTwice": "2× Büyüme",
|
||||
"scenarioFiveTimes": "5× Büyüme",
|
||||
"resetEstimate": "Sıfırla",
|
||||
"expectedUsage": "Beklenen Aylık Kullanım",
|
||||
"expectedUsageHint": "Bu dönemin şu ana kadarki verileriyle önceden dolduruldu",
|
||||
"loadingHistory": "Kullanım geçmişiniz okunuyor…",
|
||||
"yourEstimate": "Tahmininiz",
|
||||
"assumptionsFrom": "Son 30 gününüze göre",
|
||||
"scanNote": "Ayda yaklaşık {volume} tarar.",
|
||||
"qtyScanned": "{volume} tarandı",
|
||||
"qtyProcessed": "{volume} işlendi",
|
||||
"qtyKeptFor": "{volume}, {days} gün için",
|
||||
"qtyCredits": "{count} kredi",
|
||||
"rateIncluded": "Planınıza dahil",
|
||||
"rateTooltip": "{rate}. Faturalandırma {basis} üzerinden yapılır.",
|
||||
"rateIncludedTooltip": "{basis} planınıza dahil olduğundan bu tahmine hiçbir şey eklemez.",
|
||||
"byIncident": "Olay Bazında",
|
||||
"colIncident": "Olay",
|
||||
"colCredits": "Krediler",
|
||||
"byCheck": "Kontrol Bazında",
|
||||
"colCheck": "Kontrol",
|
||||
"colSteps": "Adımlar",
|
||||
"unknownCheck": "Silinmiş kontrol",
|
||||
"noBreakdownYet": "Bu döngüde kullanım yok, bu nedenle henüz ayrıştırılacak bir şey yok.",
|
||||
"rangeLastCycle": "Son Döngü",
|
||||
"rangeLast90Days": "Son 90 Gün",
|
||||
"runRate": "Gidiş Hızı",
|
||||
"dailyAverage": "Günlük Ortalama",
|
||||
"peakDay": "En Yoğun Gün",
|
||||
"runRateWindow": "Son {days} gün",
|
||||
"acrossDays": "{days} gün boyunca",
|
||||
"atPaceProjected": "Son {days} günün hızına göre {cost}",
|
||||
"atPace": "{days} Günlük Hıza Göre",
|
||||
"topMeterOfCost": "{cost} · maliyetin %{percent}'i",
|
||||
"perDay": "Gün başına {cost}",
|
||||
"spendToDate": "Bugüne Kadar Harcama",
|
||||
"costOverTime": "Zaman İçinde Maliyet",
|
||||
"volumeOverTime": "Zaman İçinde Hacim",
|
||||
"volumeSubtitle": "Günlük veri alımı, arama ve boru hatları",
|
||||
"usageByMeter": "Ölçüm Birimine Göre Kullanım",
|
||||
"dailyVolume": "Günlük Hacim",
|
||||
"lastDaysRange": "Son {days} gün: {start} - {end}",
|
||||
"enableVolumeSubtitle": "Her ölçüm biriminin gün gün ne kadar kullanıldığını görmek için kullanım raporlamasını açın.",
|
||||
"measureCost": "Maliyet",
|
||||
"measureVolume": "Hacim",
|
||||
"estimatedAtCyclePrice": "Kullanıma göre, her döngünün fiyatı üzerinden tahmin edilir. Veri Saklama hariçtir.",
|
||||
"showOnlyMeter": "Yalnızca {meter} göster",
|
||||
"showAllMeters": "Tüm ölçümleri göster",
|
||||
"noUsageInRange": "Bu aralıkta kullanım yok",
|
||||
"trendsLoadFailed": "Bu aralık için kullanım yüklenemedi.",
|
||||
"rangeDates": "{start} - {end}",
|
||||
"forecast": "Tahmin",
|
||||
"dayTotal": "Gün toplamı",
|
||||
"costByOrg": "Organizasyona Göre Maliyet",
|
||||
"orgFilter": "Organizasyon",
|
||||
"organizationsTitle": "Organizasyonlar",
|
||||
"orgCountThisCycle": "{count} organizasyon · bu döngü",
|
||||
"overallBudget": "Genel Bütçe",
|
||||
"overallBudgetLabel": "Bu faturalandırma döngüsü için genel bütçe",
|
||||
"appliesToGroup": "Tüm grubun faturası",
|
||||
"orgBudgetsTotal": "Organizasyon bütçeleri toplamda {amount} ediyor",
|
||||
"orgBudgetsOver": "Organizasyon bütçeleri toplamda {amount} ediyor; genel bütçeden {over} fazla",
|
||||
"needsAttention": "Dikkat Gerektiriyor",
|
||||
"attentionHint": "{over} bütçe aşımı, {near} uyarı sınırına yakın",
|
||||
"colSpendThisCycle": "Bu Döngüdeki Harcama",
|
||||
"colBudget": "Bütçe",
|
||||
"colAlerts": "Uyarılar",
|
||||
"statusNone": "Bütçe yok",
|
||||
"statusOk": "Yolunda",
|
||||
"statusWarn": "Sınıra yakın",
|
||||
"statusOver": "Bütçe aşıldı",
|
||||
"budgetFor": "{org} için bütçe",
|
||||
"warnPercentFor": "{org} için uyarı yüzdesi",
|
||||
"alertsFor": "{org} için e-posta uyarıları",
|
||||
"groupAlertsOnHint": "Grup harcaması bu döngüde uyarı tutarının üzerinde kaldığı sürece yöneticiler e-posta alır. Durdurmak için duraklatın. Her organizasyon satırının kendi anahtarı vardır.",
|
||||
"unsavedChanges": "Kaydedilmemiş: {parts}",
|
||||
"unsavedOrgs": "{count} organizasyon",
|
||||
"unsavedOverall": "genel ayarlar",
|
||||
"allChangesSaved": "Tüm değişiklikler kaydedildi",
|
||||
"discard": "Vazgeç",
|
||||
"saveChanges": "Değişiklikleri Kaydet",
|
||||
"orgCostsFailed": "Organizasyona göre maliyet yüklenemedi. Yeniden denemek için yenileyin.",
|
||||
"clickForOrg": "Bu organizasyonun ölçümlerini görmek için tıklayın",
|
||||
"clickForBreakdown": "Bu ölçümü neyin oluşturduğunu görmek için tıklayın",
|
||||
"allOrgs": "Tüm organizasyonlar",
|
||||
"colOrganization": "Organizasyon",
|
||||
"shareOfOrg": "{org} payı",
|
||||
"thisOrg": "{org} (bu organizasyon)",
|
||||
"formerMemberIngested": "Eski üye · {volume} alındı",
|
||||
"ingestedVolume": "{volume} alındı",
|
||||
"notSplitByOrg": "Tüm gruba faturalandırılır, organizasyonlara bölünmez",
|
||||
"orgSummary": "{cost} faturalandırıldı · faturanın {share} kadarı",
|
||||
"orgCostEstimated": "Bu fatura, her organizasyonun kullanım payına göre bölünür",
|
||||
"byOrg": "Organizasyona göre",
|
||||
"historyStartsOn": "Kullanım geçmişi, kullanım verisi bulunan en eski faturalandırma döngünüzün başlangıcı olan {date} tarihinde başlar. Daha önceki günlerde gösterilecek kullanım yok.",
|
||||
"budgetTab": "Bütçe",
|
||||
"budgetKpi": "Bütçe",
|
||||
"budgetNotSet": "Ayarlanmadı",
|
||||
"budgetThisCycle": "Bu faturalandırma döngüsü",
|
||||
"spentOfBudget": "Bütçenin %{percent} kadarı · {total} günün {day}. günü",
|
||||
"overBudgetBy": "{amount} bütçe aşımı",
|
||||
"toSpare": "{amount} kaldı",
|
||||
"budgetReached": "Bütçeye Ulaşıldı",
|
||||
"reachedOnDay": "Gün {day}",
|
||||
"notThisCycle": "Bu döngüde değil",
|
||||
"atPacePerDay": "Günde {cost}",
|
||||
"atCurrentPace": "Mevcut hızda",
|
||||
"spendAgainstBudget": "Bütçeye Göre Harcama",
|
||||
"ofBudget": "bütçenin {budget} kadarı · %{percent}",
|
||||
"leftInBudget": "{amount} kaldı",
|
||||
"overBy": "{amount} aşım",
|
||||
"warnAtPercent": "Uyarı: %{percent}",
|
||||
"shareOfBudget": "Bütçenin %{percent} kadarı",
|
||||
"setBudgetFirst": "Bu döngünün harcamasını bütçeyle karşılaştırmak için bir bütçe belirleyin.",
|
||||
"budgetSettings": "Bütçe Ayarları",
|
||||
"budgetAmount": "Bu faturalandırma dönemi için bütçe",
|
||||
"budgetAmountHelp": "Bugünkü hızla bu dönem {amount} civarında sona erer",
|
||||
"warnAt": "Uyarı eşiği",
|
||||
"warnAmount": "Dolar cinsinden uyarı tutarı",
|
||||
"warnPercent": "Bütçenin yüzdesi olarak uyarı tutarı",
|
||||
"warnHelp": "Harcama {amount} değerini aştığında uyar",
|
||||
"emailAlerts": "E-posta uyarıları",
|
||||
"alertsOnHint": "Harcama bu dönemde uyarı tutarının üzerinde kaldığı sürece yöneticiler e-posta alır. Durdurmak için duraklatın.",
|
||||
"alertsPausedHint": "Bunu yeniden açana kadar e-posta gönderilmez.",
|
||||
"saveBudget": "Ayarları Kaydet",
|
||||
"budgetSaved": "Bütçe kaydedildi",
|
||||
"budgetRemoved": "Bütçe kaldırıldı",
|
||||
"budgetSaveFailed": "Bütçe kaydedilemedi",
|
||||
"budgetLoadFailedTitle": "Bütçeleriniz yüklenemedi",
|
||||
"budgetLoadFailed": "Düzenleme, bütçeler yüklenene kadar bekler; böylece kaydetme işlemi hâlihazırda ayarlanmış bütçelerin üzerine yazmaz. Birazdan tekrar deneyin."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Marketplace Belirteci Bulunamadı",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "So sánh các gói",
|
||||
"contactSupport": "Liên hệ hỗ trợ"
|
||||
},
|
||||
"previousCycle": "Chu kỳ trước",
|
||||
"currentCycle": "Chu kỳ hiện tại",
|
||||
"period30Days": "30 Ngày",
|
||||
"period60Days": "60 Ngày",
|
||||
"period3Months": "3 Tháng",
|
||||
"period6Months": "6 Tháng",
|
||||
"pricingErrorMessage": "Không tải được thông tin giá.",
|
||||
"awsManagedMessage": "Việc thanh toán được xử lý qua tài khoản AWS của bạn",
|
||||
"azureManagedMessage": "Việc thanh toán được xử lý qua tài khoản Azure của bạn",
|
||||
"contractManagedMessage": "Việc thanh toán được xử lý qua hợp đồng của bạn — liên hệ với người quản lý tài khoản để thay đổi",
|
||||
"upgradeToPlanMessage": "Nâng cấp lên gói để tiếp tục sử dụng các dịch vụ của OpenObserve.",
|
||||
"creditsUsedLabel": "tín dụng đã sử dụng",
|
||||
"errorTracking": "Theo dõi lỗi",
|
||||
"rumSession": "Phiên RUM",
|
||||
"licenseExpiryContactAdmin": "Vui lòng cập nhật giấy phép của bạn bằng cách liên hệ với quản trị viên.",
|
||||
"subscribed": "Đã đăng ký",
|
||||
"invoiceHistory": "Lịch sử hóa đơn",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "Trạng thái",
|
||||
"action": "Hành động",
|
||||
"manageSubscription": "Quản lý đăng ký",
|
||||
"messageDataNotFound": "Không tìm thấy dữ liệu",
|
||||
"invoiceStartDate": "Ngày bắt đầu hóa đơn",
|
||||
"invoiceEndDate": "Ngày kết thúc hóa đơn",
|
||||
"downloadInvoice": "Tải xuống hóa đơn",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "Cần khối lượng lớn? Hãy nói về quy mô! Liên hệ với chúng tôi để có giải pháp phù hợp.",
|
||||
"contactSupport": "Liên hệ hỗ trợ",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "Sử dụng hàng ngày",
|
||||
"enableTitle": "Xem mức sử dụng hàng ngày của bạn",
|
||||
"enableSubtitle": "Bật báo cáo sử dụng để biểu đồ khối lượng ingestion và truy vấn của bạn từng ngày. Tổng số ở trên vẫn có sẵn dù cách nào.",
|
||||
"enableSubtitle": "Bật báo cáo mức sử dụng để xem biểu đồ chi phí và khối lượng hàng ngày cho từng đồng hồ đo. Hóa đơn của bạn trong Tổng quan vẫn luôn khả dụng dù bạn có bật hay không.",
|
||||
"enableButton": "Bật báo cáo sử dụng",
|
||||
"enablePending": "Báo cáo sử dụng đã được bật. Biểu đồ hàng ngày của bạn sẽ sớm được điền khi mức sử dụng mới được ghi lại.",
|
||||
"enableConfirmTitle": "Bật báo cáo sử dụng?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "Liên hệ với quản lý tài khoản của bạn để thêm tín dụng AI",
|
||||
"aiPayerSubscribeMessage": "Việc thanh toán cho tổ chức này do {payer} quản lý. Hãy yêu cầu quản trị viên của {payer} đăng ký; phần sử dụng thêm sẽ được tính vào hóa đơn của tổ chức đó.",
|
||||
"aiFreeUsedLabel": "tín dụng miễn phí đã dùng",
|
||||
"aiBillableCredits": "Tín dụng AI phải trả phí",
|
||||
"aiFreeOneTime": "Hạn mức dùng một lần.",
|
||||
"aiUsedByFeature": "Trợ lý và tạo truy vấn: {chat} · Phân tích sự cố: {incident} · Phân tích lại: {reanalysis}",
|
||||
"aiPaygMessage": "Tín dụng miễn phí đã hết — mức sử dụng được tính vào đăng ký",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "Gói được quản lý bởi tổ chức mẹ",
|
||||
"chipNoInvoices": "Không có hóa đơn riêng",
|
||||
"viewOrgGroup": "Xem nhóm tổ chức",
|
||||
"currentOrgTitle": "Tổ chức hiện tại",
|
||||
"memberOrgsTitle": "Các tổ chức thành viên",
|
||||
"searchMemberOrg": "Tìm kiếm tổ chức",
|
||||
"noMemberMatch": "Không có tổ chức phù hợp",
|
||||
"remove": "Gỡ bỏ",
|
||||
"removeTitle": "Gỡ bỏ {name} khỏi nhóm thanh toán của bạn?",
|
||||
"removeBody": "Tổ chức này trở thành tổ chức miễn phí và tự chi trả cho mức sử dụng của mình. Các giới hạn của gói miễn phí sẽ được áp dụng. Người dùng, dữ liệu và mức sử dụng trước đây của tổ chức không bị ảnh hưởng.",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "Trạng thái cảnh báo",
|
||||
"contactAdministratorToResolve": "Vui lòng liên hệ quản trị viên của bạn để giải quyết sự cố này.",
|
||||
"contactAdministratorUsageReporting": "Vui lòng liên hệ quản trị viên của bạn để đảm bảo báo cáo sử dụng được khôi phục.",
|
||||
"credits": "Tín dụng",
|
||||
"dataRetention": "Lưu trữ dữ liệu",
|
||||
"event": "Sự kiện",
|
||||
"ingestion": "Tiếp nhận",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "Thời gian dùng thử của bạn đã hết hạn.",
|
||||
"trialDaysRemaining": "Còn {count} ngày trong tài khoản dùng thử của bạn | Còn {count} ngày trong tài khoản dùng thử của bạn",
|
||||
"usageReportingFailedForDays": "Việc báo cáo sử dụng đã thất bại trong {count} ngày, các tính năng RBAC và SSO bị vô hiệu hóa cho đến khi báo cáo sử dụng thành công | Việc báo cáo sử dụng đã thất bại trong {count} ngày, các tính năng RBAC và SSO bị vô hiệu hóa cho đến khi báo cáo sử dụng thành công",
|
||||
"usageReportingFailingForHours": "Việc báo cáo sử dụng đang thất bại trong {count} giờ, một số tính năng sẽ sớm bị vô hiệu hóa | Việc báo cáo sử dụng đang thất bại trong {count} giờ, một số tính năng sẽ sớm bị vô hiệu hóa"
|
||||
"usageReportingFailingForHours": "Việc báo cáo sử dụng đang thất bại trong {count} giờ, một số tính năng sẽ sớm bị vô hiệu hóa | Việc báo cáo sử dụng đang thất bại trong {count} giờ, một số tính năng sẽ sớm bị vô hiệu hóa",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-giờ",
|
||||
"unitCredit": "Tín dụng",
|
||||
"unitRun": "Lần chạy",
|
||||
"ratePerUnit": "{rate} mỗi {unit}",
|
||||
"cycleRange": "Chu kỳ thanh toán: {start} đến {end}",
|
||||
"syntheticsBrowser": "Synthetics (Trình duyệt)",
|
||||
"syntheticsProtocol": "Synthetics (Giao thức)",
|
||||
"overviewTab": "Tổng quan",
|
||||
"trendsTab": "Xu hướng",
|
||||
"estimationTab": "Ước tính giá",
|
||||
"costBreakdown": "Phân tách chi phí",
|
||||
"colMeter": "Đồng hồ đo",
|
||||
"colVolume": "Khối lượng",
|
||||
"colCost": "Chi phí",
|
||||
"totalCost": "Tổng chi phí",
|
||||
"missingMeters": "Chúng tôi không thể tính giá cho các đồng hồ đo này: {meters}. Các hàng tương ứng hiển thị dấu gạch ngang thay vì chi phí.",
|
||||
"noPricingTitle": "Không có sẵn chi phí cho tổ chức này",
|
||||
"noPricingSubtitle": "Tổ chức này không có tài khoản thanh toán theo đồng hồ đo, nên chúng tôi không có giá để hiển thị. Khối lượng sử dụng vẫn có sẵn trong tab Xu hướng.",
|
||||
"loadFailedTitle": "Không thể tải mức sử dụng của bạn",
|
||||
"loadFailedNetwork": "Không thể kết nối đến máy chủ. Hãy kiểm tra kết nối mạng và thử lại.",
|
||||
"loadFailedServer": "Dịch vụ thanh toán trả về lỗi. Hãy thử lại sau giây lát.",
|
||||
"refreshFailed": "Không thể làm mới mức sử dụng. Số liệu hiển thị là từ lần tải trước.",
|
||||
"byStream": "Theo Stream",
|
||||
"colStream": "Stream",
|
||||
"byUser": "Theo người dùng",
|
||||
"colUser": "Người dùng",
|
||||
"byAlert": "Theo cảnh báo",
|
||||
"colAlert": "Cảnh báo",
|
||||
"byDashboard": "Theo bảng điều khiển",
|
||||
"colDashboard": "Bảng điều khiển",
|
||||
"byScheduledPipeline": "Theo pipeline theo lịch",
|
||||
"colScheduledPipeline": "Pipeline theo lịch",
|
||||
"byPipeline": "Theo pipeline",
|
||||
"colPipeline": "Pipeline",
|
||||
"byDestination": "Theo đích đến",
|
||||
"colDestination": "Đích đến",
|
||||
"colRetentionPeriod": "Thời gian lưu trữ",
|
||||
"colCurrentSize": "Kích thước hiện tại",
|
||||
"drilldownFailed": "Không thể tải phân tách này.",
|
||||
"retry": "Thử lại",
|
||||
"unknownPipeline": "Không xác định",
|
||||
"rangeCurrentCycle": "Chu kỳ hiện tại",
|
||||
"assumptions": "Giả định",
|
||||
"avgStreamSize": "Dữ liệu trung bình mỗi stream",
|
||||
"ingestPerMonth": "Dữ liệu tiếp nhận mỗi tháng",
|
||||
"searchesPerMonth": "Lượt tìm kiếm mỗi tháng",
|
||||
"scanPerSearch": "Dữ liệu được quét mỗi lần tìm kiếm",
|
||||
"pipelineCount": "Pipeline đang chạy trên stream",
|
||||
"retentionDays": "Dữ liệu được lưu trong",
|
||||
"aiCreditsPerMonth": "Tín dụng AI mỗi tháng",
|
||||
"browserChecks": "Kiểm tra trình duyệt",
|
||||
"stepsPerCheck": "Bước mỗi lần kiểm tra",
|
||||
"checkRegions": "Khu vực",
|
||||
"checkDevices": "Thiết bị",
|
||||
"checkInterval": "Chạy mỗi",
|
||||
"apiChecks": "Kiểm tra API",
|
||||
"browserRunsNote": "Khoảng {runs} lượt chạy bước mỗi tháng.",
|
||||
"apiRunsNote": "Khoảng {runs} lượt chạy mỗi tháng.",
|
||||
"qtyStepRuns": "{count} lượt chạy bước",
|
||||
"qtyRuns": "{count} lượt chạy",
|
||||
"estimatedTotal": "Tổng ước tính",
|
||||
"perMonth": "mỗi tháng",
|
||||
"estimateDisclaimer": "Đây là ước tính theo giá niêm yết. Số tiền này không bao gồm bất kỳ khoản giảm giá nào trên tài khoản của bạn.",
|
||||
"cycleDay": "Ngày {day} trên {total}",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "Giảm giá",
|
||||
"costSoFar": "Chi phí đến nay",
|
||||
"estimatedCostSoFar": "Chi phí ước tính đến nay",
|
||||
"projectedTotal": "Tổng dự kiến",
|
||||
"tooEarlyToProject": "Chu kỳ còn quá sớm để dự báo",
|
||||
"colStorageShare": "% Dung lượng lưu trữ",
|
||||
"orgDefaultRetention": "Mặc định tổ chức",
|
||||
"retentionDaysValue": "{days} ngày",
|
||||
"topMeter": "Chỉ số tính phí hàng đầu",
|
||||
"topMeterShare": "{cost} · {percent}% hóa đơn",
|
||||
"billedIngestion": "Dữ liệu được tiếp nhận",
|
||||
"billedQuery": "Dữ liệu được quét bởi tìm kiếm",
|
||||
"billedPipeline": "Dữ liệu được xử lý bởi pipeline",
|
||||
"billedRemotePipeline": "Dữ liệu gửi đến đích từ xa",
|
||||
"billedRetention": "Dữ liệu được lưu theo thời gian",
|
||||
"billedAi": "Tín dụng AI đã dùng",
|
||||
"billedSynthetics": "Lượt chạy kiểm tra tổng hợp",
|
||||
"meterShare": "Tỷ trọng hóa đơn",
|
||||
"everythingElse": "Phần còn lại",
|
||||
"otherBand": "Khác",
|
||||
"colTotal": "Tổng cộng",
|
||||
"groupBy": "Nhóm theo",
|
||||
"groupNotRecorded": "Chưa ghi nhận mức sử dụng {group}.",
|
||||
"segmentTitle": "{meter}: {billed} đã tính phí, chiếm {share} hóa đơn",
|
||||
"colEstCost": "Chi phí ước tính",
|
||||
"shareOfMeter": "Tỷ trọng của {meter}",
|
||||
"costByMeter": "Chi phí theo mục đo lường",
|
||||
"meterByGroup": "{meter} theo {group}",
|
||||
"billedDay": "Đã tính phí, ngày {day} của {total}",
|
||||
"segmentTitleDiscounted": "{meter}: {billed} đã tính phí ({gross} gộp, {discount} giảm giá), chiếm {share} hóa đơn",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "Được tính phí qua {payer}. Chi phí và ngân sách có trên trang Sử dụng của tổ chức đó.",
|
||||
"volumeAtRateBilled": "{volume} · {rate}, được tính phí là {amount} {unit}",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "Gộp",
|
||||
"colBilled": "Đã tính phí",
|
||||
"allMeters": "Tất cả mục đo lường",
|
||||
"noDailyRetention": "Thời gian lưu giữ không có lịch sử hàng ngày. Thay vào đó, bảng hiển thị dung lượng lưu trữ của từng Stream.",
|
||||
"grossLabel": "Tổng gộp",
|
||||
"cycleCloses": "Chu kỳ kết thúc {date}",
|
||||
"dailyCost": "Chi phí hàng ngày",
|
||||
"breakdownSummary": "{cost} đã tính phí · {volume} · chiếm {share} hóa đơn",
|
||||
"scenarioFrom": "Bắt đầu từ",
|
||||
"scenarioCurrent": "Mức sử dụng hiện tại của tôi",
|
||||
"scenarioTwice": "Tăng trưởng 2×",
|
||||
"scenarioFiveTimes": "Tăng trưởng 5×",
|
||||
"resetEstimate": "Đặt lại",
|
||||
"expectedUsage": "Mức sử dụng hàng tháng dự kiến",
|
||||
"expectedUsageHint": "Được điền sẵn từ chu kỳ này đến nay",
|
||||
"loadingHistory": "Đang đọc lịch sử sử dụng của bạn…",
|
||||
"yourEstimate": "Ước tính của bạn",
|
||||
"assumptionsFrom": "Từ 30 ngày qua của bạn",
|
||||
"scanNote": "Quét khoảng {volume} mỗi tháng.",
|
||||
"qtyScanned": "{volume} đã quét",
|
||||
"qtyProcessed": "{volume} đã xử lý",
|
||||
"qtyKeptFor": "{volume} trong {days} ngày",
|
||||
"qtyCredits": "{count} tín dụng",
|
||||
"rateIncluded": "Đã bao gồm trong gói của bạn",
|
||||
"rateTooltip": "{rate}. Bạn được tính phí trên {basis}.",
|
||||
"rateIncludedTooltip": "{basis} đã được bao gồm trong gói của bạn, nên không thêm gì vào ước tính này.",
|
||||
"byIncident": "Theo sự cố",
|
||||
"colIncident": "Sự cố",
|
||||
"colCredits": "Tín dụng",
|
||||
"byCheck": "Theo kiểm tra",
|
||||
"colCheck": "Kiểm tra",
|
||||
"colSteps": "Bước",
|
||||
"unknownCheck": "Kiểm tra đã xóa",
|
||||
"noBreakdownYet": "Không có mức sử dụng trong chu kỳ này, nên chưa có gì để phân tích.",
|
||||
"rangeLastCycle": "Chu kỳ trước",
|
||||
"rangeLast90Days": "90 ngày qua",
|
||||
"runRate": "Tốc độ chi tiêu",
|
||||
"dailyAverage": "Trung bình ngày",
|
||||
"peakDay": "Ngày cao nhất",
|
||||
"runRateWindow": "{days} ngày qua",
|
||||
"acrossDays": "Trong {days} ngày",
|
||||
"atPaceProjected": "{cost} theo tốc độ {days} ngày gần nhất",
|
||||
"atPace": "Theo tốc độ {days} ngày",
|
||||
"topMeterOfCost": "{cost} · {percent}% chi phí",
|
||||
"perDay": "{cost} mỗi ngày",
|
||||
"spendToDate": "Chi tiêu đến nay",
|
||||
"costOverTime": "Chi phí theo thời gian",
|
||||
"volumeOverTime": "Khối lượng theo thời gian",
|
||||
"volumeSubtitle": "Tiếp nhận dữ liệu, tìm kiếm và pipeline, theo ngày",
|
||||
"usageByMeter": "Mức sử dụng theo chỉ số đo",
|
||||
"dailyVolume": "Khối lượng hàng ngày",
|
||||
"lastDaysRange": "{days} ngày qua: {start} đến {end}",
|
||||
"enableVolumeSubtitle": "Bật báo cáo sử dụng để xem mỗi chỉ số đo được dùng bao nhiêu, theo từng ngày.",
|
||||
"measureCost": "Chi phí",
|
||||
"measureVolume": "Khối lượng",
|
||||
"estimatedAtCyclePrice": "Ước tính từ mức sử dụng, theo giá của từng chu kỳ. Không bao gồm Lưu giữ dữ liệu.",
|
||||
"showOnlyMeter": "Chỉ hiển thị {meter}",
|
||||
"showAllMeters": "Hiển thị tất cả bộ đếm",
|
||||
"noUsageInRange": "Không có mức sử dụng trong phạm vi này",
|
||||
"trendsLoadFailed": "Không thể tải mức sử dụng cho phạm vi này.",
|
||||
"rangeDates": "{start} đến {end}",
|
||||
"forecast": "Dự báo",
|
||||
"dayTotal": "Tổng ngày",
|
||||
"costByOrg": "Chi phí theo tổ chức",
|
||||
"orgFilter": "Tổ chức",
|
||||
"organizationsTitle": "Tổ chức",
|
||||
"orgCountThisCycle": "{count} tổ chức · chu kỳ này",
|
||||
"overallBudget": "Ngân sách tổng",
|
||||
"overallBudgetLabel": "Ngân sách tổng cho chu kỳ thanh toán này",
|
||||
"appliesToGroup": "Hóa đơn của toàn bộ nhóm",
|
||||
"orgBudgetsTotal": "Ngân sách các tổ chức cộng lại là {amount}",
|
||||
"orgBudgetsOver": "Ngân sách các tổ chức cộng lại là {amount}, nhiều hơn {over} so với ngân sách tổng",
|
||||
"needsAttention": "Cần chú ý",
|
||||
"attentionHint": "{over} vượt ngân sách, {near} gần mức cảnh báo",
|
||||
"colSpendThisCycle": "Chi tiêu chu kỳ này",
|
||||
"colBudget": "Ngân sách",
|
||||
"colAlerts": "Cảnh báo",
|
||||
"statusNone": "Chưa có ngân sách",
|
||||
"statusOk": "Đúng tiến độ",
|
||||
"statusWarn": "Gần giới hạn",
|
||||
"statusOver": "Vượt ngân sách",
|
||||
"budgetFor": "Ngân sách cho {org}",
|
||||
"warnPercentFor": "Phần trăm cảnh báo cho {org}",
|
||||
"alertsFor": "Cảnh báo email cho {org}",
|
||||
"groupAlertsOnHint": "Quản trị viên sẽ nhận email chừng nào chi tiêu của nhóm còn vượt mức cảnh báo trong chu kỳ này. Tạm dừng để ngừng nhận. Mỗi dòng tổ chức có công tắc riêng.",
|
||||
"unsavedChanges": "Chưa lưu: {parts}",
|
||||
"unsavedOrgs": "{count} tổ chức",
|
||||
"unsavedOverall": "cài đặt tổng thể",
|
||||
"allChangesSaved": "Đã lưu tất cả thay đổi",
|
||||
"discard": "Hủy bỏ",
|
||||
"saveChanges": "Lưu thay đổi",
|
||||
"orgCostsFailed": "Không thể tải chi phí theo tổ chức. Làm mới để thử lại.",
|
||||
"clickForOrg": "Nhấp để xem các chỉ số đo của tổ chức này",
|
||||
"clickForBreakdown": "Nhấp để xem điều gì tạo nên chỉ số đo này",
|
||||
"allOrgs": "Tất cả tổ chức",
|
||||
"colOrganization": "Tổ chức",
|
||||
"shareOfOrg": "Tỷ lệ của {org}",
|
||||
"thisOrg": "{org} (tổ chức này)",
|
||||
"formerMemberIngested": "Thành viên cũ · {volume} đã tiếp nhận",
|
||||
"ingestedVolume": "{volume} đã tiếp nhận",
|
||||
"notSplitByOrg": "Tính phí cho toàn nhóm, không chia theo tổ chức",
|
||||
"orgSummary": "{cost} đã tính phí · {share} của hóa đơn",
|
||||
"orgCostEstimated": "Hóa đơn này được chia theo tỷ lệ sử dụng của từng tổ chức",
|
||||
"byOrg": "Theo tổ chức",
|
||||
"historyStartsOn": "Lịch sử sử dụng bắt đầu từ {date}, thời điểm bắt đầu chu kỳ thanh toán cũ nhất mà chúng tôi có dữ liệu sử dụng. Những ngày trước đó không có dữ liệu sử dụng để hiển thị.",
|
||||
"budgetTab": "Ngân sách",
|
||||
"budgetKpi": "Ngân sách",
|
||||
"budgetNotSet": "Chưa đặt",
|
||||
"budgetThisCycle": "Chu kỳ thanh toán này",
|
||||
"spentOfBudget": "{percent}% ngân sách · ngày {day} của {total}",
|
||||
"overBudgetBy": "Vượt ngân sách {amount}",
|
||||
"toSpare": "Còn dư {amount}",
|
||||
"budgetReached": "Đã đạt ngân sách",
|
||||
"reachedOnDay": "Ngày {day}",
|
||||
"notThisCycle": "Không phải chu kỳ này",
|
||||
"atPacePerDay": "Với {cost} mỗi ngày",
|
||||
"atCurrentPace": "Theo tốc độ hiện tại",
|
||||
"spendAgainstBudget": "Chi tiêu so với ngân sách",
|
||||
"ofBudget": "của {budget} · {percent}%",
|
||||
"leftInBudget": "Còn {amount}",
|
||||
"overBy": "Vượt {amount}",
|
||||
"warnAtPercent": "Cảnh báo ở {percent}%",
|
||||
"shareOfBudget": "{percent}% ngân sách",
|
||||
"setBudgetFirst": "Đặt ngân sách để theo dõi chi tiêu của chu kỳ này so với ngân sách đó.",
|
||||
"budgetSettings": "Cài đặt ngân sách",
|
||||
"budgetAmount": "Ngân sách cho chu kỳ thanh toán này",
|
||||
"budgetAmountHelp": "Với tốc độ như hiện tại, chu kỳ này sẽ kết thúc quanh mức {amount}",
|
||||
"warnAt": "Cảnh báo khi đạt",
|
||||
"warnAmount": "Số tiền cảnh báo bằng đô-la",
|
||||
"warnPercent": "Số tiền cảnh báo theo phần trăm ngân sách",
|
||||
"warnHelp": "Cảnh báo khi chi phí vượt quá {amount}",
|
||||
"emailAlerts": "Cảnh báo qua email",
|
||||
"alertsOnHint": "Quản trị viên sẽ nhận email chừng nào chi phí còn vượt quá số tiền cảnh báo trong chu kỳ này. Tạm dừng để ngừng nhận.",
|
||||
"alertsPausedHint": "Không có email cho đến khi bạn bật lại.",
|
||||
"saveBudget": "Lưu cài đặt",
|
||||
"budgetSaved": "Đã lưu ngân sách",
|
||||
"budgetRemoved": "Đã xóa ngân sách",
|
||||
"budgetSaveFailed": "Không thể lưu ngân sách",
|
||||
"budgetLoadFailedTitle": "Không thể tải ngân sách của bạn",
|
||||
"budgetLoadFailed": "Tính năng chỉnh sửa sẽ chờ đến khi tải xong, để việc lưu không ghi đè lên ngân sách đã được đặt. Hãy thử lại sau giây lát."
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "Không tìm thấy mã thông báo Marketplace",
|
||||
|
||||
@@ -8127,20 +8127,12 @@
|
||||
"comparePlans": "比较计划",
|
||||
"contactSupport": "联系支持"
|
||||
},
|
||||
"previousCycle": "上一个周期",
|
||||
"currentCycle": "当前周期",
|
||||
"period30Days": "30 天",
|
||||
"period60Days": "60 天",
|
||||
"period3Months": "3 个月",
|
||||
"period6Months": "6 个月",
|
||||
"pricingErrorMessage": "定价详情未能加载。",
|
||||
"awsManagedMessage": "计费通过您的 AWS 账户处理",
|
||||
"azureManagedMessage": "计费通过您的 Azure 账户处理",
|
||||
"contractManagedMessage": "计费通过您的合同处理 — 如需更改请联系您的客户经理",
|
||||
"upgradeToPlanMessage": "升级套餐以继续享受 OpenObserve 服务。",
|
||||
"creditsUsedLabel": "已用积分",
|
||||
"errorTracking": "错误追踪",
|
||||
"rumSession": "RUM 会话",
|
||||
"licenseExpiryContactAdmin": "请通过联系您的管理员来更新许可证。",
|
||||
"subscribed": "已订阅",
|
||||
"invoiceHistory": "发票历史记录",
|
||||
@@ -8149,7 +8141,6 @@
|
||||
"status": "状态",
|
||||
"action": "操作",
|
||||
"manageSubscription": "管理订阅",
|
||||
"messageDataNotFound": "未找到数据",
|
||||
"invoiceStartDate": "发票起始日期",
|
||||
"invoiceEndDate": "发票结束日期",
|
||||
"downloadInvoice": "下载发票",
|
||||
@@ -8171,9 +8162,8 @@
|
||||
"enterpriseNote": "高容量需求?让我们来谈谈规模!联系我们获取量身定制的解决方案。",
|
||||
"contactSupport": "联系支持部门",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "每日使用量",
|
||||
"enableTitle": "查看每日使用量",
|
||||
"enableSubtitle": "启用使用量报告,以逐日绘制摄入和查询量图表。无论是否启用,上述总量仍然可用。",
|
||||
"enableSubtitle": "开启用量报告,即可按计量项绘制每日费用和用量图表。无论是否开启,概览中的账单仍可查看。",
|
||||
"enableButton": "启用使用量报告",
|
||||
"enablePending": "使用量报告已启用。随着新使用量被记录,您的每日图表将很快开始填充。",
|
||||
"enableConfirmTitle": "启用使用量报告?",
|
||||
@@ -8189,7 +8179,6 @@
|
||||
"aiContractExhaustedMessage": "联系您的客户经理以添加更多 AI 积分",
|
||||
"aiPayerSubscribeMessage": "本组织的账单由 {payer} 管理。请让 {payer} 的管理员进行订阅;后续用量将计入其账单。",
|
||||
"aiFreeUsedLabel": "已用免费额度",
|
||||
"aiBillableCredits": "可计费 AI 额度",
|
||||
"aiFreeOneTime": "一次性额度。",
|
||||
"aiUsedByFeature": "助手与查询生成:{chat} · 事件分析:{incident} · 重新分析:{reanalysis}",
|
||||
"aiPaygMessage": "免费积分已用尽 — 使用量计入订阅账单",
|
||||
@@ -8240,10 +8229,6 @@
|
||||
"chipPlanManaged": "由家长管理的计划",
|
||||
"chipNoInvoices": "没有单独的发票",
|
||||
"viewOrgGroup": "查看组织群组",
|
||||
"currentOrgTitle": "当前组织",
|
||||
"memberOrgsTitle": "成员组织",
|
||||
"searchMemberOrg": "搜索组织",
|
||||
"noMemberMatch": "无匹配的组织",
|
||||
"remove": "移除",
|
||||
"removeTitle": "从计费组中移除 {name}?",
|
||||
"removeBody": "该组织将变为免费组织,自行支付其用量。适用免费计划限制。其用户、数据和历史用量不受影响。",
|
||||
@@ -8285,7 +8270,6 @@
|
||||
"alertStatus": "告警状态",
|
||||
"contactAdministratorToResolve": "请联系您的管理员以解决此问题。",
|
||||
"contactAdministratorUsageReporting": "请联系您的管理员以确保用量报告恢复。",
|
||||
"credits": "额度",
|
||||
"dataRetention": "数据保留",
|
||||
"event": "事件",
|
||||
"ingestion": "摄取",
|
||||
@@ -8310,7 +8294,249 @@
|
||||
"trialPeriodHasExpired": "您的试用期已过期。",
|
||||
"trialDaysRemaining": "试用账户剩余 {count} 天 | 试用账户剩余 {count} 天",
|
||||
"usageReportingFailedForDays": "用量报告已失败 {count} 天,在成功报告用量之前 RBAC 和 SSO 功能将被禁用 | 用量报告已失败 {count} 天,在成功报告用量之前 RBAC 和 SSO 功能将被禁用",
|
||||
"usageReportingFailingForHours": "用量报告已持续失败 {count} 小时,某些功能即将被禁用 | 用量报告已持续失败 {count} 小时,某些功能即将被禁用"
|
||||
"usageReportingFailingForHours": "用量报告已持续失败 {count} 小时,某些功能即将被禁用 | 用量报告已持续失败 {count} 小时,某些功能即将被禁用",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-小时",
|
||||
"unitCredit": "额度",
|
||||
"unitRun": "次",
|
||||
"ratePerUnit": "{rate}/{unit}",
|
||||
"cycleRange": "账单周期:{start} 至 {end}",
|
||||
"syntheticsBrowser": "合成监控(浏览器)",
|
||||
"syntheticsProtocol": "合成监控(协议)",
|
||||
"overviewTab": "概览",
|
||||
"trendsTab": "趋势",
|
||||
"estimationTab": "价格估算",
|
||||
"costBreakdown": "费用构成",
|
||||
"colMeter": "计量项",
|
||||
"colVolume": "用量",
|
||||
"colCost": "费用",
|
||||
"totalCost": "总费用",
|
||||
"missingMeters": "我们无法为这些计量项计价:{meters}。这些行会显示短横线,而不是费用。",
|
||||
"noPricingTitle": "该组织暂无可用费用信息",
|
||||
"noPricingSubtitle": "该组织没有按量计费账户,因此没有可显示的价格。用量仍可在“趋势”标签页中查看。",
|
||||
"loadFailedTitle": "无法加载您的用量",
|
||||
"loadFailedNetwork": "无法连接到服务器。请检查网络连接后重试。",
|
||||
"loadFailedServer": "账单服务返回错误。请稍后重试。",
|
||||
"refreshFailed": "无法刷新用量。当前显示的是上次加载的数据。",
|
||||
"byStream": "按数据流",
|
||||
"colStream": "数据流",
|
||||
"byUser": "按用户",
|
||||
"colUser": "用户",
|
||||
"byAlert": "按告警",
|
||||
"colAlert": "告警",
|
||||
"byDashboard": "按仪表盘",
|
||||
"colDashboard": "仪表盘",
|
||||
"byScheduledPipeline": "按定时流水线",
|
||||
"colScheduledPipeline": "定时流水线",
|
||||
"byPipeline": "按流水线",
|
||||
"colPipeline": "流水线",
|
||||
"byDestination": "按目标",
|
||||
"colDestination": "目标",
|
||||
"colRetentionPeriod": "保留期",
|
||||
"colCurrentSize": "当前大小",
|
||||
"drilldownFailed": "无法加载此细分数据。",
|
||||
"retry": "重试",
|
||||
"unknownPipeline": "未知",
|
||||
"rangeCurrentCycle": "当前周期",
|
||||
"assumptions": "假设",
|
||||
"avgStreamSize": "每个数据流的平均数据量",
|
||||
"ingestPerMonth": "每月摄取的数据量",
|
||||
"searchesPerMonth": "每月搜索次数",
|
||||
"scanPerSearch": "每次搜索扫描的数据量",
|
||||
"pipelineCount": "数据流上运行的流水线",
|
||||
"retentionDays": "数据保留时长",
|
||||
"aiCreditsPerMonth": "每月 AI 额度",
|
||||
"browserChecks": "浏览器检查",
|
||||
"stepsPerCheck": "每次检查的步骤数",
|
||||
"checkRegions": "区域",
|
||||
"checkDevices": "设备",
|
||||
"checkInterval": "运行频率",
|
||||
"apiChecks": "API 检查",
|
||||
"browserRunsNote": "每月约 {runs} 次步骤运行。",
|
||||
"apiRunsNote": "每月约 {runs} 次运行。",
|
||||
"qtyStepRuns": "{count} 次步骤运行",
|
||||
"qtyRuns": "{count} 次运行",
|
||||
"estimatedTotal": "预估总计",
|
||||
"perMonth": "每月",
|
||||
"estimateDisclaimer": "这是按目录价估算的金额,不包含您账户的任何折扣。",
|
||||
"cycleDay": "第 {day} 天,共 {total} 天",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "折扣",
|
||||
"costSoFar": "当前费用",
|
||||
"estimatedCostSoFar": "截至目前预估费用",
|
||||
"projectedTotal": "预计总计",
|
||||
"tooEarlyToProject": "周期尚早,无法预测",
|
||||
"colStorageShare": "占存储的 %",
|
||||
"orgDefaultRetention": "组织默认",
|
||||
"retentionDaysValue": "{days} 天",
|
||||
"topMeter": "最高计量项",
|
||||
"topMeterShare": "{cost} · 占账单 {percent}%",
|
||||
"billedIngestion": "摄取的数据量",
|
||||
"billedQuery": "搜索扫描的数据量",
|
||||
"billedPipeline": "流水线处理的数据量",
|
||||
"billedRemotePipeline": "发送到远程目标的数据量",
|
||||
"billedRetention": "随时间存储的数据量",
|
||||
"billedAi": "已用 AI 额度",
|
||||
"billedSynthetics": "合成检查运行次数",
|
||||
"meterShare": "账单占比",
|
||||
"everythingElse": "其他全部",
|
||||
"otherBand": "其他",
|
||||
"colTotal": "总计",
|
||||
"groupBy": "分组依据",
|
||||
"groupNotRecorded": "尚未记录 {group} 用量。",
|
||||
"segmentTitle": "{meter}:{billed} 计费,占账单 {share}",
|
||||
"colEstCost": "预估费用",
|
||||
"shareOfMeter": "占 {meter} 的比例",
|
||||
"costByMeter": "按计量项的费用",
|
||||
"meterByGroup": "按 {group} 的 {meter}",
|
||||
"billedDay": "计费,第 {day} 天,共 {total} 天",
|
||||
"segmentTitleDiscounted": "{meter}:{billed} 计费(原价 {gross},折扣 {discount}),占账单 {share}",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "账单计入 {payer}。费用和预算位于该组织的用量页面。",
|
||||
"volumeAtRateBilled": "{volume} · {rate},按 {amount} {unit} 计费",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "原价",
|
||||
"colBilled": "计费",
|
||||
"allMeters": "全部计量项",
|
||||
"noDailyRetention": "数据保留没有每日历史记录。表格改为显示每个数据流的存储量。",
|
||||
"grossLabel": "原价",
|
||||
"cycleCloses": "周期于 {date} 结束",
|
||||
"dailyCost": "每日费用",
|
||||
"breakdownSummary": "{cost} 计费 · {volume} · 占账单 {share}",
|
||||
"scenarioFrom": "起始基准",
|
||||
"scenarioCurrent": "我的当前用量",
|
||||
"scenarioTwice": "增长 2 倍",
|
||||
"scenarioFiveTimes": "增长 5 倍",
|
||||
"resetEstimate": "重置",
|
||||
"expectedUsage": "预期月度用量",
|
||||
"expectedUsageHint": "根据本周期迄今用量预填",
|
||||
"loadingHistory": "正在读取您的用量历史…",
|
||||
"yourEstimate": "您的预估",
|
||||
"assumptionsFrom": "基于您过去 30 天",
|
||||
"scanNote": "每月扫描约 {volume}。",
|
||||
"qtyScanned": "已扫描 {volume}",
|
||||
"qtyProcessed": "已处理 {volume}",
|
||||
"qtyKeptFor": "{volume},保留 {days} 天",
|
||||
"qtyCredits": "{count} 个额度",
|
||||
"rateIncluded": "已包含在您的计划中",
|
||||
"rateTooltip": "{rate}。按 {basis} 计费。",
|
||||
"rateIncludedTooltip": "{basis} 已包含在您的计划中,因此不会增加此估算费用。",
|
||||
"byIncident": "按事件",
|
||||
"colIncident": "事件",
|
||||
"colCredits": "额度",
|
||||
"byCheck": "按检查",
|
||||
"colCheck": "检查",
|
||||
"colSteps": "步骤",
|
||||
"unknownCheck": "已删除的检查",
|
||||
"noBreakdownYet": "本周期没有用量,因此暂无可拆分的明细。",
|
||||
"rangeLastCycle": "上一周期",
|
||||
"rangeLast90Days": "过去 90 天",
|
||||
"runRate": "消耗速率",
|
||||
"dailyAverage": "日均值",
|
||||
"peakDay": "峰值日",
|
||||
"runRateWindow": "过去 {days} 天",
|
||||
"acrossDays": "共 {days} 天",
|
||||
"atPaceProjected": "按最近 {days} 天的速度预估为 {cost}",
|
||||
"atPace": "按 {days} 天速度",
|
||||
"topMeterOfCost": "{cost} · 占费用 {percent}%",
|
||||
"perDay": "每天 {cost}",
|
||||
"spendToDate": "累计支出",
|
||||
"costOverTime": "费用趋势",
|
||||
"volumeOverTime": "用量趋势",
|
||||
"volumeSubtitle": "数据摄取、搜索和流水线用量,按天统计",
|
||||
"usageByMeter": "按计量项的用量",
|
||||
"dailyVolume": "每日用量",
|
||||
"lastDaysRange": "最近 {days} 天:{start} 至 {end}",
|
||||
"enableVolumeSubtitle": "开启用量报告,即可按天查看每个计量项的使用量。",
|
||||
"measureCost": "费用",
|
||||
"measureVolume": "用量",
|
||||
"estimatedAtCyclePrice": "按各周期价格根据用量预估。不含数据保留。",
|
||||
"showOnlyMeter": "仅显示 {meter}",
|
||||
"showAllMeters": "显示所有计量项",
|
||||
"noUsageInRange": "该范围内无用量",
|
||||
"trendsLoadFailed": "无法加载该范围内的用量。",
|
||||
"rangeDates": "{start} 至 {end}",
|
||||
"forecast": "预测",
|
||||
"dayTotal": "当天总计",
|
||||
"costByOrg": "按组织统计费用",
|
||||
"orgFilter": "组织",
|
||||
"organizationsTitle": "组织",
|
||||
"orgCountThisCycle": "{count} 个组织 · 本周期",
|
||||
"overallBudget": "总预算",
|
||||
"overallBudgetLabel": "本账单周期的总预算",
|
||||
"appliesToGroup": "整个组的账单",
|
||||
"orgBudgetsTotal": "组织预算合计为 {amount}",
|
||||
"orgBudgetsOver": "组织预算合计为 {amount},比总预算多 {over}",
|
||||
"needsAttention": "需关注",
|
||||
"attentionHint": "{over} 个超出预算,{near} 个接近预警线",
|
||||
"colSpendThisCycle": "本周期支出",
|
||||
"colBudget": "预算",
|
||||
"colAlerts": "告警",
|
||||
"statusNone": "无预算",
|
||||
"statusOk": "正常",
|
||||
"statusWarn": "接近上限",
|
||||
"statusOver": "超出预算",
|
||||
"budgetFor": "{org} 的预算",
|
||||
"warnPercentFor": "{org} 的预警百分比",
|
||||
"alertsFor": "{org} 的邮件告警",
|
||||
"groupAlertsOnHint": "只要该组本周期内的支出持续高于预警金额,管理员就会收到邮件。暂停即可停止接收。每个组织行都有独立开关。",
|
||||
"unsavedChanges": "未保存:{parts}",
|
||||
"unsavedOrgs": "{count} 个组织",
|
||||
"unsavedOverall": "总体设置",
|
||||
"allChangesSaved": "所有更改已保存",
|
||||
"discard": "放弃",
|
||||
"saveChanges": "保存更改",
|
||||
"orgCostsFailed": "无法加载各组织费用。请刷新后重试。",
|
||||
"clickForOrg": "点击查看此组织的计量项",
|
||||
"clickForBreakdown": "点击查看该计量项的构成",
|
||||
"allOrgs": "所有组织",
|
||||
"colOrganization": "组织",
|
||||
"shareOfOrg": "占 {org} 的比例",
|
||||
"thisOrg": "{org}(此组织)",
|
||||
"formerMemberIngested": "前成员 · 已摄取 {volume}",
|
||||
"ingestedVolume": "已摄取 {volume}",
|
||||
"notSplitByOrg": "按整个组计费,不按组织拆分",
|
||||
"orgSummary": "已计费 {cost} · 占账单的 {share}",
|
||||
"orgCostEstimated": "此账单按各组织的用量占比拆分",
|
||||
"byOrg": "按组织",
|
||||
"historyStartsOn": "用量历史从 {date} 开始,这是我们存有用量数据的最早账单周期的起始日。更早的日期没有可显示的用量。",
|
||||
"budgetTab": "预算",
|
||||
"budgetKpi": "预算",
|
||||
"budgetNotSet": "未设置",
|
||||
"budgetThisCycle": "本账单周期",
|
||||
"spentOfBudget": "占预算 {percent}% · 第 {day} 天,共 {total} 天",
|
||||
"overBudgetBy": "超出预算 {amount}",
|
||||
"toSpare": "剩余 {amount}",
|
||||
"budgetReached": "已达预算",
|
||||
"reachedOnDay": "第 {day} 天",
|
||||
"notThisCycle": "非本周期",
|
||||
"atPacePerDay": "每天 {cost}",
|
||||
"atCurrentPace": "按当前速度",
|
||||
"spendAgainstBudget": "预算支出对比",
|
||||
"ofBudget": "占 {budget} · {percent}%",
|
||||
"leftInBudget": "剩余 {amount}",
|
||||
"overBy": "超出 {amount}",
|
||||
"warnAtPercent": "预警阈值 {percent}%",
|
||||
"shareOfBudget": "占预算 {percent}%",
|
||||
"setBudgetFirst": "设置预算,以跟踪本周期支出与预算的对比。",
|
||||
"budgetSettings": "预算设置",
|
||||
"budgetAmount": "本账单周期的预算",
|
||||
"budgetAmountHelp": "按当前速度,本周期结束时约为 {amount}",
|
||||
"warnAt": "预警阈值",
|
||||
"warnAmount": "预警金额(美元)",
|
||||
"warnPercent": "预警金额占预算的百分比",
|
||||
"warnHelp": "当支出超过 {amount} 时告警",
|
||||
"emailAlerts": "邮件告警",
|
||||
"alertsOnHint": "只要本周期支出持续高于预警金额,管理员就会收到邮件。暂停即可停止发送。",
|
||||
"alertsPausedHint": "在重新开启之前,不会发送邮件。",
|
||||
"saveBudget": "保存设置",
|
||||
"budgetSaved": "预算已保存",
|
||||
"budgetRemoved": "预算已移除",
|
||||
"budgetSaveFailed": "无法保存预算",
|
||||
"budgetLoadFailedTitle": "无法加载您的预算",
|
||||
"budgetLoadFailed": "加载完成前无法编辑,以免保存时覆盖已设置的预算。请稍后重试。"
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "未找到 Marketplace 令牌",
|
||||
|
||||
@@ -8122,20 +8122,12 @@
|
||||
"comparePlans": "比較方案",
|
||||
"contactSupport": "聯絡支援"
|
||||
},
|
||||
"previousCycle": "上一週期",
|
||||
"currentCycle": "目前週期",
|
||||
"period30Days": "30 天",
|
||||
"period60Days": "60 天",
|
||||
"period3Months": "3 個月",
|
||||
"period6Months": "6 個月",
|
||||
"pricingErrorMessage": "定價詳情載入失敗。",
|
||||
"awsManagedMessage": "計費是透過您的 AWS 帳戶處理",
|
||||
"azureManagedMessage": "計費是透過您的 Azure 帳戶處理",
|
||||
"contractManagedMessage": "計費是透過您的合約處理 — 如需變更,請聯絡您的客戶經理",
|
||||
"upgradeToPlanMessage": "升級方案以繼續享受 OpenObserve 的服務。",
|
||||
"creditsUsedLabel": "已使用的點數",
|
||||
"errorTracking": "錯誤追蹤",
|
||||
"rumSession": "RUM 工作階段",
|
||||
"licenseExpiryContactAdmin": "請聯絡您的管理員以更新您的授權。",
|
||||
"subscribed": "已訂閱",
|
||||
"invoiceHistory": "發票歷史紀錄",
|
||||
@@ -8144,7 +8136,6 @@
|
||||
"status": "狀態",
|
||||
"action": "操作",
|
||||
"manageSubscription": "管理訂閱",
|
||||
"messageDataNotFound": "找不到資料",
|
||||
"invoiceStartDate": "發票起始日期",
|
||||
"invoiceEndDate": "發票結束日期",
|
||||
"downloadInvoice": "下載發票",
|
||||
@@ -8166,9 +8157,8 @@
|
||||
"enterpriseNote": "大量需求?讓我們談談規模吧!聯絡我們取得量身打造的方案。",
|
||||
"contactSupport": "聯絡支援",
|
||||
"usageTrends": {
|
||||
"dailyUsage": "每日使用量",
|
||||
"enableTitle": "查看每日使用量",
|
||||
"enableSubtitle": "開啟使用量報告,以每日圖表顯示您的資料攝入和查詢量。無論如何,上方的總計仍然可用。",
|
||||
"enableSubtitle": "開啟用量報表,即可用圖表呈現每個計量項目的每日成本與用量。無論如何,總覽頁的帳單仍可查看。",
|
||||
"enableButton": "啟用使用量報告",
|
||||
"enablePending": "使用量報告已啟用。您的每日圖表將在記錄新使用量後立即開始填補。",
|
||||
"enableConfirmTitle": "啟用使用量報告?",
|
||||
@@ -8184,7 +8174,6 @@
|
||||
"aiContractExhaustedMessage": "聯絡您的帳戶管理員以新增更多 AI 額度",
|
||||
"aiPayerSubscribeMessage": "此組織的帳務由 {payer} 管理。請要求 {payer} 的管理員訂閱;後續用量將計入其帳單。",
|
||||
"aiFreeUsedLabel": "已使用的免費額度",
|
||||
"aiBillableCredits": "可計費 AI 額度",
|
||||
"aiFreeOneTime": "一次性額度。",
|
||||
"aiUsedByFeature": "助手與查詢生成:{chat} · 事件分析:{incident} · 重新分析:{reanalysis}",
|
||||
"aiPaygMessage": "免費額度已用完 — 使用量計入訂閱費用",
|
||||
@@ -8235,10 +8224,6 @@
|
||||
"chipPlanManaged": "方案由上層管理",
|
||||
"chipNoInvoices": "無個別發票",
|
||||
"viewOrgGroup": "檢視組織群組",
|
||||
"currentOrgTitle": "目前組織",
|
||||
"memberOrgsTitle": "成員組織",
|
||||
"searchMemberOrg": "搜尋組織",
|
||||
"noMemberMatch": "無相符的組織",
|
||||
"remove": "移除",
|
||||
"removeTitle": "要將 {name} 從您的帳務群組中移除嗎?",
|
||||
"removeBody": "此組織將成為免費組織,並自行支付其用量費用。將適用免費方案限制。其使用者、數據和過往用量不受影響。",
|
||||
@@ -8280,7 +8265,6 @@
|
||||
"alertStatus": "警示狀態",
|
||||
"contactAdministratorToResolve": "請聯絡您的管理員以解決此問題。",
|
||||
"contactAdministratorUsageReporting": "請聯絡您的管理員以確保使用情況回報功能恢復。",
|
||||
"credits": "額度",
|
||||
"dataRetention": "資料保留",
|
||||
"event": "事件",
|
||||
"ingestion": "資料擷取",
|
||||
@@ -8305,7 +8289,249 @@
|
||||
"trialPeriodHasExpired": "您的試用期已過期。",
|
||||
"trialDaysRemaining": "您的試用帳戶剩餘 {count} 天 | 您的試用帳戶剩餘 {count} 天",
|
||||
"usageReportingFailedForDays": "使用量回報已連續 {count} 天失敗,RBAC 和 SSO 功能將停用,直到用量成功回報為止 | 使用量回報已連續 {count} 天失敗,RBAC 和 SSO 功能將停用,直到用量成功回報為止",
|
||||
"usageReportingFailingForHours": "使用量回報已連續失敗 {count} 小時,部分功能即將停用 | 使用量回報已連續失敗 {count} 小時,部分功能即將停用"
|
||||
"usageReportingFailingForHours": "使用量回報已連續失敗 {count} 小時,部分功能即將停用 | 使用量回報已連續失敗 {count} 小時,部分功能即將停用",
|
||||
"usageV2": {
|
||||
"unitGb": "GB",
|
||||
"unitGbHour": "GB-小時",
|
||||
"unitCredit": "額度",
|
||||
"unitRun": "執行次數",
|
||||
"ratePerUnit": "每 {unit} {rate}",
|
||||
"cycleRange": "計費週期:{start} 至 {end}",
|
||||
"syntheticsBrowser": "合成監控(瀏覽器)",
|
||||
"syntheticsProtocol": "合成監控(協定)",
|
||||
"overviewTab": "總覽",
|
||||
"trendsTab": "趨勢",
|
||||
"estimationTab": "價格估算",
|
||||
"costBreakdown": "成本拆分",
|
||||
"colMeter": "計量項目",
|
||||
"colVolume": "用量",
|
||||
"colCost": "成本",
|
||||
"totalCost": "總成本",
|
||||
"missingMeters": "我們無法為這些計量項目計價:{meters}。其資料列會顯示破折號而非成本。",
|
||||
"noPricingTitle": "此組織無法取得成本",
|
||||
"noPricingSubtitle": "此組織沒有計量計費帳戶,因此沒有可顯示的價格。用量仍可在「趨勢」分頁查看。",
|
||||
"loadFailedTitle": "無法載入您的用量",
|
||||
"loadFailedNetwork": "無法連線至伺服器。請檢查網路連線後再試一次。",
|
||||
"loadFailedServer": "帳務服務傳回錯誤。請稍後再試。",
|
||||
"refreshFailed": "無法重新整理用量。顯示的數字來自上次載入。",
|
||||
"byStream": "依串流",
|
||||
"colStream": "串流",
|
||||
"byUser": "依使用者",
|
||||
"colUser": "使用者",
|
||||
"byAlert": "依警示",
|
||||
"colAlert": "警示",
|
||||
"byDashboard": "依儀表板",
|
||||
"colDashboard": "儀表板",
|
||||
"byScheduledPipeline": "依排程管線",
|
||||
"colScheduledPipeline": "排程管線",
|
||||
"byPipeline": "依管線",
|
||||
"colPipeline": "管線",
|
||||
"byDestination": "依目標",
|
||||
"colDestination": "目標",
|
||||
"colRetentionPeriod": "保留期間",
|
||||
"colCurrentSize": "目前大小",
|
||||
"drilldownFailed": "無法載入此拆分。",
|
||||
"retry": "重試",
|
||||
"unknownPipeline": "未知",
|
||||
"rangeCurrentCycle": "目前週期",
|
||||
"assumptions": "假設",
|
||||
"avgStreamSize": "每個串流的平均數據量",
|
||||
"ingestPerMonth": "每月擷取的數據量",
|
||||
"searchesPerMonth": "每月搜尋次數",
|
||||
"scanPerSearch": "每次搜尋掃描的數據量",
|
||||
"pipelineCount": "在串流上執行的管線",
|
||||
"retentionDays": "數據保留期間",
|
||||
"aiCreditsPerMonth": "每月 AI 額度",
|
||||
"browserChecks": "瀏覽器檢查",
|
||||
"stepsPerCheck": "每次檢查的步驟數",
|
||||
"checkRegions": "區域",
|
||||
"checkDevices": "裝置",
|
||||
"checkInterval": "執行頻率",
|
||||
"apiChecks": "API 檢查",
|
||||
"browserRunsNote": "每月約 {runs} 次步驟執行。",
|
||||
"apiRunsNote": "每月約 {runs} 次執行。",
|
||||
"qtyStepRuns": "{count} 次步驟執行",
|
||||
"qtyRuns": "{count} 次執行",
|
||||
"estimatedTotal": "預估總計",
|
||||
"perMonth": "每月",
|
||||
"estimateDisclaimer": "這是依定價計算的預估值,不包含您帳戶的任何折扣。",
|
||||
"cycleDay": "第 {day} 天,共 {total} 天",
|
||||
"discountBadge": "−{percent}%",
|
||||
"discount": "折扣",
|
||||
"costSoFar": "目前費用",
|
||||
"estimatedCostSoFar": "迄今估計費用",
|
||||
"projectedTotal": "預測總計",
|
||||
"tooEarlyToProject": "週期尚早,無法預測",
|
||||
"colStorageShare": "佔儲存空間 %",
|
||||
"orgDefaultRetention": "組織預設",
|
||||
"retentionDaysValue": "{days} 天",
|
||||
"topMeter": "最高計量項目",
|
||||
"topMeterShare": "{cost} · 佔帳單 {percent}%",
|
||||
"billedIngestion": "已擷取數據",
|
||||
"billedQuery": "搜尋掃描的數據量",
|
||||
"billedPipeline": "管線處理的數據量",
|
||||
"billedRemotePipeline": "傳送至遠端目標的數據量",
|
||||
"billedRetention": "長期儲存的數據量",
|
||||
"billedAi": "已使用 AI 額度",
|
||||
"billedSynthetics": "合成監控檢查執行次數",
|
||||
"meterShare": "帳單佔比",
|
||||
"everythingElse": "其他全部",
|
||||
"otherBand": "其他",
|
||||
"colTotal": "總計",
|
||||
"groupBy": "分組依據",
|
||||
"groupNotRecorded": "尚無 {group} 使用量記錄。",
|
||||
"segmentTitle": "{meter}:計費 {billed},佔帳單 {share}",
|
||||
"colEstCost": "預估費用",
|
||||
"shareOfMeter": "佔 {meter} 的比例",
|
||||
"costByMeter": "各計量項目費用",
|
||||
"meterByGroup": "依 {group} 的 {meter}",
|
||||
"billedDay": "已計費,第 {day} 天/共 {total} 天",
|
||||
"segmentTitleDiscounted": "{meter}:計費 {billed}(原價 {gross},折扣 {discount}),佔帳單 {share}",
|
||||
"volumeAtRate": "{volume} · {rate}",
|
||||
"billedThrough": "透過 {payer} 計費。費用與預算位於該組織的用量頁面。",
|
||||
"volumeAtRateBilled": "{volume} · {rate},以 {amount} {unit} 計費",
|
||||
"percentInParens": "({percent}%)",
|
||||
"colGross": "原價",
|
||||
"colBilled": "計費",
|
||||
"allMeters": "全部計量項目",
|
||||
"noDailyRetention": "保留期沒有每日歷史記錄。表格改為顯示各串流的儲存量。",
|
||||
"grossLabel": "原價",
|
||||
"cycleCloses": "週期結束於 {date}",
|
||||
"dailyCost": "每日費用",
|
||||
"breakdownSummary": "計費 {cost} · {volume} · 佔帳單 {share}",
|
||||
"scenarioFrom": "起始基準",
|
||||
"scenarioCurrent": "目前使用量",
|
||||
"scenarioTwice": "2 倍成長",
|
||||
"scenarioFiveTimes": "5 倍成長",
|
||||
"resetEstimate": "重設",
|
||||
"expectedUsage": "預期每月使用量",
|
||||
"expectedUsageHint": "依本期迄今資料預先填入",
|
||||
"loadingHistory": "正在讀取您的用量歷程…",
|
||||
"yourEstimate": "您的預估",
|
||||
"assumptionsFrom": "依您最近 30 天",
|
||||
"scanNote": "每月掃描約 {volume}。",
|
||||
"qtyScanned": "已掃描 {volume}",
|
||||
"qtyProcessed": "已處理 {volume}",
|
||||
"qtyKeptFor": "{volume},保留 {days} 天",
|
||||
"qtyCredits": "{count} 點數",
|
||||
"rateIncluded": "已包含在您的方案中",
|
||||
"rateTooltip": "{rate}。計費依據為 {basis}。",
|
||||
"rateIncludedTooltip": "{basis} 已包含在您的方案中,因此不會增加此預估值。",
|
||||
"byIncident": "依事件",
|
||||
"colIncident": "事件",
|
||||
"colCredits": "點數",
|
||||
"byCheck": "依檢查",
|
||||
"colCheck": "檢查",
|
||||
"colSteps": "步驟",
|
||||
"unknownCheck": "已刪除的檢查",
|
||||
"noBreakdownYet": "本週期沒有用量,因此尚無可細分的內容。",
|
||||
"rangeLastCycle": "上一週期",
|
||||
"rangeLast90Days": "過去 90 天",
|
||||
"runRate": "消耗速率",
|
||||
"dailyAverage": "每日平均",
|
||||
"peakDay": "高峰日",
|
||||
"runRateWindow": "過去 {days} 天",
|
||||
"acrossDays": "共 {days} 天",
|
||||
"atPaceProjected": "按最近 {days} 天的速度計算:{cost}",
|
||||
"atPace": "按 {days} 天速度",
|
||||
"topMeterOfCost": "{cost} · 佔成本 {percent}%",
|
||||
"perDay": "每天 {cost}",
|
||||
"spendToDate": "迄今支出",
|
||||
"costOverTime": "成本隨時間變化",
|
||||
"volumeOverTime": "用量隨時間變化",
|
||||
"volumeSubtitle": "每日的資料擷取、搜尋與管線",
|
||||
"usageByMeter": "各計量器用量",
|
||||
"dailyVolume": "每日用量",
|
||||
"lastDaysRange": "過去 {days} 天:{start} 至 {end}",
|
||||
"enableVolumeSubtitle": "開啟用量回報,即可逐日查看每個計量器的使用量。",
|
||||
"measureCost": "成本",
|
||||
"measureVolume": "用量",
|
||||
"estimatedAtCyclePrice": "依用量並按各週期價格估算。不含資料保留。",
|
||||
"showOnlyMeter": "僅顯示 {meter}",
|
||||
"showAllMeters": "顯示所有計量項目",
|
||||
"noUsageInRange": "此範圍內沒有用量",
|
||||
"trendsLoadFailed": "無法載入此範圍的用量。",
|
||||
"rangeDates": "{start} 至 {end}",
|
||||
"forecast": "預測",
|
||||
"dayTotal": "當日總計",
|
||||
"costByOrg": "依組織的成本",
|
||||
"orgFilter": "組織",
|
||||
"organizationsTitle": "組織",
|
||||
"orgCountThisCycle": "{count} 個組織 · 本週期",
|
||||
"overallBudget": "總預算",
|
||||
"overallBudgetLabel": "本計費週期的總預算",
|
||||
"appliesToGroup": "整個群組的帳單",
|
||||
"orgBudgetsTotal": "組織預算合計為 {amount}",
|
||||
"orgBudgetsOver": "組織預算合計為 {amount},比總預算多出 {over}",
|
||||
"needsAttention": "需要關注",
|
||||
"attentionHint": "{over} 超出預算,{near} 接近警告線",
|
||||
"colSpendThisCycle": "本期支出",
|
||||
"colBudget": "預算",
|
||||
"colAlerts": "警示",
|
||||
"statusNone": "未設定預算",
|
||||
"statusOk": "進度正常",
|
||||
"statusWarn": "接近上限",
|
||||
"statusOver": "超出預算",
|
||||
"budgetFor": "{org} 的預算",
|
||||
"warnPercentFor": "{org} 的警告百分比",
|
||||
"alertsFor": "{org} 的電子郵件警示",
|
||||
"groupAlertsOnHint": "只要群組本期支出持續高於警告金額,管理員就會收到電子郵件。暫停即可停止寄送。每個組織列都有各自的開關。",
|
||||
"unsavedChanges": "未儲存:{parts}",
|
||||
"unsavedOrgs": "{count} 個組織",
|
||||
"unsavedOverall": "整體設定",
|
||||
"allChangesSaved": "所有變更已儲存",
|
||||
"discard": "捨棄",
|
||||
"saveChanges": "儲存變更",
|
||||
"orgCostsFailed": "無法載入各組織成本。請重新整理再試。",
|
||||
"clickForOrg": "點擊以查看此組織的計量項目",
|
||||
"clickForBreakdown": "點擊以查看此計量項目的用量來源",
|
||||
"allOrgs": "所有組織",
|
||||
"colOrganization": "組織",
|
||||
"shareOfOrg": "佔 {org} 的比例",
|
||||
"thisOrg": "{org}(此組織)",
|
||||
"formerMemberIngested": "前成員 · 已擷取 {volume}",
|
||||
"ingestedVolume": "已擷取 {volume}",
|
||||
"notSplitByOrg": "計費歸屬整個群組,不按組織拆分",
|
||||
"orgSummary": "已計費 {cost} · 帳單佔比 {share}",
|
||||
"orgCostEstimated": "此帳單依各組織的用量佔比拆分",
|
||||
"byOrg": "依組織",
|
||||
"historyStartsOn": "用量紀錄從 {date} 開始,這是我們有用量資料的最早帳務週期起始日。更早的日期沒有用量可顯示。",
|
||||
"budgetTab": "預算",
|
||||
"budgetKpi": "預算",
|
||||
"budgetNotSet": "未設定",
|
||||
"budgetThisCycle": "本帳務週期",
|
||||
"spentOfBudget": "預算的 {percent}% · 第 {day} 天,共 {total} 天",
|
||||
"overBudgetBy": "超出預算 {amount}",
|
||||
"toSpare": "剩餘 {amount}",
|
||||
"budgetReached": "已達預算",
|
||||
"reachedOnDay": "第 {day} 天",
|
||||
"notThisCycle": "非本期",
|
||||
"atPacePerDay": "每天 {cost}",
|
||||
"atCurrentPace": "依目前速度",
|
||||
"spendAgainstBudget": "支出對預算",
|
||||
"ofBudget": "佔 {budget} · {percent}%",
|
||||
"leftInBudget": "剩餘 {amount}",
|
||||
"overBy": "超出 {amount}",
|
||||
"warnAtPercent": "於 {percent}% 警告",
|
||||
"shareOfBudget": "佔預算 {percent}%",
|
||||
"setBudgetFirst": "設定預算,即可追蹤本期支出相對於預算的狀況。",
|
||||
"budgetSettings": "預算設定",
|
||||
"budgetAmount": "此帳務週期的預算",
|
||||
"budgetAmountHelp": "依目前速度,本週期結束時約為 {amount}",
|
||||
"warnAt": "警告閾值",
|
||||
"warnAmount": "警告金額(美元)",
|
||||
"warnPercent": "警告金額佔預算的百分比",
|
||||
"warnHelp": "支出超過 {amount} 時發出警示",
|
||||
"emailAlerts": "電子郵件警示",
|
||||
"alertsOnHint": "只要本週期支出持續高於警告金額,管理員就會收到電子郵件。暫停即可停止寄送。",
|
||||
"alertsPausedHint": "重新開啟前不會寄送電子郵件。",
|
||||
"saveBudget": "儲存設定",
|
||||
"budgetSaved": "預算已儲存",
|
||||
"budgetRemoved": "預算已移除",
|
||||
"budgetSaveFailed": "無法儲存預算",
|
||||
"budgetLoadFailedTitle": "無法載入您的預算",
|
||||
"budgetLoadFailed": "載入完成前無法編輯,以免儲存時覆寫已設定的預算。請稍後再試。"
|
||||
}
|
||||
},
|
||||
"awsMarketplace": {
|
||||
"noTokenFound": "找不到 Marketplace Token",
|
||||
|
||||
@@ -332,80 +332,15 @@ describe("Billings Service", () => {
|
||||
});
|
||||
|
||||
describe("get_data_usage", () => {
|
||||
it("should fetch data usage for specific date and type", async () => {
|
||||
const usageDate = "2024-01-01";
|
||||
const dataType = "logs";
|
||||
const mockUsage = {
|
||||
data: {
|
||||
date: usageDate,
|
||||
data_type: dataType,
|
||||
usage_bytes: 1024000000,
|
||||
usage_readable: "1.02 GB",
|
||||
cost: 0.15,
|
||||
},
|
||||
};
|
||||
it("fetches the org's metering without the query params the server ignores", async () => {
|
||||
const mockUsage = { data: { price_details: null } };
|
||||
mockHttp.mockReturnValue({ get: vi.fn().mockResolvedValue(mockUsage) } as any);
|
||||
|
||||
mockHttp.mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue(mockUsage),
|
||||
} as any);
|
||||
const result = await billings.get_data_usage(mockOrgId, "30days");
|
||||
|
||||
const result = await billings.get_data_usage(mockOrgId, usageDate, dataType);
|
||||
|
||||
expect(mockHttp().get).toHaveBeenCalledWith(
|
||||
`/api/${mockOrgId}/billings/data_usage/${usageDate}?data_type=${dataType}`,
|
||||
);
|
||||
expect(mockHttp().get).toHaveBeenCalledWith(`/api/${mockOrgId}/billings/data_usage/30days`);
|
||||
expect(result).toEqual(mockUsage);
|
||||
});
|
||||
|
||||
it("should handle different data types", async () => {
|
||||
const usageDate = "2024-01-01";
|
||||
const dataTypes = ["logs", "metrics", "traces"];
|
||||
|
||||
mockHttp.mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
} as any);
|
||||
|
||||
for (const dataType of dataTypes) {
|
||||
await billings.get_data_usage(mockOrgId, usageDate, dataType);
|
||||
|
||||
expect(mockHttp().get).toHaveBeenCalledWith(
|
||||
`/api/${mockOrgId}/billings/data_usage/${usageDate}?data_type=${dataType}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockHttp().get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should append member query param when a member org is provided", async () => {
|
||||
const usageDate = "2024-01-01";
|
||||
const dataType = "gb";
|
||||
const member = "child-org";
|
||||
|
||||
mockHttp.mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
} as any);
|
||||
|
||||
await billings.get_data_usage(mockOrgId, usageDate, dataType, member);
|
||||
|
||||
expect(mockHttp().get).toHaveBeenCalledWith(
|
||||
`/api/${mockOrgId}/billings/data_usage/${usageDate}?data_type=${dataType}&member=${member}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not append member query param for empty member values", async () => {
|
||||
const usageDate = "2024-01-01";
|
||||
const dataType = "gb";
|
||||
|
||||
mockHttp.mockReturnValue({
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
} as any);
|
||||
|
||||
await billings.get_data_usage(mockOrgId, usageDate, dataType, "");
|
||||
|
||||
expect(mockHttp().get).toHaveBeenCalledWith(
|
||||
`/api/${mockOrgId}/billings/data_usage/${usageDate}?data_type=${dataType}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing group", () => {
|
||||
|
||||
@@ -72,18 +72,9 @@ const billings = {
|
||||
list_invoice_history: (org_identifier: string) => {
|
||||
return http().get(`/api/${org_identifier}/billings/invoices`);
|
||||
},
|
||||
get_data_usage: (
|
||||
org_identifier: string,
|
||||
usage_date: string,
|
||||
data_type: string,
|
||||
member?: string,
|
||||
) => {
|
||||
let url = `/api/${org_identifier}/billings/data_usage/${usage_date}?data_type=${data_type}`;
|
||||
if (member) {
|
||||
url += `&member=${member}`;
|
||||
}
|
||||
return http().get(url);
|
||||
},
|
||||
// The route still needs a {usage_date} segment, though the server returns the current cycles whatever it is.
|
||||
get_data_usage: (org_identifier: string, usage_date: string) =>
|
||||
http().get(`/api/${org_identifier}/billings/data_usage/${usage_date}`),
|
||||
submit_new_user_info: async (org_identifier: string, payload: any) => {
|
||||
return http().post(`/api/${org_identifier}/billings/new_user_attribution`, payload);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user