diff --git a/.github/scripts/build-ci-matrix.js b/.github/scripts/build-ci-matrix.js index 3331ff6046..fecd477e82 100644 --- a/.github/scripts/build-ci-matrix.js +++ b/.github/scripts/build-ci-matrix.js @@ -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 # 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 [overlay.json] [--select-for-changes ]"); + die("usage: build-ci-matrix.js [overlay.json] [--select-for-changes | --only-shards ]"); } 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`. diff --git a/.github/scripts/build-ci-matrix.test.js b/.github/scripts/build-ci-matrix.test.js index dfd1ad978a..4dd864a83e 100644 --- a/.github/scripts/build-ci-matrix.test.js +++ b/.github/scripts/build-ci-matrix.test.js @@ -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/**"] }; diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 3bd77dea95..5989ab16a4 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -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 diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 9e528430ed..ca7c686ff2 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -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", diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index d13a542c10..ec936b404f 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -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", diff --git a/src/api/http/src/handler/http/router/openapi.rs b/src/api/http/src/handler/http/router/openapi.rs index 7abd326ed4..09f53d7827 100644 --- a/src/api/http/src/handler/http/router/openapi.rs +++ b/src/api/http/src/handler/http/router/openapi.rs @@ -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, diff --git a/src/api/management/src/request/alerts/ack.rs b/src/api/management/src/request/alerts/ack.rs index 040977f0b0..2d2ac2ec80 100644 --- a/src/api/management/src/request/alerts/ack.rs +++ b/src/api/management/src/request/alerts/ack.rs @@ -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 { diff --git a/src/api/management/src/request/fleet/mod.rs b/src/api/management/src/request/fleet/mod.rs index ee1f88a4bf..874776b79d 100644 --- a/src/api/management/src/request/fleet/mod.rs +++ b/src/api/management/src/request/fleet/mod.rs @@ -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), diff --git a/src/api/management/src/request/oncall/mod.rs b/src/api/management/src/request/oncall/mod.rs index b8147dd692..ce88883bca 100644 --- a/src/api/management/src/request/oncall/mod.rs +++ b/src/api/management/src/request/oncall/mod.rs @@ -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, } +#[derive(Debug, Default, Deserialize)] +pub struct TeamContextQuery { + /// Comma-separated team ids. + pub team_ids: Option, + /// 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, +} + /// 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::() { - 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, 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, 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, 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 { 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) -> Result, String> { + let mut seen = std::collections::HashSet::new(); + let mut ids: Vec = 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, ) -> Vec { let ids: Vec = rows.iter().map(|r| r.id.clone()).collect(); + let incident_ids: Vec = rows + .iter() + .filter_map(|r| r.incident_id.clone()) + .collect::>() + .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, + rungs: &std::collections::HashMap, + titles: &std::collections::HashMap, +) -> 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, 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, + Query(q): Query, + #[cfg(feature = "enterprise")] Headers(user_email): Headers, +) -> 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 = 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, Query, description = "Comma-separated team IDs (at most 200; `include_mine` teams are not counted)"), + ("include_mine" = Option, 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, + Query(q): Query, + #[cfg(feature = "enterprise")] Headers(user_email): Headers, +) -> 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 = + 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 = 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, Vec, Option>); + let ids = |n: usize| (0..n).map(|i| format!("t{i}")).collect::>(); + let cases: Vec = 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::(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() { diff --git a/src/core/src/alerts/ack.rs b/src/core/src/alerts/ack.rs index ead1c0773a..dbc49314a6 100644 --- a/src/core/src/alerts/ack.rs +++ b/src/core/src/alerts/ack.rs @@ -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 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, Vec)> = 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::>(), + "{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 = 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; diff --git a/src/core/src/self_profiles/mod.rs b/src/core/src/self_profiles/mod.rs index 73143e141a..090248efed 100644 --- a/src/core/src/self_profiles/mod.rs +++ b/src/core/src/self_profiles/mod.rs @@ -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)); - } -} diff --git a/src/core/src/users.rs b/src/core/src/users.rs index 236ccf0e97..d66b194251 100644 --- a/src/core/src/users.rs +++ b/src/core/src/users.rs @@ -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}" ), diff --git a/src/db/src/alerts/alert_acks.rs b/src/db/src/alerts/alert_acks.rs index 6651d92ffb..e4f9ce8931 100644 --- a/src/db/src/alerts/alert_acks.rs +++ b/src/db/src/alerts/alert_acks.rs @@ -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> { + 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> { let row = infra::table::alert_acks::get(org_id, alert_id, group_key).await?; if let Some(row) = row.as_ref() { publish(row).await; diff --git a/src/infra/src/table/alert_acks.rs b/src/infra/src/table/alert_acks.rs index 97c29f2597..ea685bb1a9 100644 --- a/src/infra/src/table/alert_acks.rs +++ b/src/infra/src/table/alert_acks.rs @@ -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 { + 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 { apply_replicated_with(get_orm_client_rw().await, ack).await @@ -259,25 +268,45 @@ pub async fn unack_with_outbox_with( unacked_by: &str, unacked_at: i64, ) -> Result { - 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( + conn: &C, + held: &AlertAck, + unacked_by: &str, + unacked_at: i64, +) -> Result { + 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( @@ -348,6 +377,34 @@ pub async fn create_table_for_test(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( + conn: &C, + row: Condition, + alert_id: &str, + group_key: &str, + firing_started_at: i64, + unacked_by: &str, + unacked_at: i64, +) -> Result { + 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; diff --git a/src/infra/src/table/alert_incidents.rs b/src/infra/src/table/alert_incidents.rs index a770b3784c..728e7c1e3a 100644 --- a/src/infra/src/table/alert_incidents.rs +++ b/src/infra/src/table/alert_incidents.rs @@ -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, errors::Error> { + titles_with(get_orm_client_ro().await, org_id, ids).await +} + +/// [`titles`] on a given connection. +pub async fn titles_with( + client: &C, + org_id: &str, + ids: &[String], +) -> Result, 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)>() + .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; diff --git a/src/infra/src/table/fleets.rs b/src/infra/src/table/fleets.rs index 7178768dc5..5d936db11c 100644 --- a/src/infra/src/table/fleets.rs +++ b/src/infra/src/table/fleets.rs @@ -142,6 +142,26 @@ pub async fn set_desired_with( Ok(()) } +/// Leaves the fleet with no desired version, keeping stable and its settings. +pub async fn clear_desired_with( + 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::::None)) + .col_expr(Column::ConfigVersion, Expr::value(Option::::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( 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(); diff --git a/src/jobs/src/job/mod.rs b/src/jobs/src/job/mod.rs index ab42627da2..c8bd52d71f 100644 --- a/src/jobs/src/job/mod.rs +++ b/src/jobs/src/job/mod.rs @@ -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!( diff --git a/tests/ui-testing/pages/generalPages/dateTimePickerPage.js b/tests/ui-testing/pages/generalPages/dateTimePickerPage.js index a27f93c638..5b3ca8f745 100644 --- a/tests/ui-testing/pages/generalPages/dateTimePickerPage.js +++ b/tests/ui-testing/pages/generalPages/dateTimePickerPage.js @@ -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() { diff --git a/tests/ui-testing/pages/generalPages/ingestionConfigPage.js b/tests/ui-testing/pages/generalPages/ingestionConfigPage.js index 978b7dca5d..08b816b1a0 100644 --- a/tests/ui-testing/pages/generalPages/ingestionConfigPage.js +++ b/tests/ui-testing/pages/generalPages/ingestionConfigPage.js @@ -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)); + } + } + } } diff --git a/tests/ui-testing/pages/logsPages/jobScheduler.js b/tests/ui-testing/pages/logsPages/jobScheduler.js index 66e0960c38..9f32e21e7a 100644 --- a/tests/ui-testing/pages/logsPages/jobScheduler.js +++ b/tests/ui-testing/pages/logsPages/jobScheduler.js @@ -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() diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index aef687db06..5aff5cec76 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -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; diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js index c57b67da8a..8b0498dbd6 100644 --- a/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js @@ -118,7 +118,8 @@ test.describe("Create Alert from Dashboard Panel testcases", () => { // is a span, not an , 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. diff --git a/tests/ui-testing/playwright-tests/GeneralTests/datetime-picker-copy-paste.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/datetime-picker-copy-paste.spec.js index e7417d96ca..c97eeb5b16 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/datetime-picker-copy-paste.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/datetime-picker-copy-paste.spec.js @@ -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'); }); }); diff --git a/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js b/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js index c2005dff61..834949a3c2 100644 --- a/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js @@ -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 && diff --git a/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js b/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js index 37cad4e4af..49ad941c83 100644 --- a/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js +++ b/tests/ui-testing/playwright-tests/Metrics/metrics-aggregations.spec.js @@ -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. */ diff --git a/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js b/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js index a672f7a7ae..332f9a8fc9 100644 --- a/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js +++ b/tests/ui-testing/playwright-tests/Streams/ingestion-config.spec.js @@ -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)); diff --git a/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js b/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js index 5eab069c5d..376b694847 100644 --- a/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js +++ b/tests/ui-testing/playwright-tests/utils/metrics-ingestion.js @@ -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 diff --git a/web/src/components/DateTime.paste.spec.ts b/web/src/components/DateTime.paste.spec.ts index feeb11acde..a693e729cd 100644 --- a/web/src/components/DateTime.paste.spec.ts +++ b/web/src/components/DateTime.paste.spec.ts @@ -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 }); diff --git a/web/src/components/DateTime.vue b/web/src/components/DateTime.vue index 3edd152d1f..4e2dbab606 100644 --- a/web/src/components/DateTime.vue +++ b/web/src/components/DateTime.vue @@ -1540,6 +1540,10 @@ export default defineComponent({ }); return; } + if (!text.trim()) { + toast({ variant: "info", message: t("common.dateRangePasteEmpty") }); + return; + } applyPastedTextWithToast(text); }; diff --git a/web/src/components/Header.spec.ts b/web/src/components/Header.spec.ts index 7fec38813a..a279e69cb5 100644 --- a/web/src/components/Header.spec.ts +++ b/web/src/components/Header.spec.ts @@ -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", diff --git a/web/src/components/Header.vue b/web/src/components/Header.vue index 021b8a46bd..f520775d55 100644 --- a/web/src/components/Header.vue +++ b/web/src/components/Header.vue @@ -234,15 +234,21 @@ along with this program. If not, see . 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. --> -