Merge remote-tracking branch 'origin/main' into merge/sample-data-explorer

# Conflicts:
#	web/src/components/Header.vue
#	web/src/components/ingestion/FirstEventStatus.spec.ts
#	web/src/components/ingestion/setupCard/DataSourceSetupCard.vue
#	web/src/components/ingestion/setupCard/SetupCardRenderer.vue
#	web/src/components/login/GetStarted.spec.ts
#	web/src/components/login/GetStarted.vue
#	web/src/lib/styles/tokens/colorMath.spec.ts
#	web/src/views/HomeNoDataState.vue
This commit is contained in:
sai nikhil kethe
2026-11-04 17:30:07 +05:30
527 changed files with 17917 additions and 2758 deletions
+21 -1
View File
@@ -18,6 +18,8 @@
* emitted. With an overlay manifest, a sibling smoke_config.ent.json (same dir as
* the overlay) refines selection for ENT shards — folder_paths and path lists
* concatenate onto the base smoke config, core_shards/always_run replace it.
* ... --only-shards <a,b> # manual run: emit only these testfolders;
* an unknown name dies, an empty list emits the full matrix.
*
* The base (OSS tests/ui-testing/ci-matrix/ci_matrix.json) is the ONLY place shared shards are
* listed. ENT never re-lists shared specs — its overlay (ci_matrix.ent.json) carries
@@ -197,9 +199,18 @@ if (selectIdx !== -1) {
if (!changedFilesPath) die("--select-for-changes requires a path to a changed-files list");
args.splice(selectIdx, 2);
}
const onlyIdx = args.indexOf("--only-shards");
let onlyShards = null;
if (onlyIdx !== -1) {
if (args[onlyIdx + 1] === undefined) die("--only-shards requires a comma-separated list of testfolder names");
onlyShards = [...new Set(args[onlyIdx + 1].split(",").map((s) => s.trim()).filter(Boolean))];
args.splice(onlyIdx, 2);
if (onlyShards.length === 0) onlyShards = null;
}
if (onlyShards && changedFilesPath) die("--only-shards and --select-for-changes cannot be combined");
const [basePath, overlayPath] = args;
if (!basePath) {
die("usage: build-ci-matrix.js <base.json> [overlay.json] [--select-for-changes <changed.txt>]");
die("usage: build-ci-matrix.js <base.json> [overlay.json] [--select-for-changes <changed.txt> | --only-shards <a,b>]");
}
const base = readJson(basePath);
@@ -359,6 +370,15 @@ if (changedFilesPath) {
log(`smoke: falling back to full matrix (${runnable.length} shards)`);
}
}
if (onlyShards) {
// A typo must fail the run, never quietly run fewer shards than were asked for.
const unknown = onlyShards.filter((f) => !runnable.some((s) => s.testfolder === f));
if (unknown.length) {
die(`--only-shards names no runnable shard: ${unknown.join(", ")} (known: ${runnable.map((s) => s.testfolder).join(", ")})`);
}
emitted = runnable.filter((s) => onlyShards.includes(s.testfolder));
log(`only-shards: ${emitted.length}/${runnable.length} shards — ${emitted.map((s) => s.testfolder).join(", ")}`);
}
// Emit ONLY the fields the CI matrix consumes — disabled/_comment/notes are stripped,
// so turned-off specs never reach `npx playwright test`.
+17
View File
@@ -158,6 +158,23 @@ test("selection refuses to run for non-pull_request events", () => {
);
});
test("only-shards emits exactly the named shards and dies on an unknown name", () => {
const only = (list, extra = []) =>
JSON.parse(
execFileSync("node", [SCRIPT, BASE, "--only-shards", list, ...extra], {
encoding: "utf8",
env: { ...process.env, GITHUB_EVENT_NAME: "workflow_dispatch" },
stdio: ["ignore", "pipe", "pipe"],
})
).include.map((s) => s.testfolder);
assert.deepStrictEqual(only(" RUM-Token, Org ,RUM-Token"), ["Org", "RUM-Token"]);
assert.strictEqual(only("").length, FULL_COUNT);
assert.throws(() => only("RUM-Token,No-Such-Shard"), /names no runnable shard: No-Such-Shard/);
const changed = path.join(TMP, "only-changed.txt");
fs.writeFileSync(changed, "web/src/app.ts\n");
assert.throws(() => only("Org", ["--select-for-changes", changed]), /cannot be combined/);
});
test("config errors die eagerly, even when the changed file is global", () => {
const shard = { testfolder: "X", actual_folder: "X", run_files: ["a.spec.js"] };
const goodPaths = { folder_paths: { X: ["web/x/**"] }, global_paths: ["web/global/**"] };
+21 -3
View File
@@ -14,9 +14,18 @@ on:
merge_group:
schedule:
- cron: '30 20 * * *' # 2:00 AM IST
# Manual run on any branch (the "Use workflow from" ref); no PR, label or path filter applies.
workflow_dispatch:
inputs:
shards:
description: "Comma-separated testfolder names from tests/ui-testing/ci-matrix/ci_matrix.json (empty = all shards)"
required: false
default: ""
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }}
# A manual run gets its own group so it never cancels, or is cancelled by, a PR, queue or nightly run.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'workflow_dispatch' && format('dispatch-{0}', github.run_id)) || github.ref }}
# Only a new commit cancels: a cancelled same-SHA run leaves a failed required summary that a newer run cannot clear.
cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.action == 'synchronize' }}
@@ -118,6 +127,12 @@ jobs:
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
# Rejects an unknown shard name here, before build_binary spends a runner on it.
- name: Validate requested shards
if: github.event_name == 'workflow_dispatch'
env:
SHARDS: ${{ inputs.shards }}
run: node .github/scripts/build-ci-matrix.js tests/ui-testing/ci-matrix/ci_matrix.json --only-shards "$SHARDS" > /dev/null
- id: check
env:
EVENT_NAME: ${{ github.event_name }}
@@ -127,6 +142,8 @@ jobs:
run: |
if [ "$EVENT_NAME" = "schedule" ]; then
echo "has_changes=$([ "$MAIN_CHANGED" = "false" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
elif [ "$HAS_CHANGES" = "true" ] || echo " $CHANGED_WORKFLOWS " | grep -q ' playwright.yml '; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
@@ -315,6 +332,7 @@ jobs:
HAS_FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'e2e-full') || github.event.pull_request.auto_merge != null }}
SMOKE_DISABLED: ${{ vars.PW_SMOKE_DISABLED }}
BASE_REF: ${{ github.base_ref }}
SHARDS: ${{ inputs.shards }}
run: |
SMOKE_DISABLED=$(echo "$SMOKE_DISABLED" | tr '[:upper:]' '[:lower:]')
SELECT_ARGS=""
@@ -330,7 +348,7 @@ jobs:
fi
fi
# || true: under bash -e a die() would abort before the log below ever prints.
MATRIX=$(node .github/scripts/build-ci-matrix.js tests/ui-testing/ci-matrix/ci_matrix.json $SELECT_ARGS 2> /tmp/pw-select-log.txt) || true
MATRIX=$(node .github/scripts/build-ci-matrix.js tests/ui-testing/ci-matrix/ci_matrix.json $SELECT_ARGS --only-shards "$SHARDS" 2> /tmp/pw-select-log.txt) || true
cat /tmp/pw-select-log.txt >&2
if [ -z "$MATRIX" ]; then echo "::error::matrix generation failed (see build-ci-matrix log above)"; exit 1; fi
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
@@ -339,7 +357,7 @@ jobs:
# Durable record of what ran and why, visible from the PR checks page.
{
echo "### Playwright shard selection"
echo "Mode: $([ -n "$SELECT_ARGS" ] && echo "smoke (changed-path selection)" || echo "full matrix")"
echo "Mode: $([ -n "$SELECT_ARGS" ] && echo "smoke (changed-path selection)" || { [ -n "$SHARDS" ] && echo "chosen shards (manual run)"; } || echo "full matrix")"
echo '```'
echo "$COUNT"
cat /tmp/pw-select-log.txt
+41 -3
View File
@@ -252,6 +252,22 @@
"feature_traces": "998741a58ed2b411",
"feature_vrl_functions": "4484dd94e9355480",
"feature_workload_management": "8004f04c30d8bddc",
"features": "5697d03daef4de9c",
"features_all_unlocked": "0791a3840eb6061a",
"features_filter_all": "a52ace420f2175d0",
"features_filter_free": "f411a1fb62758b4c",
"features_filter_new": "18fdd549b2ed367a",
"features_free_heading": "5892004a2a606ee5",
"features_group_heading": "98bb11ae86a0c557",
"features_missing": "694943b3db420a2e",
"features_new": "d9fe8aa562864fe0",
"features_no_match": "c7655586749bb9b3",
"features_none": "6b474f2c0cba33e7",
"features_paid_ratio": "3257b4364ee32149",
"features_request_access": "22d6edf4b21d5e25",
"features_search": "55e327604647ce88",
"features_summary": "5d2957d8f8c9d8e7",
"features_unlocked": "531c7b28abf27600",
"foreverNoLimits": "c4e4f35814b1022b",
"fullyManaged": "49964f86a467ca7b",
"gbPerDay": "dd9d1b63afeb0945",
@@ -7768,6 +7784,7 @@
"dataStreamingInProgress": "033dae5dca78d269",
"dateRangeCopied": "f6572e6cc0308a61",
"dateRangePasteBlocked": "fd89888bc86ea6aa",
"dateRangePasteEmpty": "76b9d6f9884dc752",
"dateRangePasteError": "2c748bcb6075304e",
"dateRangePasted": "3e15b3f669c459e6",
"datetimeMessage": "dfe7d74839c7b8b0",
@@ -13059,6 +13076,19 @@
"tabLabel": "cb702378f31507ef",
"title": "2a932f90172e99ea"
},
"featureLock": {
"bannerMessage": "2ad54d7dcf939937",
"bannerMessageGeneric": "9e4736ff1b60331d",
"contactSales": "604abea32b499a4c",
"description": "052f7504abd2c39a",
"headerLocked": "ce0f1697c8a3a6d5",
"routeTitle": "694943b3db420a2e",
"thisFeature": "2598604c06f0d223",
"title": "84027eb92891f1fd",
"toastMessage": "b17241a153486b9c",
"toastMessageGeneric": "4cf1f345d3caeb96",
"toastTitle": "feff10da75dad3ed"
},
"fleet": {
"addCollector": "6d30a69cb01ed55a",
"audit": {
@@ -13199,6 +13229,7 @@
"restart": "6b983a81e5e8099e",
"restartManyTitle": "b48d3498ba6feded",
"restartMessage": "22ee1481ad969aff",
"restartOffline": "35842abc83dff75b",
"restartQueued": "43da4197b31c94b2",
"restartUnsupported": "322b4fd3d40b0ff7",
"search": "773656193742d0b1",
@@ -13534,7 +13565,7 @@
"confirmFixForward": "6e986df0401cda4a",
"confirmResume": "323c118fa4613787",
"confirmRollback": "ad34d76a7f53f6b5",
"confirmRollbackNoStable": "76cba5181ffc44a2",
"confirmRollbackNoStable": "5b00aa2620aa662d",
"confirmSkip": "fbf2627372d3e0cc",
"confirmed": "fe00b67b6dd1143f",
"emptyFleet": "40e87c8e8e200c76",
@@ -13595,6 +13626,7 @@
"phaseStopped": "1a4f630ac1b69fd0",
"phaseTimedOut": "9718cc761cfd5810",
"phaseTimeoutNote": "f11dc4d3138e81ac",
"phaseTimeoutNoteSeconds": "fafc2b0d6a049798",
"phaseVerifying": "a7d4a9f301edaa61",
"phases": "b4b0b75a774761b3",
"pickConfig": "90ea303d63b6e1b9",
@@ -13661,8 +13693,8 @@
"noAccess": "cb92165578c84ffb",
"offlineHeartbeats": "b82c0b4fc3ceead6",
"offlineHeartbeatsHelp": "91687a5a34666ebf",
"offlineRatio": "ab13785c48fe3a97",
"offlineRatioHelp": "8fa2bcd13b66f2f3",
"offlineRatio": "98affbf40cf01c0c",
"offlineRatioHelp": "fcf7765ecca25ef0",
"outOfRange": "f9a9c12c180ebfe6",
"phaseTimeout": "47d919e06d87345e",
"phaseTimeoutHelp": "019f8feb0a24eaa2",
@@ -20331,8 +20363,14 @@
"allClear": "22d352f617758292",
"allClearDesc": "bfe3044f55b21533",
"allClearDescScoped": "7c66a0cc083a98ba",
"allClearPartial": "f5a637211cc64316",
"allClearPartialDesc": "e5ed97f9af61f860",
"anomalyDetectedDescription": "67a554a9305c968d",
"anomalyDetectedTitle": "42b3e5f78e8b2967",
"areaAlerts": "f62e9267d196c761",
"areaAnomalies": "a0a660cc9daa76b9",
"areaIncidents": "bfe868931580fb3b",
"areaServices": "604dce445e326d95",
"colErrorRate": "33a224722fc7b1e4",
"colLatency": "e0e7d293043c8cf6",
"colReqs": "61985debc6417c53",
@@ -2398,6 +2398,11 @@ fn oncall_routes(router: Router) -> Router {
"/{org_id}/oncall/teams",
get(oncall::list_teams).post(oncall::create_team),
)
// A literal, so axum routes it here rather than to `/teams/{team_id}`.
.route(
"/{org_id}/oncall/teams/context",
get(oncall::list_team_context),
)
.route(
"/{org_id}/oncall/teams/{team_id}",
get(oncall::get_team)
@@ -2520,6 +2525,11 @@ fn oncall_routes(router: Router) -> Router {
"/{org_id}/oncall/responses/{response_id}/escalation",
get(oncall::get_escalation_progress),
)
// A literal, so axum routes it here rather than to `/responses/{response_id}`.
.route(
"/{org_id}/oncall/responses/escalation",
get(oncall::list_escalation_progress),
)
.route(
"/{org_id}/oncall/responses/{response_id}/prior-causes",
get(oncall::get_prior_causes),
@@ -5400,10 +5410,12 @@ mod tests {
"GET /{org_id}/mcp",
"GET /{org_id}/oncall/incidents/{incident_id}/responses",
"GET /{org_id}/oncall/responses",
"GET /{org_id}/oncall/responses/escalation",
"GET /{org_id}/oncall/responses/{response_id}",
"GET /{org_id}/oncall/responses/{response_id}/escalation",
"GET /{org_id}/oncall/responses/{response_id}/history",
"GET /{org_id}/oncall/teams",
"GET /{org_id}/oncall/teams/context",
"GET /{org_id}/oncall/teams/{team_id}",
"GET /{org_id}/oncall/teams/{team_id}/members",
"GET /{org_id}/oncall/teams/{team_id}/on-call",
@@ -1312,6 +1312,7 @@ pub struct ApiDoc;
openobserve_api_management::request::oncall::handoff_response,
openobserve_api_management::request::oncall::list_coverage_gaps,
openobserve_api_management::request::oncall::list_deliveries,
openobserve_api_management::request::oncall::list_escalation_progress,
openobserve_api_management::request::oncall::list_members,
openobserve_api_management::request::oncall::list_my_deliveries,
openobserve_api_management::request::oncall::list_my_teams,
@@ -1322,6 +1323,7 @@ pub struct ApiDoc;
openobserve_api_management::request::oncall::list_responses_for_incident,
openobserve_api_management::request::oncall::list_schedule_presets,
openobserve_api_management::request::oncall::list_team_config_risks,
openobserve_api_management::request::oncall::list_team_context,
openobserve_api_management::request::oncall::list_teams,
openobserve_api_management::request::oncall::list_unavailability,
openobserve_api_management::request::oncall::list_unrouted_signals,
+1 -1
View File
@@ -291,7 +291,7 @@ pub(crate) async fn act_on(
verb: Verb,
source: AckSource,
) -> Response {
if let Err(e) = ack::authorize_problem(problem, actor, Act::Ack).await {
if let Err(e) = ack::authorize_problem(problem, actor, Act::from(verb)).await {
return error_response(e);
}
match act(problem, actor, verb, source).await {
+3 -3
View File
@@ -264,7 +264,7 @@ pub async fn delete_collector(
(status = 202, description = "Queued", content_type = "application/json", body = config::meta::fleet::RestartQueued),
(status = 403, description = "Forbidden", content_type = "application/json", body = Object),
(status = 404, description = "Not found", content_type = "application/json", body = Object),
(status = 409, description = "The collector does not accept restart commands", content_type = "application/json", body = Object),
(status = 409, description = "The collector is offline or does not accept restart commands", content_type = "application/json", body = Object),
),
)]
pub async fn restart_collector(
@@ -1536,14 +1536,14 @@ pub async fn skip_rollout(
context_path = "/api",
tag = "Fleet",
operation_id = "RollbackFleetRollout",
summary = "Start an immediate rollout back to stable and mark this one rolled back",
summary = "Roll back to stable, or to the placeholder without one, and mark this rollout rolled back",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Rollout id"),
),
responses(
(status = 200, description = "The rollout to stable", content_type = "application/json", body = config::meta::fleet::Rollout),
(status = 200, description = "The rollout to stable, or this rollout when the fleet has no stable version", content_type = "application/json", body = config::meta::fleet::Rollout),
(status = 403, description = "Forbidden", content_type = "application/json", body = Object),
(status = 404, description = "Not found", content_type = "application/json", body = Object),
(status = 409, description = "Not halted, or the halt does not offer this exit", content_type = "application/json", body = Object),
+403 -39
View File
@@ -49,6 +49,14 @@ const RESPONSES: &str = "oncall_responses";
#[cfg(feature = "enterprise")]
const SETTINGS: &str = "settings";
/// Bounds one batch read, as `list_responses` bounds a page at 200 rows.
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
const MAX_BATCH_IDS: usize = 200;
/// Ids a batch route works on at once; a team-context id runs three reads, so 48 at most.
#[cfg(feature = "enterprise")]
const BATCH_READ_CONCURRENCY: usize = 16;
#[cfg(feature = "enterprise")]
const HANDOFF_NEEDS_A_TARGET: &str =
"a handoff needs either `to` (a person) or `to_team_id` (another team)";
@@ -283,6 +291,21 @@ pub struct OnCallQuery {
pub at: Option<i64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct TeamContextQuery {
/// Comma-separated team ids.
pub team_ids: Option<String>,
/// Also answer for the caller's own teams.
#[serde(default)]
pub include_mine: bool,
}
#[derive(Debug, Default, Deserialize)]
pub struct EscalationBatchQuery {
/// Comma-separated response record ids.
pub response_ids: Option<String>,
}
/// Points one identity-dimension path at the team that gets woken for it.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateOwnershipRuleRequest {
@@ -564,9 +587,9 @@ fn internal_error(context: &str, e: &impl std::fmt::Display) -> Response {
fn to_response(e: anyhow::Error) -> Response {
use o2_enterprise::enterprise::oncall::service::OncallError;
let status = match e.downcast_ref::<OncallError>() {
Some(OncallError::TeamNotFound(_)) | Some(OncallError::ResponseNotFound(_)) => {
StatusCode::NOT_FOUND
}
Some(OncallError::TeamNotFound(_))
| Some(OncallError::MemberNotFound { .. })
| Some(OncallError::ResponseNotFound(_)) => StatusCode::NOT_FOUND,
// Not 404: the record exists, and the honest answer is that working it is not theirs.
Some(OncallError::NotOnThisTeam { .. }) => StatusCode::FORBIDDEN,
// A conflict, not a 400: the caller fixes it by changing the org's state, not the request.
@@ -623,10 +646,27 @@ async fn page_problem(
org_id: &str,
response_id: &str,
actor: &str,
) -> Result<Option<openobserve_core::alerts::ack::Problem>, Response> {
page_problem_for(
org_id,
response_id,
actor,
openobserve_core::alerts::ack::Act::Ack,
)
.await
}
/// [`page_problem`] for an ack verb, so that a refusal names the verb refused.
#[cfg(feature = "enterprise")]
async fn page_problem_for(
org_id: &str,
response_id: &str,
actor: &str,
act: openobserve_core::alerts::ack::Act,
) -> Result<Option<openobserve_core::alerts::ack::Problem>, Response> {
use openobserve_core::alerts::ack::AckError;
page_problem_of(org_id, response_id, actor)
page_problem_of(org_id, response_id, actor, act)
.await
.map_err(|e| match e {
AckError::NotFound => MetaHttpResponse::not_found("Response not found"),
@@ -640,17 +680,18 @@ async fn page_problem_of(
org_id: &str,
response_id: &str,
actor: &str,
act: openobserve_core::alerts::ack::Act,
) -> Result<Option<openobserve_core::alerts::ack::Problem>, openobserve_core::alerts::ack::AckError>
{
use openobserve_core::alerts::ack::{self, AckError, Act};
use openobserve_core::alerts::ack::{self, AckError};
match ack::problem_of_page(org_id, response_id).await {
Ok(problem) => ack::authorize_problem(&problem, actor, Act::Ack)
Ok(problem) => ack::authorize_problem(&problem, actor, act)
.await
.map(|_| Some(problem)),
Err(AckError::NotFound) => {
match infra::table::oncall_responses::get(org_id, response_id).await? {
Some(page) => ack::authorize_orphan_page(&page, actor, Act::Ack)
Some(page) => ack::authorize_orphan_page(&page, actor, act)
.await
.map(|_| None)
.map_err(AckError::Refused),
@@ -1090,10 +1131,14 @@ pub async fn remove_member(
)
.await
{
Ok(true) => MetaHttpResponse::ok("Member removed"),
Ok(false) => {
MetaHttpResponse::error(StatusCode::NOT_FOUND.as_u16(), "Member not found")
.into_response()
Ok(released_pages) => {
openobserve_core::alerts::ack::release_leaver_pages(
&org_id,
&released_pages,
&q.user_email,
)
.await;
MetaHttpResponse::ok("Member removed")
}
Err(e) => to_response(e),
}
@@ -2265,7 +2310,7 @@ async fn act_by_phone(
use o2_enterprise::enterprise::oncall::{notify::PhoneAction, service};
use openobserve_core::alerts::ack::{self, AckError};
let problem = match page_problem_of(&org_id, &response_id, &actor).await {
let problem = match page_problem_of(&org_id, &response_id, &actor, ack::Act::Ack).await {
Ok(problem) => problem,
Err(e) => return phone_answer_to(e),
};
@@ -2375,6 +2420,28 @@ fn parse_subject_type(s: &str) -> Option<config::meta::oncall::SubjectType> {
SUBJECT_TYPES.into_iter().find(|t| t.as_str() == s)
}
/// Listed then `extra` ids, de-duplicated in order; only the listed count to the cap. [pure]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
fn batch_ids(listed: Option<&str>, extra: Vec<String>) -> Result<Vec<String>, String> {
let mut seen = std::collections::HashSet::new();
let mut ids: Vec<String> = listed
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
.filter(|id| seen.insert(id.clone()))
.collect();
if ids.len() > MAX_BATCH_IDS {
return Err(format!(
"at most {MAX_BATCH_IDS} ids per request, got {}",
ids.len()
));
}
ids.extend(extra.into_iter().filter(|id| seen.insert(id.clone())));
Ok(ids)
}
#[utoipa::path(
get,
path = "/{org_id}/oncall/responses",
@@ -2489,13 +2556,25 @@ pub async fn list_responses(
}
}
/// Two queries for the whole page, never one per row, which is the N+1 this exists to avoid.
/// Three queries for the whole page, never one per row, which is the N+1 this exists to avoid.
#[cfg(feature = "enterprise")]
async fn with_page_details(
org_id: &str,
rows: Vec<config::meta::oncall::Response>,
) -> Vec<serde_json::Value> {
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
let incident_ids: Vec<String> = rows
.iter()
.filter_map(|r| r.incident_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let titles = infra::table::alert_incidents::titles(org_id, &incident_ids)
.await
.unwrap_or_else(|e| {
tracing::error!("[oncall] incident title lookup: {e}");
Default::default()
});
let runbooks = infra::table::oncall_responses::runbook_urls(org_id, &ids)
.await
.unwrap_or_else(|e| {
@@ -2514,29 +2593,41 @@ async fn with_page_details(
Default::default()
});
rows.into_iter()
.map(|r| {
let mut value = serde_json::json!(r);
if let Some(obj) = value.as_object_mut() {
if let Some(url) = runbooks.get(&r.id) {
obj.insert("runbook_url".to_string(), url.clone().into());
}
// The rung's `after_micros`: an index would not survive a reordered ladder.
if let Some(rung) = rungs.get(&r.id) {
obj.insert("reached_rung_micros".to_string(), (*rung).into());
}
// Only when answered: a null is indistinguishable from "answered instantly".
if let Some(acked_at) = r.acked_at {
obj.insert(
"time_to_ack_micros".to_string(),
(acked_at - r.opened_at).max(0).into(),
);
}
}
value
})
.map(|r| page_row(r, &runbooks, &rungs, &titles))
.collect()
}
/// One list row with the page-wide lookups, keyed by record or incident id, folded in. [pure]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
fn page_row(
r: config::meta::oncall::Response,
runbooks: &std::collections::HashMap<String, String>,
rungs: &std::collections::HashMap<String, i64>,
titles: &std::collections::HashMap<String, String>,
) -> serde_json::Value {
let mut value = serde_json::json!(r);
if let Some(obj) = value.as_object_mut() {
if let Some(url) = runbooks.get(&r.id) {
obj.insert("runbook_url".to_string(), url.clone().into());
}
// The rung's `after_micros`: an index would not survive a reordered ladder.
if let Some(rung) = rungs.get(&r.id) {
obj.insert("reached_rung_micros".to_string(), (*rung).into());
}
// Only when answered: a null is indistinguishable from "answered instantly".
if let Some(acked_at) = r.acked_at {
obj.insert(
"time_to_ack_micros".to_string(),
(acked_at - r.opened_at).max(0).into(),
);
}
if let Some(title) = r.incident_id.as_ref().and_then(|id| titles.get(id)) {
obj.insert("incident_title".to_string(), title.clone().into());
}
}
value
}
#[utoipa::path(
get,
path = "/{org_id}/oncall/responses/{response_id}",
@@ -2628,7 +2719,14 @@ pub async fn resolve_response(
if !allowed(&org_id, &user_email.user_id, RESPONSES, "POST").await {
return MetaHttpResponse::forbidden("Forbidden");
}
let problem = match page_problem(&org_id, &response_id, &user_email.user_id).await {
let problem = match page_problem_for(
&org_id,
&response_id,
&user_email.user_id,
openobserve_core::alerts::ack::Act::Resolve,
)
.await
{
Ok(p) => p,
Err(resp) => return resp,
};
@@ -2796,6 +2894,85 @@ pub async fn get_escalation_progress(
}
}
/// One read for a page of open records; the per-id route stays for the screens that show one.
#[utoipa::path(
get,
path = "/{org_id}/oncall/responses/escalation",
context_path = "/api",
tag = "OnCall",
operation_id = "OnCallEscalationProgressBatch",
summary = "Where the escalation ladder has got to, for many records",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("response_ids" = Option<String>, Query, description = "Comma-separated response record IDs (at most 200)"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "More than 200 ids", content_type = "application/json", body = Object),
),
extensions(
("x-o2-ratelimit" = json!({"module": "OnCall", "operation": "list"})),
("x-o2-mcp" = json!({"enabled": false}))
)
)]
pub async fn list_escalation_progress(
Path(org_id): Path<String>,
Query(q): Query<EscalationBatchQuery>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
) -> Response {
#[cfg(feature = "enterprise")]
{
use futures::StreamExt;
if !allowed(&org_id, &user_email.user_id, RESPONSES, "GET").await {
return MetaHttpResponse::forbidden("Forbidden");
}
let ids = match batch_ids(q.response_ids.as_deref(), Vec::new()) {
Ok(ids) => ids,
Err(msg) => return MetaHttpResponse::bad_request(msg),
};
// Owned per read: a borrowing closure in the stream fails axum's `Handler` lifetime bound.
let reads: Vec<_> =
ids.iter()
.map(|id| {
let (org, id) = (org_id.clone(), id.clone());
async move {
o2_enterprise::enterprise::oncall::escalation::progress(&org, &id).await
}
})
.collect();
// `buffered` keeps the input order, so each answer stays beside its id.
let reads: Vec<_> = futures::stream::iter(reads)
.buffered(BATCH_READ_CONCURRENCY)
.collect()
.await;
let progress: Vec<serde_json::Value> = ids
.into_iter()
.zip(reads)
.filter_map(|(id, read)| match read {
Ok(p) => {
let mut value = serde_json::json!(p);
if let Some(obj) = value.as_object_mut() {
obj.insert("response_id".to_string(), id.into());
}
Some(value)
}
Err(e) => {
tracing::warn!("[oncall] escalation progress for {id}: {e}");
None
}
})
.collect();
MetaHttpResponse::json(serde_json::json!({ "progress": progress }))
}
#[cfg(not(feature = "enterprise"))]
{
let _ = (org_id, q);
MetaHttpResponse::forbidden("Not Supported")
}
}
#[utoipa::path(
get,
path = "/{org_id}/oncall/responses/{response_id}/prior-causes",
@@ -2930,10 +3107,11 @@ pub async fn acknowledge_response(
Ok(false) => Verb::Acknowledge,
Err(resp) => return resp,
};
let problem = match page_problem(&org_id, &response_id, &user_email.user_id).await {
Ok(p) => p,
Err(resp) => return resp,
};
let problem =
match page_problem_for(&org_id, &response_id, &user_email.user_id, verb.into()).await {
Ok(p) => p,
Err(resp) => return resp,
};
match page_is_closed(&org_id, &response_id).await {
Ok(true) => return page_with_ack(&org_id, &response_id, unchanged()).await,
Ok(false) => {}
@@ -3000,7 +3178,14 @@ pub async fn unacknowledge_response(
if !allowed(&org_id, &user_email.user_id, RESPONSES, "POST").await {
return MetaHttpResponse::forbidden("Forbidden");
}
let problem = match page_problem(&org_id, &response_id, &user_email.user_id).await {
let problem = match page_problem_for(
&org_id,
&response_id,
&user_email.user_id,
openobserve_core::alerts::ack::Act::Unack,
)
.await
{
Ok(p) => p,
Err(resp) => return resp,
};
@@ -5241,6 +5426,114 @@ pub async fn list_my_teams(
}
}
// ── Team context for the pages list ───────────────────────────────────────────
/// One read for the pages list, answering each team as its three per-team routes would.
#[utoipa::path(
get,
path = "/{org_id}/oncall/teams/context",
context_path = "/api",
tag = "OnCall",
operation_id = "ListOnCallTeamContext",
summary = "Policy, who is on call and schedule for many teams",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("team_ids" = Option<String>, Query, description = "Comma-separated team IDs (at most 200; `include_mine` teams are not counted)"),
("include_mine" = Option<bool>, Query, description = "Also answer for the caller's own teams (default false)"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "More than 200 listed teams", content_type = "application/json", body = Object),
),
extensions(
("x-o2-ratelimit" = json!({"module": "OnCall", "operation": "list"})),
("x-o2-mcp" = json!({"enabled": false}))
)
)]
pub async fn list_team_context(
Path(org_id): Path<String>,
Query(q): Query<TeamContextQuery>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
) -> Response {
#[cfg(feature = "enterprise")]
{
use futures::StreamExt;
if !allowed(&org_id, &user_email.user_id, CONFIG, "GET").await {
return MetaHttpResponse::forbidden("Forbidden");
}
let mine = if q.include_mine {
match infra::table::oncall_teams::list_for_user(&org_id, &user_email.user_id).await {
Ok(teams) => teams.into_iter().map(|t| t.id).collect(),
Err(e) => {
// The listed teams still answer; without the caller's, the page shows no shift.
tracing::warn!("[oncall] team context: the caller's teams: {e}");
Vec::new()
}
}
} else {
Vec::new()
};
let ids = match batch_ids(q.team_ids.as_deref(), mine) {
Ok(ids) => ids,
Err(msg) => return MetaHttpResponse::bad_request(msg),
};
let known: std::collections::HashSet<String> =
match infra::table::oncall_teams::list(&org_id).await {
Ok(teams) => teams.into_iter().map(|t| t.id).collect(),
Err(e) => return internal_error("list_team_context", &e),
};
// Owned per read: a borrowing closure in the stream fails axum's `Handler` lifetime bound.
let reads: Vec<_> = ids
.into_iter()
.filter(|id| known.contains(id))
.map(|id| {
let org = org_id.clone();
async move { team_context(&org, &id).await }
})
.collect();
let teams: Vec<serde_json::Value> = futures::stream::iter(reads)
.buffered(BATCH_READ_CONCURRENCY)
.collect()
.await;
MetaHttpResponse::json(serde_json::json!({ "teams": teams }))
}
#[cfg(not(feature = "enterprise"))]
{
let _ = (org_id, q);
MetaHttpResponse::forbidden("Not Supported")
}
}
/// One team's policy, rotation and schedule; a field whose read fails is left out and logged.
#[cfg(feature = "enterprise")]
async fn team_context(org_id: &str, team_id: &str) -> serde_json::Value {
use o2_enterprise::enterprise::oncall::service;
let (policy, on_call, schedule) = futures::join!(
service::get_policy_view(org_id, team_id),
service::who_is_on_call(org_id, team_id, None),
service::get_schedule(org_id, team_id),
);
let mut out = serde_json::Map::new();
out.insert("team_id".to_string(), team_id.into());
let fields = [
("policy", policy.map(|p| serde_json::json!(p))),
("on_call", on_call.map(|s| serde_json::json!(s))),
("schedule", schedule.map(|s| serde_json::json!(s))),
];
for (field, read) in fields {
match read {
Ok(value) => {
out.insert(field.to_string(), value);
}
Err(e) => tracing::warn!("[oncall] team context {field} for {team_id}: {e}"),
}
}
serde_json::Value::Object(out)
}
// ── Cause analytics (U26) ─────────────────────────────────────────────────────
/// Counted in the database: the org with most to learn from this has the most rows.
@@ -6867,6 +7160,77 @@ mod tests {
assert_not_supported(voice_ack(path, form).await).await;
}
#[test]
fn test_batch_ids_trim_dedupe_in_order_and_cap_only_the_listed() {
// `None` as the answer means refused.
type Case = (Option<String>, Vec<String>, Option<Vec<String>>);
let ids = |n: usize| (0..n).map(|i| format!("t{i}")).collect::<Vec<_>>();
let cases: Vec<Case> = vec![
(None, vec![], Some(vec![])),
(Some(" ,, ".into()), vec![], Some(vec![])),
(
Some(" a, b,,a ".into()),
vec![],
Some(vec!["a".into(), "b".into()]),
),
(
Some("a".into()),
vec!["b".into(), "a".into()],
Some(vec!["a".into(), "b".into()]),
),
(Some(ids(200).join(",")), ids(200), Some(ids(200))),
(Some(ids(201).join(",")), vec![], None),
(
Some(ids(150).join(",")),
ids(201)[150..].to_vec(),
Some(ids(201)),
),
(Some(ids(200).join(",")), ids(230), Some(ids(230))),
(
Some(format!("{},t0", ids(200).join(","))),
vec![],
Some(ids(200)),
),
];
for (listed, extra, want) in cases {
let got = batch_ids(listed.as_deref(), extra.clone()).ok();
assert_eq!(got, want, "{listed:?} + {extra:?}");
}
}
#[test]
fn test_page_row_titles_only_rows_whose_incident_is_known() {
let record = |id: &str, incident_id: Option<&str>| {
serde_json::from_value::<config::meta::oncall::Response>(serde_json::json!({
"id": id,
"org_id": "acme",
"subject": {"subject_type": "alert", "source_id": "a1", "firing": 1},
"priority": 2,
"state": "triggered",
"opened_at": 1,
"incident_id": incident_id,
}))
.unwrap()
};
let titles =
std::collections::HashMap::from([("inc-1".to_string(), "DB down".to_string())]);
let cases = [
(record("r1", Some("inc-1")), Some("DB down")),
(record("r2", Some("inc-gone")), None),
(record("r3", None), None),
];
for (r, want) in cases {
let id = r.id.clone();
let row = page_row(r, &Default::default(), &Default::default(), &titles);
assert_eq!(
row.get("incident_title").and_then(|t| t.as_str()),
want,
"{id}"
);
assert_eq!(row["id"], id.as_str());
}
}
/// Disconnecting stops the org's own phone pages, so it needs the same write as connecting.
#[test]
fn test_telephony_permission_is_get_put_put() {
+225 -5
View File
@@ -40,13 +40,27 @@ use crate::alerts::lifecycle;
const CAN_ACT_SHOWN: usize = 2;
const ON_CALL_SHOWN: usize = 3;
/// What the caller wants to do; Ack and Mute share one rule (Δ2).
/// What the caller wants to do; every act shares one rule (Δ2) and a refusal names it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Act {
Ack,
Unack,
TakeOver,
Resolve,
Mute,
}
impl From<Verb> for Act {
fn from(verb: Verb) -> Self {
match verb {
Verb::Acknowledge => Self::Ack,
Verb::TakeOver => Self::TakeOver,
Verb::Unack | Verb::HandOff => Self::Unack,
Verb::Resolve => Self::Resolve,
}
}
}
/// Which part of the rule let the actor in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Via {
@@ -197,8 +211,16 @@ pub struct PageCover {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RecordWrite {
Claim,
Move { from: String },
Clear { holder: String },
Move {
from: String,
},
Clear {
holder: String,
},
/// A clear that writes only while `holder` still holds the record.
Release {
holder: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -332,6 +354,9 @@ pub fn refusal(
.collect();
let (code, verb) = match act {
Act::Ack => (RefusalCode::AckRefused, "acknowledge"),
Act::Unack => (RefusalCode::AckRefused, "unacknowledge"),
Act::TakeOver => (RefusalCode::AckRefused, "take over"),
Act::Resolve => (RefusalCode::AckRefused, "resolve"),
Act::Mute => (RefusalCode::MuteRefused, "mute"),
};
let message = if facts.fga_enabled {
@@ -399,6 +424,34 @@ pub fn plan(f: &PlanFacts) -> AckPlan {
}
}
/// Clears each record `f.actor`, the leaver, holds and leaves every page to its release. [pure]
pub fn plan_leaver_release(f: &PlanFacts) -> AckPlan {
let writes: Vec<(AckRecord, RecordWrite)> = f
.records
.iter()
.filter_map(|r| {
r.holder
.as_ref()
.filter(|h| same(h, &f.actor))
.map(|h| (r.record.clone(), RecordWrite::Release { holder: h.clone() }))
})
.collect();
AckPlan {
answer: if writes.is_empty() {
Answer::NotHeld
} else {
Answer::Changed
},
writes,
pages: f
.pages
.iter()
.map(|p| (p.response_id.clone(), PageStep::Leave))
.collect(),
still_paging: vec![],
}
}
/// The alert or incident a page's subject names, without its impacted and group suffixes. [pure]
pub fn source_of_page(source_id: &str) -> &str {
let origin = source_id.split(":impacted:").next().unwrap_or(source_id);
@@ -581,6 +634,41 @@ pub fn page_cover(subject: &config::meta::oncall::SubjectRef, closed_at: i64) ->
}
}
/// Clears, as the engine, what the leaver holds. [weak: cleared stays cleared; a retry ends it]
#[cfg(feature = "enterprise")]
pub async fn release_leaver(p: &Problem, leaver: &str) -> Result<(), AckError> {
use o2_enterprise::enterprise::oncall::escalation::ENGINE_ACTOR;
// A release verb, so a named group is read even when it no longer fires.
let g = gather(p, Verb::HandOff, leaver).await?;
let plan = plan_leaver_release(&g.facts);
carry_out(p, &g, &plan, ENGINE_ACTOR, AckSource::Oncall)
.await
.map(|_| ())
}
/// [`release_leaver`] on each released page's problem; a failure is logged. [no-fail]
#[cfg(feature = "enterprise")]
pub async fn release_leaver_pages(org_id: &str, response_ids: &[String], leaver: &str) {
for response_id in response_ids {
let released = match problem_of_page(org_id, response_id).await {
Ok(p) => release_leaver(&p, leaver).await,
Err(e) => Err(e),
};
match released {
Ok(()) => {}
Err(AckError::NotFound) => {
log::info!(
"[ALERT_ACK] page {response_id} has no alert or incident left to release"
)
}
Err(e) => log::error!(
"[ALERT_ACK] page {response_id}: the acks {leaver} held could not be released: {e:?}"
),
}
}
}
/// A firing that joins an acknowledged incident takes the incident's ack, so nobody is re-paged.
/// [weak: a row that fails to write is logged and the firing reads unacknowledged; the next join
/// writes it]
@@ -1556,7 +1644,7 @@ fn ordered_views(
fn status_after(write: &RecordWrite) -> IncidentStatus {
match write {
RecordWrite::Claim | RecordWrite::Move { .. } => IncidentStatus::Acknowledged,
RecordWrite::Clear { .. } => IncidentStatus::Open,
RecordWrite::Clear { .. } | RecordWrite::Release { .. } => IncidentStatus::Open,
}
}
@@ -1632,6 +1720,18 @@ async fn write_firing(
reopen_silence(target, group_key, started, now).await;
(row, AckAction::Unack, None)
}
RecordWrite::Release { holder } => {
let held = AlertAck {
acked_by: holder.clone(),
..ack(None)
};
// Someone else's ack landed since the read: theirs stands.
let Some(row) = db::alerts::alert_acks::unack_held(&held, actor, now).await? else {
return Ok((None, false));
};
reopen_silence(target, group_key, started, now).await;
(row, AckAction::Unack, None)
}
};
record_event(target, group_key, actor, action, source, from);
lifecycle::on_view_changed(&target.org_id, &target.alert_id, Some(group_key)).await;
@@ -1663,7 +1763,7 @@ async fn write_incident(
alert_incidents::take_over_acknowledgement(org_id, incident_id, from, actor).await?,
IncidentEvent::acknowledged(actor),
),
RecordWrite::Clear { holder } => (
RecordWrite::Clear { holder } | RecordWrite::Release { holder } => (
alert_incidents::unacknowledge(org_id, incident_id, holder).await?,
match verb {
Verb::Resolve => IncidentEvent::ack_ended_by_page_resolve(actor),
@@ -2469,6 +2569,126 @@ mod tests {
}
}
fn released(r: &HeldRecord) -> (AckRecord, RecordWrite) {
let holder = r.holder.clone().unwrap_or_default();
(r.record.clone(), RecordWrite::Release { holder })
}
#[test]
fn a_leaver_release_clears_only_the_leavers_records_and_leaves_every_page() {
let (bo, ana) = (Some("Bo@acme.io"), Some("ana@acme.io"));
let pages = || vec![page("p1", bo), page("p2", None), leftover("old", ana)];
let cases: Vec<(&str, Vec<HeldRecord>, Vec<HeldRecord>)> = vec![
(
"the incident only",
vec![incident(bo), firing("", None, false)],
vec![incident(bo)],
),
(
"the alerts only",
vec![
incident(None),
firing("g1", bo, false),
firing("g2", bo, false),
],
vec![firing("g1", bo, false), firing("g2", bo, false)],
),
(
"both",
vec![incident(bo), firing("", bo, false)],
vec![incident(bo), firing("", bo, false)],
),
(
"nothing",
vec![incident(None), firing("", None, false)],
vec![],
),
(
"a mix with another holder",
vec![
incident(ana),
firing("g1", bo, false),
firing("g2", ana, true),
],
vec![firing("g1", bo, false)],
),
];
for (name, records, cleared) in cases {
let got = plan_leaver_release(&facts_for(Verb::HandOff, records, pages()));
assert_eq!(
got.writes,
cleared.iter().map(released).collect::<Vec<_>>(),
"{name}"
);
assert!(
got.pages.iter().all(|(_, step)| *step == PageStep::Leave),
"{name}: {:?}",
got.pages
);
assert_eq!(got.pages.len(), 3, "{name}");
let answer = if cleared.is_empty() {
Answer::NotHeld
} else {
Answer::Changed
};
assert_eq!(got.answer, answer, "{name}");
}
}
#[test]
fn after_a_leaver_release_a_plain_ack_holds_the_page_the_incident_and_the_alert() {
let bo = Some("bo@acme.io");
let records = vec![incident(bo), firing("", bo, false)];
let release = plan_leaver_release(&facts_for(Verb::HandOff, records.clone(), vec![]));
let after: Vec<HeldRecord> = records
.into_iter()
.map(|r| HeldRecord {
holder: (!release.writes.iter().any(|(rec, _)| *rec == r.record))
.then(|| r.holder.clone())
.flatten(),
..r
})
.collect();
let ack = plan(&PlanFacts {
actor: "cy@acme.io".into(),
..facts_for(Verb::Acknowledge, after, vec![page("p1", None)])
});
assert_eq!(
ack.writes,
vec![claim(&incident(None)), claim(&firing("", None, false))]
);
assert_eq!(
ack.pages,
vec![(
"p1".to_string(),
PageStep::Stop {
by: "cy@acme.io".into()
}
)]
);
assert_eq!(ack.answer, Answer::Changed);
}
#[test]
fn a_refusal_names_the_verb_it_refused() {
for (act, verb) in [
(Act::Ack, "acknowledge"),
(Act::Unack, "unacknowledge"),
(Act::TakeOver, "take over"),
(Act::from(Verb::TakeOver), "take over"),
(Act::from(Verb::Unack), "unacknowledge"),
(Act::from(Verb::Resolve), "resolve"),
] {
let r = refusal(act, &facts(Some(UserRole::Viewer)), &[]);
assert_eq!(r.code, RefusalCode::AckRefused, "{verb}");
assert!(
r.message.ends_with(&format!("can {verb} it")),
"{verb}: {}",
r.message
);
}
}
#[test]
fn a_mute_snoozes_its_pages_for_its_whole_minutes_up_to_a_day() {
const MINUTE: i64 = 60 * 1_000_000;
-12
View File
@@ -27,15 +27,3 @@ pub use sample::{dump_memory_flamegraph, dump_memory_pprof, jemalloc_stats};
pub(crate) const SERVICE_NAME: &str = "openobserve";
pub(crate) const CPU_FREQUENCY_HZ: i32 = 100;
#[cfg(test)]
mod tests {
use config::{META_ORG_ID, meta::self_reporting::usage::is_system_stream};
use super::*;
#[test]
fn test_stream_name_is_a_meta_system_stream() {
assert!(is_system_stream(META_ORG_ID, STREAM_NAME));
}
}
+3 -2
View File
@@ -1183,12 +1183,13 @@ pub async fn remove_user_from_org(
match oncall_service::offboard_member(org_id, email_id, config::utils::time::now_micros())
.await
{
Ok(report) if report.needs_attention() => {
Ok(report) => {
for warning in report.warnings() {
log::warn!("[ONCALL] {email_id} left {org_id}: {warning}");
}
crate::alerts::ack::release_leaver_pages(org_id, &report.released_pages, email_id)
.await;
}
Ok(_) => {}
Err(e) => log::error!(
"[ONCALL] {email_id} was removed from {org_id} but their on-call membership could not be cleaned up, so they may still be paged: {e}"
),
+21
View File
@@ -68,6 +68,27 @@ pub async fn unack(
{
return Ok(None);
}
publish_current(org_id, alert_id, group_key).await
}
/// [`unack`] only while `held.acked_by` still holds that firing's ack.
pub async fn unack_held(
held: &AlertAck,
unacked_by: &str,
unacked_at: i64,
) -> Result<Option<AlertAck>> {
if !infra::table::alert_acks::unack_held_with_outbox(held, unacked_by, unacked_at).await? {
return Ok(None);
}
publish_current(&held.org_id, &held.alert_id, &held.group_key).await
}
/// Replicates the row as it now stands, and returns it.
async fn publish_current(
org_id: &str,
alert_id: &str,
group_key: &str,
) -> Result<Option<AlertAck>> {
let row = infra::table::alert_acks::get(org_id, alert_id, group_key).await?;
if let Some(row) = row.as_ref() {
publish(row).await;
+104 -19
View File
@@ -84,6 +84,15 @@ pub async fn unack_with_outbox(
.await
}
/// [`unack_with_outbox`] only while `held.acked_by` still holds that firing's ack.
pub async fn unack_held_with_outbox(
held: &AlertAck,
unacked_by: &str,
unacked_at: i64,
) -> Result<bool, Error> {
unack_held_with_outbox_with(get_orm_client_rw().await, held, unacked_by, unacked_at).await
}
/// Applies a row from another region; false when the stored row is as new or newer.
pub async fn apply_replicated(ack: &AlertAck) -> Result<bool, Error> {
apply_replicated_with(get_orm_client_rw().await, ack).await
@@ -259,25 +268,45 @@ pub async fn unack_with_outbox_with<C: ConnectionTrait + TransactionTrait>(
unacked_by: &str,
unacked_at: i64,
) -> Result<bool, Error> {
let txn = conn.begin().await?;
let result = Entity::update_many()
.col_expr(Column::UnackedAt, Expr::value(unacked_at))
.col_expr(Column::UnackedBy, Expr::value(unacked_by))
.col_expr(Column::UpdatedAt, Expr::value(unacked_at))
.filter(Column::OrgId.eq(org_id))
.filter(Column::AlertId.eq(alert_id))
.filter(Column::GroupKey.eq(group_key))
.filter(Column::FiringStartedAt.eq(firing_started_at))
.filter(Column::UnackedAt.is_null())
.exec(&txn)
.await?;
let changed = result.rows_affected > 0;
if changed {
slack_alert_messages::bump_wanted_with(&txn, alert_id, group_key, firing_started_at)
.await?;
}
txn.commit().await?;
Ok(changed)
let row = Condition::all()
.add(Column::OrgId.eq(org_id))
.add(Column::AlertId.eq(alert_id))
.add(Column::GroupKey.eq(group_key))
.add(Column::FiringStartedAt.eq(firing_started_at));
unack_matching(
conn,
row,
alert_id,
group_key,
firing_started_at,
unacked_by,
unacked_at,
)
.await
}
pub async fn unack_held_with_outbox_with<C: ConnectionTrait + TransactionTrait>(
conn: &C,
held: &AlertAck,
unacked_by: &str,
unacked_at: i64,
) -> Result<bool, Error> {
let row = Condition::all()
.add(Column::OrgId.eq(&held.org_id))
.add(Column::AlertId.eq(&held.alert_id))
.add(Column::GroupKey.eq(&held.group_key))
.add(Column::FiringStartedAt.eq(held.firing_started_at))
.add(Column::AckedBy.eq(&held.acked_by));
unack_matching(
conn,
row,
&held.alert_id,
&held.group_key,
held.firing_started_at,
unacked_by,
unacked_at,
)
.await
}
pub async fn apply_replicated_with<C: ConnectionTrait>(
@@ -348,6 +377,34 @@ pub async fn create_table_for_test<C: ConnectionTrait>(conn: &C) -> Result<(), s
super::migration::create_alert_acks_table(conn).await
}
/// Unacks the acked rows `row` matches and bumps the firing's Slack messages, in one transaction.
async fn unack_matching<C: ConnectionTrait + TransactionTrait>(
conn: &C,
row: Condition,
alert_id: &str,
group_key: &str,
firing_started_at: i64,
unacked_by: &str,
unacked_at: i64,
) -> Result<bool, Error> {
let txn = conn.begin().await?;
let result = Entity::update_many()
.col_expr(Column::UnackedAt, Expr::value(unacked_at))
.col_expr(Column::UnackedBy, Expr::value(unacked_by))
.col_expr(Column::UpdatedAt, Expr::value(unacked_at))
.filter(row)
.filter(Column::UnackedAt.is_null())
.exec(&txn)
.await?;
let changed = result.rows_affected > 0;
if changed {
slack_alert_messages::bump_wanted_with(&txn, alert_id, group_key, firing_started_at)
.await?;
}
txn.commit().await?;
Ok(changed)
}
fn upsert() -> OnConflict {
OnConflict::columns([Column::OrgId, Column::AlertId, Column::GroupKey])
.update_columns(
@@ -606,6 +663,34 @@ mod tests {
assert_eq!(wanted(&db, "d1", "", 10).await, 2);
}
#[tokio::test]
async fn a_held_unack_clears_only_while_the_holder_it_names_still_holds_it() {
let db = db().await;
put_with_outbox_with(&db, &ack("g", 10, 20)).await.unwrap();
let leaver = AlertAck {
acked_by: "lee@acme.com".into(),
..ack("g", 10, 20)
};
assert!(
!unack_held_with_outbox_with(&db, &leaver, "o2-engine", 30)
.await
.unwrap(),
"someone else holds it"
);
let stored = get_with(&db, "acme", "a1", "g").await.unwrap().unwrap();
assert_eq!((stored.unacked_at, stored.updated_at), (None, 20));
assert!(
unack_held_with_outbox_with(&db, &ack("g", 10, 20), "o2-engine", 31)
.await
.unwrap()
);
let stored = get_with(&db, "acme", "a1", "g").await.unwrap().unwrap();
assert_eq!(
(stored.unacked_at, stored.unacked_by.as_deref()),
(Some(31), Some("o2-engine"))
);
}
#[tokio::test]
async fn apply_replicated_ignores_an_older_or_equal_updated_at() {
let db = db().await;
+71
View File
@@ -601,6 +601,39 @@ pub async fn update_severity(
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))
}
/// The titles of the incidents among `ids` in this org; untitled and unknown ids are absent.
pub async fn titles(
org_id: &str,
ids: &[String],
) -> Result<HashMap<String, String>, errors::Error> {
titles_with(get_orm_client_ro().await, org_id, ids).await
}
/// [`titles`] on a given connection.
pub async fn titles_with<C: ConnectionTrait>(
client: &C,
org_id: &str,
ids: &[String],
) -> Result<HashMap<String, String>, errors::Error> {
if ids.is_empty() {
return Ok(HashMap::new());
}
Ok(alert_incidents::Entity::find()
.select_only()
.column(alert_incidents::Column::Id)
.column(alert_incidents::Column::Title)
.filter(alert_incidents::Column::OrgId.eq(org_id))
.filter(alert_incidents::Column::Id.is_in(ids.to_vec()))
.filter(alert_incidents::Column::Title.is_not_null())
.into_tuple::<(String, Option<String>)>()
.all(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?
.into_iter()
.filter_map(|(id, title)| title.map(|t| (id, t)))
.collect())
}
/// List incidents with optional filters
pub async fn list(
org_id: &str,
@@ -1818,6 +1851,44 @@ mod tests {
assert!(result.is_err(), "Should reject malformed JSON");
}
#[tokio::test]
async fn titles_answer_only_titled_incidents_of_the_org_in_one_read() {
let db = incidents_db().await;
let titled = |org: &str, title: Option<&str>| {
new_incident(
org,
"P2",
serde_json::json!({}),
"service",
1,
title.map(str::to_string),
None,
)
};
let paid = titled("acme", Some("Payments down"))
.insert(&db)
.await
.unwrap();
let untitled = titled("acme", None).insert(&db).await.unwrap();
let foreign = titled("other", Some("Not yours"))
.insert(&db)
.await
.unwrap();
let asked = [
paid.id.clone(),
untitled.id,
foreign.id,
"missing".to_string(),
];
let found = titles_with(&db, "acme", &asked).await.unwrap();
assert_eq!(
found,
HashMap::from([(paid.id, "Payments down".to_string())])
);
assert!(titles_with(&db, "acme", &[]).await.unwrap().is_empty());
}
#[tokio::test]
async fn the_bulk_auto_resolve_clears_the_mute_and_reports_it() {
let db = incidents_db().await;
+51
View File
@@ -142,6 +142,26 @@ pub async fn set_desired_with<C: ConnectionTrait>(
Ok(())
}
/// Leaves the fleet with no desired version, keeping stable and its settings.
pub async fn clear_desired_with<C: ConnectionTrait>(
conn: &C,
org_id: &str,
fleet_id: &str,
actor: &str,
now_us: i64,
) -> Result<(), Error> {
Entity::update_many()
.col_expr(Column::ConfigId, Expr::value(Option::<String>::None))
.col_expr(Column::ConfigVersion, Expr::value(Option::<i64>::None))
.col_expr(Column::UpdatedAt, Expr::value(now_us))
.col_expr(Column::UpdatedBy, Expr::value(actor))
.filter(Column::OrgId.eq(org_id))
.filter(Column::FleetId.eq(fleet_id))
.exec(conn)
.await?;
Ok(())
}
/// Replaces the fleet's setting overrides, keeping its versions; creates the row when absent.
pub async fn set_settings_with<C: ConnectionTrait>(
conn: &C,
@@ -221,6 +241,37 @@ mod tests {
use super::*;
use crate::table::migration::create_fleet_collectors_for_test;
#[tokio::test]
async fn clearing_desired_keeps_stable_and_settings() {
let db = Database::connect("sqlite::memory:").await.unwrap();
create_fleet_collectors_for_test(&db).await.unwrap();
set_settings_with(&db, "o1", "f1", Some("{}".to_string()), "u", 1)
.await
.unwrap();
set_desired_with(&db, "o1", "f1", ("edge", 2), "u", 2)
.await
.unwrap();
set_stable_with(&db, "o1", "f1", ("edge", 1)).await.unwrap();
set_desired_with(&db, "o1", "f2", ("edge", 2), "u", 2)
.await
.unwrap();
clear_desired_with(&db, "o1", "f1", "system", 3)
.await
.unwrap();
let row = get_with(&db, "o1", "f1").await.unwrap().unwrap();
assert_eq!((row.config_id, row.config_version), (None, None));
assert_eq!(
(row.stable_config_id.as_deref(), row.stable_config_version),
(Some("edge"), Some(1))
);
assert_eq!(row.settings.as_deref(), Some("{}"));
assert_eq!((row.updated_at, row.updated_by.as_str()), (3, "system"));
let other = get_with(&db, "o1", "f2").await.unwrap().unwrap();
assert_eq!(other.config_version, Some(2), "only this fleet");
}
#[tokio::test]
async fn settings_and_desired_never_overwrite_each_other() {
let db = Database::connect("sqlite::memory:").await.unwrap();
+24 -3
View File
@@ -695,14 +695,15 @@ pub async fn init() -> Result<(), anyhow::Error> {
#[cfg(feature = "enterprise")]
openobserve_core::workflows::http_trigger::spawn_node_tasks();
// Routers profile themselves too; without an ingester role they need ZO_SELF_PROFILES_URL.
#[cfg(feature = "cpu-profiling")]
tokio::task::spawn(openobserve_core::self_profiles::run());
// Router doesn't need to initialize job
if LOCAL_NODE.is_router() && LOCAL_NODE.is_single_role() {
return Ok(());
}
#[cfg(feature = "cpu-profiling")]
tokio::task::spawn(openobserve_core::self_profiles::run());
// telemetry run
if cfg.common.telemetry_enabled && LOCAL_NODE.is_querier() {
spawn_pausable_job!(
@@ -1865,6 +1866,26 @@ mod tests {
);
}
#[test]
fn self_profiles_are_spawned_before_the_router_return() {
let source = include_str!("mod.rs");
let init = source
.split("pub async fn init()")
.nth(1)
.and_then(|rest| rest.split("mod tests {").next())
.expect("init() body");
let self_profiles = init
.find("tokio::task::spawn(openobserve_core::self_profiles::run());")
.expect("init() must spawn self_profiles::run()");
let router_return = init
.find("if LOCAL_NODE.is_router() && LOCAL_NODE.is_single_role()")
.expect("router return in init()");
assert!(
self_profiles < router_return,
"single-role routers must run the self-profiles loop"
);
}
#[test]
fn steady_states_hold() {
assert_eq!(
@@ -69,11 +69,12 @@ export class DateTimePickerPage {
this.customPeriodTrigger = this.customRow.locator('[role="combobox"]');
// ==================== Expected toast messages ====================
// Values of common.dateRangeCopied / dateRangePasted / dateRangePasteError
// Values of common.dateRangeCopied / dateRangePasted / dateRangePasteError / dateRangePasteEmpty
// in web/src/locales/languages/en-US.json.
this.copiedToastMessage = 'Date range copied';
this.pastedToastMessage = 'Date range pasted';
this.pasteErrorToastMessage = 'Could not parse date range';
this.pasteEmptyToastMessage = 'Clipboard is empty. Copy a date range first.';
}
// ==================== Locator factories ====================
@@ -250,6 +251,11 @@ export class DateTimePickerPage {
await expect(this.toastByMessage(this.pasteErrorToastMessage)).toBeVisible();
}
async expectPasteEmptyToast() {
await expect(this.toastByMessage(this.pasteEmptyToastMessage)).toBeVisible();
await expect(this.toastByMessage(this.pasteErrorToastMessage)).toHaveCount(0);
}
// ==================== Value readers ====================
async getStartTimeValue() {
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
export class IngestionConfigPage {
constructor(page) {
@@ -77,6 +78,7 @@ export class IngestionConfigPage {
this.rumContentText = this.legacyCodeBlockPre.first();
// Shared by every link in the test so 429 waits stay well under the test timeout
this.rateLimitRetryBudgetMs = 60000;
this.networkErrorRetryBudgetMs = 60000;
}
// ==================== Navigation ====================
@@ -460,8 +462,7 @@ export class IngestionConfigPage {
}
async headWithRateLimitRetry(href, maxRetries = 2) {
// 5 second timeout to prevent hanging
let response = await this.page.request.head(href, { timeout: 5000 });
let response = await this.headWithNetworkErrorRetry(href);
for (let attempt = 0; attempt < maxRetries && response.status() === 429; attempt++) {
const retryAfter = Number(response.headers()['retry-after']);
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2000;
@@ -478,4 +479,20 @@ export class IngestionConfigPage {
}
return response;
}
async headWithNetworkErrorRetry(href, maxRetries = 2) {
for (let attempt = 0; ; attempt++) {
try {
// 5 second timeout to prevent hanging
return await this.page.request.head(href, { timeout: 5000 });
} catch (error) {
const retryCostMs = 1000 * (attempt + 1) + 5000;
// A spent budget would push the whole test past its timeout, so the real error is reported instead
if (attempt >= maxRetries || retryCostMs > this.networkErrorRetryBudgetMs) throw error;
testLogger.warn(`[doc-links] HEAD failed (${error.message}), retry ${attempt + 1}/${maxRetries}: ${href}`);
this.networkErrorRetryBudgetMs -= retryCostMs;
await this.page.waitForTimeout(1000 * (attempt + 1));
}
}
}
}
@@ -2,6 +2,10 @@ import { expect } from '@playwright/test';
import testLogger from '../../playwright-tests/utils/test-logger.js';
const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
// The search-job worker starts one queued job per 10 s tick, and parallel tests queue several jobs.
const SEARCH_JOB_START_TIMEOUT_MS = 120000;
const JOB_STATUS = { PENDING: 0, RUNNING: 1, FINISHED: 2 };
export class JobSchedulerPage {
constructor(page) {
this.page = page;
@@ -96,6 +100,16 @@ export class JobSchedulerPage {
return jobs.findIndex(job => job.trace_id === trace_id);
}
async _getJobStatus(trace_id) {
const orgId = getOrgIdentifier();
const url = `${process.env["ZO_BASE_URL"]}/api/${orgId}/search_jobs?type=logs&search_type=UI&use_cache=true`;
const response = await this.page.request.get(url, { headers: getAuthHeaders() });
expect(response.status(), `GET search_jobs failed: ${await response.text()}`).toBe(200);
const job = (await response.json()).find(j => j.trace_id === trace_id);
expect(job, `Job with trace ID ${trace_id} not found in search_jobs`).toBeTruthy();
return job.status;
}
async deleteJobSearch(trace_id) {
const rowIndex = await this._getJobRowIndex(trace_id);
@@ -165,33 +179,27 @@ async cancelJobSearch(trace_id) {
}
async exploreJob(trace_id) {
// The explore button is disabled while the job is pending (status=0) or failed (status=3).
// Retry: each attempt re-fetches the list and checks whether the button is enabled.
const maxRetries = 5;
let clicked = false;
// Explore is enabled only once the job has left pending (0) and was not cancelled (3).
let status;
await expect.poll(async () => {
status = await this._getJobStatus(trace_id);
return status;
}, {
message: `Search job ${trace_id} never left the pending queue`,
timeout: SEARCH_JOB_START_TIMEOUT_MS,
intervals: [2000, 5000],
}).not.toBe(JOB_STATUS.PENDING);
expect([JOB_STATUS.RUNNING, JOB_STATUS.FINISHED], `Search job ${trace_id} ended in status ${status}`).toContain(status);
for (let attempt = 0; attempt < maxRetries; attempt++) {
const rowIndex = await this._getJobRowIndex(trace_id, 15000);
if (rowIndex === -1) throw new Error(`Job with trace ID ${trace_id} not found in scheduler list`);
const rowIndex = await this._getJobRowIndex(trace_id, 15000);
if (rowIndex === -1) throw new Error(`Job with trace ID ${trace_id} not found in scheduler list`);
const row = this.page.locator(`[data-test="search-scheduler-table"] [data-test="o2-table-row-${rowIndex}"]`);
await row.waitFor({ state: 'visible', timeout: 10000 });
const row = this.page.locator(`[data-test="search-scheduler-table"] [data-test="o2-table-row-${rowIndex}"]`);
await row.waitFor({ state: 'visible', timeout: 10000 });
const exploreButton = row.locator('[data-test="search-scheduler-explore-btn"]');
const isEnabled = await exploreButton.isEnabled();
if (isEnabled) {
await exploreButton.click();
clicked = true;
break;
}
testLogger.warn(`Explore button for trace ID ${trace_id} is not enabled (attempt ${attempt + 1}/${maxRetries}).`);
if (attempt < maxRetries - 1) await this.page.waitForTimeout(5000);
}
if (!clicked) {
throw new Error(`Explore button for trace ID ${trace_id} remained disabled after ${maxRetries} attempts.`);
}
const exploreButton = row.locator('[data-test="search-scheduler-explore-btn"]');
await expect(exploreButton, `Explore button for trace ID ${trace_id} is disabled`).toBeEnabled({ timeout: 10000 });
await exploreButton.click();
await expect(
this.page.locator('[data-test="o-toast-message"]').filter({ hasText: 'Search Job have been applied successfully' }).first()
+1 -5
View File
@@ -4819,11 +4819,7 @@ export class LogsPage {
// When the show-query toggle is clicked, it switches to results view
// Check if we're in results view by looking for the logs table
const logsTable = this.page.locator('[data-test="logs-search-result-logs-table"]');
const isResultsVisible = await logsTable.isVisible();
if (!isResultsVisible) {
throw new Error('Expected results view to be visible after toggling query editor off');
}
await expect(logsTable, 'Expected results view to be visible after toggling query editor off').toBeVisible({ timeout: 15000 });
testLogger.info('Results view is visible - query editor toggled off successfully');
return true;
@@ -118,7 +118,8 @@ test.describe("Create Alert from Dashboard Panel testcases", () => {
// is a span, not an <input>, so read the committed value via the helper.
const alertNameValue = pm.alertsPage.getAlertNameValueLocator();
await expect(alertNameValue).toBeVisible({ timeout: 30000 });
expect((await alertNameValue.textContent()) ?? "").toContain("Alert_from_");
// The temporary auto-name shows first until the prefill resetForm applies the panel name
await expect(alertNameValue).toContainText("Alert_from_", { timeout: 30000 });
testLogger.info("Landed on the alert form pre-filled from the panel");
// Navigate back to the dashboard folder and clean up.
@@ -276,19 +276,21 @@ test.describe("DateTime Picker Copy Paste testcases", () => {
});
// TC-12
test("should show the parse error toast when the clipboard is empty", {
test("should say the clipboard is empty when pasting an empty clipboard", {
tag: ['@datetime-picker', '@all', '@P2']
}, async () => {
testLogger.info('Pasting with an empty clipboard');
await pm.dateTimePickerPage.selectRelativePeriod('15-m');
const labelBeforePaste = await pm.dateTimePickerPage.getTriggerLabel();
await pm.dateTimePickerPage.seedClipboard('');
await pm.dateTimePickerPage.clickPaste();
await pm.dateTimePickerPage.expectPasteErrorToast();
await pm.dateTimePickerPage.expectPasteEmptyToast();
await pm.dateTimePickerPage.expectRelativePanelActive();
expect(await pm.dateTimePickerPage.getTriggerLabel()).toBe(labelBeforePaste);
testLogger.info('Empty clipboard rejected with the parse error toast');
testLogger.info('Empty clipboard rejected with the empty-clipboard toast');
});
});
@@ -107,6 +107,8 @@ async function openResults(page) {
).toHaveCount(0, {
timeout: 60000,
});
// Stale rows stay on screen during the re-run and the app ignores J/K until the hits settle.
expect(await logs.getResultHitsCount()).toBe(50);
await page.evaluate(
() =>
document.activeElement instanceof HTMLElement &&
@@ -2,11 +2,16 @@ const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const { ensureMetricsIngested } = require('../utils/shared-metrics-setup.js');
const metricsIngestion = require('../utils/metrics-ingestion.js');
const { verifyDataOnUIWithValues: verifyDataOnUI } = require('../utils/metrics-assertions.js');
test.describe("Metrics Aggregation and Grouping Tests", () => {
test.beforeAll(async () => {
await ensureMetricsIngested();
// The random seed puts ~1 point per series, and rate() needs 2+ points in one series.
const seeded = await metricsIngestion.ingestFixedRequestCountSeries();
expect(seeded.success, `fixed request_count seed failed: ${seeded.error}`).toBe(true);
expect(await metricsIngestion.waitForMetricQueryable('sum by (service) (rate(request_count[5m]))'), 'rate(request_count[5m]) never became queryable').toBe(true);
});
/** Create fresh PageManager per test — avoids data races in parallel workers. */
@@ -328,7 +328,7 @@ test.describe("Ingestion Configuration Tests", () => {
testLogger.info(` Path: ${item.path}`);
for (const link of item.brokenLinks) {
testLogger.info(` ✗ ${link.url}`);
testLogger.info(` Status: ${link.status || link.error}`);
testLogger.info(` Status: ${link.error ? `${link.status} (${link.error})` : link.status}`);
}
}
testLogger.info('\n' + '='.repeat(80));
@@ -585,6 +585,49 @@ class MetricsIngestion {
};
}
/**
* Send several request_count points into one fixed series so rate() over it is never empty.
*/
async ingestFixedRequestCountSeries(points = 5, spacingMs = 15000) {
const now = Date.now();
const dataPoints = [];
for (let i = 0; i < points; i++) {
dataPoints.push({
timeUnixNano: ((now - (points - 1 - i) * spacingMs) * 1000000).toString(),
asDouble: 100 + i * 10,
attributes: [
{ key: "node", value: { stringValue: "server-01" } },
{ key: "service", value: { stringValue: "api-gateway" } },
{ key: "region", value: { stringValue: "us-east-1" } }
]
});
}
const metricsData = {
resourceMetrics: [{
resource: {
attributes: [
{ key: "service.name", value: { stringValue: "api-gateway" } },
{ key: "service.version", value: { stringValue: "1.0.0" } },
{ key: "environment", value: { stringValue: "production" } },
{ key: "host.name", value: { stringValue: "server-01" } },
{ key: "region", value: { stringValue: "us-east-1" } }
]
},
scopeMetrics: [{
scope: { name: "opentelemetry-instrumentation", version: "1.0.0" },
metrics: [{ name: "request_count", unit: "count", gauge: { dataPoints } }]
}]
}]
};
const result = await this.sendMetrics(metricsData);
if (result.success) {
testLogger.info('Sent fixed request_count series', { points });
} else {
testLogger.error('Failed to send fixed request_count series', { error: result.error });
}
return result;
}
/**
* Continuous metrics ingestion
* Uses while loop with await to prevent overlapping executions
+23
View File
@@ -156,6 +156,29 @@ describe("DateTime paste", () => {
expect(lastToast()).toMatchObject({ variant: "success" });
});
it.each([
["an empty", ""],
["a whitespace-only", " \n\t "],
])("says the clipboard is empty when the paste button reads %s clipboard", async (_, text) => {
stubReadText(() => Promise.resolve(text));
createWrapper();
await openMenu();
const emitsBefore = (wrapper!.emitted("on:date-change") ?? []).length;
await wrapper!.vm.pasteRange();
await flushPromises();
expect(toastMock).toHaveBeenCalledTimes(1);
expect(lastToast()).toMatchObject({
variant: "info",
message: "Clipboard is empty. Copy a date range first.",
});
expect(toastMock).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Could not parse date range" }),
);
expect((wrapper!.emitted("on:date-change") ?? []).length).toBe(emitsBefore);
});
it("keeps the default focus inside the panel when the rail has nothing to focus", async () => {
localStorage.clear();
createWrapper({ calendarPresets: false });
+4
View File
@@ -1540,6 +1540,10 @@ export default defineComponent({
});
return;
}
if (!text.trim()) {
toast({ variant: "info", message: t("common.dateRangePasteEmpty") });
return;
}
applyPastedTextWithToast(text);
};
+20
View File
@@ -17,6 +17,7 @@ import { afterEach, describe, expect, it, beforeEach, vi } from "vitest";
import { mount, shallowMount } from "@vue/test-utils";
import { computed, nextTick } from "vue";
import i18n from "@/locales";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import Header from "@/components/Header.vue";
import * as cookies from "@/utils/cookies";
import { chartColor } from "@/utils/chartTheme";
@@ -543,6 +544,25 @@ describe("Header Component", () => {
aiWrapper.unmount();
});
it("keeps the AI logo with a lock badge and under a locked license", async () => {
const aiWrapper = createWrapper({
mountType: "mount",
storeOverrides: {
state: { zoConfig: { ai_enabled: true, features: { ai_assistant: "locked" } } },
},
configOverrides: { isEnterprise: "true" },
stubsOverrides: {},
});
await aiWrapper.vm.$nextTick();
const aiButton = aiWrapper.find('[data-test="menu-link-ai-locked"]');
expect(aiButton.exists()).toBe(true);
expect(aiButton.find("img").exists()).toBe(true);
expect(aiButton.findComponent(OLockBadge).exists()).toBe(true);
expect(aiButton.attributes("aria-label")).toContain("Locked: needs");
aiWrapper.unmount();
});
it("should not display AI button if ai_enabled is false", async () => {
const aiWrapper = createWrapper({
mountType: "mount",
+48 -17
View File
@@ -234,15 +234,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
Leads the utility-icon cluster, set off by a separator from the
org selector so it reads as the primary action in this group.
Shown locked (not hidden) in builds that don't unlock it. -->
<template v-if="aiButtonVisible">
<template v-if="aiButtonVisible || aiLicenseLocked">
<OButton
variant="ghost"
size="icon-toolbar"
@click="toggleAIChat"
data-test="menu-link-ai-item"
:aria-label="t('menu.aiAssistant')"
@click="aiLicenseLocked ? openLockedAiAssistant() : toggleAIChat()"
:data-test="aiLicenseLocked ? 'menu-link-ai-locked' : 'menu-link-ai-item'"
:aria-label="
aiLicenseLocked
? t('featureLock.headerLocked', {
feature: featureName('ai_assistant') ?? t('featureLock.thisFeature'),
})
: t('menu.aiAssistant')
"
class="group text-ai-accent! hover:shadow-ai-accent/35 dark:shadow-ai-accent/20 dark:hover:shadow-ai-accent/35 [background:var(--color-gradient-ai-subtle)]! [transition:background_0.3s_ease,box-shadow_0.3s_ease,color_0.3s_ease] hover:text-white! hover:shadow-md hover:[background:var(--color-gradient-ai)]! dark:text-white! dark:shadow-md dark:hover:shadow-md"
:class="store.state.isAiChatEnabled ? 'ai-btn-active' : ''"
:class="store.state.isAiChatEnabled && !aiLicenseLocked ? 'ai-btn-active' : ''"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
@@ -251,19 +257,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:src="getBtnLogo"
class="h-5 w-5 shrink-0 [transition:transform_0.6s_ease] group-hover:rotate-180 group-hover:brightness-0 group-hover:invert group-hover:[transition:filter_0.3s_ease]"
/>
<span
v-if="!aiAccess.allowed"
aria-hidden="true"
class="bg-surface-overlay border-surface-base absolute -top-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border shadow-sm"
>
<OIcon name="lock" size="xs" class="text-warning-600 size-2.5!" />
</span>
<OLockBadge v-if="!aiAccess.allowed || aiLicenseLocked" />
<OTooltip
v-if="aiAccess.allowed"
side="bottom"
align="center"
:content="t('menu.aiAssistant')"
shortcut-id="aiChatToggle"
:content="
aiLicenseLocked
? t('featureLock.headerLocked', {
feature: featureName('ai_assistant') ?? t('featureLock.thisFeature'),
})
: t('menu.aiAssistant')
"
:shortcut-id="aiLicenseLocked ? undefined : 'aiChatToggle'"
/>
</OButton>
</template>
@@ -509,6 +515,7 @@ import LinkedAccountsDialog from "./settings/LinkedAccountsDialog.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OPageHeaderStatus from "@/lib/core/PageHeader/OPageHeaderStatus.vue";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { checkFeatureAccess } from "@/utils/enterpriseFeatures";
import { readSlackOrgConfig } from "@/utils/slackSettings";
@@ -519,6 +526,8 @@ import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import { getImageURL } from "@/utils/zincutils";
import { featureLockedPath, menuState } from "@/utils/featureGate";
import { useFeatureNames } from "@/composables/useFeatureLock";
import { chartColor } from "@/utils/chartTheme";
export default defineComponent({
@@ -532,6 +541,7 @@ export default defineComponent({
LinkedAccountsDialog,
OButton,
OIcon,
OLockBadge,
OTooltip,
ODropdown,
ODropdownItem,
@@ -623,6 +633,7 @@ export default defineComponent({
],
setup(props, { emit }) {
const { t } = useI18nTyped();
const { featureName } = useFeatureNames();
const router = useRouter();
const { isDark } = useTheme();
@@ -728,14 +739,23 @@ export default defineComponent({
);
// A focus-mode route has no side menu and no AI panel, so their buttons go too.
const focusMode = computed(() => props.router?.currentRoute?.value?.meta?.focusMode === true);
// The license map applies once the edition allows the assistant.
const aiAssistantState = computed(() =>
menuState(
props.store.state.zoConfig?.features,
"ai_assistant",
Boolean(props.store.state.zoConfig.ai_enabled),
),
);
const aiButtonVisible = computed(
() =>
!focusMode.value &&
(!aiAccess.value.allowed || Boolean(props.store.state.zoConfig.ai_enabled)),
() => !focusMode.value && (!aiAccess.value.allowed || aiAssistantState.value === "enabled"),
);
const aiSlotPending = computed(
() => aiAccess.value.allowed && !props.store.state.zoConfig?.version,
);
const aiLicenseLocked = computed(
() => !focusMode.value && aiAccess.value.allowed && aiAssistantState.value === "locked",
);
// The edition gate and the org's runtime switch both have to pass, as for Settings › Slack.
const linkedAccountsVisible = computed(
@@ -795,6 +815,13 @@ export default defineComponent({
emit("toggleAIChat");
};
const openLockedAiAssistant = () => {
router.push({
path: featureLockedPath("ai_assistant"),
query: { org_identifier: props.store.state.selectedOrganization?.identifier },
});
};
const openSlack = () => {
emit("openSlack");
};
@@ -842,6 +869,7 @@ export default defineComponent({
isDark,
t,
raw,
featureName,
getImageURL,
enterpriseButtonText,
aiSlotPending,
@@ -858,9 +886,12 @@ export default defineComponent({
homeUrl,
goToAbout,
toggleAIChat,
aiAssistantState,
openLockedAiAssistant,
aiAccess,
aiButtonVisible,
focusMode,
aiLicenseLocked,
linkedAccountsVisible,
showLinkedAccounts,
openSlack,
+32
View File
@@ -387,6 +387,38 @@ describe("MenuLink", async () => {
});
});
describe("license-locked tile", () => {
it("keeps a real link to the page and adds a lock badge", () => {
const w = mount(MenuLink, {
props: { title: "Workflows", link: "/workflows", licenseLocked: true },
global: { plugins: [i18n, mockRouter], provide: { store } },
});
const tile = w.get('[data-test="menu-link-/workflows-item"]');
expect(tile.element.tagName).toBe("A");
expect(tile.attributes("href")).toContain("/workflows");
expect(w.find('[data-test="menu-link-/workflows-license-lock"]').exists()).toBe(true);
w.unmount();
});
});
describe("license-locked tile screen-reader text", () => {
it("names the lock with the supplied label and paints it gold", () => {
const w = mount(MenuLink, {
props: {
title: "Workflows",
link: "/workflows",
licenseLocked: true,
licenseLockedLabel: "Locked: needs Workflows",
},
global: { plugins: [i18n, mockRouter], provide: { store } },
});
const badge = w.get('[data-test="menu-link-/workflows-license-lock"]');
expect(badge.get(".sr-only").text()).toBe("Locked: needs Workflows");
expect(badge.find(".text-icon-locked").exists()).toBe(true);
w.unmount();
});
});
describe("trial-paywalled tile (AC-14)", () => {
function mountTile(props: Record<string, unknown> = {}) {
return mount(MenuLink, {
+16 -7
View File
@@ -36,12 +36,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Rail icons are a hair smaller than the md (24px) default. -->
<OIcon v-if="icon" :name="icon" size="md" class="size-5.5!" />
<component v-else-if="hasIconComponent" :is="iconComponent" class="o-icon size-5.5" />
<div
v-if="locked"
class="bg-surface-base absolute -top-1 -right-1 z-1 flex h-3.5 w-3.5 items-center justify-center rounded-full"
>
<OIcon name="lock" size="xs" class="text-warning-600" />
</div>
<OLockBadge
v-if="locked || licenseLocked"
:data-test="licenseLocked ? `menu-link-${link}-license-lock` : undefined"
:label="licenseLocked ? licenseLockedLabel : undefined"
/>
<div
v-if="badge && badge > 0"
class="menu-badge text-3xs text-text-inverse shadow-error-500/50 border-menu-badge-ring bg-gradient-notification absolute -top-1 -right-2 z-1 flex h-4 min-w-4 animate-pulse items-center justify-center rounded-full border-2 px-1 leading-none font-bold shadow-md"
@@ -104,6 +103,7 @@ import { injectTooltipRootContext } from "reka-ui";
import { useTheme } from "@/composables/useTheme";
import { raw, type I18nText, useI18nTyped } from "@/types/i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useIsTruncated } from "@/lib/overlay/Tooltip/useIsTruncated";
import { RailIndicatorActiveKey } from "@/lib/core/Navbar/ONavbar.types";
@@ -131,7 +131,7 @@ const TooltipSilencer = defineComponent({
export default defineComponent({
name: "MenuLink",
components: { OIcon, OTooltip, TooltipSilencer },
components: { OIcon, OLockBadge, OTooltip, TooltipSilencer },
// Attrs are re-bound onto the tile element by hand: the tooltip wrapper is a fragment.
inheritAttrs: false,
props: {
@@ -218,6 +218,15 @@ export default defineComponent({
type: String as unknown as PropType<I18nText>,
default: raw(""),
},
// The license locks the feature: badge only, since the real page renders its own locked state.
licenseLocked: {
type: Boolean,
default: false,
},
licenseLockedLabel: {
type: String as unknown as PropType<I18nText>,
default: undefined,
},
},
emits: ["trigger"],
setup(props, { emit }) {
@@ -18,6 +18,7 @@ import { describe, expect, it, vi } from "vitest";
import {
buildNavigationRoute,
generateNavigationFromToolResult,
navigationFeature,
navigationPageName,
} from "@/components/O2AIChat.navigation";
import type { NavigationAction } from "@/ts/interfaces/chat";
@@ -355,3 +356,26 @@ describe("buildNavigationRoute", () => {
).toBeNull();
});
});
describe("navigationFeature", () => {
it.each([
["logs", "logs"],
["metrics", "metrics"],
["traces", "traces"],
["enrichment_tables", null],
])("keys a %s query to %s", (type, key) => {
expect(
navigationFeature({ action: "load_query", resource_type: type, target: {} } as any),
).toBe(key);
});
it.each([
["alert", "alerts"],
["dashboard", "dashboards"],
["pipeline", null],
])("keys a direct %s navigation to %s", (type, key) => {
expect(
navigationFeature({ action: "navigate_direct", resource_type: type, target: {} } as any),
).toBe(key);
});
});
+10
View File
@@ -15,6 +15,7 @@
import type { NavigationAction } from "@/ts/interfaces/chat";
import { raw, type TranslateFn } from "@/types/i18n";
import { streamTypeFeature } from "@/utils/featureGate";
export interface NavigationRoute {
path: string;
@@ -175,6 +176,15 @@ export function generateNavigationFromToolResult(
};
}
const RESOURCE_FEATURES: Record<string, string> = { alert: "alerts", dashboard: "dashboards" };
/** License key of the page a navigation action opens; null when that page has no key. */
export function navigationFeature(action: NavigationAction): string | null {
return action.action === "load_query"
? streamTypeFeature(action.resource_type)
: (RESOURCE_FEATURES[action.resource_type] ?? null);
}
export function navigationPageName(action: NavigationAction): string {
return (
action.label ||
+1 -1
View File
@@ -135,7 +135,7 @@
aria-hidden="true"
class="bg-surface-overlay border-surface-base absolute -top-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border shadow-sm"
>
<OIcon name="lock" size="xs" class="text-warning-600 size-2.5!" />
<OIcon name="lock" size="xs" class="text-icon-locked size-2.5!" />
</span>
<OTooltip
v-if="aiAccess.allowed"
@@ -15,6 +15,7 @@
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { createStore } from "vuex";
import i18n from "@/locales";
vi.mock("dompurify", () => ({
@@ -93,6 +94,49 @@ describe("O2AIChatMessage", () => {
]);
});
it("locks View in Logs, in the block and the tool-call icon, while logs is locked", async () => {
const action = {
action: "load_query",
resource_type: "logs",
label: "View in Logs",
target: {},
};
const message = assistant({
contentBlocks: [
{
type: "tool_call",
tool: "SearchSQL",
message: "m",
success: true,
navigationAction: action,
},
{ type: "navigation", navigationAction: action },
],
});
const lockedStore = createStore({ state: { zoConfig: { features: { logs: "locked" } } } });
const locked = mount(O2AIChatMessage, {
global: { plugins: [i18n, lockedStore] },
props: {
message,
index: 0,
isLoading: false,
currentAnalyzingMessage: "",
expandedToolCalls: new Set<string>(),
expandedLogEntries: new Set<string>(),
},
});
const btn = locked.find('[data-test="o2-ai-chat-navigate-locked"]');
expect(btn.attributes("disabled")).toBeDefined();
await btn.trigger("click");
await locked.find('[data-test="o2-ai-chat-tool-navigate-locked"]').trigger("click");
expect(locked.emitted("navigate")).toBeUndefined();
const open = mountMessage(message);
expect(open.find('[data-test="o2-ai-chat-navigate-locked"]').exists()).toBe(false);
await open.find(".navigation-block-btn").trigger("click");
expect(open.emitted("navigate")).toEqual([[action]]);
});
it("emits retry with the message and like/dislike without arguments", async () => {
const message = assistant();
const w = mountMessage(message);
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import type { ChatMessage, ContentBlock, NavigationAction } from "@/ts/interfaces/chat";
import {
formatLogEntryContent,
@@ -16,6 +16,8 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { navigationFeature } from "@/components/O2AIChat.navigation";
import { useLicenseLocks } from "@/composables/useFeatureLock";
// ContentBlock has no log_entry member, but parseLogEntries puts them into user messages.
type LogEntryBlock = { type: "log_entry"; preview: string; content: string };
@@ -48,6 +50,11 @@ const emit = defineEmits<{
}>();
const { t } = useI18nTyped();
const { isLocked, lockedReason } = useLicenseLocks();
const navLock = (action: NavigationAction): I18nText | null => {
const key = navigationFeature(action);
return key && isLocked(key) ? lockedReason(key) : null;
};
// Positional key shape must match the shell's toggleLogEntryExpanded.
const isLogEntryExpanded = (blockIndex: number) =>
@@ -165,8 +172,27 @@ const isStopped = computed(() =>
>
{{ t("aiAssistant.errorMayBeTemporary") }}
</div>
<OTooltip
v-if="block.navigationAction && navLock(block.navigationAction)"
:content="navLock(block.navigationAction) ?? undefined"
>
<span class="ms-6 mt-2 inline-flex self-start">
<OButton
variant="primary"
size="xs"
class="stream-error-action text-compact"
disabled
data-test="o2-ai-chat-error-navigate-locked"
>
<template #icon-left
><OIcon name="lock" class="text-icon-locked" size="sm"
/></template>
{{ block.navigationAction.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="block.navigationAction"
v-else-if="block.navigationAction"
variant="primary"
size="xs"
class="stream-error-action text-compact ms-6 mt-2 self-start"
@@ -215,7 +241,27 @@ const isStopped = computed(() =>
v-else-if="block.type === 'navigation' && block.navigationAction && !readonly"
class="navigation-block my-1 [background:color-mix(in_srgb,var(--color-info)_8%,transparent)] dark:[background:color-mix(in_srgb,var(--color-info)_12%,transparent)]"
>
<OTooltip
v-if="navLock(block.navigationAction)"
:content="navLock(block.navigationAction) ?? undefined"
>
<span class="inline-flex">
<OButton
variant="primary"
size="xs"
class="navigation-block-btn text-compact"
disabled
data-test="o2-ai-chat-navigate-locked"
>
<template #icon-left
><OIcon name="lock" class="text-icon-locked" size="sm"
/></template>
{{ block.navigationAction.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-else
variant="primary"
size="xs"
class="navigation-block-btn text-compact"
@@ -13,6 +13,8 @@ import { copyToClipboard } from "@/utils/clipboard";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { navigationFeature } from "@/components/O2AIChat.navigation";
import { useLicenseLocks } from "@/composables/useFeatureLock";
const props = defineProps<{
block: ToolCallBlock;
@@ -37,6 +39,12 @@ const isExpanded = computed(() =>
const formatToolCallMessage = (block: ToolCallBlock) => formatToolCall(block, t);
const formatTimestamp = (timestamp: number) => formatToolCallTimestamp(timestamp, t);
const { isLocked, lockedReason } = useLicenseLocks();
const navLockReason = computed(() => {
const key = props.block.navigationAction ? navigationFeature(props.block.navigationAction) : null;
return key && isLocked(key) ? lockedReason(key) : null;
});
</script>
<template>
@@ -86,8 +94,23 @@ const formatTimestamp = (timestamp: number) => formatToolCallTimestamp(timestamp
>{{ formatToolCallMessage(block).suffix }}
</span>
<!-- Navigation icon -->
<OTooltip
v-if="block.navigationAction && !block.pendingConfirmation && !readonly && navLockReason"
:content="navLockReason"
>
<span class="ms-auto inline-flex" @click.stop>
<OButton
variant="ghost"
size="icon-xs"
disabled
icon-left="lock"
:aria-label="block.navigationAction.label"
data-test="o2-ai-chat-tool-navigate-locked"
/>
</span>
</OTooltip>
<OIcon
v-if="block.navigationAction && !block.pendingConfirmation && !readonly"
v-else-if="block.navigationAction && !block.pendingConfirmation && !readonly"
name="open-in-new"
size="xs"
class="navigation-icon ms-auto cursor-pointer opacity-70 transition-opacity duration-200 hover:opacity-100"
@@ -361,6 +361,18 @@ describe("LibraryCard", () => {
expect(scheduled.find('[data-test="alert-library-card-anomaly"]').exists()).toBe(false);
});
it("disables the card, its checkbox and its title button when the license locks it", async () => {
const wrapper = mountCard({ entry: anomaly, locked: true, licenseLockedReason: "Locked" });
const card = wrapper.find(`[data-test="alert-library-card-${anomaly.id}"]`);
expect(card.attributes("aria-disabled")).toBe("true");
expect(card.classes()).toContain("cursor-not-allowed");
expect(
wrapper.find(`[data-test="alert-library-card-title-${anomaly.id}"]`).attributes("disabled"),
).toBeDefined();
await card.trigger("click");
expect(wrapper.emitted("open")).toBeUndefined();
});
it("shows the Enterprise badge only when locked", () => {
const locked = mountCard({ entry: anomaly, locked: true });
expect(locked.find('[data-test="alert-library-card-enterprise"]').text()).toBe("Enterprise");
@@ -43,20 +43,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<!-- Not a button any more: a control nested in role="button" is invalid for keyboard and AT. -->
<article
class="rounded-default bg-surface-base flex h-full cursor-pointer flex-col gap-2 border p-3"
:class="
class="rounded-default bg-surface-base flex h-full flex-col gap-2 border p-3"
:class="[
licenseLockedReason ? 'cursor-not-allowed' : 'cursor-pointer',
selected
? 'border-accent ring-accent/40 ring-2'
: 'border-border-default hover:border-border-strong'
"
: licenseLockedReason
? 'border-border-default'
: 'border-border-default hover:border-border-strong',
]"
:aria-disabled="licenseLockedReason ? 'true' : undefined"
:data-test="`alert-library-card-${entry.id}`"
:data-selected="selected ? 'true' : 'false'"
@click="emit('open')"
@click="!licenseLockedReason && emit('open')"
>
<div class="flex items-start gap-2">
<OCheckbox
size="sm"
:model-value="selected"
:disabled="!!licenseLockedReason"
:aria-label="t('alert_library.selectAlert', { title: entry.title })"
class="mt-0.5 shrink-0"
:data-test="`alert-library-select-${entry.id}`"
@@ -66,7 +71,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<button
:id="titleId"
type="button"
class="text-text-heading focus-visible:ring-accent/40 rounded-default cursor-pointer text-left outline-none focus-visible:ring-2"
:disabled="!!licenseLockedReason"
class="text-text-heading focus-visible:ring-accent/40 rounded-default text-left outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-not-allowed"
:data-test="`alert-library-card-title-${entry.id}`"
@click.stop="emit('open')"
>
@@ -133,8 +139,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="t('alert_library.alertTypeAnomaly')"
data-test="alert-library-card-anomaly"
/>
<OTooltip v-if="licenseLockedReason" :content="licenseLockedReason">
<span class="inline-flex" data-test="alert-library-card-license-locked">
<OIcon name="lock" size="xs" class="lock-chip shrink-0" />
</span>
</OTooltip>
<OTag
v-if="locked"
v-else-if="locked"
variant="default-soft"
icon="lock"
:label="t('alert_library.enterprise')"
@@ -143,7 +154,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<!-- Locked cards open the upsell instead, so they offer nothing to act on here. -->
<div v-if="!locked" class="ms-auto flex shrink-0 items-center gap-2">
<div v-if="!locked && !licenseLockedReason" class="ms-auto flex shrink-0 items-center gap-2">
<!-- The editor prefill speaks the scheduled alert shape only. -->
<OButton
v-if="!isAnomaly"
@@ -183,7 +194,7 @@ import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { AlertLibraryEntry } from "@/types/alertLibrary";
import { useI18nTyped } from "@/types/i18n";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { alertTypeOf, queryTypeLabel, severityBadgeValue, severityLabel } from "./libraryFacets";
@@ -195,6 +206,8 @@ const props = defineProps<{
selected?: boolean;
/** An anomaly alert this build is not entitled to; the gallery turns its clicks into the upsell. */
locked?: boolean;
/** Set when the license, not the edition, locks this alert; the card shows a lock with this reason. */
licenseLockedReason?: I18nText;
}>();
const emit = defineEmits<{
@@ -1271,6 +1271,23 @@ describe("LibraryInstallDialog", () => {
expect(mocks.createAlert).toHaveBeenCalledTimes(1);
});
it("keeps anomaly alerts out of the batch while anomaly_detection is license-locked", async () => {
store.state.zoConfig = {
...store.state.zoConfig,
features: { anomaly_detection: "locked" },
} as typeof original.zoConfig;
const wrapper = await mountDialog({
seed: null,
preselect: [seedEntry(), anomalyEntry()],
});
await advanceToInstall(wrapper);
expect(wrapper.find('[data-test="alert-library-install-run"]').text()).toContain("1");
await click(wrapper, "alert-library-install-run");
expect(mocks.createAnomaly).not.toHaveBeenCalled();
expect(mocks.createAlert).toHaveBeenCalledTimes(1);
});
it("disables Run when the feature turns off under an anomaly-only batch", async () => {
const wrapper = await mountDialog({
seed: { entry: anomalyEntry(), file: anomalyFile() },
@@ -0,0 +1,112 @@
// 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 { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import { ref } from "vue";
import { createMemoryHistory, createRouter } from "vue-router";
import store from "@/test/unit/helpers/store";
import AlertCalibrationDigestDetail from "./AlertCalibrationDigestDetail.vue";
import type { RunRow } from "./digests";
const DAY_MICROS = 86_400_000_000;
const NOW = Date.now() * 1000;
const RUN: RunRow = {
window_start: NOW - 30 * DAY_MICROS,
window_end: NOW,
emitted_at: NOW,
finding_count: 0,
skip_count: 0,
alerts_in_scope: 8,
coverage_gap: false,
coverage_unreadable_definitions: 0,
coverage_facts_without_definition: 0,
coverage_definitions_without_facts: 0,
coverage_transitions_truncated: false,
coverage_counters_truncated: false,
coverage_error_clusters_truncated: false,
coverage_error_clusters_unattributed: 0,
coverage_stream_substrate_empty: false,
};
vi.mock("@/composables/alertCalibration/useAlertCalibrationEnabled", () => ({
useLeaveIfAlertCalibrationOff: () => {},
}));
vi.mock("./useDigestSearch", () => ({
useDigestSearch: () => ({ search: vi.fn(), orgId: ref("default") }),
useLatestRead: () => ({
data: ref({ hits: 1, run: RUN, findings: [] }),
error: ref(null),
loading: ref(false),
load: vi.fn(),
}),
}));
const mountPage = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div />" } },
{ path: "/logs", name: "logs", component: { template: "<div />" } },
{ path: "/runs", name: "alertCalibrationRuns", component: { template: "<div />" } },
],
});
await router.push("/");
const push = vi.spyOn(router, "push");
const wrapper = mount(AlertCalibrationDigestDetail, {
props: { windowStart: RUN.window_start, windowEnd: RUN.window_end },
global: { plugins: [store, router] },
});
await flushPromises();
return { wrapper, push };
};
const VIEW_IN_LOGS = '[data-test="alert-calibration-digest-view-in-logs"]';
describe("AlertCalibrationDigestDetail view in logs", () => {
afterEach(() => {
delete (store.state.zoConfig as Record<string, unknown>).features;
});
it("logs locked: View in logs is disabled with a lock and opens nothing", async () => {
(store.state.zoConfig as Record<string, unknown>).features = { logs: "locked" };
const { wrapper, push } = await mountPage();
const button = wrapper.find(VIEW_IN_LOGS);
expect(button.attributes("disabled")).toBeDefined();
expect(wrapper.find('[data-test="alert-calibration-digest-view-in-logs-lock"]').exists()).toBe(
true,
);
await button.trigger("click");
expect(push).not.toHaveBeenCalled();
wrapper.unmount();
});
it("logs enabled: View in logs opens Logs on the digest stream", async () => {
const { wrapper, push } = await mountPage();
expect(wrapper.find('[data-test="alert-calibration-digest-view-in-logs-lock"]').exists()).toBe(
false,
);
await wrapper.find(VIEW_IN_LOGS).trigger("click");
expect(push).toHaveBeenCalledWith(
expect.objectContaining({
path: "/logs",
query: expect.objectContaining({ stream_type: "logs" }),
}),
);
wrapper.unmount();
});
});
@@ -37,8 +37,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-calibration-digest-page"
>
<template #actions>
<OTooltip v-if="run && logsLock.locked.value" :content="logsLock.lockedReason.value">
<span class="inline-flex">
<OButton
variant="outline"
size="sm"
disabled
data-test="alert-calibration-digest-view-in-logs"
>
<template #icon-left>
<OIcon
name="lock"
class="text-icon-locked"
size="sm"
data-test="alert-calibration-digest-view-in-logs-lock"
/>
</template>
{{ t("alertCalibration.detail.viewInLogs") }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="run"
v-else-if="run"
variant="outline"
size="sm"
icon-left="open-in-new"
@@ -174,6 +194,8 @@ import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OStatStrip from "@/lib/data/StatStrip/OStatStrip.vue";
@@ -202,6 +224,7 @@ const props = defineProps<{ windowStart: string | number; windowEnd: string | nu
const { t } = useI18nTyped();
useLeaveIfAlertCalibrationOff("alertList");
const router = useRouter();
const logsLock = useLicenseLock("logs");
const route = useRoute();
const { search, orgId } = useDigestSearch();
@@ -1358,6 +1358,23 @@ describe("AddAlert (OForm owner)", () => {
expect(document.querySelector('[data-test="alert-save-mode-dialog"]')).not.toBeNull();
});
it("locks the anomaly alert type while anomaly_detection is license-locked", async () => {
store.state.zoConfig.features = { anomaly_detection: "locked" };
try {
wrapper = mountAlert();
await flushPromises();
const anomaly = wrapper.vm.alertTypeOptions.find((o: any) => o.value === "anomaly");
expect(anomaly?.locked).toBe(true);
const item = wrapper.find('[data-test="add-alert-type-tab-anomaly"]');
expect(item.attributes("disabled")).toBeDefined();
wrapper.vm.onAlertTypeChange("anomaly");
await flushPromises();
expect(wrapper.vm.form.state.values.is_real_time).not.toBe("anomaly");
} finally {
delete store.state.zoConfig.features;
}
});
it("falls back to Builder and keeps the SQL when Alert Type switches to Realtime", async () => {
wrapper = mountAlert();
await flushPromises();
+15 -2
View File
@@ -198,10 +198,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="option.value"
:value="option.value"
size="sm"
:disabled="option.locked"
:focusable-unavailable="option.locked"
:tooltip="option.locked ? anomalyLockedReason : undefined"
:data-test="`add-alert-type-tab-${option.value}`"
>
<template #icon-left>
<OIcon :name="option.icon" size="sm" />
<OIcon
:class="option.locked ? 'lock-chip' : undefined"
:name="option.locked ? 'lock' : option.icon"
size="sm"
/>
</template>
{{ option.label }}
</OToggleGroupItem>
@@ -668,6 +675,7 @@ import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import CompositeAlertForm from "./composite/CompositeAlertForm.vue";
import alertsService from "@/services/alerts";
import { useLicenseLock } from "@/composables/useFeatureLock";
export default defineComponent({
name: "ComponentAddUpdateAlert",
@@ -767,6 +775,8 @@ export default defineComponent({
const isAnomalyDetectionEnabled = computed(
() => alertForm.store.state.zoConfig.anomaly_detection_enabled === true,
);
const { locked: anomalyLicenseLocked, lockedReason: anomalyLockedReason } =
useLicenseLock("anomaly_detection");
// §4.8: keyed off the config API's notice_class field alone.
const anomalyNoticeBadge = computed(() => {
@@ -872,12 +882,13 @@ export default defineComponent({
const alertTypeOptions = computed(() => [
{ label: alertForm.t("alerts.scheduled"), value: "false", icon: "schedule" },
{ label: alertForm.t("alerts.realTime"), value: "true", icon: "bolt" },
...(isAnomalyDetectionEnabled.value
...(isAnomalyDetectionEnabled.value || anomalyLicenseLocked.value
? [
{
label: alertForm.t("alerts.anomalyDetection"),
value: "anomaly",
icon: "query-stats",
locked: anomalyLicenseLocked.value,
},
]
: []),
@@ -958,6 +969,7 @@ export default defineComponent({
// the ONE form directly. The existing watch on is_real_time still drives the
// anomaly/composite tab switch.
const onAlertTypeChange = (value: unknown) => {
if (value === "anomaly" && anomalyLicenseLocked.value) return;
alertForm.setF("is_real_time", value);
// Realtime runs Builder only. The query text and Compare-with-Past windows
// stay in the form for a switch back; the payload never sends windows here.
@@ -1047,6 +1059,7 @@ export default defineComponent({
anomalyNoticeBadge,
anomalyNoticeTooltip,
alertTypeOptions,
anomalyLockedReason,
alertTabs,
activeFolderName,
goBackToAlertsList,
@@ -43,7 +43,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:workflow-options="workflowOptions"
:workflows-enabled="workflowsEnabled"
:workflows-locked="workflowsLocked"
:workflows-lock-message="workflowsAccess.message"
:workflows-license-locked="workflowsLicenseLocked"
:workflows-lock-message="
workflowsLicenseLocked ? workflowsLicenseReason : workflowsAccess.message
"
:placeholder="placeholder"
:error="!!error"
@update:destinations="emit('update:destinations', $event)"
@@ -90,6 +93,7 @@ import AlertTargetsSelect from "@/components/alerts/AlertTargetsSelect.vue";
import { workflowsQuery } from "@/services/workflows.queries";
import { queryClient } from "@/composables/query/queryClient";
import { useLockedAffordance } from "@/composables/useLockedAffordance";
import { useLicenseLock } from "@/composables/useFeatureLock";
type RawOption = string | { label: I18nText; value: string };
@@ -143,13 +147,24 @@ const tooltipText = computed<I18nText>(() =>
// it); a deployment that simply hasn't turned the runtime flag on stays
// HIDDEN (upgrading wouldn't change that).
const workflowsAccess = useLockedAffordance("workflows");
const { locked: workflowsLicenseDenied, lockedReason: workflowsLicenseReason } =
useLicenseLock("workflows");
const workflowsRuntimeEnabled = computed(() => store.state.zoConfig?.workflows_enabled === true);
const workflowsEnabled = computed(
() => props.supportsWorkflows && workflowsAccess.value.allowed && workflowsRuntimeEnabled.value,
() =>
props.supportsWorkflows &&
workflowsAccess.value.allowed &&
!workflowsLicenseDenied.value &&
workflowsRuntimeEnabled.value,
);
// Shown (disabled) instead of hidden outright when the family supports
// workflows but this edition doesn't unlock them.
const workflowsLocked = computed(() => props.supportsWorkflows && !workflowsAccess.value.allowed);
const workflowsLicenseLocked = computed(
() => props.supportsWorkflows && workflowsLicenseDenied.value,
);
const workflowsLocked = computed(
() => props.supportsWorkflows && (!workflowsAccess.value.allowed || workflowsLicenseDenied.value),
);
const workflowOptions = ref<{ label: I18nText; value: string }[]>([]);
const fetchWorkflows = async (force = false) => {
@@ -87,6 +87,7 @@ vi.mock("@/aws-exports", () => ({
import AlertInsights from "@/components/alerts/AlertInsights.vue";
import alertsService from "@/services/alerts";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
async function mountComp(props: Record<string, any> = {}) {
return mount(AlertInsights, {
@@ -354,6 +355,22 @@ describe("AlertInsights - handleChartContextMenu", () => {
});
});
describe("AlertInsights - filter toggle tooltips", () => {
it.each([
["failed-only-toggle", "alerts.insights.filters.failedOnlyTooltip"],
["silenced-only-toggle", "alerts.insights.filters.silencedTooltip"],
])("renders the %s tooltip through the OSwitch tooltip slot", async (dataTest, key) => {
const w = await mountComp();
await flushPromises();
const info = w.find(`[data-test="${dataTest}-info"]`);
expect(info.exists()).toBe(true);
const tooltip = info.findComponent(OTooltip);
expect(tooltip.exists()).toBe(true);
expect(tooltip.props("content")).toBe(i18n.global.t(key));
expect(tooltip.props("content")).not.toBe(key);
});
});
describe("AlertInsights - action buttons row", () => {
it("action-buttons-row not visible when selectedAlertForAction is null", async () => {
const w = await mountComp();
+6 -2
View File
@@ -92,7 +92,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:model-value="onFilterChange"
data-test="failed-only-toggle"
>
<OTooltip :content="t('alerts.insights.filters.failedOnlyTooltip')"> </OTooltip>
<template #tooltip>
<OTooltip :content="t('alerts.insights.filters.failedOnlyTooltip')" />
</template>
</OSwitch>
<!-- Silenced Only Toggle -->
@@ -103,7 +105,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:model-value="onFilterChange"
data-test="silenced-only-toggle"
>
<OTooltip :content="t('alerts.insights.filters.silencedTooltip')"> </OTooltip>
<template #tooltip>
<OTooltip :content="t('alerts.insights.filters.silencedTooltip')" />
</template>
</OSwitch>
<!-- Range Filter Chips -->
@@ -1468,6 +1468,78 @@ describe("AlertList - isAnomalyDetectionEnabled", () => {
// The computed initialises activeTab to 'all' when the feature is disabled
expect(wrapper.vm.activeTab).not.toBe("anomalyDetection");
});
describe("anomaly_detection license lock", () => {
afterEach(() => {
delete (store.state as any).zoConfig.features;
});
const lockedList = async () => {
(config as any).isEnterprise = "true";
(store.state as any).zoConfig.build_type = "enterprise";
(store.state as any).zoConfig.anomaly_detection_enabled = true;
(store.state as any).zoConfig.features = { anomaly_detection: "locked" };
router.currentRoute.value.query = {} as any;
const wrapper: any = await mountAlertList();
await waitData(wrapper);
return wrapper;
};
it("opens the locked tab on the license empty state without an anomaly list call", async () => {
const wrapper = await lockedList();
expect(wrapper.vm.alertTabs.find((t: any) => t.value === "anomalyDetection").locked).toBe(
true,
);
(alertsSvc.listByFolderId as any).mockClear();
await wrapper.vm.onAlertTabChange("anomalyDetection");
await flushPromises();
expect(wrapper.vm.activeTab).toBe("anomalyDetection");
expect(alertsSvc.listByFolderId).not.toHaveBeenCalled();
expect(wrapper.vm.displayedAlerts).toEqual([]);
expect(wrapper.vm.anomalyTabLicenseLocked).toBe(true);
// An earlier spec leaves an edit route behind, which opens the editor instead of the list.
wrapper.vm.showAddAlertDialog = false;
await flushPromises();
const add = wrapper.find('[data-test="alert-list-add-alert-btn"]');
expect(add.attributes("disabled")).toBeDefined();
expect(wrapper.find('[data-test="alert-list-add-anomaly-lock"]').exists()).toBe(true);
});
it("locks editing anomaly rows but not scheduled ones", async () => {
const wrapper = await lockedList();
expect(wrapper.vm.rowAnomalyLocked({ type: "anomaly" })).toBe(true);
expect(wrapper.vm.rowAnomalyLocked({ type: "alert", is_real_time: false })).toBe(false);
});
it("guards every action on an anomaly row: no dialogs, no state toggle", async () => {
const wrapper = await lockedList();
const row = { type: "anomaly", alert_id: "an-1", uuid: "u-an-1", name: "an", enabled: true };
wrapper.vm.showDeleteDialogFn({ row });
wrapper.vm.moveAlertToAnotherFolder(row);
await wrapper.vm.duplicateAlert(row);
wrapper.vm.toggleAlertState(row);
await flushPromises();
expect(wrapper.vm.confirmDelete).toBe(false);
expect(wrapper.vm.showMoveAlertDialog).toBe(false);
expect(wrapper.vm.showForm).toBe(false);
expect(wrapper.vm.alertStateLoadingMap["u-an-1"]).toBeUndefined();
expect(wrapper.vm.anomalyRowLocked(row)).toBe(true);
expect(wrapper.vm.anomalyRowLocked({ type: "alert" })).toBe(false);
});
it("leaves the anomaly tab unlocked while anomaly_detection is enabled", async () => {
(config as any).isEnterprise = "true";
(store.state as any).zoConfig.build_type = "enterprise";
(store.state as any).zoConfig.anomaly_detection_enabled = true;
(store.state as any).zoConfig.features = { anomaly_detection: "enabled" };
const wrapper: any = await mountAlertList();
const tab = wrapper.vm.alertTabs.find((t: any) => t.value === "anomalyDetection");
expect(tab.locked).toBe(false);
expect(wrapper.vm.anomalyTabLicenseLocked).toBe(false);
});
});
});
// 12. ODialog / ODrawer migration coverage
@@ -2077,6 +2149,12 @@ describe("AlertList - trigger", () => {
expect(calls).toEqual([{ variant: "warning", message }]);
});
it("warns with the reason when the trigger ran but something was not delivered", async () => {
const warning = "not paged: On-Call is not included in your license";
const calls = await triggerWith({ message: "Alert triggered", warning });
expect(calls).toEqual([{ variant: "warning", message: warning }]);
});
it("warns instead of claiming success when a detection run already holds the claim", async () => {
const calls = await triggerWith({ claim_lost: true, message: "already running" });
expect(calls).toEqual([
+177 -28
View File
@@ -38,8 +38,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #actions>
<!-- The provider behind the Terraform export tab, which is otherwise
only discoverable once the export dialog is already open. -->
<OTooltip v-if="anomalyTabLicenseLocked" :content="anomalyLockedReason">
<span class="inline-flex">
<OButton data-test="alert-list-add-alert-btn" variant="primary" size="sm" disabled>
<template #icon-left>
<OIcon
name="lock"
class="lock-chip"
size="sm"
data-test="alert-list-add-anomaly-lock"
/>
</template>
{{ t(`alerts.add`) }}
</OButton>
</span>
</OTooltip>
<!-- Add button — routes to anomaly creation on anomaly tab, alert creation otherwise -->
<OButton
v-else
data-test="alert-list-add-alert-btn"
variant="primary"
size="sm"
@@ -120,6 +136,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="oTableRef"
:frame="false"
v-model:selected-ids="selectedAlertIds"
:is-row-selectable="(row: any) => !anomalyRowLocked(row)"
selection="multiple"
data-test="alert-list-table"
:data="displayedAlerts"
@@ -128,7 +145,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
row-key="alert_id"
:loading="loading"
:loading-reset-key="`${activeFolderId}:${activeTab}`"
:forbidden="forbidden"
:forbidden="anomalyTabLicenseLocked ? ['anomaly_detection'] : forbidden"
forbidden-resource="alert_folders"
pagination="client"
:page-size="pageSize"
@@ -188,13 +205,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:value="tab.value"
size="sm"
:icon-left="tab.icon"
:title="lgUp && !tab.locked ? undefined : tab.label"
:icon-right="tab.locked ? 'lock' : undefined"
:tooltip="tab.locked ? anomalyLockedReason : undefined"
:title="lgUp || tab.locked ? undefined : tab.label"
:data-test="`alert-list-tab-${tab.value}`"
>
<span class="@max-[42rem]/alert-toolbar:hidden">{{ tab.label }}</span>
<template v-if="tab.locked" #icon-right>
<OIcon name="lock" size="xs" class="text-warning-600 shrink-0" />
</template>
</OToggleGroupItem>
</template>
</OToggleGroup>
@@ -586,6 +602,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OSpinner size="xs" />
</div>
<OTooltip
v-else-if="anomalyRowLocked(row)"
side="bottom"
:content="anomalyLockedReason"
>
<span class="ms-1 inline-flex max-md:hidden">
<OButton
:data-row-action="row.enabled ? 'pause' : 'resume'"
:data-test="`alert-list-${row.name}-pause-start-alert`"
:aria-label="row.enabled ? t('alerts.pause') : t('alerts.start')"
variant="ghost"
size="icon-sm"
icon-left="lock"
disabled
/>
</span>
</OTooltip>
<OButton
v-else
:data-row-action="row.enabled ? 'pause' : 'resume'"
@@ -602,7 +635,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:shortcut-id="row.enabled ? 'alertsRowPause' : undefined"
/>
</OButton>
<OTooltip
v-if="rowAnomalyLocked(row)"
side="bottom"
:content="anomalyLockedReason"
>
<span class="inline-flex max-md:hidden">
<OButton
data-row-action="edit"
:data-test="`alert-list-${row.name}-update-alert`"
:aria-label="t('alerts.edit')"
variant="ghost"
size="icon-sm"
icon-left="lock"
disabled
/>
</span>
</OTooltip>
<OButton
v-else
data-row-action="edit"
:data-test="`alert-list-${row.name}-update-alert`"
variant="ghost"
@@ -624,7 +675,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
with no way to find out why. -->
<span class="inline-flex max-md:hidden">
<OTooltip
v-if="isSloRow(row)"
v-if="anomalyRowLocked(row)"
side="bottom"
:content="anomalyLockedReason"
/>
<OTooltip
v-else-if="isSloRow(row)"
side="bottom"
:content="t('alerts.sloCloneDisabled')"
/>
@@ -632,14 +688,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-row-action="duplicate"
variant="ghost"
size="icon-sm"
icon-left="content-copy"
:disabled="isSloRow(row)"
:icon-left="anomalyRowLocked(row) ? 'lock' : 'content-copy'"
:disabled="isSloRow(row) || anomalyRowLocked(row)"
:aria-label="isSloRow(row) ? t('alerts.sloCloneDisabled') : t('alerts.clone')"
@click.stop="duplicateAlert(row)"
:data-test="`alert-list-${row.name}-clone-alert`"
>
<OTooltip
v-if="!isSloRow(row)"
v-if="!isSloRow(row) && !anomalyRowLocked(row)"
side="bottom"
:content="t('alerts.clone')"
shortcut-id="alertsRowDuplicate"
@@ -678,43 +734,71 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<ODropdownItem
class="md:hidden"
:disabled="!!alertStateLoadingMap[row.uuid]"
:disabled="!!alertStateLoadingMap[row.uuid] || anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-pause-start-alert-menu`"
@select="toggleAlertState(row)"
>
<template #icon-left>
<OIcon :name="row.enabled ? 'pause' : 'play-arrow'" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="
anomalyRowLocked(row) ? 'lock' : row.enabled ? 'pause' : 'play-arrow'
"
size="sm"
/>
</template>
{{ row.enabled ? t("alerts.pause") : t("alerts.start") }}
</ODropdownItem>
<ODropdownItem
class="md:hidden"
:disabled="rowAnomalyLocked(row)"
:focusable-unavailable="rowAnomalyLocked(row)"
:description="rowAnomalyLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-update-alert-menu`"
@select="editAlert(row)"
>
<template #icon-left>
<OIcon name="edit" size="sm" />
<OIcon
:class="rowAnomalyLocked(row) ? 'lock-chip' : undefined"
:name="rowAnomalyLocked(row) ? 'lock' : 'edit'"
size="sm"
/>
</template>
{{ t("alerts.edit") }}
</ODropdownItem>
<ODropdownItem
class="md:hidden"
:disabled="isSloRow(row)"
:disabled="isSloRow(row) || anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-clone-alert-menu`"
@select="duplicateAlert(row)"
>
<template #icon-left>
<OIcon name="content-copy" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'content-copy'"
size="sm"
/>
</template>
{{ t("alerts.clone") }}
</ODropdownItem>
<ODropdownSeparator class="md:hidden" />
<ODropdownItem
:disabled="anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-move-alert`"
@select="moveAlertToAnotherFolder(row)"
>
<template #icon-left>
<OIcon name="drive-file-move" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'drive-file-move'"
size="sm"
/>
</template>
{{ t("common.move") }}
</ODropdownItem>
@@ -730,7 +814,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("alerts.ack.acknowledge") }}
</ODropdownItem>
</template>
<template v-if="downtimesEnabled">
<template v-if="downtimesEnabled && !anomalyRowLocked(row)">
<ODropdownSeparator />
<template v-if="isAlertScopedMute(row) && row.active_downtime">
<MuteMenuItems
@@ -763,24 +847,38 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<ODropdownSeparator />
<ODropdownItem
:disabled="anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-delete-alert`"
variant="destructive"
shortcut-id="alertsRowDelete"
@select="showDeleteDialogFn({ row })"
>
<template #icon-left>
<OIcon name="delete" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'delete'"
size="sm"
/>
</template>
{{ t("alerts.delete") }}
</ODropdownItem>
<ODropdownSeparator />
<ODropdownItem
:disabled="anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-export-alert`"
shortcut-id="alertsRowExport"
@select="exportAlert(row)"
>
<template #icon-left>
<OIcon size="sm" name="download" />
<OIcon
size="sm"
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'download'"
/>
</template>
{{ t("common.export") }}
</ODropdownItem>
@@ -788,20 +886,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Anomaly Detection: Trigger Detection + Re-train -->
<template v-if="row.type === 'anomaly'">
<ODropdownItem
:disabled="anomalyLicenseLocked"
:focusable-unavailable="anomalyLicenseLocked"
:description="anomalyLicenseLocked ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-trigger-detection`"
@select="triggerAlert(row)"
>
<template #icon-left>
<OIcon size="sm" name="sound-sampler" />
<OIcon
size="sm"
:class="anomalyLicenseLocked ? 'lock-chip' : undefined"
:name="anomalyLicenseLocked ? 'lock' : 'sound-sampler'"
/>
</template>
{{ t("alerts.triggerDetection") }}
</ODropdownItem>
<ODropdownItem
:disabled="anomalyLicenseLocked"
:focusable-unavailable="anomalyLicenseLocked"
:description="anomalyLicenseLocked ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-retrain-anomaly`"
@select="retrainAnomaly(row)"
>
<template #icon-left>
<OIcon size="sm" name="replay" />
<OIcon
size="sm"
:class="anomalyLicenseLocked ? 'lock-chip' : undefined"
:name="anomalyLicenseLocked ? 'lock' : 'replay'"
/>
</template>
{{ t("alerts.retrain") }}
</ODropdownItem>
@@ -1202,6 +1314,8 @@ import type { StatItem } from "@/lib/data/StatStrip/OStatStrip.types";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { COL } from "@/lib/core/Table/OTable.types";
@@ -1355,7 +1469,7 @@ export default defineComponent({
// spinner. `loading` is the skeleton, which only a cold read wants.
const fetching = ref(false);
const lastUpdatedAt = ref<number | null>(null);
const forbidden = ref(false);
const forbidden = ref<ForbiddenState>(false);
const oTableRef: any = ref(null);
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
watch(
@@ -1484,9 +1598,13 @@ export default defineComponent({
const anomalyDetectionEntitled = computed(
() => store.state.zoConfig.build_type !== "opensource" && config.isEnterprise === "true",
);
const { locked: anomalyLicenseLocked, lockedReason: anomalyLockedReason } =
useLicenseLock("anomaly_detection");
const anomalyDetectionTabVisible = computed(
() =>
!anomalyDetectionEntitled.value || store.state.zoConfig.anomaly_detection_enabled === true,
!anomalyDetectionEntitled.value ||
store.state.zoConfig.anomaly_detection_enabled === true ||
anomalyLicenseLocked.value,
);
const isAnomalyDetectionEnabled = computed(
() =>
@@ -1498,7 +1616,11 @@ export default defineComponent({
// Prevent forcing anomalyDetection tab when the feature is not available.
const rawTab = (router.currentRoute.value.query.tab as string) || "all";
const activeTab = ref(
rawTab === "anomalyDetection" && !isAnomalyDetectionEnabled.value ? "all" : rawTab,
rawTab === "anomalyDetection" &&
!isAnomalyDetectionEnabled.value &&
!anomalyLicenseLocked.value
? "all"
: rawTab,
);
const filteredResults: Ref<any[]> = ref([]);
@@ -1514,6 +1636,9 @@ export default defineComponent({
? alertDrafts.savedAtVariant(ANOMALY_DRAFT_VARIANT)
: alertDrafts.savedAt(null),
);
const anomalyTabLicenseLocked = computed(
() => activeTab.value === "anomalyDetection" && anomalyLicenseLocked.value,
);
// ── "Calm Signal" table helpers ─────────────────────────────────────────
// Keyed on firing time, not run time; raw µs keeps it timezone-independent.
@@ -1538,6 +1663,11 @@ export default defineComponent({
// instead, and links there — which is also where it is edited (D1).
const isSloRow = (row: any): boolean => isSloAlert(row);
const rowAnomalyLocked = (row: any): boolean =>
anomalyLicenseLocked.value && (row?.type === "anomaly" || isSloRow(row));
// Every action on an anomaly row calls the anomaly detection API; SLO alerts only edit through it.
const anomalyRowLocked = (row: any): boolean =>
anomalyLicenseLocked.value && row?.type === "anomaly";
/** id -> name for every SLO in the org. `list_slos` is unpaginated and
* folder-agnostic when asked without a folder, so ONE call resolves every
@@ -1551,7 +1681,7 @@ export default defineComponent({
let sloNamesLoaded = false;
const ensureSloNames = (rows: any[]): void => {
if (sloNamesInFlight || sloNamesLoaded) return;
if (sloNamesInFlight || sloNamesLoaded || anomalyLicenseLocked.value) return;
if (!rows?.some((row: any) => isSloRow(row))) return;
// The org can legitimately be unset on the very first tick; throwing here
// would surface as an unhandled error inside a watcher, from a lookup
@@ -1794,6 +1924,7 @@ export default defineComponent({
"active" | "paused" | "failed" | "firing" | "acknowledged" | "recent" | null
>(savedAlertListFilters.stateFilter);
const displayedAlerts = computed(() => {
if (anomalyTabLicenseLocked.value) return [];
const rows = filteredResults.value || [];
const f = stateFilter.value;
if (!f) return rows;
@@ -1835,7 +1966,7 @@ export default defineComponent({
label: t("alerts.anomalyDetection"),
value: "anomalyDetection",
icon: "query-stats",
locked: !isAnomalyDetectionEnabled.value,
locked: anomalyLicenseLocked.value || !isAnomalyDetectionEnabled.value,
});
}
return tabs;
@@ -2429,7 +2560,7 @@ export default defineComponent({
} catch (error: any) {
console.error(error);
dismiss();
forbidden.value = error?.response?.status === 403;
forbidden.value = forbiddenState(error);
if (!forbidden.value) {
toast({
variant: "error",
@@ -2538,6 +2669,10 @@ export default defineComponent({
// OToggleGroup is controlled (`:model-value`), so refusing to advance
// `activeTab` here is what keeps a locked tab from becoming active —
// clicking it opens the upgrade dialog instead of switching.
if (tab === "anomalyDetection" && anomalyLicenseLocked.value) {
activeTab.value = tab;
return;
}
if (tab === "anomalyDetection" && !isAnomalyDetectionEnabled.value) {
openUpgradeDialog();
return;
@@ -2908,6 +3043,7 @@ export default defineComponent({
};
const duplicateAlert = async (row: any) => {
if (anomalyRowLocked(row)) return;
// SLO alerts are not clonable (D1). The dialog's first act is to demand a
// stream type and name, which this family has none of — and a clone that
// did succeed would be a creation path outside the SLO page that silently
@@ -3361,6 +3497,7 @@ export default defineComponent({
});
};
const showDeleteDialogFn = (props: any) => {
if (anomalyRowLocked(props?.row)) return;
selectedDelete.value = props.row;
confirmDelete.value = true;
};
@@ -3414,6 +3551,7 @@ export default defineComponent({
};
const toggleAlertState = (row: any) => {
if (anomalyRowLocked(row)) return;
alertStateLoadingMap.value[row.uuid] = true;
// Two mutations, not one conditional invalidation: an anomaly row is also a config Overview renders.
@@ -3536,7 +3674,7 @@ export default defineComponent({
};
const exportAlert = async (row: any) => {
if (exportLoading.value) return;
if (exportLoading.value || anomalyRowLocked(row)) return;
exportLoading.value = true;
try {
const alert = await fetchAlertForExport(row.alert_id);
@@ -3566,6 +3704,7 @@ export default defineComponent({
};
const triggerAlert = async (row: any) => {
if (anomalyLicenseLocked.value && row?.type === "anomaly") return;
try {
const res = await alertsService.trigger_alert(
store.state.selectedOrganization.identifier,
@@ -3581,7 +3720,9 @@ export default defineComponent({
variant: "warning",
message: res.data.message || t("alerts.messages.triggerAlertFailed"),
}
: { variant: "success", message: t("alerts.alertTriggeredSuccess") },
: res?.data?.warning
? { variant: "warning", message: raw(res.data.warning) }
: { variant: "success", message: t("alerts.alertTriggeredSuccess") },
);
if (row.type === "anomaly") {
await getAlertsFn(store, activeFolderId.value, "", true, "", true);
@@ -3596,6 +3737,7 @@ export default defineComponent({
};
const retrainAnomaly = async (row: any) => {
if (anomalyLicenseLocked.value) return;
try {
await retrainAnomalyAlert.mutateAsync(row.alert_id);
row.status = "training";
@@ -3665,6 +3807,7 @@ export default defineComponent({
};
const editAlert = async (row: any) => {
if (rowAnomalyLocked(row)) return;
// SLO alerts are authored on the SLO page: the generic editor has no way
// to represent one (no stream, no query, a different condition), and
// saving from it would either fail forever or strip the SLO wiring.
@@ -3708,6 +3851,7 @@ export default defineComponent({
};
const moveAlertToAnotherFolder = (row: any) => {
if (anomalyRowLocked(row)) return;
showMoveAlertDialog.value = true;
if (row.type === "anomaly") {
selectedAlertToMove.value = [];
@@ -4386,6 +4530,11 @@ export default defineComponent({
config,
isCompactToolbar,
isAnomalyDetectionEnabled,
anomalyLicenseLocked,
anomalyLockedReason,
anomalyTabLicenseLocked,
rowAnomalyLocked,
anomalyRowLocked,
refreshAlerts,
onAlertTabChange,
referenceDrawerOpen,
@@ -13,8 +13,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, beforeEach, vi } from "vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { defineComponent, h, ref } from "vue";
import { provideFeatureLock } from "@/composables/useFeatureLock";
import AlertSectionTabs from "./AlertSectionTabs.vue";
import i18n from "@/locales";
@@ -102,4 +104,56 @@ describe("AlertSectionTabs", () => {
const wrapper = mountTabs();
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toContain("Alert Library");
});
describe("license", () => {
afterEach(() => {
delete store.state.zoConfig.features;
});
const lockOf = (wrapper: ReturnType<typeof mountTabs>, key: string) =>
wrapper.find(`[data-test="alert-section-tab-${key}-lock"]`).exists();
it("puts a lock on every tab when alerts is locked", () => {
store.state.zoConfig.features = { alerts: "locked" };
const wrapper = mountTabs();
expect(lockOf(wrapper, "alertList")).toBe(true);
expect(lockOf(wrapper, "alertLibrary")).toBe(true);
expect(lockOf(wrapper, "alertDestinations")).toBe(true);
expect(lockOf(wrapper, "alertTemplates")).toBe(true);
});
it("shows no lock when alerts is enabled", () => {
store.state.zoConfig.features = { alerts: "enabled" };
const wrapper = mountTabs();
expect(wrapper.find('[data-test$="-lock"]').exists()).toBe(false);
});
it("drops the alert-owned tabs when alerts is hidden, keeping destinations and templates", () => {
store.state.zoConfig.features = { alerts: "hidden" };
const wrapper = mountTabs();
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toEqual([
"Notification Destinations",
"Destination Templates",
]);
});
it("still navigates to the free destinations from a license-locked alert page", async () => {
store.state.zoConfig.features = { alerts: "locked" };
const push = vi.spyOn(router, "push");
const LockedPage = defineComponent({
setup() {
provideFeatureLock(ref("alerts"));
return () => h(AlertSectionTabs);
},
});
const wrapper = mount(LockedPage, {
global: { provide: { store }, plugins: [i18n, router] },
});
await wrapper.find('[data-test="alert-section-tab-alertDestinations"]').trigger("mousedown");
expect(push).toHaveBeenCalledWith({
name: "alertDestinations",
query: { org_identifier: store.state.selectedOrganization.identifier },
});
});
});
});
+49 -24
View File
@@ -25,24 +25,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ships this pattern for Stream Pipelines / Functions.
-->
<template>
<OTabs
:model-value="activeSectionKey"
align="left"
data-test="alert-section-tabs"
@change="navigateToSection"
>
<OTab
v-for="section in sections"
:key="section.key"
:name="section.key"
:data-test="`alert-section-tab-${section.key}`"
<!-- The tabs only navigate, so a license-locked alert page still reaches the free destinations and templates. -->
<FeatureLockExempt class="contents">
<OTabs
:model-value="activeSectionKey"
align="left"
data-test="alert-section-tabs"
@change="navigateToSection"
>
<OIcon :name="section.icon" size="sm" class="shrink-0" />
<span :class="section.key === activeSectionKey ? '' : 'max-md:hidden'">{{
section.label
}}</span>
</OTab>
</OTabs>
<OTab
v-for="section in sections"
:key="section.key"
:name="section.key"
:data-test="`alert-section-tab-${section.key}`"
>
<OIcon :name="section.icon" size="sm" class="shrink-0" />
<span :class="section.key === activeSectionKey ? '' : 'max-md:hidden'">{{
section.label
}}</span>
<OIcon
v-if="section.licenseLocked"
name="lock"
size="xs"
class="lock-chip shrink-0"
:data-test="`alert-section-tab-${section.key}-lock`"
aria-hidden="true"
/>
</OTab>
</OTabs>
</FeatureLockExempt>
</template>
<script setup lang="ts">
@@ -54,7 +65,9 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { featureState } from "@/utils/featureGate";
const { t } = useI18nTyped();
const store = useStore();
@@ -68,6 +81,7 @@ interface Section {
label: I18nText;
icon: IconName;
to: RouteLocationRaw;
licenseLocked?: boolean;
}
const sections = computed<Section[]>(() => {
@@ -76,31 +90,41 @@ const sections = computed<Section[]>(() => {
const hideLibrary = (store.state.zoConfig?.custom_hide_menus ?? "")
.split(",")
.includes("alertLibrary");
const alerts = featureState(store.state.zoConfig?.features, "alerts");
const alertsHidden = alerts === "hidden";
const licenseLocked = alerts === "locked";
return [
// "All Alerts", not "Alerts": the page title above this strip already says
// "Alerts", so a tab repeating it names nothing. The rail's Alerts
// subsection uses the same string for the same reason.
{
key: "alertList",
label: t("alerts.allAlerts"),
icon: "shield-alert-outline",
to: { name: "alertList", query },
},
...(alertsHidden
? []
: [
{
key: "alertList",
label: t("alerts.allAlerts"),
icon: "shield-alert-outline" as IconName,
to: { name: "alertList", query },
licenseLocked,
},
]),
{
key: "alertDestinations",
label: t("alert_destinations.header"),
icon: "location-on",
to: { name: "alertDestinations", query },
licenseLocked,
},
{
key: "alertTemplates",
label: t("alert_templates.header"),
icon: "description",
to: { name: "alertTemplates", query },
licenseLocked,
},
// Last: the catalog is where you go once to fetch an alert, not where you
// work. The rail's Reliability flyout lists these four in this same order.
...(hideLibrary
...(hideLibrary || alertsHidden
? []
: [
{
@@ -108,6 +132,7 @@ const sections = computed<Section[]>(() => {
label: t("alert_library.header"),
icon: "menu-book" as IconName,
to: { name: "alertLibrary", query },
licenseLocked,
},
]),
];
@@ -370,6 +370,23 @@ describe("AlertTargetsSelect", () => {
});
});
it("shows a disabled Create Workflow button for a license lock and never opens the upgrade dialog", async () => {
const wrapper = createWrapper({
workflowsEnabled: false,
workflowsLocked: true,
workflowsLicenseLocked: true,
workflowsLockMessage: "Locked: needs Workflows",
});
const btn = wrapper.find('[data-test="create-workflow-btn"]');
expect(btn.exists()).toBe(true);
expect(btn.attributes("disabled")).toBeDefined();
const { isOpen, close } = useEnterpriseUpgradeDialog();
close();
await btn.trigger("click");
expect(isOpen.value).toBe(false);
});
// ── Malformed / partially-loaded lists ─────────────────────────────────────
// REGRESSION: with the backend unreachable, `getFormattedDestinations` maps
// `destination.name` over rows that have no `name`, producing [undefined].
@@ -85,6 +85,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Locked (not hidden): the family supports workflow routing, but this
edition doesn't unlock it — kept as the discovery point since the
dropdown's own "Workflows" header has no icon/tooltip slot to attach. -->
<OTooltip v-else-if="workflowsLicenseLocked" :content="workflowsLockMessage">
<span class="ms-2 inline-flex">
<OButton
data-test="create-workflow-btn"
variant="outline"
size="sm"
icon-left="lock"
disabled
>
{{ t("workflow.create") }}
</OButton>
</span>
</OTooltip>
<template v-else-if="workflowsLocked">
<OButton
data-test="create-workflow-btn"
@@ -93,7 +106,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="ms-2"
@click="openUpgradeDialog"
>
<template #icon-left><OIcon name="lock" size="sm" class="text-warning-600" /></template>
<template #icon-left><OIcon name="lock" size="sm" class="lock-chip" /></template>
{{ t("workflow.create") }}
</OButton>
</template>
@@ -106,6 +119,7 @@ import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useEnterpriseUpgradeDialog } from "@/composables/useEnterpriseUpgradeDialog";
const { open: openUpgradeDialog } = useEnterpriseUpgradeDialog();
@@ -125,6 +139,7 @@ const props = defineProps<{
workflowsEnabled: boolean;
/** Family supports workflows but this edition doesn't unlock them — show the group locked instead of hiding it. */
workflowsLocked?: boolean;
workflowsLicenseLocked?: boolean;
workflowsLockMessage?: I18nText;
error?: boolean;
placeholder?: I18nText;
@@ -465,6 +465,7 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import AlertSectionTabs from "@/components/alerts/AlertSectionTabs.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { COL } from "@/lib/core/Table/OTable.types";
@@ -752,7 +753,7 @@ export default defineComponent({
};
const loading = ref(false);
const forbidden = ref(false);
const forbidden = ref<ForbiddenState>(false);
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
watch(
loading,
@@ -829,7 +830,7 @@ export default defineComponent({
return true;
})
.catch((err: any) => {
forbidden.value = err?.response?.status === 403;
forbidden.value = forbiddenState(err);
if (!forbidden.value) {
toast({
variant: "error",
@@ -16,6 +16,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import { createStore } from "vuex";
import OButton from "@/lib/core/Button/OButton.vue";
import CreateAlertAction from "@/components/alerts/CreateAlertAction.vue";
@@ -53,7 +54,11 @@ const ButtonStub = {
const stubs = {
ODropdownItem: DropdownItemStub,
OButton: ButtonStub,
OTooltip: { name: "OTooltip", props: ["content"], template: `<span>{{ content }}</span>` },
OTooltip: {
name: "OTooltip",
props: ["content"],
template: `<span>{{ content }}<slot /></span>`,
},
};
const prefill = (): AlertPrefill => ({
@@ -327,4 +332,54 @@ describe("CreateAlertAction", () => {
wrapper = mountAction({ source: "logs" });
expect(wrapper.text()).toContain("Create Alert");
});
describe("license lock", () => {
const mountWithFeatures = (features: Record<string, string>, props = {}) =>
mount(CreateAlertAction, {
props: { source: "logs", build: vi.fn(needsDialog), variant: "button", ...props },
global: {
plugins: [
i18n,
createStore({ state: { zoConfig: { features, feature_names: { alerts: "Alerts" } } } }),
],
stubs,
},
});
it.each(["button", "toolbar", "icon"])(
"wraps the disabled %s in a hover tooltip with the license reason",
(variant) => {
wrapper = mountWithFeatures({ alerts: "locked" }, { variant });
const tooltip = wrapper.findComponent({ name: "OTooltip" });
expect(tooltip.props("content")).toBe("Locked: needs Alerts");
const button = tooltip.find("span.inline-flex").findComponent({ name: "OButton" });
expect(button.find("button").attributes("disabled")).toBeDefined();
expect(button.props("iconLeft")).toBe("lock");
},
);
it("opens nothing while locked", async () => {
wrapper = mountWithFeatures({ alerts: "locked" });
(wrapper.vm as any).$.setupState.onActivate();
expect(alertCreationDialog.value).toBeNull();
expect(mockOpenAlertCreation).not.toHaveBeenCalled();
});
it("disables the menu item with the license reason", () => {
wrapper = mountWithFeatures({ alerts: "locked" }, { variant: "menu-item" });
const item = wrapper.findComponent(DropdownItemStub);
expect(item.props("disabled")).toBe(true);
expect(item.props("description")).toBe("Locked: needs Alerts");
});
it("is unchanged while alerts is enabled", async () => {
wrapper = mountWithFeatures({ alerts: "enabled" });
const button = wrapper.findComponent({ name: "OButton" });
expect(button.props("disabled")).toBe(false);
expect(button.props("iconLeft")).not.toBe("lock");
await wrapper.find("button").trigger("click");
expect(alertCreationDialog.value).not.toBeNull();
});
});
});
+46 -18
View File
@@ -31,9 +31,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<ODropdownItem
v-if="variant === 'menu-item'"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:description="disabledReason || note"
:description="blockedReason || note"
:data-test="dataTest"
@select="onActivate"
>
@@ -41,28 +41,46 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
the dashboards panel menu does not — so the presentation is the host's
call while the icon NAME stays the registry's. -->
<template #icon-left>
<slot name="icon-left" :icon="source_.icon">
<OIcon :name="source_.icon" size="sm" />
<slot name="icon-left" :icon="icon">
<OIcon :name="icon" size="sm" />
</slot>
</template>
<span>{{ label }}</span>
</ODropdownItem>
<!-- A disabled button fires no pointer events, so the span is the tooltip's hover target. -->
<OTooltip v-else-if="locked" :content="lockedReason">
<span class="inline-flex">
<OButton
:variant="lockedButton.variant"
:size="lockedButton.size"
disabled
icon-left="lock"
:aria-label="label"
:data-test="dataTest"
>
<span v-if="variant !== 'icon'" :class="{ 'max-md:hidden': variant === 'toolbar' }">{{
label
}}</span>
</OButton>
</span>
</OTooltip>
<OButton
v-else-if="variant === 'button'"
:variant="buttonVariant"
size="sm-action"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:icon-left="source_.icon"
:icon-left="icon"
:data-test="dataTest"
@click="onActivate"
>
<template #unavailable-reason>{{ disabledReason }}</template>
<template #unavailable-reason>{{ blockedReason }}</template>
{{ label }}
<OTooltip
v-if="disabledReason || note"
:content="disabledReason || note || undefined"
v-if="blockedReason || note"
:content="blockedReason || note || undefined"
side="top"
/>
</OButton>
@@ -71,31 +89,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-else-if="variant === 'toolbar'"
variant="outline"
size="sm-toolbar"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:icon-left="source_.icon"
:icon-left="icon"
:aria-label="label"
:data-test="dataTest"
@click="onActivate"
>
<template #unavailable-reason>{{ disabledReason }}</template>
<template #unavailable-reason>{{ blockedReason }}</template>
<span class="max-md:hidden">{{ label }}</span>
<OTooltip v-if="disabledReason || isMobile" :content="disabledReason || label" />
<OTooltip v-if="blockedReason || isMobile" :content="blockedReason || label" />
</OButton>
<OButton
v-else
variant="ghost"
size="sm"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:icon-left="source_.icon"
:icon-left="icon"
:aria-label="label"
:data-test="dataTest"
@click="onActivate"
>
<template #unavailable-reason>{{ disabledReason }}</template>
<OTooltip :content="disabledReason || label" side="top" />
<template #unavailable-reason>{{ blockedReason }}</template>
<OTooltip :content="blockedReason || label" side="top" />
</OButton>
</template>
@@ -111,6 +129,7 @@ import { getAlertSource } from "@/utils/alerts/alertSourceRegistry";
import { needsConfirmation, normalizePrefill } from "@/utils/alerts/alertPrefill";
import { requestAlertCreation, useAlertCreation } from "@/composables/alerts/useAlertCreation";
import useBreakpoint from "@/composables/useBreakpoint";
import { useLicenseLock } from "@/composables/useFeatureLock";
const props = withDefaults(
defineProps<{
@@ -147,8 +166,17 @@ const { isMobile } = useBreakpoint();
const source_ = computed(() => getAlertSource(props.source));
const label = computed(() => t(source_.value.labelKey));
const { locked, lockedReason } = useLicenseLock("alerts");
const blockedReason = computed(() => (locked.value ? lockedReason.value : props.disabledReason));
const icon = computed(() => (locked.value ? "lock" : source_.value.icon));
const lockedButton = computed(() => {
if (props.variant === "toolbar") return { variant: "outline", size: "sm-toolbar" } as const;
if (props.variant === "icon") return { variant: "ghost", size: "sm" } as const;
return { variant: props.buttonVariant, size: "sm-action" } as const;
});
const onActivate = () => {
if (props.disabledReason) return;
if (blockedReason.value) return;
const prefill = normalizePrefill(props.build());
@@ -16,6 +16,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import i18n from "@/locales";
import { createStore } from "vuex";
import CreateAlertDialogProvider from "@/components/alerts/CreateAlertDialogProvider.vue";
import { ALERT_PREFILL_VERSION, type AlertPrefill } from "@/ts/interfaces/alertPrefill";
@@ -155,4 +156,18 @@ describe("CreateAlertDialogProvider", () => {
expect(alertCreationDialog.value).toBeNull();
});
it("shows no dialog and drops the request while alerts is license-locked", async () => {
wrapper = mount(CreateAlertDialogProvider, {
global: {
plugins: [i18n, createStore({ state: { zoConfig: { features: { alerts: "locked" } } } })],
stubs: { CreateAlertFromSourceDialog: DialogStub },
},
});
requestAlertCreation(prefill());
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(DialogStub).exists()).toBe(false);
expect(alertCreationDialog.value).toBeNull();
});
});
@@ -27,7 +27,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<CreateAlertFromSourceDialog
v-if="dialog"
v-if="dialog && !locked"
:open="dialog.open"
:prefill="dialog.prefill"
@update:open="onOpenChange"
@@ -38,8 +38,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { defineAsyncComponent, watch } from "vue";
import type { AlertPrefill } from "@/ts/interfaces/alertPrefill";
import { useLicenseLock } from "@/composables/useFeatureLock";
import {
alertCreationDialog as dialog,
closeAlertCreationDialog,
@@ -58,6 +59,14 @@ const CreateAlertFromSourceDialog = defineAsyncComponent({
});
const { openAlertCreation } = useAlertCreation();
const { locked } = useLicenseLock("alerts");
// Surfaces that request the dialog without CreateAlertAction must not leave a request that pops up after an unlock.
watch(
() => !!dialog.value && locked.value,
(blocked) => blocked && closeAlertCreationDialog(),
{ immediate: true },
);
const onConfirm = (prefill: AlertPrefill) => {
openAlertCreation(prefill, dialog.value?.options ?? {});
@@ -65,6 +65,19 @@ describe("DependencyUsageCell", () => {
wrapper.unmount();
});
it("shows an em dash and no badges or dialog while alerts is license-locked", async () => {
store.state.zoConfig.features = { alerts: "locked" };
try {
const wrapper = mountCell(graph, { kind: "destination", name: "slack" });
expect(wrapper.find('[data-test="used-by-slack-locked"]').text()).toBe("—");
expect(wrapper.find('[data-test="used-by-slack"]').exists()).toBe(false);
expect(wrapper.findComponent({ name: "DependencyImpactDialog" }).exists()).toBe(false);
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("shows the Unused chip for an orphan entity", async () => {
const wrapper = mountCell(graph, { kind: "destination", name: "lonely" });
await flushPromises();
@@ -21,7 +21,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
cell opens the impact dialog.
-->
<template>
<!-- Usage is counted from the alerts list, which a license without alerts cannot read. -->
<span v-if="alertsLocked" class="text-text-muted" :data-test="`used-by-${key}-locked`">{{
raw("—")
}}</span>
<OButton
v-else
variant="ghost"
size="sm"
class="h-auto! gap-1! px-1.5! py-1! font-normal!"
@@ -58,13 +63,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</OButton>
<DependencyImpactDialog v-model:open="open" :focus="focus" @deleted="emit('deleted', $event)" />
<DependencyImpactDialog
v-if="!alertsLocked"
v-model:open="open"
:focus="focus"
@deleted="emit('deleted', $event)"
/>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
import type { I18nText } from "@/types/i18n";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
@@ -82,6 +93,7 @@ const props = defineProps<{ graph: DepGraph; focus: DepFocus }>();
const emit = defineEmits<{ (e: "deleted", kind: DepNodeKind): void }>();
const { t } = useI18nTyped();
const { locked: alertsLocked } = useLicenseLock("alerts");
const open = ref(false);
@@ -349,6 +349,7 @@ import { getAlertSourceStatus } from "@/utils/alertSourceStatus";
import { formatTimeAgoUs } from "@/utils/synthetics/format";
import { copyToClipboard } from "@/utils/clipboard";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { getEndPoint, getIngestionURL } from "@/utils/zincutils";
import { COL } from "@/lib/core/Table/OTable.types";
import type { AlertSourceIntegration } from "@/ts/interfaces/alertSources";
@@ -428,7 +429,7 @@ export default defineComponent({
// Request in flight with rows still on screen — the refresh button's
// spinner. `loading` is the skeleton, for a cold read only.
fetching: false,
forbidden: false,
forbidden: false as ForbiddenState,
lastUpdatedAt: null as number | null,
filterQuery: "",
showAddDrawer: false,
@@ -627,7 +628,7 @@ export default defineComponent({
this.lastUpdatedAt =
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
} catch (e: any) {
this.forbidden = e?.response?.status === 403;
this.forbidden = forbiddenState(e);
if (!this.forbidden) {
toast({ variant: "error", message: this.t("alert_sources.error") });
}
@@ -341,6 +341,17 @@ describe("IncidentDetailDrawer.vue", () => {
expect(wrapper.vm.incidentDetails.title).toBe("Test Incident");
});
it("shows the license empty state when the load is license-denied", async () => {
(incidentsService.get as any).mockRejectedValue({
response: { status: 403, data: { code: 40001, error_detail: "incidents" } },
});
wrapper = await createWrapper({}, {}, "test-123");
await flushPromises();
expect(wrapper.find('[data-test="incident-detail-license-denied"]').text()).toContain(
"incidents",
);
});
it("shows no Alert Triggers count until the incident has loaded", async () => {
let resolveIncident: (value: any) => void = () => {};
(incidentsService.get as any).mockReturnValue(
@@ -1030,6 +1041,46 @@ describe("IncidentDetailDrawer.vue", () => {
});
});
describe("correlation license lock", () => {
afterEach(() => {
delete store.state.zoConfig.features;
});
it("locks the telemetry tabs and never calls the correlation API", async () => {
store.state.zoConfig.features = { correlation: "locked" };
wrapper = await createWrapper({}, {}, "1");
await flushPromises();
(incidentsService.getCorrelatedStreams as any).mockClear();
wrapper.vm.activeTab = "logs";
await flushPromises();
await wrapper.vm.refreshCorrelation();
expect(wrapper.find('[data-test="incident-correlation-license-denied"]').exists()).toBe(true);
expect(wrapper.findComponent({ name: "CorrelatedLogsTable" }).exists()).toBe(false);
expect(incidentsService.getCorrelatedStreams).not.toHaveBeenCalled();
const logsTab = wrapper
.findAllComponents({ name: "OTab" })
.find((tab) => tab.props("name") === "logs");
expect(logsTab?.props("suffixIcon")).toBe("lock");
});
it("loads correlated streams when correlation is enabled", async () => {
store.state.zoConfig.features = { correlation: "enabled" };
(incidentsService.getCorrelatedStreams as any).mockResolvedValue(null);
wrapper = await createWrapper({}, {}, "1");
await flushPromises();
wrapper.vm.activeTab = "logs";
await flushPromises();
expect(wrapper.find('[data-test="incident-correlation-license-denied"]').exists()).toBe(
false,
);
expect(incidentsService.getCorrelatedStreams).toHaveBeenCalled();
});
});
// Note: Loading State Centering tests removed
// Testing loading state UI presentation is better handled through visual regression tests
// Component logic tests should focus on loading state management, not CSS/layout
@@ -147,6 +147,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:name="tab"
:label="tab === 'alertTriggers' ? undefined : tabLabels[tab]"
:data-test="TAB_TEST_IDS[tab]"
:suffix-icon="isCorrelationTab(tab) && correlationLocked ? 'lock' : undefined"
:tooltip="
isCorrelationTab(tab) && correlationLocked ? correlationLockedReason : undefined
"
>
<template v-if="tab === 'alertTriggers'" #default>
<div class="flex items-center gap-1.5">
@@ -1203,7 +1207,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Logs Tab Content -->
<div v-if="activeTab === 'logs'" class="flex h-full flex-1 flex-col overflow-hidden">
<LicenseDeniedState
v-if="correlationLocked && isCorrelationTab(activeTab)"
:features="['correlation']"
data-test="incident-correlation-license-denied"
/>
<div
v-else-if="activeTab === 'logs'"
class="flex h-full flex-1 flex-col overflow-hidden"
>
<!-- Loading State -->
<div
v-if="correlationLoading"
@@ -1284,7 +1296,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Metrics Tab Content -->
<div
v-if="activeTab === 'metrics'"
v-else-if="activeTab === 'metrics'"
class="flex h-full flex-1 flex-col overflow-hidden"
>
<!-- Loading State -->
@@ -1365,7 +1377,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Traces Tab Content -->
<div
v-if="activeTab === 'traces'"
v-else-if="activeTab === 'traces'"
class="flex h-full flex-1 flex-col overflow-hidden"
>
<!-- Refresh Button (shown when traces data is loaded) -->
@@ -1470,6 +1482,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div v-if="loading" class="flex flex-1 items-center justify-center">
<OSpinner size="md" />
</div>
<LicenseDeniedState
v-else-if="licenseDenied"
:features="licenseDenied"
data-test="incident-detail-license-denied"
/>
</OPageLayout>
</div>
</template>
@@ -1539,6 +1556,9 @@ import { useIncidentRca } from "@/composables/useIncidentRca";
import type { RcaAskPayload } from "@/utils/incidents/rcaAskAttachment";
import BillingService, { type AiCreditCounts } from "@/services/billings";
import config from "@/aws-exports";
import { licenseDeniedFeatures } from "@/utils/featureGate";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
import { useLicenseLock } from "@/composables/useFeatureLock";
const LS_INCIDENT_TAB_ORDER_KEY = "o2_incident_detail_tab_order";
const DEFAULT_INCIDENT_TAB_ORDER = [
@@ -1563,6 +1583,12 @@ const TAB_TEST_IDS: Record<IncidentTab, string> = {
traces: "incident-traces-tab",
};
const CORRELATION_TABS: readonly string[] = ["logs", "metrics", "traces"];
function isCorrelationTab(name: unknown): boolean {
return CORRELATION_TABS.includes(name as string);
}
function isIncidentTab(name: unknown): name is IncidentTab {
return DEFAULT_INCIDENT_TAB_ORDER.includes(name as IncidentTab);
}
@@ -1603,6 +1629,7 @@ export default defineComponent({
OTruncatedText,
OInlineEdit,
OBanner,
LicenseDeniedState,
},
emits: ["close", "status-updated", "sendToAiChat"],
setup(props, { emit }) {
@@ -1637,6 +1664,7 @@ export default defineComponent({
};
const loading = ref(false);
const licenseDenied = ref<string[] | null>(null);
const updating = ref(false);
// Backend also returns a top-level correlation_reason not declared on IncidentWithAlerts.
const incidentDetails = ref<(IncidentWithAlerts & { correlation_reason?: string }) | null>(
@@ -1810,6 +1838,8 @@ export default defineComponent({
{ label: t("alerts.incidents.severityP4"), value: "P4" },
];
const { locked: correlationLocked, lockedReason: correlationLockedReason } =
useLicenseLock("correlation");
// Telemetry correlation state
const correlationData = ref<IncidentCorrelatedStreams | null>(null);
const correlationLoading = ref(false);
@@ -2095,7 +2125,7 @@ export default defineComponent({
// Fetch correlated telemetry streams
const fetchCorrelatedStreams = async (force: boolean = false) => {
if (!incidentDetails.value) return;
if (!incidentDetails.value || correlationLocked.value) return;
// Skip if already loaded and not forcing refresh
if (!force && correlationData.value) return;
@@ -2508,6 +2538,7 @@ export default defineComponent({
const loadDetails = async (incidentId: string) => {
loading.value = true;
licenseDenied.value = null;
// Reset correlation state when loading new incident
correlationData.value = null;
@@ -2539,6 +2570,7 @@ export default defineComponent({
}
} catch (error) {
console.error("Failed to load incident details:", error);
licenseDenied.value = licenseDeniedFeatures(error);
toast({
variant: "error",
message: t("toastMessages.alerts.failedToLoadIncidentDetails"),
@@ -3099,6 +3131,7 @@ export default defineComponent({
};
return {
licenseDenied,
mutedBannerText,
openMutingDowntime,
raw,
@@ -3181,6 +3214,9 @@ export default defineComponent({
alertActivityChartData,
getSemanticGroupDisplayName,
refreshCorrelation,
correlationLocked,
correlationLockedReason,
isCorrelationTab,
close,
handleSendToAiChat,
acknowledgeIncident,
@@ -311,6 +311,18 @@ describe("IncidentList.vue", () => {
await (wrapper.vm as any).refreshIncidents();
await flushPromises();
expect(incidentsService.list).toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "success" }));
});
it("does not report success when the refresh fails", async () => {
wrapper = createWrapper();
await flushPromises();
vi.clearAllMocks();
(incidentsService.list as any).mockRejectedValue({ response: { status: 403 } });
await (wrapper.vm as any).refreshIncidents();
await flushPromises();
expect(incidentsService.list).toHaveBeenCalled();
expect(toast).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -397,6 +397,7 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
import { forbiddenState } from "@/utils/featureGate";
import OTable from "@/lib/core/Table/OTable.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import ODimensionChip from "@/lib/core/Badge/ODimensionChip.vue";
@@ -466,7 +467,7 @@ export default defineComponent({
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
const forbidden = computed(() => {
const e: any = incidentsList.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// The first real load lands after mount: land on the saved page, or page 1 when it no longer exists. setTimeout(0) runs after the table has taken the new rows.
watch(
@@ -1120,8 +1121,7 @@ export default defineComponent({
);
const refreshIncidents = async () => {
// Explicit refresh: must reach the server.
await loadIncidents(true);
if ((await incidentsList.refetch()).isError) return;
toast({
variant: "success",
message: t("toastMessages.alerts.incidentsRefreshed"),
@@ -241,6 +241,7 @@ import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { COL } from "@/lib/core/Table/OTable.types";
interface ConformDelete {
@@ -414,7 +415,7 @@ export default defineComponent({
// spinner. `loading` is the skeleton, for a cold read only.
const fetching = ref(false);
const lastUpdatedAt = ref<number | null>(null);
const forbidden = ref(false);
const forbidden = ref<ForbiddenState>(false);
// Bound to refresh / post-write reloads: always reaches the server.
const refreshDestinations = () => getDestinations(true);
@@ -457,7 +458,7 @@ export default defineComponent({
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
})
.catch((err: any) => {
forbidden.value = err?.response?.status === 403;
forbidden.value = forbiddenState(err);
if (!forbidden.value) {
toast({
variant: "error",
+2 -1
View File
@@ -426,6 +426,7 @@ import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import { useDraftLabel } from "@/components/common/drafts/useDraftLabel";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { forbiddenState } from "@/utils/featureGate";
const AddTemplate = defineAsyncComponent(() => import("@/components/alerts/AddTemplate.vue"));
@@ -565,7 +566,7 @@ const refreshTemplates = () => getTemplates(true);
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
const forbidden = computed(() => {
const e: any = templatesList.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
watch(
@@ -133,6 +133,58 @@ describe("AddDowntime", () => {
await flushPromises();
};
it("locks the license-locked target modules and reads no synthetics folders", async () => {
vi.mocked(common.list_Folders).mockClear();
store.state.zoConfig.features = { synthetics: "locked", anomaly_detection: "locked" };
try {
const { wrapper } = await mountPage();
for (const module of ["synthetics", "anomaly_detections", "slos"]) {
const item = wrapper.get(`[data-test="add-downtime-module-${module}"]`);
expect(item.attributes("disabled")).toBeDefined();
}
expect(
wrapper.get('[data-test="add-downtime-module-alerts"]').attributes("disabled"),
).toBeUndefined();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).not.toContain("synthetics");
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("drops a prefilled module when a late config locks it", async () => {
const router = makeRouter();
await router.push({
name: "addDowntime",
query: { org_identifier: "default", module: "synthetics" },
});
await router.isReady();
const wrapper = mount(AddDowntime, {
global: { plugins: [store, router] },
attachTo: document.body,
});
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics"]').exists()).toBe(true);
try {
store.state.zoConfig.features = { synthetics: "locked" };
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics"]').exists()).toBe(false);
} finally {
delete store.state.zoConfig.features;
wrapper.unmount();
}
});
it("leaves every target module open while the features are enabled", async () => {
const { wrapper } = await mountPage();
for (const module of ["alerts", "synthetics", "anomaly_detections", "slos"]) {
const item = wrapper.get(`[data-test="add-downtime-module-${module}"]`);
expect(item.attributes("disabled")).toBeUndefined();
}
wrapper.unmount();
});
it("counts the reason against the server's 1,000-character cap", async () => {
const { wrapper } = await mountPage();
const field = wrapper.get('[data-test="add-downtime-reason"]');
@@ -162,7 +162,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="m"
:value="m"
size="sm"
:icon-left="MODULE_ICONS[m]"
:icon-left="moduleLocks[m].locked.value ? 'lock' : MODULE_ICONS[m]"
:disabled="moduleLocks[m].locked.value"
:focusable-unavailable="moduleLocks[m].locked.value"
:tooltip="
moduleLocks[m].locked.value ? moduleLocks[m].lockedReason.value : undefined
"
:data-test="`add-downtime-module-${m}`"
>
{{ t(MODULE_LABEL_KEYS[m]) }}
@@ -378,7 +383,7 @@ import { useMutation, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
import { useOrgId } from "@/composables/query";
import { useAutoName } from "@/composables/useAutoName";
import { useDowntimeItems } from "@/composables/downtimes/useDowntimeItems";
import { useDowntimeItems, useTargetModuleLocks } from "@/composables/downtimes/useDowntimeItems";
import { useDefaultDowntimeFolder } from "@/composables/downtimes/useDefaultDowntimeFolder";
import { foldersQuery, optionalFoldersQuery } from "@/services/common.queries";
import {
@@ -465,7 +470,10 @@ const queryList = (value: unknown): string[] =>
.filter(Boolean);
const browserZone = browserTimezone();
const prefillModule = MODULE_ORDER.find((m) => m === route.query.module);
const moduleLocks = useTargetModuleLocks();
const prefillModule = MODULE_ORDER.find(
(m) => m === route.query.module && !moduleLocks[m].locked.value,
);
// A new downtime without a folder in the URL is filed where the user may create one.
const folderDefault = useDefaultDowntimeFolder();
@@ -493,7 +501,9 @@ const alertFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "alerts"), { enabled: !!orgId.value }),
);
const syntheticFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), {
enabled: !!orgId.value && !moduleLocks.synthetics.locked.value,
}),
);
const detailQuery = useQuery(() =>
Object.assign(downtimeDetailQuery(orgId.value, sourceId.value, sourceFolder), {
@@ -653,6 +663,16 @@ watch(
() => values.value.modules,
(modules) => (listedModules.value = [...modules]),
);
// The org config can land after mount; a new downtime then drops a module its license locks.
watch(
() => MODULE_ORDER.filter((m) => moduleLocks[m].locked.value),
(locked) => {
if (isEdit.value || !locked.length) return;
const kept = values.value.modules.filter((m) => !locked.includes(m));
if (kept.length !== values.value.modules.length) form.setFieldValue("modules", kept);
},
{ immediate: true },
);
const folderId = computed(() => values.value.folder_id);
const setFolder = (value: string) => form.setFieldValue("folder_id", value || "default");
@@ -21,6 +21,7 @@ import DowntimeDetail from "./DowntimeDetail.vue";
import downtimes from "@/services/downtimes";
import type { DowntimeDetail as DowntimeDetailData } from "@/services/downtimes";
import alerts from "@/services/alerts";
import common from "@/services/common";
import { queryClient } from "@/composables/query/queryClient";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OTable from "@/lib/core/Table/OTable.vue";
@@ -256,6 +257,22 @@ describe("DowntimeDetail", () => {
wrapper.unmount();
});
it("reads no synthetics folders while synthetics is license-locked", async () => {
vi.mocked(downtimes.get).mockResolvedValue({ data: detail } as never);
vi.mocked(common.list_Folders).mockClear();
store.state.zoConfig.features = { synthetics: "locked" };
try {
const { wrapper } = await mountDetail();
await flushPromises();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).toContain("alerts");
expect(types).not.toContain("synthetics");
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("shows a not-found state on a 404", async () => {
vi.mocked(downtimes.get).mockRejectedValue({ response: { status: 404 } });
@@ -369,6 +369,7 @@ import { useRoute, useRouter } from "vue-router";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
import { useOrgId } from "@/composables/query";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { foldersQuery, optionalFoldersQuery } from "@/services/common.queries";
import { alertHistoryQuery } from "@/services/alerts.queries";
import {
@@ -478,8 +479,11 @@ const downtimeFolders = useQuery(() =>
const alertFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "alerts"), { enabled: !!orgId.value }),
);
const { locked: syntheticsLocked } = useLicenseLock("synthetics");
const syntheticFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), {
enabled: !!orgId.value && !syntheticsLocked.value,
}),
);
const historyQuery = useQuery(() =>
@@ -208,6 +208,28 @@ describe("DowntimeList", () => {
},
} as never);
it("reads no synthetics folders while synthetics is license-locked", async () => {
cutList();
store.state.zoConfig.features = { synthetics: "locked" };
try {
const wrapper = await mountList();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).toContain("alerts");
expect(types).not.toContain("synthetics");
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("reads the synthetics folders while synthetics is enabled", async () => {
cutList();
const wrapper = await mountList();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).toContain("synthetics");
wrapper.unmount();
});
it("says when the list is cut and keeps a folder's tiles on the rows it shows", async () => {
cutList();
const wrapper = await mountList({ folder: "team-a" });
@@ -616,7 +616,9 @@ import { useStore } from "vuex";
import { useRoute, useRouter } from "vue-router";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { useOrgId } from "@/composables/query";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { queryClient } from "@/composables/query/queryClient";
import { foldersQuery, optionalFoldersQuery } from "@/services/common.queries";
import { folderKeys } from "@/services/common.querykeys";
@@ -718,8 +720,11 @@ const listQuery = useQuery(() =>
const alertFoldersList = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "alerts"), { enabled: !!orgId.value }),
);
const { locked: syntheticsLocked } = useLicenseLock("synthetics");
const syntheticFoldersList = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), {
enabled: !!orgId.value && !syntheticsLocked.value,
}),
);
const downtimeFoldersList = useQuery(() =>
Object.assign(foldersQuery(orgId.value, "downtimes"), { enabled: !!orgId.value }),
@@ -731,9 +736,10 @@ const truncated = computed(() => listQuery.data.value?.truncated === true);
const loading = listQuery.isPending;
const fetching = listQuery.isFetching;
const lastUpdatedAt = listQuery.dataUpdatedAt;
const forbidden = computed(() => {
// A license denial carries its missing keys, so the table shows the license copy instead of the RBAC one.
const forbidden = computed<ForbiddenState>(() => {
const e: any = listQuery.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// A failed load must not read as "no downtimes yet" with a Create button.
@@ -1232,7 +1238,7 @@ const refreshAll = async () => {
void getFoldersListByType(store, "downtimes");
void listQuery.refetch();
void alertFoldersList.refetch();
void syntheticFoldersList.refetch();
if (!syntheticsLocked.value) void syntheticFoldersList.refetch();
};
useShortcuts([
@@ -103,6 +103,19 @@ describe("DowntimeResourcePicker", () => {
expect(keys[keys.length - 1]).toBe("k8s-pod");
});
it("asks the correlation analytics nothing while correlation is license-locked", async () => {
vi.mocked(serviceStreams.getSemanticGroups).mockResolvedValue({
data: [group("aws-ecs-cluster"), group("service"), group("k8s-pod")],
} as any);
store.state.zoConfig.features = { correlation: "locked" };
try {
await mountPicker();
expect(serviceStreams.getDimensionAnalytics).not.toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it("keeps Load resources for the full list with streams", async () => {
vi.mocked(serviceStreams.getSemanticGroups).mockResolvedValue({
data: [group("service"), group("host")],
@@ -125,6 +125,7 @@ import { computed, ref, watch } from "vue";
import { useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped } from "@/types/i18n";
import { useOrgId } from "@/composables/query";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { queryClient } from "@/composables/query/queryClient";
import { dimensionAnalyticsQuery, semanticGroupsQuery } from "@/services/service_streams.queries";
import { downtimeResourcesQuery, downtimeValuesQuery } from "@/services/downtimes.queries";
@@ -164,6 +165,7 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
const { toast } = useToast();
const orgId = useOrgId();
const { locked: correlationLocked } = useLicenseLock("correlation");
const semanticGroups = useQuery(() =>
Object.assign(semanticGroupsQuery(orgId.value), { enabled: !!orgId.value }),
@@ -178,7 +180,8 @@ const hasHost = computed(() => dimensionOptions.value.some((o) => o.value === DE
// The registry's ranking is only needed when the org has no `host` group.
const analytics = useQuery(() =>
Object.assign(dimensionAnalyticsQuery(orgId.value), {
enabled: !!orgId.value && !!semanticGroups.data.value && !hasHost.value,
enabled:
!!orgId.value && !!semanticGroups.data.value && !hasHost.value && !correlationLocked.value,
}),
);
@@ -8,12 +8,14 @@ import { useOForm } from "@/lib/forms/Form/useOForm";
import { ALL_FOLDERS, defaultDowntimeValues } from "@/utils/downtimes/downtimeForm";
import store from "@/test/unit/helpers/store";
import common from "@/services/common";
import { queryClient } from "@/composables/query/queryClient";
import DowntimeTargetCard from "./DowntimeTargetCard.vue";
const toast = vi.fn();
vi.mock("@/lib/feedback/Toast/useToast", () => ({ useToast: () => ({ toast }) }));
vi.mock("@/composables/downtimes/useDowntimeItems", () => ({
vi.mock("@/composables/downtimes/useDowntimeItems", async (importOriginal) => ({
...(await importOriginal<object>()),
useDowntimeItems: () => ({
items: ref([
{ id: "a1", name: "payments-db-replica-lag", folderId: "payments", tags: [] },
@@ -125,4 +127,31 @@ describe("DowntimeTargetCard", () => {
});
wrapper.unmount();
});
it("shows the lock and reads no synthetics folders while synthetics is license-locked", async () => {
queryClient.clear();
vi.mocked(common.list_Folders).mockClear();
store.state.zoConfig.features = { synthetics: "locked" };
try {
const { wrapper } = mountCard("synthetics", false);
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics-locked"]').exists()).toBe(true);
const picker = wrapper.find('[data-test="downtime-target-synthetics-folders"]');
expect(picker.find("[disabled]").exists()).toBe(true);
expect(wrapper.find('[data-test="downtime-target-synthetics-add-items"]').exists()).toBe(
false,
);
expect(common.list_Folders).not.toHaveBeenCalled();
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("shows no lock while the module's feature is enabled", async () => {
const { wrapper } = mountCard("synthetics", false);
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics-locked"]').exists()).toBe(false);
wrapper.unmount();
});
});
@@ -26,6 +26,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="text-text-heading text-sm font-semibold">
{{ t(MODULE_LABEL_KEYS[module]) }}
</span>
<OTooltip v-if="locked" :content="lockedReason">
<span class="inline-flex" :data-test="`downtime-target-${module}-locked`">
<OIcon name="lock" size="xs" class="lock-chip shrink-0" />
</span>
</OTooltip>
<span
v-if="matchCount !== null"
class="text-text-secondary ms-auto text-xs"
@@ -43,7 +48,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: t('alerts.downtimes.form.alertFolders')
"
:options="folderOptions"
:disabled="noAccess"
:disabled="noAccess || locked"
:help-text="noAccess ? noAccessHint : undefined"
multiple
searchable
@@ -111,7 +116,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div
v-if="!noAccess && (!idsOpen || (module === 'synthetics' && !tagsOpen))"
v-if="!noAccess && !locked && (!idsOpen || (module === 'synthetics' && !tagsOpen))"
class="flex flex-wrap gap-1"
>
<OButton
@@ -213,7 +218,8 @@ import type { IncidentMode, TargetModule } from "@/services/downtimes";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
import type { SelectOption } from "@/lib/forms/Select/OSelect.types";
import { useToast } from "@/lib/feedback/Toast/useToast";
import { useDowntimeItems } from "@/composables/downtimes/useDowntimeItems";
import { useDowntimeItems, useTargetModuleLocks } from "@/composables/downtimes/useDowntimeItems";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import {
ALL_FOLDERS,
exclusiveAllFolders,
@@ -269,9 +275,15 @@ const tagsOpen = computed(() => !!values.value?.tags_open);
const idsOpen = computed(() => !!values.value?.ids_open);
const sloMode = computed(() => values.value?.slo_mode);
const moduleLocks = useTargetModuleLocks();
const locked = computed(() => moduleLocks[props.module].locked.value);
const lockedReason = computed(() => moduleLocks[props.module].lockedReason.value);
const folderType = computed(() => (props.module === "synthetics" ? "synthetics" : "alerts"));
const foldersList = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, folderType.value), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, folderType.value), {
enabled: !!orgId.value && !locked.value,
}),
);
const folders = computed(() => foldersList.data.value?.folders ?? []);
const noAccess = computed(() => !!foldersList.data.value?.forbidden);
@@ -20,6 +20,7 @@ const { push } = vi.hoisted(() => ({ push: vi.fn() }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push }) }));
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import RcaBlindSpots from "./RcaBlindSpots.vue";
import type { RcaBlindSpot } from "@/services/incidents";
import {
@@ -73,6 +74,26 @@ describe("RcaBlindSpots (03 VE8, VE34, VE37)", () => {
expect(push).toHaveBeenCalledWith({ name, query: { org_identifier: "default" } });
});
it.each([
["connect_toolset", "ai_toolsets"],
["open_pipeline", "pipelines"],
] as const)("%s: disabled with a lock while %s is license-locked", async (fix, feature) => {
store.state.zoConfig.features = { [feature]: "locked" };
try {
const wrapper = mount(RcaBlindSpots, {
props: { blindSpots: [rcaBlindSpot({ fix })], org: "default", live: false },
global: { plugins: [i18n, store] },
});
const button = wrapper.find('[data-test="rca-blind-spot-action"]');
expect(button.attributes("disabled")).toBeDefined();
expect(wrapper.find('[data-test="rca-blind-spot-action-lock"]').exists()).toBe(true);
await button.trigger("click");
expect(push).not.toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it.each(["resume_run", "none"] as const)(
"%s renders no button without canResume (VE37)",
(fix) => {
@@ -52,8 +52,29 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="text-text-heading text-sm font-semibold">{{ spot.title }}</div>
<div class="text-text-secondary text-compact [text-wrap:pretty]">{{ spot.why }}</div>
</div>
<OTooltip v-if="actions[spot.id]?.lockedReason" :content="actions[spot.id]!.lockedReason!">
<span class="inline-flex">
<OButton
data-test="rca-blind-spot-action"
:data-fix="spot.fix"
variant="outline"
size="sm"
disabled
>
<template #icon-left>
<OIcon
name="lock"
class="lock-chip"
size="sm"
data-test="rca-blind-spot-action-lock"
/>
</template>
{{ actions[spot.id]!.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="actions[spot.id]"
v-else-if="actions[spot.id]"
data-test="rca-blind-spot-action"
:data-fix="spot.fix"
variant="outline"
@@ -89,7 +110,9 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import type { RcaBlindSpot, RcaBlindSpotFix } from "@/services/incidents";
import { rcaBlindSpotRoute } from "@/utils/incidents/rcaEvidenceLinks";
import { rcaBlindSpotFeature, rcaBlindSpotRoute } from "@/utils/incidents/rcaEvidenceLinks";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
const props = withDefaults(
defineProps<{
@@ -114,6 +137,10 @@ defineEmits<{
const { t } = useI18nTyped();
const router = useRouter();
const headingId = useId();
const featureLocks = {
ai_toolsets: useLicenseLock("ai_toolsets"),
pipelines: useLicenseLock("pipelines"),
};
const actionLabel = (fix: RcaBlindSpotFix): { label: I18nText; icon: IconName } | null => {
switch (fix) {
@@ -132,11 +159,17 @@ const actionLabel = (fix: RcaBlindSpotFix): { label: I18nText; icon: IconName }
/** Blind spot id → its button, for the spots that have one. */
const actions = computed(() => {
const byId: Record<string, { label: I18nText; icon: IconName; route: RouteLocationRaw }> = {};
const byId: Record<
string,
{ label: I18nText; icon: IconName; route: RouteLocationRaw; lockedReason: I18nText | null }
> = {};
for (const spot of props.blindSpots) {
const route = rcaBlindSpotRoute(spot.fix, props.org);
const action = actionLabel(spot.fix);
if (route && action) byId[spot.id] = { ...action, route };
const feature = rcaBlindSpotFeature(spot.fix);
const lock = feature ? featureLocks[feature] : null;
const lockedReason = lock?.locked.value ? lock.lockedReason.value : null;
if (route && action) byId[spot.id] = { ...action, route, lockedReason };
}
return byId;
});
@@ -21,6 +21,7 @@ vi.mock("vue-router", () => ({ useRouter: () => ({ push }) }));
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: toastMock }));
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import RcaEvidenceCard from "./RcaEvidenceCard.vue";
import type { RcaEvidenceEntry, RcaSummaryHypothesis } from "@/services/incidents";
import { rcaEvidenceLink } from "@/utils/incidents/rcaEvidenceLinks";
@@ -167,6 +168,45 @@ describe("RcaEvidenceCard (03 VE23–VE29)", () => {
expect(push).toHaveBeenCalledWith(rcaEvidenceLink(entry.link, "default")!.route);
});
it.each([
["logs", rcaEvidenceEntry()],
["metrics", rcaEvidenceEntry({ link: { signal: "metrics", streams: [], promql: "up" } })],
[
"traces",
rcaEvidenceEntry({ link: { signal: "traces", streams: ["default"], filter: "a=1" } }),
],
] as const)("%s locked: the open button is disabled with a lock", async (feature, entry) => {
store.state.zoConfig.features = { [feature]: "locked" };
try {
const wrapper = mount(RcaEvidenceCard, {
props: { entry, hypotheses: HYPOTHESES, org: "default" },
global: { plugins: [i18n, store] },
});
const open = byTest(wrapper, "rca-evidence-open");
expect(open.attributes("disabled")).toBeDefined();
expect(byTest(wrapper, "rca-evidence-open-lock").exists()).toBe(true);
await open.trigger("click");
expect(push).not.toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it("another signal locked: the open button stays live", async () => {
store.state.zoConfig.features = { traces: "locked" };
try {
const wrapper = mount(RcaEvidenceCard, {
props: { entry: rcaEvidenceEntry(), hypotheses: HYPOTHESES, org: "default" },
global: { plugins: [i18n, store] },
});
expect(byTest(wrapper, "rca-evidence-open-lock").exists()).toBe(false);
await byTest(wrapper, "rca-evidence-open").trigger("click");
expect(push).toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it.each([
["change", { signal: "change", streams: [] }],
["infra", { signal: "infra", streams: ["prod"] }],
@@ -92,8 +92,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ entry.result_summary }}
<span v-if="source" class="text-text-secondary">· {{ source }}</span>
</span>
<OTooltip v-if="link && linkLocked" :content="lockedReason(link.page)">
<span class="inline-flex">
<OButton data-test="rca-evidence-open" variant="ghost-primary" size="sm" disabled>
<template #icon-left>
<OIcon
name="lock"
class="text-icon-locked"
size="sm"
data-test="rca-evidence-open-lock"
/>
</template>
{{ openLabel }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="link"
v-else-if="link"
data-test="rca-evidence-open"
variant="ghost-primary"
size="sm"
@@ -134,6 +149,9 @@ import { rcaEvidenceLink, rcaEvidenceQuery } from "@/utils/incidents/rcaEvidence
import { rcaEvidenceKindIcon, rcaEvidenceTitle, rcaUtcRange } from "@/utils/incidents/rcaSummary";
import RcaInlineText from "./RcaInlineText.vue";
import RcaPill from "./RcaPill.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useLicenseLocks } from "@/composables/useFeatureLock";
import { streamTypeFeature } from "@/utils/featureGate";
const props = withDefaults(
defineProps<{
@@ -159,6 +177,9 @@ const router = useRouter();
const link = computed(() => rcaEvidenceLink(props.entry.link, props.org));
const query = computed(() => rcaEvidenceQuery(props.entry.link));
const { isLocked, lockedReason } = useLicenseLocks();
const linkLocked = computed(() => !!link.value && isLocked(streamTypeFeature(link.value.page)));
/** "Logs · checkout_api": the target and its streams. */
const title = computed<I18nText>(() => rcaEvidenceTitle(props.entry));
@@ -18,16 +18,23 @@ import { mount, type VueWrapper } from "@vue/test-utils";
import type * as VueRouter from "vue-router";
import type * as Vuex from "vuex";
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
const { pushMock, storeState } = vi.hoisted(() => ({
pushMock: vi.fn(),
storeState: {
selectedOrganization: { identifier: "acme" },
zoConfig: {} as { features?: Record<string, string> },
},
}));
vi.mock("vue-router", async (importOriginal) => ({
...(await importOriginal<typeof VueRouter>()),
useRouter: () => ({ push: pushMock }),
}));
vi.mock("vuex", async (importOriginal) => ({
...(await importOriginal<typeof Vuex>()),
useStore: () => ({ state: { selectedOrganization: { identifier: "acme" } } }),
useStore: () => ({ state: storeState }),
}));
import { createStore } from "vuex";
import i18n from "@/locales";
import RcaVerdictCard from "./RcaVerdictCard.vue";
import {
@@ -796,6 +803,28 @@ describe("RcaVerdictCard", () => {
expect(byTest(without, "rca-paused-connect-btn").exists()).toBe(false);
});
it("'Connect AI Toolset' is disabled with a lock while ai_toolsets is license-locked", async () => {
storeState.zoConfig.features = { ai_toolsets: "locked" };
try {
// useFeatureLock is loaded by the test setup before this file's vuex mock, so it injects a real store.
const wrapper = mount(RcaVerdictCard, {
props: {
runId: "run-2",
...PAUSED,
summary: rcaRunSummaryLive({ blind_spots: [rcaBlindSpot()] }),
},
global: { plugins: [i18n, createStore({ state: storeState })], stubs },
});
const button = byTest(wrapper, "rca-paused-connect-btn");
expect(button.attributes("disabled")).toBeDefined();
expect(byTest(wrapper, "rca-paused-connect-lock").exists()).toBe(true);
await button.trigger("click");
expect(pushMock).not.toHaveBeenCalled();
} finally {
delete storeState.zoConfig.features;
}
});
it("no paused banner while running", () => {
const wrapper = mountCard({ ...PAUSED, waitingInput: false });
expect(byTest(wrapper, "rca-paused-banner").exists()).toBe(false);
@@ -248,8 +248,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ t("alerts.incidents.rcaSteer.paused.tellAgent") }}
</OButton>
<OTooltip v-if="connectToolsetRoute && toolsetsLocked" :content="toolsetsLockedReason">
<span class="inline-flex">
<OButton data-test="rca-paused-connect-btn" variant="outline" size="sm" disabled>
<template #icon-left>
<OIcon
name="lock"
class="lock-chip"
size="sm"
data-test="rca-paused-connect-lock"
/>
</template>
{{ t("alerts.incidents.rcaSummary.fix.connectToolset") }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="connectToolsetRoute"
v-else-if="connectToolsetRoute"
data-test="rca-paused-connect-btn"
variant="outline"
size="sm"
@@ -444,6 +459,7 @@ import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import type {
RcaBand,
@@ -463,6 +479,7 @@ import {
} from "@/utils/incidents/rcaSummary";
import { formatTimestampInTimezone } from "@/utils/date";
import { rcaBlindSpotRoute } from "@/utils/incidents/rcaEvidenceLinks";
import { useLicenseLock } from "@/composables/useFeatureLock";
import type { RcaCardState } from "@/utils/incidents/rcaThread";
import type { RcaUnblockedLine } from "@/utils/incidents/rcaSteering";
import type { RcaRefusal } from "@/composables/useIncidentRca";
@@ -571,8 +588,11 @@ const connectToolsetRoute = computed(() =>
? rcaBlindSpotRoute("connect_toolset", props.org)
: null,
);
const { locked: toolsetsLocked, lockedReason: toolsetsLockedReason } =
useLicenseLock("ai_toolsets");
const openConnectToolset = () => {
if (connectToolsetRoute.value) void router.push(connectToolsetRoute.value);
if (connectToolsetRoute.value && !toolsetsLocked.value)
void router.push(connectToolsetRoute.value);
};
const unblockedText = computed<I18nText | null>(() => {
@@ -0,0 +1,82 @@
// 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 { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import i18n from "@/locales";
import { raw, type TranslateFn } from "@/types/i18n";
import { coverageFor } from "./fixtures";
import CoverageServiceDrawer from "./CoverageServiceDrawer.vue";
const t = ((key: string) => raw(key)) as unknown as TranslateFn;
const stubs = {
ODrawer: { template: "<div><slot name='header' /><slot /></div>" },
CoverageMetricChart: true,
CoverageSignalDetail: true,
};
const original = { ...store.state.zoConfig };
const mountDrawer = () => {
const row = coverageFor("demo", t)[0];
return mount(CoverageServiceDrawer, {
props: {
row,
serviceStates: {},
scenario: "demo",
ledger: [],
expanded: null,
anchor: "",
fix: null,
message: "",
},
global: { plugins: [i18n, store, router], stubs },
});
};
afterEach(() => {
store.commit("setConfig", original);
vi.restoreAllMocks();
});
describe("CoverageServiceDrawer — traces links", () => {
it("opens Traces and the catalog while traces is enabled", async () => {
const push = vi.spyOn(router, "push").mockResolvedValue(undefined);
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-test="autoobserve-coverage-drawer-traces"]').trigger("click");
await wrapper.find('[data-test="autoobserve-coverage-drawer-catalog"]').trigger("click");
expect(push).toHaveBeenCalledTimes(2);
expect(push).toHaveBeenLastCalledWith(
expect.objectContaining({
name: "traces",
query: expect.objectContaining({ tab: "services-catalog" }),
}),
);
});
it("disables both links while traces is locked", async () => {
store.commit("setConfig", { ...original, features: { traces: "locked" } });
const push = vi.spyOn(router, "push").mockResolvedValue(undefined);
const wrapper = mountDrawer();
await flushPromises();
for (const id of ["traces", "catalog"]) {
const btn = wrapper.find(`[data-test="autoobserve-coverage-drawer-${id}"]`);
expect(btn.attributes("disabled")).toBeDefined();
await btn.trigger("click");
}
expect(push).not.toHaveBeenCalled();
});
});
@@ -39,24 +39,38 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<span class="text-text-secondary block truncate text-xs">{{ subTitle }}</span>
</div>
<OButton
variant="outline"
size="sm-action"
icon-left="timeline"
:aria-label="t('autoobserve.coverage.openTraces')"
data-test="autoobserve-coverage-drawer-traces"
@click="openTraces()"
><span class="max-md:sr-only">{{ t("autoobserve.coverage.openTraces") }}</span></OButton
>
<OButton
variant="outline"
size="sm-action"
icon-left="hub"
:aria-label="t('autoobserve.coverage.openCatalog')"
data-test="autoobserve-coverage-drawer-catalog"
@click="openTraces('services-catalog')"
><span class="max-md:sr-only">{{ t("autoobserve.coverage.openCatalog") }}</span></OButton
>
<OTooltip :content="tracesLock.lockedReason.value" :disabled="!tracesLock.locked.value">
<span class="inline-flex">
<OButton
variant="outline"
size="sm-action"
:icon-left="tracesLock.locked.value ? 'lock' : 'timeline'"
:disabled="tracesLock.locked.value"
:aria-label="t('autoobserve.coverage.openTraces')"
data-test="autoobserve-coverage-drawer-traces"
@click="openTraces()"
><span class="max-md:sr-only">{{
t("autoobserve.coverage.openTraces")
}}</span></OButton
>
</span>
</OTooltip>
<OTooltip :content="tracesLock.lockedReason.value" :disabled="!tracesLock.locked.value">
<span class="inline-flex">
<OButton
variant="outline"
size="sm-action"
:icon-left="tracesLock.locked.value ? 'lock' : 'hub'"
:disabled="tracesLock.locked.value"
:aria-label="t('autoobserve.coverage.openCatalog')"
data-test="autoobserve-coverage-drawer-catalog"
@click="openTraces('services-catalog')"
><span class="max-md:sr-only">{{
t("autoobserve.coverage.openCatalog")
}}</span></OButton
>
</span>
</OTooltip>
</div>
</template>
@@ -243,6 +257,8 @@ import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
@@ -289,6 +305,7 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
const router = useRouter();
const tracesLock = useLicenseLock("traces");
const store = useStore();
const HEX_POINTS = "18.23,14.75 10,19.5 1.77,14.75 1.77,5.25 10,0.5 18.23,5.25";
@@ -0,0 +1,77 @@
<!-- 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/>.
-->
<!-- Not `inert`: Chromium stops wheel-scrolling inert scroll containers, so a locked page could not be scrolled. -->
<!-- pointerdown is left alone: Reka closes header and sidebar popovers on an outside pointerdown. -->
<template>
<div
class="h-full"
:class="locked ? 'feature-locked-content' : ''"
data-test="feature-lock-boundary"
:data-locked-feature="feature ?? undefined"
@click.capture="block"
@dblclick.capture="block"
@submit.capture="block"
@dragstart.capture="block"
@dragover.capture="block"
@drop.capture="block"
@paste.capture="block"
@keydown.capture="onKeydown"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { toRef } from "vue";
import { LOCK_EXEMPT_ATTR, provideFeatureLock } from "@/composables/useFeatureLock";
const props = defineProps<{ feature: string | null }>();
const SCROLL_KEYS = new Set([
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"PageUp",
"PageDown",
"Home",
"End",
" ",
]);
const CONTROL =
'a[href], button, input, select, textarea, [contenteditable="true"], [role="button"], [role="tab"], [role="checkbox"], [role="switch"], [role="radio"], [role="combobox"], [role="menuitem"], [role="option"], [role="slider"], [role="separator"]';
const { locked } = provideFeatureLock(toRef(props, "feature"));
const block = (event: Event) => {
if (!locked.value || (event.target as Element | null)?.closest?.(`[${LOCK_EXEMPT_ATTR}]`)) return;
event.preventDefault();
event.stopPropagation();
};
// Only read-only shortcuts: a page's own Cmd/Ctrl+S or Cmd/Ctrl+Enter would still act.
const READ_SHORTCUTS = new Set(["c", "a", "f", "g"]);
// Tab, copy/select-all/find and scroll keys off a control pass; anything else aimed at the page is swallowed.
const onKeydown = (event: KeyboardEvent) => {
if (!locked.value || event.key === "Tab") return;
if ((event.metaKey || event.ctrlKey) && READ_SHORTCUTS.has(event.key.toLowerCase())) return;
const onControl = (event.target as Element | null)?.closest?.(CONTROL);
if (!onControl && SCROLL_KEYS.has(event.key)) return;
block(event);
};
</script>
@@ -0,0 +1,30 @@
<!-- 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/>.
-->
<!-- Only for controls that navigate: anything inside stays usable on a license-locked page. -->
<template>
<component :is="as" v-bind="{ [LOCK_EXEMPT_ATTR]: '' }">
<slot />
</component>
</template>
<script setup lang="ts">
import { LOCK_EXEMPT_ATTR, provideFeatureLockExempt } from "@/composables/useFeatureLock";
withDefaults(defineProps<{ as?: string }>(), { as: "div" });
provideFeatureLockExempt();
</script>
@@ -0,0 +1,48 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import FeatureLocked from "@/components/common/FeatureLocked.vue";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
vi.mock("vue-router", () => ({ useRoute: () => ({ params: { feature: "ai_assistant" } }) }));
vi.mock("vuex", () => ({
useStore: () => ({ state: { zoConfig: { feature_names: { ai_assistant: "AI Assistant" } } } }),
}));
describe("FeatureLocked", () => {
it("renders the shared license-denied state for the routed feature", () => {
const w = mount(FeatureLocked, {
global: {
plugins: [i18n],
stubs: { OPageLayout: { template: "<div><slot /></div>" } },
},
});
expect(w.findComponent(LicenseDeniedState).props("features")).toEqual(["ai_assistant"]);
});
it("tells the user to ask an administrator and offers no contact-sales action", () => {
const w = mount(FeatureLocked, {
global: {
plugins: [i18n],
stubs: { OPageLayout: { template: "<div><slot /></div>" } },
},
});
expect(w.text()).toContain("Ask your administrator to update the license");
expect(w.find('[data-test="feature-locked-contact-sales"]').exists()).toBe(false);
});
});
@@ -0,0 +1,42 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<FeatureLockExempt class="h-full">
<OPageLayout :title="label" :icon="meta?.icon ?? 'lock'" data-test="feature-locked-page">
<LicenseDeniedState :features="[key]" />
</OPageLayout>
</FeatureLockExempt>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { raw, useI18nTyped } from "@/types/i18n";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
import { useFeatureNames } from "@/composables/useFeatureLock";
import { LOCKED_FEATURES } from "@/utils/featureGate";
const { t } = useI18nTyped();
const route = useRoute();
const { featureName } = useFeatureNames();
const key = computed(() => String(route.params.feature ?? ""));
const meta = computed(() => LOCKED_FEATURES[key.value]);
const label = computed(() => raw(featureName(key.value)) || t("featureLock.thisFeature"));
</script>
@@ -0,0 +1,42 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<OEmptyState
size="hero"
preset="no-access"
:title="t('featureLock.toastTitle')"
:description="
features.length
? t('featureLock.toastMessage', { features: raw(featureNames(features)) })
: t('featureLock.toastMessageGeneric')
"
>
<template v-if="$slots.actions" #actions><slot name="actions" /></template>
</OEmptyState>
</template>
<script setup lang="ts">
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { useFeatureNames } from "@/composables/useFeatureLock";
/** The missing feature keys of a license denial, from `licenseDeniedFeatures()`. */
defineProps<{ features: readonly string[] }>();
const { t } = useI18nTyped();
const { featureNames } = useFeatureNames();
</script>
@@ -0,0 +1,58 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import SectionHub from "./SectionHub.vue";
const push = vi.fn();
vi.mock("vue-router", () => ({ useRouter: () => ({ push }) }));
const openUpgradeDialog = vi.fn();
vi.mock("@/composables/useEnterpriseUpgradeDialog", () => ({
useEnterpriseUpgradeDialog: () => ({ open: openUpgradeDialog }),
}));
describe("SectionHub", () => {
it("draws the lock on a license-locked card and still opens its page", async () => {
const wrapper = mount(SectionHub, {
props: {
groups: [
{
label: "Access",
items: [
{ key: "roles", label: "Roles", to: { name: "roles" }, licenseLocked: true },
{ key: "users", label: "Users", to: { name: "users" } },
],
},
] as any,
},
global: {
stubs: {
ConstrainedPage: { template: "<div><slot /></div>" },
OIcon: { props: ["name"], template: "<i :data-icon='name' />" },
},
},
});
const card = wrapper.get('[data-test="section-hub-card-roles"]');
expect(card.find('[data-icon="lock"]').exists()).toBe(true);
expect(
wrapper.get('[data-test="section-hub-card-users"]').find('[data-icon="lock"]').exists(),
).toBe(false);
await card.trigger("click");
expect(push).toHaveBeenCalledWith({ name: "roles" });
expect(openUpgradeDialog).not.toHaveBeenCalled();
});
});
+10 -1
View File
@@ -73,7 +73,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span
class="text-text-heading group-hover:text-accent flex items-center gap-1.5 text-sm font-semibold transition-colors"
>{{ item.label }}
<OIcon v-if="item.locked" name="lock" size="xs" class="text-warning-600" />
<OIcon
v-if="item.locked || item.licenseLocked"
name="lock"
size="xs"
class="lock-chip"
/>
</span>
<span
v-if="item.description"
@@ -117,6 +122,10 @@ export interface SectionHubItem {
locked?: boolean;
lockedMessage?: I18nText;
lockedShowUpgradeCta?: boolean;
/** The license lacks this feature: shows the lock but still opens `to`, whose page shows the license banner. */
licenseLocked?: boolean;
/** Screen-reader text for the lock, e.g. "Locked: needs X". */
lockedLabel?: I18nText;
dataTest?: string;
}
+56 -2
View File
@@ -3,7 +3,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { h } from "vue";
import SectionRail from "./SectionRail.vue";
import FeatureLockBoundary from "./FeatureLockBoundary.vue";
const push = vi.fn();
vi.mock("vue-router", () => ({
@@ -30,8 +32,8 @@ const stubs = {
OTabs: { template: "<div><slot /></div>" },
OTab: {
name: "OTab",
props: ["name", "label", "icon", "tooltip"],
template: "<button :data-name='name'>{{ label }}</button>",
props: ["name", "label", "icon", "tooltip", "suffixIcon"],
template: "<button :data-name='name'>{{ label }}<slot name='icon' /></button>",
},
OButton: {
emits: ["click"],
@@ -141,6 +143,58 @@ describe("SectionRail", () => {
expect(push).toHaveBeenCalledWith({ name: "a" });
});
const lockedGroups = (flags: Record<string, boolean>) => [
{
label: "Permissions",
items: [{ key: "roles", label: "Roles", to: { name: "roles" }, ...flags }],
},
];
it("keeps its links enabled inside a license-locked page", async () => {
const wrapper = mount(FeatureLockBoundary, {
props: { feature: "rbac" },
slots: { default: () => h(SectionRail, { groups }) },
global: { stubs: { OTooltip: stubs.OTooltip } },
});
const tab = wrapper.get('[role="tab"]');
expect(tab.attributes("disabled")).toBeUndefined();
expect(tab.attributes("aria-disabled")).toBeUndefined();
await tab.trigger("click");
expect(push).toHaveBeenCalledWith({ name: "a" });
wrapper.unmount();
});
it("routes an edition-locked item to the upgrade page", async () => {
const wrapper = mountRail({ groups: lockedGroups({ locked: true }) });
expect(wrapper.findComponent({ name: "OTab" }).props("suffixIcon")).toBe("lock");
await wrapper.get('[data-name="roles"]').trigger("click");
expect(push).toHaveBeenCalledWith({ name: "enterpriseFeatures" });
});
// The licensed page shows its own banner, so the upgrade page would only hide it.
it("shows the lock on a license-locked item but still opens its page", async () => {
const wrapper = mountRail({ groups: lockedGroups({ licenseLocked: true }) });
expect(wrapper.findComponent({ name: "OTab" }).props("suffixIcon")).toBe("lock");
await wrapper.get('[data-name="roles"]').trigger("click");
expect(push).toHaveBeenCalledWith({ name: "roles" });
});
it("swaps the lock suffix for a corner badge once collapsed", async () => {
const wrapper = mountRail({
groups: lockedGroups({
licenseLocked: true,
lockedLabel: "Locked: needs RBAC",
icon: "lock",
}),
collapsible: true,
icon: "auto-awesome",
});
await wrapper.get('[data-test="section-rail-toggle"]').trigger("click");
expect(wrapper.findComponent({ name: "OTab" }).props("suffixIcon")).toBeUndefined();
expect(wrapper.get(".sr-only").text()).toBe("Locked: needs RBAC");
expect(wrapper.find(".text-icon-locked").exists()).toBe(true);
});
const headings = (wrapper: ReturnType<typeof mountRail>) =>
wrapper.findAll("div.text-text-secondary.font-semibold");
+18 -4
View File
@@ -22,7 +22,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
tinted-active treatment. Sentence-case group headings, soft single-line items.
-->
<template>
<nav class="bg-surface-panel flex h-full min-h-0 flex-col" data-test="section-rail">
<FeatureLockExempt
as="nav"
class="bg-surface-panel flex h-full min-h-0 flex-col"
data-test="section-rail"
>
<!-- Collapsed there is room for one mark, so the module icon doubles as the
expand control rather than sitting inert beside a second button. It is
tinted like MenuLink's active module for the same reason the primary nav
@@ -104,17 +108,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="collapsed ? undefined : item.label"
:tooltip="collapsed ? item.label : undefined"
:icon="item.icon"
:suffix-icon="item.locked && !collapsed ? 'lock' : undefined"
:suffix-icon="(item.locked || item.licenseLocked) && !collapsed ? 'lock' : undefined"
:suffix-label="item.lockedLabel"
:data-test="item.locked ? `${item.dataTest}-locked` : item.dataTest"
class="w-full"
:class="collapsed ? 'justify-center!' : undefined"
@click="navigate(item.locked ? { name: 'enterpriseFeatures' } : item.to)"
/>
>
<template v-if="collapsed && (item.locked || item.licenseLocked)" #icon>
<span class="relative inline-flex shrink-0">
<OIcon :name="item.icon as IconName" size="sm" class="o-tab__icon" />
<OLockBadge :label="item.lockedLabel" />
</span>
</template>
</OTab>
</template>
</template>
</OTabs>
</div>
</nav>
</FeatureLockExempt>
</template>
<script setup lang="ts">
@@ -123,12 +135,14 @@ import { computed } from "vue";
import { useRouter, type RouteLocationRaw } from "vue-router";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import type { SectionHubGroup } from "@/components/common/SectionHub.vue";
import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
const router = useRouter();
const { t } = useI18nTyped();
@@ -117,6 +117,7 @@ import { useStore } from "vuex";
import OButton from "@/lib/core/Button/OButton.vue";
import AddFolder from "./AddFolder.vue";
import { getFoldersListByType } from "@/utils/commons";
import { licenseDeniedFeatures } from "@/utils/featureGate";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import { useFolderIcons } from "@/composables/useFolderIcons";
@@ -208,7 +209,10 @@ export default defineComponent({
};
onMounted(async () => {
await getFoldersListByType(store, props.type);
// The top bar already explains a license denial; anything else still surfaces.
await getFoldersListByType(store, props.type).catch((error) => {
if (!licenseDeniedFeatures(error)) throw error;
});
});
return {
@@ -33,6 +33,31 @@ describe("AlertContextMenu Component", () => {
vi.clearAllMocks();
});
describe("locked", () => {
it("shows disabled, focusable items with a gold lock and the reason, and emits nothing", async () => {
wrapper = createWrapper({ locked: true, lockedReason: "Locked: needs Alerts" });
for (const id of ["above", "below"]) {
const item = wrapper.find(`[data-test="alert-context-menu-${id}"]`);
expect(item.attributes("aria-disabled")).toBe("true");
expect(item.attributes("tabindex")).toBe("0");
expect(wrapper.find(`[data-test="alert-context-menu-${id}-lock"]`).classes()).toContain(
"lock-chip",
);
expect(wrapper.find(`[data-test="alert-context-menu-${id}-reason"]`).text()).toBe(
"Locked: needs Alerts",
);
await item.trigger("click");
}
expect(wrapper.emitted("select")).toBeUndefined();
});
it("locks the single forecast item", () => {
wrapper = createWrapper({ locked: true, lockedReason: "x", seriesRole: "forecast" });
expect(wrapper.find('[data-test="alert-context-menu-forecast-lock"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-context-menu-above"]').exists()).toBe(false);
});
});
describe("Component Initialization", () => {
it("should mount successfully", () => {
wrapper = createWrapper();
@@ -24,39 +24,69 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click.stop
data-test="alert-context-menu"
>
<div
v-if="seriesRole === 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('forecast')"
data-test="alert-context-menu-forecast"
>
<OIcon name="trending-up" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.forecastReaches", { value: forecastText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('above')"
data-test="alert-context-menu-above"
>
<OIcon name="arrow-upward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdAbove", { value: valueText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('below')"
data-test="alert-context-menu-below"
>
<OIcon name="arrow-downward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdBelow", { value: valueText })
}}</span>
</div>
<template v-if="locked">
<div
v-for="item in lockedItems"
:key="item.condition"
class="text-dropdown-item-disabled focus-visible:ring-focus-ring-accent flex cursor-not-allowed items-center px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-inset"
role="menuitem"
aria-disabled="true"
tabindex="0"
:data-test="`alert-context-menu-${item.condition}`"
>
<OIcon
name="lock"
size="sm"
class="lock-chip me-2"
:data-test="`alert-context-menu-${item.condition}-lock`"
/>
<span class="flex min-w-0 flex-col">
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
item.label
}}</span>
<span
class="text-text-secondary text-xs select-none"
:data-test="`alert-context-menu-${item.condition}-reason`"
>{{ lockedReason }}</span
>
</span>
</div>
</template>
<template v-else>
<div
v-if="seriesRole === 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('forecast')"
data-test="alert-context-menu-forecast"
>
<OIcon name="trending-up" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.forecastReaches", { value: forecastText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('above')"
data-test="alert-context-menu-above"
>
<OIcon name="arrow-upward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdAbove", { value: valueText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('below')"
data-test="alert-context-menu-below"
>
<OIcon name="arrow-downward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdBelow", { value: valueText })
}}</span>
</div>
</template>
</div>
</teleport>
</template>
@@ -101,6 +131,8 @@ export default defineComponent({
},
unit: { type: String, default: null },
unitCustom: { type: String, default: null },
locked: { type: Boolean, default: false },
lockedReason: { type: String, default: "" },
},
emits: ["select", "close"],
setup(props, { emit }) {
@@ -153,6 +185,26 @@ export default defineComponent({
);
});
const lockedItems = computed(() =>
props.seriesRole === "forecast"
? [
{
condition: "forecast",
label: t("dashboard.alertContextMenu.forecastReaches", { value: forecastText.value }),
},
]
: [
{
condition: "above",
label: t("dashboard.alertContextMenu.thresholdAbove", { value: valueText.value }),
},
{
condition: "below",
label: t("dashboard.alertContextMenu.thresholdBelow", { value: valueText.value }),
},
],
);
const handleMenuItemClick = (condition: "above" | "below" | "forecast") => {
emit("select", {
condition,
@@ -216,6 +268,7 @@ export default defineComponent({
valueText,
forecastText,
menuStyle,
lockedItems,
handleMenuItemClick,
};
},
@@ -0,0 +1,92 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, vi, beforeEach } from "vitest";
import { flushPromises, shallowMount } from "@vue/test-utils";
import { ref } from "vue";
import { createStore } from "vuex";
import i18n from "@/locales";
const findRelatedTelemetry = vi.fn().mockResolvedValue(null);
vi.mock("@/composables/useServiceCorrelation", () => ({
useServiceCorrelation: () => ({
findRelatedTelemetry,
semanticGroups: ref([]),
noMatch: ref(false),
error: ref(null),
}),
}));
vi.mock("@/services/search", () => ({
default: { search: vi.fn().mockResolvedValue({ data: { hits: [], total: 0 } }) },
}));
vi.mock("@/services/patterns", () => ({ default: {} }));
vi.mock("vue-router", () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
}));
import DashboardLogDrawer from "@/components/dashboards/DashboardLogDrawer.vue";
const mountDrawer = (features: Record<string, string>, streamType?: string) =>
shallowMount(DashboardLogDrawer, {
props: { stream: "app", field: "level", value: "error", startTime: 1, endTime: 2, streamType },
global: {
renderStubDefaultSlot: true,
stubs: { OButton: false },
plugins: [
i18n,
createStore({
state: { selectedOrganization: { identifier: "org" }, zoConfig: { features } },
}),
],
},
});
const openInLogsDisabled = (wrapper: ReturnType<typeof mountDrawer>) =>
wrapper.find('[data-test="log-explorer-open-in-logs"]').attributes("aria-disabled") === "true";
describe("DashboardLogDrawer related telemetry", () => {
beforeEach(() => findRelatedTelemetry.mockClear());
it("makes no correlation call while correlation is license-locked", async () => {
const wrapper = mountDrawer({ correlation: "locked" });
await flushPromises();
await (wrapper.vm as any).$.setupState.openCorrelationFromLog({ _timestamp: 1 });
expect(findRelatedTelemetry).not.toHaveBeenCalled();
});
it.each([
[undefined, "logs"],
["metrics", "metrics"],
["traces", "traces"],
])("stream type %s with %s locked: Open in Logs is disabled with a lock", (streamType, key) => {
const wrapper = mountDrawer({ [key]: "locked" }, streamType);
expect(openInLogsDisabled(wrapper)).toBe(true);
expect(wrapper.find('[data-test="log-explorer-open-in-logs-lock"]').exists()).toBe(true);
});
it("Open in Logs stays live while another signal is locked", () => {
const wrapper = mountDrawer({ traces: "locked" });
expect(openInLogsDisabled(wrapper)).toBe(false);
expect(wrapper.find('[data-test="log-explorer-open-in-logs-lock"]').exists()).toBe(false);
});
it("looks up related telemetry while correlation is enabled", async () => {
const wrapper = mountDrawer({ correlation: "enabled" });
await flushPromises();
await (wrapper.vm as any).$.setupState.openCorrelationFromLog({ _timestamp: 1 });
expect(findRelatedTelemetry).toHaveBeenCalledTimes(1);
});
});

Some files were not shown because too many files have changed in this diff Show More