chore: usage ui cloud changes (#13112)

## What

Adds a **daily usage view** to Cloud Billing → Usage for orgs with usage
reporting enabled, layered on top of the existing billing-cycle cards.
Cloud-only.

### Frontend (cloud billing components)
- **Merged into the existing Usage tab** (no separate tab): the billable
usage cards are always shown; when self-usage reporting is on, a
dashboards **date-range picker** (in the toolbar next to the GB/MB
toggle) drives **both** the cards and a daily chart.
- **Daily line chart** — a single query via `PanelSchemaRenderer` over
the org's own `usage` stream, broken down by event into **Ingestion /
Search / Pipeline / Remote Pipeline** lines (connect-nulls on).
- **Six billable usage tiles** on one row with **automatic unit
scaling** (GB→TB→PB / MB→GB→…) so large numbers stay readable; AI
Credits shown as a count.
- Cards re-fetch over the calendar range (derived `<N>days`, hardened
against bad values).

### Backend (all cloud-gated)
- `usage_stream_enabled` **defaults to `ZO_USAGE_REPORT_TO_OWN_ORG`** in
cloud builds, so orgs report to their own `usage` stream by default; a
per-org opt-out persists an explicit `false`. OSS / self-hosted default
stays `false`.
- **Reserved self-reporting streams** (`usage`, `stats`, `triggers`,
`errors`, `data_retention_usage`) are blocked from user **create /
ingest / delete** — cloud-only; the internal self-reporting writer
(`IngestionRequest::Usage`) is exempt.
- `get_org_usage`: a **missing `usage` / `data_retention_usage` stream
is tolerated** (treated as zero) instead of failing the whole request
with a 400.

## Notes
- No enterprise-repo or DB-migration changes; backend logic stays in OSS
behind `#[cfg(feature = "cloud")]`, per the OSS/Enterprise layer map.
- `Cargo.toml` / `Cargo.lock` are intentionally **not** included (local
`downloadO2.sh` dev-setup).

## Testing
- `cargo check` (OSS) and `cargo check --features cloud` — pass; `cargo
fmt` clean.
- Frontend: **411 billing unit tests** pass; `vue-tsc` typecheck clean;
production build succeeds.
This commit is contained in:
Ashish Kolhe
2026-07-09 14:51:01 +00:00
committed by GitHub
parent 5ec3e960c7
commit c2ac5fb89c
14 changed files with 945 additions and 181 deletions
+65 -2
View File
@@ -387,6 +387,26 @@ fn default_enable_streaming_search() -> bool {
false
}
/// Default for `usage_stream_enabled`. In **cloud** builds, when self-reporting
/// to the org's own usage stream is turned on globally
/// (`ZO_USAGE_REPORT_TO_OWN_ORG`), orgs opt in by default so their `usage`
/// stream is populated and the daily Usage Trends view works out of the box.
/// Users can still toggle it off per org, which persists an explicit `false`
/// and falls back to the billing-API (cycle-total) view.
///
/// In OSS and self-hosted enterprise builds — or when the global flag is off —
/// the default stays `false` (nothing writes per-org usage streams by default).
fn default_usage_stream_enabled() -> bool {
#[cfg(feature = "cloud")]
{
config::get_config().common.usage_report_to_own_org
}
#[cfg(not(feature = "cloud"))]
{
false
}
}
#[cfg(feature = "enterprise")]
fn default_claim_parser_function() -> String {
"".to_string()
@@ -453,7 +473,7 @@ pub struct OrganizationSetting {
pub dark_mode_theme_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_series_per_query: Option<usize>,
#[serde(default)]
#[serde(default = "default_usage_stream_enabled")]
pub usage_stream_enabled: bool,
#[cfg(feature = "enterprise")]
#[serde(default = "default_claim_parser_function")]
@@ -490,7 +510,7 @@ impl Default for OrganizationSetting {
light_mode_theme_color,
dark_mode_theme_color,
max_series_per_query: None,
usage_stream_enabled: false,
usage_stream_enabled: default_usage_stream_enabled(),
#[cfg(feature = "enterprise")]
claim_parser_function: default_claim_parser_function(),
cross_links: Vec::new(),
@@ -1376,4 +1396,47 @@ mod tests {
assert!(obj.contains_key("dark_mode_theme_color"));
assert!(obj.contains_key("max_series_per_query"));
}
/// In OSS / self-hosted enterprise builds (cloud feature off), usage stream
/// self-reporting must NOT be on by default — the default helper and the
/// struct default both stay `false`, and a stored setting that omits the
/// field deserializes to `false`.
#[cfg(not(feature = "cloud"))]
#[test]
fn test_usage_stream_enabled_defaults_off_when_not_cloud() {
assert!(!default_usage_stream_enabled());
assert!(!OrganizationSetting::default().usage_stream_enabled);
// A stored setting missing the field falls back to the (false) default.
let json = r#"{
"scrape_interval": 15,
"trace_id_field_name": "trace_id",
"span_id_field_name": "span_id"
}"#;
let parsed: OrganizationSetting = serde_json::from_str(json).unwrap();
assert!(!parsed.usage_stream_enabled);
}
/// An explicit value always wins over the default, in every build — this is
/// what makes a per-org opt-out (or opt-in) stick.
#[test]
fn test_usage_stream_enabled_explicit_value_is_respected() {
let json_true = r#"{
"scrape_interval": 15,
"trace_id_field_name": "trace_id",
"span_id_field_name": "span_id",
"usage_stream_enabled": true
}"#;
let parsed: OrganizationSetting = serde_json::from_str(json_true).unwrap();
assert!(parsed.usage_stream_enabled);
let json_false = r#"{
"scrape_interval": 15,
"trace_id_field_name": "trace_id",
"span_id_field_name": "span_id",
"usage_stream_enabled": false
}"#;
let parsed: OrganizationSetting = serde_json::from_str(json_false).unwrap();
assert!(!parsed.usage_stream_enabled);
}
}
@@ -30,6 +30,25 @@ pub const TRIGGERS_STREAM: &str = "triggers";
pub const ERROR_STREAM: &str = "errors";
pub const DATA_RETENTION_USAGE_STREAM: &str = "data_retention_usage";
/// The reserved self-reporting stream names. These are written only by internal
/// self-reporting jobs (usage/stats/triggers/errors/data-retention). Users must
/// not be able to create, ingest into, or delete them — doing so would corrupt
/// billing/usage accounting.
pub const RESERVED_SELF_REPORTING_STREAMS: [&str; 5] = [
USAGE_STREAM,
STATS_STREAM,
TRIGGERS_STREAM,
ERROR_STREAM,
DATA_RETENTION_USAGE_STREAM,
];
/// Returns true if `stream_name` is a reserved self-reporting stream that users
/// are not allowed to create, ingest into, or delete. Internal self-reporting
/// writes bypass this via the `IngestionRequest::Usage` channel.
pub fn is_reserved_self_reporting_stream(stream_name: &str) -> bool {
RESERVED_SELF_REPORTING_STREAMS.contains(&stream_name)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum TriggerDataStatus {
#[serde(rename = "completed")]
@@ -646,6 +665,25 @@ mod tests {
assert_eq!(format!("{}", UsageEvent::Other), "Other");
}
#[test]
fn test_is_reserved_self_reporting_stream() {
// All self-reporting streams are reserved.
assert!(is_reserved_self_reporting_stream(USAGE_STREAM));
assert!(is_reserved_self_reporting_stream(STATS_STREAM));
assert!(is_reserved_self_reporting_stream(TRIGGERS_STREAM));
assert!(is_reserved_self_reporting_stream(ERROR_STREAM));
assert!(is_reserved_self_reporting_stream(
DATA_RETENTION_USAGE_STREAM
));
assert!(is_reserved_self_reporting_stream("usage"));
// Ordinary user streams are not.
assert!(!is_reserved_self_reporting_stream("my_logs"));
assert!(!is_reserved_self_reporting_stream("usage_production"));
assert!(!is_reserved_self_reporting_stream("_usage"));
assert!(!is_reserved_self_reporting_stream(""));
}
#[test]
fn test_usage_event_from_usage_type() {
assert_eq!(UsageEvent::from(UsageType::Bulk), UsageEvent::Ingestion);
+8 -3
View File
@@ -140,9 +140,13 @@ pub async fn get_org_setting_toggle_ingestion_logs(org_id: &str) -> Result<bool,
Ok(toggle_ingestion_logs)
}
/// Get the usage stream enabled setting for an org
/// If the setting is not found, return false (disabled by default)
/// This allows orgs to opt-in to querying usage stream data
/// Get the usage stream enabled setting for an org.
/// When no explicit setting exists, this falls back to
/// `OrganizationSetting::default()`, whose `usage_stream_enabled` defaults to
/// the global `ZO_USAGE_REPORT_TO_OWN_ORG` flag (see
/// `default_usage_stream_enabled`). So with self-reporting on, orgs report to
/// their own `usage` stream by default; a user can toggle it off per org
/// (which persists an explicit `false`), reverting to the billing-API view.
pub async fn get_org_setting_usage_stream_enabled(org_id: &str) -> Result<bool, Error> {
let key = format!("{ORG_SETTINGS_KEY_PREFIX}/{org_id}");
if let Some(v) = ORGANIZATION_SETTING.read().await.get(&key) {
@@ -180,6 +184,7 @@ pub async fn org_settings_cache() -> Result<(), anyhow::Error> {
.insert(key, json_val);
}
log::info!("Organization settings Cached");
Ok(())
}
+33
View File
@@ -19,6 +19,8 @@ use std::{
};
use axum::body::Bytes;
#[cfg(feature = "cloud")]
use config::meta::self_reporting::usage::is_reserved_self_reporting_stream;
use config::{
BLOCKED_STREAMS, TIMESTAMP_COL_NAME, get_config,
meta::stream::StreamType,
@@ -146,6 +148,37 @@ pub async fn ingest(
stream_name = format_stream_name(stream_name);
}
// Reject reserved self-reporting streams (usage/stats/triggers/...).
// Bulk is always a user path (internal self-reporting uses the
// non-bulk `IngestionRequest::Usage` channel), so this is safe.
// Cloud-only: OSS / self-hosted may legitimately use these names.
#[cfg(feature = "cloud")]
if is_reserved_self_reporting_stream(&stream_name) {
let err_msg =
format!("stream '{stream_name}' is reserved and cannot be ingested into");
log::warn!("[LOGS:BULK] {err_msg}");
bulk_res.errors = true;
let err = BulkResponseError::new(
err_msg.clone(),
stream_name.to_string(),
err_msg,
"0".to_string(),
);
let mut item = HashMap::new();
item.insert(
action.to_string(),
BulkResponseItem::new_failed(
stream_name.to_string(),
doc_id.clone().unwrap_or_default(),
err,
Some(value),
stream_name.to_string(),
),
);
bulk_res.items.push(item);
continue; // skip
}
// skip blocked streams
if !stream_key_cache.contains_key(&stream_name) {
let key = format!("{org_id}/{}/{stream_name}", stream_type);
+15
View File
@@ -20,6 +20,8 @@ use std::{
use axum::http;
use chrono::Utc;
#[cfg(feature = "cloud")]
use config::meta::self_reporting::usage::is_reserved_self_reporting_stream;
use config::{
ALL_VALUES_COL_NAME, ID_COL_NAME, ORIGINAL_DATA_COL_NAME, TIMESTAMP_COL_NAME,
meta::{
@@ -109,6 +111,19 @@ pub async fn ingest(
return Err(Error::IngestionError("Stream name is empty".to_string()));
}
// Block user ingestion into reserved self-reporting streams
// (usage/stats/triggers/errors/...). The internal self-reporting job writes
// these via `IngestionRequest::Usage` (for which `should_report_usage()` is
// false → `need_usage_report == false`), so it is exempt; any other request
// targeting a reserved stream is a user write and is rejected. Cloud-only:
// OSS / self-hosted may legitimately use these stream names.
#[cfg(feature = "cloud")]
if need_usage_report && is_reserved_self_reporting_stream(&stream_name) {
return Err(Error::IngestionError(format!(
"stream '{stream_name}' is reserved and cannot be ingested into"
)));
}
// check system resource
check_ingestion_allowed(org_id, stream_type, Some(&stream_name)).await?;
+31 -13
View File
@@ -64,13 +64,20 @@ pub async fn get_org_usage(
let (sql, start_time, end_time) =
org_usage::create_usage_query_sql_and_time_range(org_id, usage_range, cycle_details);
let mut usage_results = get_usage(sql, start_time, end_time, false)
.await
.map_err(|e| billings::BillingError::PartialUsageResults(e.to_string()))?
.into_iter()
.filter_map(|hit| json::from_value::<OrgUsageQueryResult>(hit).ok())
.filter(|r| r.event.is_billable())
.collect::<Vec<_>>();
// The main `usage` stream may not exist yet for a brand-new org. As with
// data retention below, a missing stream should read as "no usage" rather
// than failing the whole request.
let mut usage_results = match get_usage(sql, start_time, end_time, false).await {
Ok(hits) => hits
.into_iter()
.filter_map(|hit| json::from_value::<OrgUsageQueryResult>(hit).ok())
.filter(|r| r.event.is_billable())
.collect::<Vec<_>>(),
Err(e) => {
log::warn!("usage query failed for org {org_id}, treating as zero: {e}");
Vec::new()
}
};
let (data_retention_query, ..) = org_usage::create_data_retention_usage_sql_and_time_range(
org_id,
@@ -78,12 +85,23 @@ pub async fn get_org_usage(
cycle_details,
);
let mut data_retention_results = get_usage(data_retention_query, start_time, end_time, false)
.await
.map_err(|e| billings::BillingError::PartialUsageResults(e.to_string()))?
.into_iter()
.filter_map(|hit| json::from_value::<OrgUsageQueryResult>(hit).ok())
.collect::<Vec<_>>();
// Data-retention usage lives in a separate `data_retention_usage` stream
// that may not exist for an org yet (e.g. no retention usage recorded). A
// failure here (most commonly "stream not found") must NOT fail the whole
// usage response — treat it as zero retention and return the rest.
let mut data_retention_results =
match get_usage(data_retention_query, start_time, end_time, false).await {
Ok(hits) => hits
.into_iter()
.filter_map(|hit| json::from_value::<OrgUsageQueryResult>(hit).ok())
.collect::<Vec<_>>(),
Err(e) => {
log::warn!(
"data_retention_usage query failed for org {org_id}, treating as zero: {e}"
);
Vec::new()
}
};
usage_results.append(&mut data_retention_results);
+39
View File
@@ -20,6 +20,10 @@ use axum::{
response::{IntoResponse, Response as HttpResponse},
};
use chrono::{TimeZone, Timelike, Utc};
// Reserved self-reporting stream guards are a Cloud-only concern (Cloud manages
// these streams for billing); OSS / self-hosted must not block user streams.
#[cfg(feature = "cloud")]
use config::meta::self_reporting::usage::is_reserved_self_reporting_stream;
use config::{
ALL_VALUES_COL_NAME, ORIGINAL_DATA_COL_NAME, SIZE_IN_MB, TIMESTAMP_COL_NAME, get_config,
is_local_disk_storage,
@@ -243,6 +247,24 @@ pub async fn create_stream(
stream_type: StreamType,
mut stream: StreamCreate,
) -> Result<HttpResponse, Error> {
// Reserved self-reporting streams (usage/stats/triggers/errors/...) are
// managed internally by Cloud and must not be user-created — doing so would
// corrupt billing/usage accounting. The internal self-reporting job creates
// its schema directly (not via create_stream), so blocking here is safe.
// Cloud-only: OSS / self-hosted may legitimately use these stream names.
#[cfg(feature = "cloud")]
if is_reserved_self_reporting_stream(stream_name) {
return Ok((
http::StatusCode::BAD_REQUEST,
[(ERROR_HEADER, "stream name is reserved")],
Json(MetaHttpResponse::error(
http::StatusCode::BAD_REQUEST,
format!("stream name '{stream_name}' is reserved and cannot be created"),
)),
)
.into_response());
}
// check if the stream already exists
let schema = match infra::schema::get(org_id, stream_name, stream_type).await {
Ok(schema) => schema,
@@ -949,6 +971,23 @@ pub async fn delete_stream(
stream_type: StreamType,
del_related_feature_resources: bool,
) -> Result<HttpResponse, Error> {
// Reserved self-reporting streams (usage/stats/triggers/errors/...) are
// managed internally by Cloud and must not be user-deleted — retention/
// compaction uses a separate internal path, so blocking this user-facing
// delete is safe and preserves billing/usage accounting. Cloud-only.
#[cfg(feature = "cloud")]
if is_reserved_self_reporting_stream(stream_name) {
return Ok((
http::StatusCode::BAD_REQUEST,
[(ERROR_HEADER, "stream name is reserved")],
Json(MetaHttpResponse::error(
http::StatusCode::BAD_REQUEST,
format!("stream '{stream_name}' is reserved and cannot be deleted"),
)),
)
.into_response());
}
let schema = infra::schema::get_versions(org_id, stream_name, stream_type, None)
.await
.unwrap();
@@ -5,9 +5,29 @@ import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
// Mock utils
vi.mock("@/utils/zincutils", () => ({
getImageURL: vi.fn((imagePath: string) => `img:${imagePath}`)
// Mock utils — partial so the store (pulled in transitively via usage.vue's
// dashboards renderer import) still resolves useLocalOrganization etc.
vi.mock("@/utils/zincutils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/zincutils")>();
return {
...actual,
getImageURL: vi.fn((imagePath: string) => `img:${imagePath}`),
};
});
// usage.vue (rendered by the Billing shell) imports these; stub them so the
// Billing shell test doesn't pull in the dashboards query pipeline.
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>",
},
}));
vi.mock("@/aws-exports", () => ({
@@ -145,8 +165,8 @@ describe("Billing Component", () => {
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" }),
expect.objectContaining({ label: 'GB', value: "gb" }),
expect.objectContaining({ label: 'MB', value: "mb" }),
])
);
});
@@ -657,4 +677,25 @@ describe("Billing Component", () => {
expect(() => wrapper.vm.updateActiveTab(undefined)).not.toThrow();
});
});
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();
});
});
});
@@ -121,7 +121,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="isUsageRoute"
class="flex gap-2 items-center justify-end overflow-y-auto shrink-0 mb-2 ml-2 mr-3 px-3 py-2"
>
<div class="custom-usage-date-select">
<!-- 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"
@@ -135,6 +138,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</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-[36px]">
<AppTabs class="tabs-selection-container" :tabs="tabs" :activeTab="usageDataType" @update:activeTab="(value: any) => updateActiveTab(value)" />
@@ -168,7 +180,7 @@ 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, onBeforeMount, computed, onMounted, provide, reactive } from "vue";
import { defineComponent, ref, onBeforeMount, computed, onMounted, provide, reactive, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
@@ -184,17 +196,22 @@ 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";
export default defineComponent({
name: "PageIngestion",
components: {
AppPageHeader, OTabs, ORouteTab, ConfirmDialog, Usage, AppTabs, OSelect,
OIcon, OSplitter, OButton, UsageMemberList },
OIcon, OSplitter, OButton, UsageMemberList, DateTimePickerDashboard },
setup() {
const { t } = useI18n();
const store = useStore();
const router: any = useRouter();
const billingtab = ref(resolveTab("billings", router.currentRoute.value.name as string, "usage"));
// Default/fallback tab is "plans" — that's where /billings redirects on
// mount, so falling back to "usage" made the Usage tab flash-highlight
// first before the redirect settled.
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);
@@ -311,6 +328,52 @@ export default defineComponent({
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";
})
@@ -338,12 +401,12 @@ export default defineComponent({
}
const tabs = [
{
label: 'Gb',
label: 'GB',
value: "gb",
icon: "storage",
},
{
label: 'Mb',
label: 'MB',
value: "mb",
icon: "database",
}
@@ -359,6 +422,10 @@ export default defineComponent({
splitterModel,
headerBasedOnRoute,
options,
usageStreamEnabled,
dateTimePicker,
dateRange,
onRangeChange,
usageDate,
selectUsageDate,
isUsageRoute,
@@ -47,10 +47,15 @@ vi.mock("vue-router", () => ({
useRouter: () => mockRouter
}));
// Mock zincutils
vi.mock("@/utils/zincutils", () => ({
getImageURL: vi.fn((path) => `mocked-image-url-${path}`),
}));
// 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", () => ({
@@ -65,6 +70,25 @@ vi.mock("@/components/dashboards/panels/CustomChartRenderer.vue", () => ({
},
}));
// 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);
@@ -95,6 +119,8 @@ describe("Usage Component", () => {
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,
@@ -234,7 +260,6 @@ describe("Usage Component", () => {
expect(wrapper.text()).toContain("Ingestion");
expect(wrapper.text()).toContain("Search");
expect(wrapper.text()).toContain("Functions");
});
// Test 16: Usage data formatting in tiles
@@ -247,9 +272,39 @@ describe("Usage Component", () => {
wrapper.vm.dataLoading = false;
await nextTick();
expect(wrapper.text()).toContain("10.50 GB");
expect(wrapper.text()).toContain("5.25 GB");
expect(wrapper.text()).toContain("2.75 GB");
// 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");
});
@@ -562,7 +617,8 @@ describe("Usage Component", () => {
expect(tileTexts).toContain('Ingestion');
expect(tileTexts).toContain('Search');
expect(tileTexts).toContain('Functions');
// Functions card was removed (not a billable line item).
expect(tileTexts).not.toContain('Functions');
});
// Test 43: Usage tiles hidden when loading
@@ -588,8 +644,10 @@ describe("Usage Component", () => {
});
// Test 45: Total usage heading display
it("should display total usage heading", () => {
expect(wrapper.text()).toContain(wrapper.vm.t("billing.totalUsage"));
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
@@ -675,4 +733,69 @@ describe("Usage Component", () => {
// 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,
);
});
});
});
+204 -141
View File
@@ -16,14 +16,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div class="p-3 " style="height: calc(100vh - 130px); width: 100%;" >
<div class="flex items-baseline justify-between">
<div class="flex text-xl tracking-[0.005em] font-[600] pb-3">
<span class="my-auto mx-[0.625rem] text-[var(--o2-text-heading)]">{{ t("billing.totalUsage") }}</span>
<span class="my-auto mx-[0.625rem] text-[var(--o2-text-heading)]"> {{ new Date(startTime/1000).toDateString() }} </span>
<span class="my-auto mx-[0.625rem] text-[var(--o2-text-heading)]">-</span>
<span class="my-auto mx-[0.625rem] text-[var(--o2-text-heading)]"> {{ new Date(endTime/1000).toDateString() }} </span>
</div>
</div>
<!-- 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-xl font-semibold text-weight-medium text-center">
@@ -95,141 +90,46 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<!-- new section introduced to show the usage for ingestion , search , functions -->
<div v-if="!dataLoading && Object.keys(usageData).length > 0" class="flex flex-col gap-4 w-full">
<div class="grid grid-cols-3 gap-4 w-full">
<div class="bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 min-h-32 flex flex-col justify-between transition-shadow duration-200 ease-in-out hover:shadow-sm">
<div class="flex flex-col justify-between rounded-[0.325rem] h-full gap-4 ">
<!-- Top Section (60%) -->
<div class="flex flex-col justify-between">
<!-- Title row -->
<div class="flex justify-between items-center">
<div class="text-(length:--text-sm) font-semibold leading-(--leading-base) tracking-normal text-(--color-text-heading) text-left" data-test="billings-usage-tile-title"> Ingestion</div>
<div style="opacity: 0.8;">
<img :src="ingestionIcon" />
</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 grid-cols-3 xl:grid-cols-6 gap-3 w-full"
>
<div
v-for="tile in usageTiles"
:key="tile.key"
data-test="billings-usage-tile"
class="usage-tile bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg px-3 py-3 flex flex-col gap-2 transition-shadow duration-200 ease-in-out hover:shadow-sm"
>
<div class="flex items-center justify-between gap-2">
<div
class="text-(length:--text-xs) font-medium text-(--o2-text-secondary) truncate"
data-test="billings-usage-tile-title"
:title="tile.label"
>
{{ tile.label }}
</div>
<div class="usage-tile__icon shrink-0 flex items-center justify-center rounded-lg">
<img :src="tile.icon" class="h-4 w-4" />
</div>
<!-- Bottom Section (40%) -->
<div class="text-(length:--text-2xl) font-semibold leading-(--leading-xl) tracking-normal text-(--color-text-heading) text-left flex items-end ">
{{ usageData.ingestion }} {{ usageDataType.toUpperCase() }} {{ usageCost.ingestion ? '| $ '+ usageCost.ingestion : '' }}
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-(length:--text-xl) font-bold text-(--color-text-heading) leading-none">
{{ tile.value }}
</span>
<span class="text-(length:--text-xs) font-medium text-(--o2-text-secondary)">
{{ tile.unit }}
</span>
</div>
</div>
<div class="bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 min-h-32 flex flex-col justify-between transition-shadow duration-200 ease-in-out hover:shadow-sm">
<div class="flex flex-col justify-between rounded-[0.325rem] h-full gap-4 ">
<!-- Top Section (60%) -->
<div class="flex flex-col justify-between">
<!-- Title row -->
<div class="flex justify-between items-center">
<div class="text-(length:--text-sm) font-semibold leading-(--leading-base) tracking-normal text-(--color-text-heading) text-left" data-test="billings-usage-tile-title">Search</div>
<div style="opacity: 0.8;">
<img :src="searchIcon" />
</div>
</div>
</div>
<!-- Bottom Section (40%) -->
<div class="text-(length:--text-2xl) font-semibold leading-(--leading-xl) tracking-normal text-(--color-text-heading) text-left flex items-end ">
{{ usageData.search }} {{ usageDataType.toUpperCase() }} {{ usageCost.search ? '| $ '+ usageCost.search : '' }}
</div>
</div>
</div>
<div class="bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 min-h-32 flex flex-col justify-between transition-shadow duration-200 ease-in-out hover:shadow-sm">
<div class="flex flex-col justify-between rounded-[0.325rem] h-full gap-4 ">
<!-- Top Section (60%) -->
<div class="flex flex-col justify-between">
<!-- Title row -->
<div class="flex justify-between items-center">
<div class="text-(length:--text-sm) font-semibold leading-(--leading-base) tracking-normal text-(--color-text-heading) text-left" data-test="billings-usage-tile-title">Functions</div>
<div style="opacity: 0.8;">
<img :src="functionsIcon" />
</div>
</div>
</div>
<!-- Bottom Section (40%) -->
<div class="text-(length:--text-2xl) font-semibold leading-(--leading-xl) tracking-normal text-(--color-text-heading) text-left flex items-end ">
{{ usageData.functions }} {{ usageDataType.toUpperCase() }} {{ usageCost.functions ? '| $ '+ usageCost.functions : '' }}
</div>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4 w-full">
<div class="bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 min-h-32 flex flex-col justify-between transition-shadow duration-200 ease-in-out hover:shadow-sm">
<div class="flex flex-col justify-between rounded-[0.325rem] h-full gap-4 ">
<div class="flex flex-col justify-between">
<div class="flex justify-between items-center">
<div class="text-(length:--text-sm) font-semibold leading-(--leading-base) tracking-normal text-(--color-text-heading) text-left">{{ t("billing.aiCredits") }}</div>
<div style="opacity: 0.8;">
<img :src="aiIcon" />
</div>
</div>
</div>
<div class="text-(length:--text-2xl) font-semibold leading-(--leading-xl) tracking-normal text-(--color-text-heading) text-left flex items-end ">
{{ usageData.ai_credits }} Credits {{ usageCost.ai_credits ? '| $ '+ usageCost.ai_credits : '' }}
</div>
</div>
</div>
<div class="bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 min-h-32 flex flex-col justify-between transition-shadow duration-200 ease-in-out hover:shadow-sm">
<div class="flex flex-col justify-between rounded-[0.325rem] h-full gap-4 ">
<!-- Top Section (60%) -->
<div class="flex flex-col justify-between">
<!-- Title row -->
<div class="flex justify-between items-center">
<div class="text-(length:--text-sm) font-semibold leading-(--leading-base) tracking-normal text-(--color-text-heading) text-left">Pipelines</div>
<div style="opacity: 0.8;" class="bg-[#E6EFFE] flex items-center justify-center rounded-[9px] h-[33px] w-[33px]">
<img :src="pipelineIcon" class="h-[18px] w-[18px] mx-[7px] my-[7px]" />
</div>
</div>
</div>
<!-- Bottom Section (40%) -->
<div class="text-(length:--text-2xl) font-semibold leading-(--leading-xl) tracking-normal text-(--color-text-heading) text-left flex items-end ">
{{ usageData.pipeline }} {{ usageDataType.toUpperCase() }} {{ usageCost.pipeline ? '| $ '+ usageCost.pipeline : '' }}
</div>
</div>
</div>
<div class="bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 min-h-32 flex flex-col justify-between transition-shadow duration-200 ease-in-out hover:shadow-sm">
<div class="flex flex-col justify-between rounded-[0.325rem] h-full gap-4 ">
<!-- Top Section (60%) -->
<div class="flex flex-col justify-between">
<!-- Title row -->
<div class="flex justify-between items-center">
<div class="text-(length:--text-sm) font-semibold leading-(--leading-base) tracking-normal text-(--color-text-heading) text-left">Remote Pipelines</div>
<div style="opacity: 0.8;" class="bg-[#F2DCF5] flex items-center justify-center rounded-[9px] h-[33px] w-[33px]">
<img :src="remotePipelineIcon" class="h-[18px] w-[18px] mx-[7px] my-[7px]" />
</div>
</div>
</div>
<!-- Bottom Section (40%) -->
<div class="text-(length:--text-2xl) font-semibold leading-(--leading-xl) tracking-normal text-(--color-text-heading) text-left flex items-end ">
{{ usageData.remotepipeline }} {{ usageDataType.toUpperCase() }} {{ usageCost.remotepipeline ? '| $ '+ usageCost.remotepipeline : '' }}
</div>
</div>
</div>
<div class="bg-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 min-h-32 flex flex-col justify-between transition-shadow duration-200 ease-in-out hover:shadow-sm">
<div class="flex flex-col justify-between rounded-[0.325rem] h-full gap-4 ">
<!-- Top Section (60%) -->
<div class="flex flex-col justify-between">
<!-- Title row -->
<div class="flex justify-between items-center">
<div class="text-(length:--text-sm) font-semibold leading-(--leading-base) tracking-normal text-(--color-text-heading) text-left">Data Retention</div>
<div style="opacity: 0.8;" class="bg-[#FFF4E6] flex items-center justify-center rounded-[9px] h-[33px] w-[33px]">
<img :src="dataRetentionIcon" class="h-[18px] w-[18px] mx-[7px] my-[7px]" />
</div>
</div>
</div>
<!-- Bottom Section (40%) -->
<div class="text-(length:--text-2xl) font-semibold leading-(--leading-xl) tracking-normal text-(--color-text-heading) text-left flex items-end ">
{{ usageData.dataretention }} {{ usageDataType.toUpperCase() }} {{ usageCost.dataretention ? '| $ '+ usageCost.dataretention : '' }}
</div>
</div>
</div>
<div
v-if="usageCost[tile.key]"
class="text-(length:--text-xs) font-medium text-(--o2-text-secondary)"
>
${{ usageCost[tile.key] }}
</div>
</div>
</div>
<!-- charts section -->
<div>
@@ -239,6 +139,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div v-if="dataLoading" class="text-xl font-semibold text-weight-medium text-center">
<OSpinner size="md" class="mx-auto block text-center mt-3" />
</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-(--o2-card-bg) border border-(--o2-border-color) rounded-lg p-4 mt-4"
>
<div class="text-(length:--text-sm) font-semibold text-(--color-text-heading) mb-2">
{{ t("billing.usageTrends.dailyUsage") }}
</div>
<div class="usage-daily-chart__body w-full">
<PanelSchemaRenderer
v-if="combinedSchema && dailyTimeObj"
:key="'chart-r-' + dailyChartKey"
class="usage-daily-chart__renderer"
:panelSchema="combinedSchema"
:selectedTimeObj="dailyTimeObj"
:variablesData="{}"
:dashboardId="''"
:folderId="''"
searchType="dashboards"
:allowAnnotationsAPI="false"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
@@ -251,8 +177,10 @@ import router from "@/router";
import { useRouter } from "vue-router";
import { getImageURL } from "@/utils/zincutils";
import CustomChartRenderer from "@/components/dashboards/panels/CustomChartRenderer.vue";
import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import { buildUsageCombinedLinePanelSchema } from "./usageDailyPanelSchema";
let currentDate = new Date(); // Get the current date and time
@@ -266,6 +194,7 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
() => import("@/components/dashboards/panels/ChartRenderer.vue")
),
CustomChartRenderer,
PanelSchemaRenderer,
OSpinner,
},
setup() {
@@ -304,6 +233,61 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
undefined as any
);
const selectedMember = computed(() => usageMember?.selected || "");
// --- 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,
);
// 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 });
});
// ---------------------------------------------------------------------
onMounted(async () => {
selectUsageDate();
});
@@ -311,7 +295,12 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
return router.currentRoute.value.query.usage_date ?? "30days";
})
const usageDataType: any = computed(() => { return router.currentRoute.value.query.data_type ?? "gb" })
watch(usageDate, (val) => {
// 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, (val) => {
@@ -329,7 +318,7 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
dataLoading.value = true;
BillingService.get_data_usage(
store.state.selectedOrganization.identifier,
usageDate.value,
effectiveUsageDate.value,
usageDataType.value,
selectedMember.value || undefined
)
@@ -396,6 +385,55 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
? 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: string, icon: any) => {
const scaled = scaleByteValue(usageData.value[key] ?? 0, base);
return { key, label, icon, value: scaled.value, unit: scaled.unit };
};
return [
byteTile("ingestion", "Ingestion", ingestionIcon),
byteTile("search", "Search", searchIcon),
byteTile("pipeline", "Pipelines", pipelineIcon),
byteTile("remotepipeline", "Remote Pipelines", remotePipelineIcon),
byteTile("dataretention", "Data Retention", dataRetentionIcon),
{
key: "ai_credits",
label: t("billing.aiCredits"),
icon: aiIcon.value,
value: usageData.value.ai_credits ?? "0.00",
unit: "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
@@ -981,6 +1019,7 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
functionsIcon,
usageData,
usageCost,
usageTiles,
getUsage,
router,
pipelineIcon,
@@ -988,7 +1027,31 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
dataRetentionIcon,
aiIcon,
selectedMember,
usageStreamEnabled,
dailyTimeObj,
combinedSchema,
dailyChartKey,
};
},
});
</script>
<style lang="scss" scoped>
/* Compact stat cards (six across): label + icon badge, then the value. */
.usage-tile__icon {
height: 1.75rem;
width: 1.75rem;
background: var(--o2-bg-gray);
}
/* PanelSchemaRenderer fills its container and needs an explicit height, or the
echarts canvas collapses to a sliver. */
.usage-daily-chart__body {
height: 22.5rem;
position: relative;
}
.usage-daily-chart__renderer {
height: 100%;
width: 100%;
}
</style>
@@ -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 { describe, it, expect } from "vitest";
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",
});
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" });
expect(gb.config.unit_custom).toBe("GB");
const mb = buildUsageCombinedLinePanelSchema({ orgId: "o", dataType: "mb" });
expect(mb.config.unit_custom).toBe("MB");
});
});
});
@@ -0,0 +1,182 @@
// 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.
*/
/** 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";
}): any {
const { orgId, dataType } = opts;
const sql = buildUsageCombinedSql({ orgId, dataType });
const unitLabel = dataType === "gb" ? "GB" : "MB";
return {
version: 2,
id: "usage-daily-combined",
title: "",
description: "",
type: "line",
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: "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: "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: [],
},
},
],
};
}
+3
View File
@@ -2971,6 +2971,9 @@
"enterpriseNote": "High volume needs? Let's talk scale! Contact us for tailored solutions.",
"contactSupport": "Contact Support",
"totalUsage": "Total Usage",
"usageTrends": {
"dailyUsage": "Daily usage"
},
"aiCredits": "AI Credits",
"aiModeFree": "Free",
"aiModePayAsYouGo": "Pay as you go",