mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user