mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
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:
@@ -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`.
|
||||
|
||||
@@ -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/**"] };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1540,6 +1540,10 @@ export default defineComponent({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!text.trim()) {
|
||||
toast({ variant: "info", message: t("common.dateRangePasteEmpty") });
|
||||
return;
|
||||
}
|
||||
applyPastedTextWithToast(text);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user