From f595787e88f77d217e650b6f471f97c24a61bf82 Mon Sep 17 00:00:00 2001 From: Subhra264 Date: Wed, 4 Nov 2026 05:46:26 +0000 Subject: [PATCH 01/12] fix(oncall): serve the on-call pages list from bulk reads (#16344) --- src/api/http/src/handler/http/router/mod.rs | 12 + .../http/src/handler/http/router/openapi.rs | 2 + src/api/management/src/request/oncall/mod.rs | 369 ++++++++++++++- src/infra/src/table/alert_incidents.rs | 71 +++ web/src/services/oncall.queries.spec.ts | 33 ++ web/src/services/oncall.queries.ts | 42 +- web/src/services/oncall.querykeys.ts | 7 + web/src/services/oncall.ts | 29 ++ web/src/ts/interfaces/oncall.ts | 13 + web/src/views/OnCall/OnCallResponses.spec.ts | 435 +++++++++++------- web/src/views/OnCall/OnCallResponses.vue | 173 +++---- 11 files changed, 892 insertions(+), 294 deletions(-) diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index a9eaabdf97..cf99e180e3 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -2397,6 +2397,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) @@ -2519,6 +2524,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), @@ -5399,10 +5409,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 633ad6dd72..86c21bb9f1 100644 --- a/src/api/http/src/handler/http/router/openapi.rs +++ b/src/api/http/src/handler/http/router/openapi.rs @@ -1306,6 +1306,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, @@ -1316,6 +1317,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/oncall/mod.rs b/src/api/management/src/request/oncall/mod.rs index b8147dd692..0378c12810 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 { @@ -2375,6 +2398,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 +2534,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 +2571,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}", @@ -2796,6 +2865,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", @@ -5241,6 +5389,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 +7123,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/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/web/src/services/oncall.queries.spec.ts b/web/src/services/oncall.queries.spec.ts index 3702c975ff..46ff196fe2 100644 --- a/web/src/services/oncall.queries.spec.ts +++ b/web/src/services/oncall.queries.spec.ts @@ -17,18 +17,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { queryClient } from "@/composables/query/queryClient"; import { LIVE_STALE_TIME, NORMAL_STALE_TIME } from "@/composables/query/cachePolicy"; import { + addTeamMembersMutation, + applySchedulePresetMutation, confirmCodeMutation, contactQuery, + createOverrideMutation, createOwnershipRuleMutation, + deleteOverrideMutation, deleteOwnershipRuleMutation, deleteTelephonyMutation, oncallTeamsQuery, pagedResponsesQuery, responsesQuery, routingConfigQuery, + removeTeamMemberMutation, saveTelephonyMutation, setContactMutation, setTeamChannelMutation, + setTeamPolicyMutation, + setTeamScheduleMutation, + teamContextQuery, telephonyQuery, testTeamSlackChannelMutation, updateOwnershipRuleMutation, @@ -127,6 +135,31 @@ describe("editing a team", () => { }); }); +describe("the Pages list's team context", () => { + beforeEach(() => queryClient.clear()); + + // One entry for many teams, so it sits outside every per-team prefix those writes drop. + it.each([ + ["a policy write", setTeamPolicyMutation], + ["a schedule write", setTeamScheduleMutation], + ["a schedule preset", applySchedulePresetMutation], + ["an override", createOverrideMutation], + ["an override removal", deleteOverrideMutation], + ["adding members", addTeamMembersMutation], + ["removing a member", removeTeamMemberMutation], + ["a channel write", setTeamChannelMutation], + ])("is marked stale by %s", async (_, mutation) => { + const key = teamContextQuery(ORG, ["t1", "t2"], true).queryKey; + queryClient.setQueryData(key, []); + + for (const scope of mutation(ORG, "t1").meta?.invalidates ?? []) { + await queryClient.invalidateQueries({ queryKey: scope, refetchType: "none" }); + } + + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + }); +}); + describe("ownership rule writes", () => { /// A rule's "never matched" finding lives in its team's risk list, so a /// deleted rule kept warning from the team page for the risk tier. diff --git a/web/src/services/oncall.queries.ts b/web/src/services/oncall.queries.ts index d025418b7c..438098f7d5 100644 --- a/web/src/services/oncall.queries.ts +++ b/web/src/services/oncall.queries.ts @@ -28,6 +28,7 @@ import type { Contact, CoverageGaps, EscalationProgress, + EscalationProgressEntry, MyDeliveries, MyOnCall, OnCallPolicy, @@ -36,6 +37,7 @@ import type { OnCallResponseReport, OnCallSchedule, OnCallTeam, + OnCallTeamContext, OnCallTeamMember, Override, OwnershipRule, @@ -162,6 +164,21 @@ export const whoIsOnCallQuery = (org: string, teamId: string, at?: number) => staleTime: LIVE_STALE_TIME, }); +/** Carries each team's rotation, which handovers move, so it takes the live tier like `whoIsOnCallQuery`. */ +export const teamContextQuery = (org: string, teamIds: string[], includeMine: boolean) => + queryOptions({ + queryKey: oncallKeys.teamContext(org, teamIds, includeMine), + queryFn: async (): Promise => + ( + await oncallService.teamContext({ + org_identifier: org, + team_ids: teamIds, + include_mine: includeMine, + }) + ).data?.teams ?? [], + staleTime: LIVE_STALE_TIME, + }); + /** Mixes the ladder (config) with `covered_now`, so it follows the live half. */ export const teamOverviewQuery = (org: string, teamId: string) => queryOptions({ @@ -342,6 +359,20 @@ export const responseProgressQuery = (org: string, responseId: string) => staleTime: LIVE_STALE_TIME, }); +/** The batch form of `responseProgressQuery`, for a list of open pages. */ +export const responsesProgressQuery = (org: string, responseIds: string[]) => + queryOptions({ + queryKey: oncallKeys.responsesProgress(org, responseIds), + queryFn: async (): Promise => + ( + await oncallService.escalationProgressBatch({ + org_identifier: org, + response_ids: responseIds, + }) + ).data?.progress ?? [], + staleTime: LIVE_STALE_TIME, + }); + /** How this signal ended the last times it fired — history, so it cannot change under us. */ export const responsePriorCausesQuery = (org: string, responseId: string) => queryOptions({ @@ -509,6 +540,7 @@ export const unavailabilityQuery = (org: string, userEmail?: string, from?: numb */ const teamWriteScopes = (org: string, teamId: string) => [ oncallKeys.team(org, teamId), + oncallKeys.teamContextAll(org), oncallKeys.coverageGaps(org), oncallKeys.myAll(org), ]; @@ -595,14 +627,20 @@ export const setTeamPolicyMutation = (org: string, teamId: string) => mutationFn: (data: Parameters[0]["data"]) => oncallService.setPolicy({ org_identifier: org, team_id: teamId, data }), // Rungs feed the overview, the dry run and the risk list; coverage is unmoved. - meta: { invalidates: [oncallKeys.team(org, teamId)], silentError: true }, + meta: { + invalidates: [oncallKeys.team(org, teamId), oncallKeys.teamContextAll(org)], + silentError: true, + }, }); export const setTeamChannelMutation = (org: string, teamId: string) => mutationOptions({ mutationFn: (data: SetTeamChannelBody) => oncallService.setTeamChannel({ org_identifier: org, team_id: teamId, data }), - meta: { invalidates: [oncallKeys.team(org, teamId)], silentError: true }, + meta: { + invalidates: [oncallKeys.team(org, teamId), oncallKeys.teamContextAll(org)], + silentError: true, + }, }); /** RO-6: posts one plain message and writes no row, so it expires nothing. */ diff --git a/web/src/services/oncall.querykeys.ts b/web/src/services/oncall.querykeys.ts index 7c6804545e..951ff85c91 100644 --- a/web/src/services/oncall.querykeys.ts +++ b/web/src/services/oncall.querykeys.ts @@ -43,6 +43,10 @@ export const oncallKeys = { members: (org: string, teamId: string) => orgKey(org, "oncall", "teams", teamId, "members"), schedule: (org: string, teamId: string) => orgKey(org, "oncall", "teams", teamId, "schedule"), policy: (org: string, teamId: string) => orgKey(org, "oncall", "teams", teamId, "policy"), + /** Many teams in one entry, outside every per-team prefix, so each team write names this scope too. */ + teamContextAll: (org: string) => orgKey(org, "oncall", "teams", "context"), + teamContext: (org: string, teamIds: string[], includeMine: boolean) => + orgKey(org, "oncall", "teams", "context", [...teamIds].sort().join(","), includeMine), /** * `at` omitted asks for "now", which every caller derives from the clock, so * it buckets to the minute; a pinned instant (a closed record's `closed_at`) @@ -108,6 +112,9 @@ export const oncallKeys = { response: (org: string, responseId: string) => orgKey(org, "oncall", "responses", responseId), responseProgress: (org: string, responseId: string) => orgKey(org, "oncall", "responses", responseId, "progress"), + /** Under `responsesAll`, so a response write drops it like the per-record entry. */ + responsesProgress: (org: string, responseIds: string[]) => + orgKey(org, "oncall", "responses", "escalation", [...responseIds].sort().join(",")), responsePriorCauses: (org: string, responseId: string) => orgKey(org, "oncall", "responses", responseId, "priorCauses"), responseReport: (org: string, responseId: string) => diff --git a/web/src/services/oncall.ts b/web/src/services/oncall.ts index e5c223c653..56c5cc1d58 100644 --- a/web/src/services/oncall.ts +++ b/web/src/services/oncall.ts @@ -33,6 +33,7 @@ import type { PresetDescriptor, OnCallPosition, OnCallTeam, + OnCallTeamContext, OnCallTeamMember, L0Policy, PolicyFinalAction, @@ -49,6 +50,7 @@ import type { DeliveryLedger, EscalateResult, EscalationProgress, + EscalationProgressEntry, MyDeliveries, MyDeliveriesRead, MyOnCall, @@ -205,6 +207,20 @@ const oncall = { at === undefined ? undefined : { params: { at } }, ), + /// Policy, rotation and schedule for many teams in one read; `include_mine` adds the caller's teams. + teamContext: ({ + org_identifier, + team_ids, + include_mine, + }: { + org_identifier: string; + team_ids: string[]; + include_mine: boolean; + }) => + http().get<{ teams: OnCallTeamContext[] }>(`/api/${org_identifier}/oncall/teams/context`, { + params: { team_ids: team_ids.join(","), include_mine }, + }), + getPolicy: ({ org_identifier, team_id }: { org_identifier: string; team_id: string }) => http().get( `/api/${org_identifier}/oncall/teams/${encodeURIComponent(team_id)}/policy`, @@ -600,6 +616,19 @@ const oncall = { `/api/${org_identifier}/oncall/responses/${encodeURIComponent(response_id)}/escalation`, ), + /// Where the ladder has got to for many records in one read; an unreadable id is left out. + escalationProgressBatch: ({ + org_identifier, + response_ids, + }: { + org_identifier: string; + response_ids: string[]; + }) => + http().get<{ progress: EscalationProgressEntry[] }>( + `/api/${org_identifier}/oncall/responses/escalation`, + { params: { response_ids: response_ids.join(",") } }, + ), + /// What previous firings of this subject turned out to be, grouped by cause. priorCauses: ({ org_identifier, response_id }: { org_identifier: string; response_id: string }) => http().get( diff --git a/web/src/ts/interfaces/oncall.ts b/web/src/ts/interfaces/oncall.ts index 338b3400a1..fdd45df63e 100644 --- a/web/src/ts/interfaces/oncall.ts +++ b/web/src/ts/interfaces/oncall.ts @@ -594,6 +594,8 @@ export interface OnCallResponse { reached_rung_micros?: number | null; /** Server-computed, and only present on a record somebody answered. */ time_to_ack_micros?: number | null; + /** The linked incident's title; absent when the incident is gone, untitled or unreadable. */ + incident_title?: string; } /** @@ -1011,6 +1013,9 @@ export interface EscalationProgress { held_until_micros?: number | null; } +/** `GET /oncall/responses/escalation` answers each record it could read, by id. */ +export type EscalationProgressEntry = EscalationProgress & { response_id: string }; + export interface OnCallResponseEvent { kind: ResponseEventKind; at: number; @@ -1228,6 +1233,14 @@ export interface MyOnCall { teams: MyOnCallTeam[]; } +/** One team in `GET /oncall/teams/context`; a field the server could not read is absent, not empty. */ +export interface OnCallTeamContext { + team_id: string; + policy?: OnCallPolicy; + on_call?: OnCallPosition[]; + schedule?: OnCallSchedule | null; +} + /** `GET /oncall/contacts/{email}` — the caller's own phone and whether a code can be sent to it. */ export interface Contact { /** E.164; absent when none is saved. */ diff --git a/web/src/views/OnCall/OnCallResponses.spec.ts b/web/src/views/OnCall/OnCallResponses.spec.ts index 4364587819..562848fe1b 100644 --- a/web/src/views/OnCall/OnCallResponses.spec.ts +++ b/web/src/views/OnCall/OnCallResponses.spec.ts @@ -19,7 +19,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { queryClient } from "@/composables/query/queryClient"; import i18n from "@/locales"; import destinationService from "@/services/alert_destination"; -import incidentsService from "@/services/incidents"; import oncallService from "@/services/oncall"; import store from "@/test/unit/helpers/store"; import { resetListViewState, useListViewState } from "@/composables/useListViewState"; @@ -44,9 +43,8 @@ vi.mock("@/services/oncall", () => ({ resolveResponse: vi.fn(), snoozeResponse: vi.fn(), unsnoozeResponse: vi.fn(), - getPolicy: vi.fn(), - getSchedule: vi.fn(), - escalationProgress: vi.fn(), + teamContext: vi.fn(), + escalationProgressBatch: vi.fn(), getResponse: vi.fn(), }, })); @@ -64,10 +62,6 @@ vi.mock("@/services/alert_destination", () => ({ default: { list: vi.fn() }, })); -vi.mock("@/services/incidents", () => ({ - default: { get: vi.fn() }, -})); - const push = vi.fn(); const routeQuery: Record = {}; vi.mock("vue-router", () => ({ @@ -92,7 +86,6 @@ vi.mock("@/composables/useBreakpoint", async () => { const service = vi.mocked(oncallService); const destinations = vi.mocked(destinationService); -const incidents = vi.mocked(incidentsService); // Every `mount()` in this file — across all three top-level `describe`s — // stays alive as a real, reactive component instance unless something @@ -271,6 +264,33 @@ function render() { return mountTracked(OnCallResponses, { global: { plugins: [i18n, store], stubs } }); } +/// What a team answers by default: "answered, with nothing in it". +const emptyTeam = { policy: { rungs: [] }, on_call: [], schedule: null }; + +/// The team-context read answers `facts(id)` for each asked team, plus `mine` when `include_mine` is set; `undefined` is a team the server left out. +function answerTeams(facts: (teamId: string) => object | undefined, mine: string[] = []) { + service.teamContext.mockImplementation((async ({ team_ids, include_mine }: any) => { + const ids = [...new Set([...team_ids, ...(include_mine ? mine : [])])]; + const teams = ids.flatMap((id) => { + const answer = facts(id); + return answer ? [{ team_id: id, ...answer }] : []; + }); + return { data: { teams } }; + }) as any); +} + +/// The batch ladder read answers `progress(id)` for each asked id; `undefined` is an id the server left out. +function answerProgress(progress: (responseId: string) => object | undefined) { + service.escalationProgressBatch.mockImplementation((async ({ response_ids }: any) => ({ + data: { + progress: response_ids.flatMap((id: string) => { + const answer = progress(id); + return answer ? [{ response_id: id, ...answer }] : []; + }), + }, + })) as any); +} + describe("OnCallResponses", () => { beforeEach(() => { vi.clearAllMocks(); @@ -290,11 +310,8 @@ describe("OnCallResponses", () => { // The ladder, the policy behind its denominator, and the expanded row's // events. Every one of them degrades a single cell rather than the page, // so the default is "answered, with nothing in it". - service.getPolicy.mockResolvedValue({ data: { rungs: [] } } as any); - service.getSchedule.mockResolvedValue({ data: null } as any); - service.escalationProgress.mockResolvedValue({ - data: { fired: [], next_targets: [], next_at: null, exhausted: false }, - } as any); + answerTeams(() => emptyTeam); + answerProgress(() => ({ fired: [], next_targets: [], next_at: null, exhausted: false })); service.getResponse.mockResolvedValue({ data: { events: [] } } as any); }); @@ -469,10 +486,8 @@ describe("OnCallResponses", () => { service.listTeams.mock.calls.length + service.coverageGaps.mock.calls.length + service.listOwnershipRules.mock.calls.length + - service.getPolicy.mock.calls.length + - service.whoIsOnCall.mock.calls.length + - service.getSchedule.mock.calls.length + - service.escalationProgress.mock.calls.length; + service.teamContext.mock.calls.length + + service.escalationProgressBatch.mock.calls.length; const first = await withPages([page()]); const afterFirst = cachedCalls(); @@ -494,23 +509,17 @@ describe("OnCallResponses", () => { expect(service.listTeams).toHaveBeenCalledTimes(2); }); - // Incident titles and the destination check are other modules' cache entries; only Refresh forces them. - it("serves incident titles and the destination check from the cache, and forces both on refresh", async () => { - incidents.get.mockResolvedValue({ data: { title: "Checkout down" } } as any); - - const first = await withPages([page({ incident_id: "inc_1" })]); - expect(incidents.get).toHaveBeenCalledTimes(1); + // The destination check is another module's cache entry; only Refresh forces it. + it("serves the destination check from the cache, and forces it on refresh", async () => { + const first = await withPages([page()]); expect(destinations.list).toHaveBeenCalledTimes(1); first.unmount(); - const second = await withPages([page({ incident_id: "inc_1" })]); - expect(incidents.get).toHaveBeenCalledTimes(1); + const second = await withPages([page()]); expect(destinations.list).toHaveBeenCalledTimes(1); - expect(second.text()).toContain("Checkout down"); await second.find('[data-test="oncall-responses-refresh"]').trigger("click"); await flushPromises(); - expect(incidents.get).toHaveBeenCalledTimes(2); expect(destinations.list).toHaveBeenCalledTimes(2); }); @@ -552,15 +561,13 @@ describe("OnCallResponses", () => { }); it("keeps an exhausted-ladder page in the ringing run until somebody claims it", async () => { - service.escalationProgress.mockResolvedValue({ - data: { - fired: [{ after_micros: 0, at: 1, targets: [] }], - next_targets: [], - next_at: null, - exhausted: true, - stopped_because: "the ladder is exhausted — nobody acknowledged", - }, - } as any); + answerProgress(() => ({ + fired: [{ after_micros: 0, at: 1, targets: [] }], + next_targets: [], + next_at: null, + exhausted: true, + stopped_because: "the ladder is exhausted — nobody acknowledged", + })); const wrapper = await withPages([page({ id: "resp_1" })]); expect(sections(wrapper)).toEqual({ ringing: 1 }); @@ -574,9 +581,10 @@ describe("OnCallResponses", () => { /// ladder at all, so `exhausted` never even gets a chance to be true. /// Same fix has to cover this path too, or the bug just moves. it("keeps a zero-rung priority in the ringing run until somebody claims it", async () => { - service.getPolicy.mockResolvedValue({ - data: { rungs: [{ priority: 1, steps: [], channels: [] }] }, - } as any); + answerTeams(() => ({ + ...emptyTeam, + policy: { rungs: [{ priority: 1, steps: [], channels: [] }] }, + })); const wrapper = await withPages([page({ id: "resp_1" })]); expect(sections(wrapper)).toEqual({ ringing: 1 }); @@ -601,16 +609,13 @@ describe("OnCallResponses", () => { /// R11: a page whose ladder stopped has a team but pages nobody, so it is not counted as paging. it("does not count a page whose ladder stopped as still paging", async () => { - service.escalationProgress.mockImplementation((({ response_id }: { response_id: string }) => - Promise.resolve({ - data: { - fired: [{ after_micros: 0, at: 1, targets: [], reached: ["ana@o2.ai"] }], - next_targets: [], - next_at: null, - exhausted: false, - stopped_because: response_id === "b" ? "the owning team was deleted" : undefined, - }, - })) as any); + answerProgress((id) => ({ + fired: [{ after_micros: 0, at: 1, targets: [], reached: ["ana@o2.ai"] }], + next_targets: [], + next_at: null, + exhausted: false, + stopped_because: id === "b" ? "the owning team was deleted" : undefined, + })); const wrapper = await withPages([page({ id: "a" }), page({ id: "b" }, "al_pay")]); const text = wrapper.find(ringingHeader).text(); @@ -624,7 +629,7 @@ describe("OnCallResponses", () => { /// F4: a deleted team pages nobody, so a page past the progress cap is counted and phrased as such. it("counts and phrases a page whose team was deleted as not paged, without its progress", async () => { - service.escalationProgress.mockRejectedValue({ response: { status: 500, data: {} } }); + service.escalationProgressBatch.mockRejectedValue({ response: { status: 500, data: {} } }); const wrapper = await withPages([ page({ id: "a" }), page({ id: "b", team_id: "team_gone" }, "al_pay"), @@ -685,20 +690,19 @@ describe("OnCallResponses", () => { }); describe("the escalation ladder", () => { - it("loads the ladder for open pages and passes it to the cell", async () => { - service.escalationProgress.mockResolvedValue({ - data: { - fired: [{ after_micros: 0, at: 1, targets: ["the on-call"] }], - next_targets: ["the next on-call"], - next_at: (Date.now() + 60_000) * 1000, - exhausted: false, - }, - } as any); - const wrapper = await withPages([page()]); + it("loads the ladder for open pages in one read and passes it to the cell", async () => { + answerProgress(() => ({ + fired: [{ after_micros: 0, at: 1, targets: ["the on-call"] }], + next_targets: ["the next on-call"], + next_at: (Date.now() + 60_000) * 1000, + exhausted: false, + })); + const wrapper = await withPages([page(), page({ id: "resp_2" }, "al_pay")]); - expect(service.escalationProgress).toHaveBeenCalledWith({ + expect(service.escalationProgressBatch).toHaveBeenCalledTimes(1); + expect(service.escalationProgressBatch).toHaveBeenCalledWith({ org_identifier: "default", - response_id: "resp_1", + response_ids: ["resp_1", "resp_2"], }); expect(wrapper.findComponent({ name: "OnCallEscalationCell" }).props("progress")).toEqual( expect.objectContaining({ next_targets: ["the next on-call"] }), @@ -709,25 +713,28 @@ describe("OnCallResponses", () => { /// request per row spent on an answer nobody reads. it("does not ask for the ladder of a page that is already closed", async () => { await withPages([page({ state: "resolved", closed_at: Date.now() * 1000 })]); - expect(service.escalationProgress).not.toHaveBeenCalled(); + expect(service.escalationProgressBatch).not.toHaveBeenCalled(); }); - /// One request per page and no bulk endpoint: past the cap the list says so - /// rather than leaving a blank cell that reads as "nothing has fired". + /// Past the route's cap the list says so rather than leaving a blank cell that reads as "nothing has fired". it("caps how many ladders it loads, and says that it did", async () => { - const many = Array.from({ length: 30 }, (_, i) => + const many = Array.from({ length: 205 }, (_, i) => page({ id: `resp_${i}`, opened_at: 1_700_000_000_000_000 + i }, `al_${i}`), ); const wrapper = await withPages(many); - expect(service.escalationProgress).toHaveBeenCalledTimes(25); + expect(service.escalationProgressBatch).toHaveBeenCalledTimes(1); + const asked = service.escalationProgressBatch.mock.calls[0][0].response_ids; + expect(asked).toHaveLength(200); + expect(asked).not.toContain("resp_200"); expect(wrapper.find('[data-test="oncall-escalation-capped"]').exists()).toBe(true); }); - /// Rows past the cap have no progress: the record's own step, or no claim, never "Not paged yet". - it("says what it can for rows past the cap, without claiming they were not paged", async () => { - service.getPolicy.mockResolvedValue({ - data: { + /// Rows the server left out have no progress: the record's own step, or no claim, never "Not paged yet". + it("says what it can for rows without an answer, without claiming they were not paged", async () => { + answerTeams(() => ({ + ...emptyTeam, + policy: { rungs: [ { priority: 1, @@ -740,48 +747,51 @@ describe("OnCallResponses", () => { }, ], }, - } as any); - const many = Array.from({ length: 30 }, (_, i) => - page( - { - id: `resp_${i}`, - opened_at: 1_700_000_000_000_000 + i, - ...(i === 29 ? { reached_rung_micros: 300_000_000 } : {}), - }, - `al_${i}`, - ), + })); + answerProgress((id) => + id === "resp_0" + ? { fired: [], next_targets: [], next_at: null, exhausted: false } + : undefined, ); - const wrapper = await withPages(many); + const wrapper = await withPages([ + page({ id: "resp_0", opened_at: 1_700_000_000_000_000 }, "al_0"), + page({ id: "resp_1", opened_at: 1_700_000_000_000_001 }, "al_1"), + page( + { id: "resp_2", opened_at: 1_700_000_000_000_002, reached_rung_micros: 300_000_000 }, + "al_2", + ), + ]); const cell = (id: string) => wrapper.find(`[data-test="oncall-escalation-cell-${id}"]`).text(); expect(cell("resp_0")).toBe("Not paged yet"); - expect(cell("resp_29")).toBe("Paging step 2 of 3"); - expect(cell("resp_28")).toBe(""); + expect(cell("resp_2")).toBe("Paging step 2 of 3"); + expect(cell("resp_1")).toBe(""); const table = wrapper.findComponent({ name: "OTable" }); const escalation = (table.props("columns") as any[]).find((c) => c.id === "escalation"); const rowFor = (id: string) => (table.props("data") as any[]).find((r) => r.latest.id === id); - expect(escalation.accessorFn(rowFor("resp_29"))).toBe(2); + expect(escalation.accessorFn(rowFor("resp_2"))).toBe(2); }); - /// An empty 200 body is not an answer: the row makes no claim rather than "Not paged yet". - it("makes no claim for a row whose progress came back empty", async () => { - service.escalationProgress.mockResolvedValue({ data: null } as any); + /// A failed ladder read is not an answer: the row makes no claim rather than "Not paged yet". + it("makes no claim for a row when the ladder read fails", async () => { + service.escalationProgressBatch.mockRejectedValue(new Error("down")); const wrapper = await withPages([page()]); expect(wrapper.find('[data-test="oncall-escalation-cell-resp_1"]').text()).toBe(""); }); it("takes the rung total for the row's priority from the team's policy", async () => { - service.getPolicy.mockResolvedValue({ - data: { + answerTeams(() => ({ + ...emptyTeam, + policy: { rungs: [ { priority: 1, steps: [{}, {}, {}], channels: [] }, { priority: 2, steps: [{}], channels: [] }, ], }, - } as any); + })); const wrapper = await withPages([page({ priority: 1 })]); expect(wrapper.findComponent({ name: "OnCallEscalationCell" }).props("totalRungs")).toBe(3); @@ -899,9 +909,10 @@ describe("OnCallResponses", () => { /// The channels shown are the policy's — what WOULD be used, not what happened. it("shows the channels the page would go out on", async () => { - service.getPolicy.mockResolvedValue({ - data: { rungs: [{ priority: 1, steps: [{}, {}], channels: ["email", "webhook"] }] }, - } as any); + answerTeams(() => ({ + ...emptyTeam, + policy: { rungs: [{ priority: 1, steps: [{}, {}], channels: ["email", "webhook"] }] }, + })); const wrapper = await withPages([page({ priority: 1 })]); expect(wrapper.find('[data-test$="-email"]').exists()).toBe(true); @@ -1291,13 +1302,11 @@ describe("OnCallResponses", () => { /// One signal: the ladder's own progress, which the "nobody answered" tag and the ladder text both read. it("tags nobody answered from the ladder's progress, before the record's flag lands", async () => { - service.escalationProgress.mockImplementation(async ({ response_id }: any) => ({ - data: { - fired: [{ after_micros: 0, at: 1, targets: [], reached: ["ana@o2.ai"] }], - next_targets: [], - next_at: null, - exhausted: response_id === "resp_done", - }, + answerProgress((id) => ({ + fired: [{ after_micros: 0, at: 1, targets: [], reached: ["ana@o2.ai"] }], + next_targets: [], + next_at: null, + exhausted: id === "resp_done", })); const wrapper = await withPages([ page({ id: "resp_done", exhausted_at: null }, "al_done"), @@ -1425,12 +1434,10 @@ describe("OnCallResponses", () => { const mineBtn = '[data-test="oncall-responses-mine-btn"]'; async function withMyShift(rows: Record[]) { - service.whoIsOnCall.mockImplementation(({ team_id }: any) => - Promise.resolve({ - data: - team_id === "team_1" ? [{ rotation: "Primary", user_email: "example@gmail.com" }] : [], - } as any), - ); + answerTeams((id) => ({ + ...emptyTeam, + on_call: id === "team_1" ? [{ rotation: "Primary", user_email: "example@gmail.com" }] : [], + })); return withPages(rows); } @@ -1570,46 +1577,52 @@ describe("OnCallResponses", () => { shift_micros, anchor_micros: 0, }); - service.getSchedule.mockResolvedValue({ - data: { - id: "sch_1", - org_id: "default", - team_id: "team_1", - timezone: "UTC", - created_at: 0, - updated_at: 0, - rotations: [ - { - id: "rot_weekly", - name: "Primary", - shift_rules: [rule(["ana@o2.ai"], 604_800_000_000)], - }, - { - id: "rot_hourly", - name: "Secondary", - shift_rules: [rule(["example@gmail.com"], 3_600_000_000)], - }, - ], - }, - } as any); - service.whoIsOnCall.mockResolvedValue({ - data: [ - { - rotation_id: "rot_weekly", - rotation_name: "Primary", - rule: "Base", - user_email: "ana@o2.ai", - }, - { - rotation_id: "rot_hourly", - rotation_name: "Secondary", - rule: "Base", - user_email: "example@gmail.com", - }, - ], - } as any); + // The viewer's team has no row on screen: only `include_mine` brings it into the answer. + answerTeams( + (id) => + id === "team_1" + ? { + ...emptyTeam, + schedule: { + id: "sch_1", + org_id: "default", + team_id: "team_1", + timezone: "UTC", + created_at: 0, + updated_at: 0, + rotations: [ + { + id: "rot_weekly", + name: "Primary", + shift_rules: [rule(["ana@o2.ai"], 604_800_000_000)], + }, + { + id: "rot_hourly", + name: "Secondary", + shift_rules: [rule(["example@gmail.com"], 3_600_000_000)], + }, + ], + }, + on_call: [ + { + rotation_id: "rot_weekly", + rotation_name: "Primary", + rule: "Base", + user_email: "ana@o2.ai", + }, + { + rotation_id: "rot_hourly", + rotation_name: "Secondary", + rule: "Base", + user_email: "example@gmail.com", + }, + ], + } + : emptyTeam, + ["team_1"], + ); try { - const wrapper = await withPages([page()]); + const wrapper = await withPages([page({ team_id: "team_other" })]); const banner = wrapper.findComponent({ name: "OnCallShiftBanner" }); expect(banner.props("rotation")).toBe("Secondary"); @@ -1621,6 +1634,120 @@ describe("OnCallResponses", () => { }); }); + describe("request fan-out", () => { + const position = (email: string) => ({ + rotation_id: "r", + rotation_name: "Primary", + rule: "Base", + user_email: email, + }); + const shownOnCall = (wrapper: VueWrapper) => + wrapper.findAllComponents({ name: "OUserCell" }).map((c) => c.props("value")); + const pickTeam = (wrapper: VueWrapper, teamId: string) => + wrapper + .findAllComponents({ name: "OSelect" }) + .find((node) => node.attributes("data-test") === "oncall-responses-team-filter")! + .vm.$emit("update:modelValue", teamId); + + it("names an incident from the row's title, and the id when the row has none", async () => { + const wrapper = await withPages([ + page({ id: "a", incident_id: "inc_1", incident_title: "Checkout down" }), + page({ id: "b", incident_id: "inc_2" }, "al_b"), + ]); + + const chips = wrapper.findAll('[data-test^="oncall-row-incident-"]').map((c) => c.text()); + expect(chips.sort()).toEqual(["Checkout down", "inc_2"]); + }); + + it("reads every team's context in one request, for the row teams and the viewer's", async () => { + service.listTeams.mockResolvedValue({ + data: [team, { ...team, id: "team_2", name: "Payments" }], + } as any); + service.listResponses.mockResolvedValue({ + data: [page(), page({ id: "b", team_id: "team_2" }, "al_b"), page({ id: "c" }, "al_c")], + } as any); + render(); + await flushPromises(); + + expect(service.teamContext).toHaveBeenCalledTimes(1); + expect(service.teamContext).toHaveBeenCalledWith({ + org_identifier: "default", + team_ids: ["team_1", "team_2"], + include_mine: true, + }); + expect(service.whoIsOnCall).not.toHaveBeenCalled(); + }); + + it("splits row teams past the route's cap across context reads, asking for the viewer's once", async () => { + answerTeams((id) => ({ ...emptyTeam, on_call: [position(`${id}@o2.ai`)] })); + const many = Array.from({ length: 205 }, (_, i) => + page({ id: `resp_${i}`, team_id: `team_${i}` }, `al_${i}`), + ); + const wrapper = await withPages(many); + + const calls = service.teamContext.mock.calls.map(([args]: any) => args); + expect(calls.map((c: any) => [c.team_ids.length, c.include_mine])).toEqual([ + [200, true], + [5, false], + ]); + expect(shownOnCall(wrapper)).toContain("team_204@o2.ai"); + }); + + it("shows the on-call cell from the context answer", async () => { + answerTeams(() => ({ ...emptyTeam, on_call: [position("ana@o2.ai")] })); + const wrapper = await withPages([page()]); + + expect(shownOnCall(wrapper)).toEqual(["ana@o2.ai"]); + }); + + it("forces the context and ladder reads on refresh", async () => { + const wrapper = await withPages([page()]); + expect(service.teamContext).toHaveBeenCalledTimes(1); + expect(service.escalationProgressBatch).toHaveBeenCalledTimes(1); + + await wrapper.find('[data-test="oncall-responses-refresh"]').trigger("click"); + await flushPromises(); + + expect(service.teamContext).toHaveBeenCalledTimes(2); + expect(service.escalationProgressBatch).toHaveBeenCalledTimes(2); + }); + + it("sends the context and ladder reads together, without waiting for the teams list", async () => { + service.listTeams.mockReturnValue(new Promise(() => {}) as any); + service.listResponses.mockResolvedValue({ data: [page()] } as any); + render(); + await flushPromises(); + + expect(service.teamContext).toHaveBeenCalledTimes(1); + expect(service.escalationProgressBatch).toHaveBeenCalledTimes(1); + }); + + it("keeps the newer context read when an older one answers last", async () => { + let answerOld!: (value: unknown) => void; + service.teamContext.mockImplementation((({ team_ids }: { team_ids: string[] }) => + team_ids.includes("team_1") + ? new Promise((resolve) => (answerOld = resolve)) + : Promise.resolve({ + data: { + teams: [{ team_id: "team_new", ...emptyTeam, on_call: [position("new@o2.ai")] }], + }, + })) as any); + const wrapper = await withPages([page()]); + service.listResponses.mockResolvedValue({ + data: [page({ id: "n", team_id: "team_new" }, "al_new")], + } as any); + pickTeam(wrapper, "team_new"); + await flushPromises(); + + answerOld({ + data: { teams: [{ team_id: "team_new", ...emptyTeam, on_call: [position("old@o2.ai")] }] }, + }); + await flushPromises(); + + expect(shownOnCall(wrapper)).toEqual(["new@o2.ai"]); + }); + }); + /// During an incident the list IS the work surface. Opening 200 pages one at /// a time to claim them is not triage. describe("acting from the list", () => { @@ -2015,11 +2142,8 @@ describe("OnCallResponses — the rows' own fields", () => { service.whoIsOnCall.mockResolvedValue({ data: [] } as any); service.listOwnershipRules.mockResolvedValue({ data: [{ id: "rule_1" }] } as any); destinations.list.mockResolvedValue({ data: [{ name: "dest_1" }] } as any); - service.getPolicy.mockResolvedValue({ data: { rungs: [] } } as any); - service.getSchedule.mockResolvedValue({ data: null } as any); - service.escalationProgress.mockResolvedValue({ - data: { fired: [], next_targets: [], next_at: null, exhausted: false }, - } as any); + answerTeams(() => emptyTeam); + answerProgress(() => ({ fired: [], next_targets: [], next_at: null, exhausted: false })); service.getResponse.mockResolvedValue({ data: { events: [] } } as any); }); @@ -2199,11 +2323,8 @@ describe("OnCallResponses — filtering by cause", () => { service.whoIsOnCall.mockResolvedValue({ data: [] } as any); service.listOwnershipRules.mockResolvedValue({ data: [{ id: "rule_1" }] } as any); destinations.list.mockResolvedValue({ data: [{ name: "dest_1" }] } as any); - service.getPolicy.mockResolvedValue({ data: { rungs: [] } } as any); - service.getSchedule.mockResolvedValue({ data: null } as any); - service.escalationProgress.mockResolvedValue({ - data: { fired: [], next_targets: [], next_at: null, exhausted: false }, - } as any); + answerTeams(() => emptyTeam); + answerProgress(() => ({ fired: [], next_targets: [], next_at: null, exhausted: false })); service.getResponse.mockResolvedValue({ data: { events: [] } } as any); }); diff --git a/web/src/views/OnCall/OnCallResponses.vue b/web/src/views/OnCall/OnCallResponses.vue index 19a7a2b000..2818882615 100644 --- a/web/src/views/OnCall/OnCallResponses.vue +++ b/web/src/views/OnCall/OnCallResponses.vue @@ -479,7 +479,7 @@ along with this program. If not, see . @click.stop > {{ - raw(incidentTitleById[row.latest.incident_id] ?? row.latest.incident_id) + raw(row.latest.incident_title ?? row.latest.incident_id) }} @@ -981,12 +981,11 @@ import { ownershipRulesQuery, pagedResponsesQuery, resolveResponseMutation, - responseProgressQuery, responseQuery, + responsesProgressQuery, snoozeResponseMutation, unsnoozeResponseMutation, - teamPolicyQuery, - teamScheduleQuery, + teamContextQuery, whoIsOnCallQuery, } from "@/services/oncall.queries"; @@ -999,12 +998,11 @@ import { toast } from "@/lib/feedback/Toast/useToast"; import analytics from "@/services/product_analytics"; import { useShortcuts } from "@/lib/vue-shortcut-manager"; import { destinationsQuery } from "@/services/alert_destination.queries"; -import type { IncidentWithAlerts } from "@/services/incidents"; -import { incidentQuery } from "@/services/incidents.queries"; import type { Channel, CoverageGaps, EscalationProgress, + EscalationProgressEntry, OnCallPolicy, OnCallResponse, OnCallResponseEvent, @@ -1012,6 +1010,7 @@ import type { OnCallSchedule, OnCallPosition, OnCallTeam, + OnCallTeamContext, OwnershipRule, ResolutionCause, } from "@/ts/interfaces/oncall"; @@ -1045,16 +1044,8 @@ const { canConfigure } = useOnCallPermissions(); /// columns and the actions never have to branch on the mode. type PageRow = OnCallResponseGroup & { rowKey: string }; -/** - * How many open pages get their escalation ladder loaded. - * - * There is no bulk escalation endpoint, so this costs one request per page, on - * every poll. Twenty-five covers the top of a triage list without turning a - * 200-row incident into 200 requests every twenty seconds; the rows past it - * still show their state, and the list says so rather than leaving a blank cell - * that would read as "nothing has fired". - */ -const ESCALATION_DETAIL_LIMIT = 25; +/** The batch routes' cap on ids per request; rows past it on the ladder read are flagged, not left blank. */ +const ESCALATION_DETAIL_LIMIT = 200; /** * The states a reader triages by, in the order they matter. @@ -1188,12 +1179,6 @@ const teamNameById = computed>(() => const teamIsDeleted = (teamId: string | null) => isDeletedTeam(teamId, teamsLoaded.value ? teams.value.map((team) => team.id) : null); -// A ksuid asks the reader to look it up somewhere else, same as team_id -// (`fetchTeamContext` below). There is no bulk "get these incidents" endpoint, -// so this fills in one id at a time and the chip falls back to the raw id -// until its title lands. -const incidentTitleById = ref>({}); - const teamOptions = computed(() => [ { label: t("oncall.allTeams"), value: "all" }, ...teams.value.map((team) => ({ label: raw(team.name), value: team.id })), @@ -1305,9 +1290,7 @@ const columns = computed[]>(() => [ header: t("oncall.incident"), size: 160, accessorFn: (row: PageRow) => - row.latest.incident_id - ? (incidentTitleById.value[row.latest.incident_id] ?? row.latest.incident_id) - : "", + row.latest.incident_id ? (row.latest.incident_title ?? row.latest.incident_id) : "", sortable: true, hideable: true, }, @@ -1955,9 +1938,8 @@ async function fetchResponses(opts: { background?: boolean; force?: boolean } = // error render the first-run checklist, telling a configured org that // nothing is set up. loaded.value = true; - // Fire-and-forget: the list is already usable with ids as the fallback, - // so titles fill in behind it rather than holding up the table. - void fetchIncidentTitles(!!opts.force); + // Fire-and-forget: the rows are already usable while their teams and ladders fill in. + void fetchRowContext(!!opts.force); } catch (err) { if (readId !== latestResponsesRead) return; // §G.8.1: the probe said "not here". Leaving `loaded` false keeps the @@ -1973,32 +1955,6 @@ async function fetchResponses(opts: { background?: boolean; force?: boolean } = } } -/// One request per incident id — there is no bulk "get these incidents" -/// endpoint (see `fetchTeamContext` for the same constraint on team data). -/// Already-resolved ids are skipped so a background fetch doesn't re-read -/// titles the list already has; only the reader's Refresh re-reads them all. -async function fetchIncidentTitles(force = false) { - const ids = [ - ...new Set( - responses.value - .map((response) => response.incident_id) - .filter((id): id is string => !!id && (force || !(id in incidentTitleById.value))), - ), - ]; - if (!ids.length) return; - const results = await Promise.allSettled( - ids.map((id) => read(incidentQuery(orgId.value, id), force)), - ); - const next = { ...incidentTitleById.value }; - ids.forEach((id, index) => { - const result = results[index]; - if (result.status === "fulfilled" && result.value?.title) { - next[id] = result.value.title; - } - }); - incidentTitleById.value = next; -} - /// Teams, coverage and ownership answer the checklist, not the list. A failure /// on any one of them degrades a single control rather than the page. async function fetchContext(force = false) { @@ -2048,73 +2004,63 @@ async function someTeamWouldPage( return slots.some((s) => s.status === "fulfilled" && s.value.length > 0); } -/// Policy and rotation per team. Both are one request per team — there is no -/// bulk endpoint for either — but team counts are small and the answers change -/// far more slowly than the pages do. -async function fetchTeamContext(force = false) { - const ids = teams.value.map((team) => team.id); - const [policies, slots, schedules] = await Promise.all([ - Promise.allSettled( - ids.map((id) => read(teamPolicyQuery(orgId.value, id), force)), - ), - Promise.allSettled( - ids.map((id) => read(whoIsOnCallQuery(orgId.value, id), force)), - ), - Promise.allSettled( - ids.map((id) => read(teamScheduleQuery(orgId.value, id), force)), - ), - ]); +// The newest row-context read: one started for rows a filter has since replaced must not overwrite the maps. +let latestRowContextRead = 0; - const nextPolicies: Record = {}; - const nextSlots: Record = {}; - const nextSchedules: Record = {}; - ids.forEach((id, index) => { - const policy = policies[index]; - if (policy.status === "fulfilled" && policy.value) nextPolicies[id] = policy.value; - const slot = slots[index]; - // A team whose rotation could not be read is left OUT rather than recorded - // as empty: an unreadable schedule is not the same fact as a coverage gap, - // and the card would otherwise accuse a staffed team of having none. - if (slot.status === "fulfilled") nextSlots[id] = slot.value; - const schedule = schedules[index]; - if (schedule.status === "fulfilled" && schedule.value) { - nextSchedules[id] = schedule.value; - } - }); - policyByTeam.value = nextPolicies; - positionsByTeam.value = nextSlots; - scheduleByTeam.value = nextSchedules; -} - -/// Ladder position for the oldest open pages. Bounded by -/// ESCALATION_DETAIL_LIMIT because each one is its own request. -async function fetchEscalationProgress(force = false) { +/// Team context for the row teams and the viewer's, and the ladder of the oldest open pages: one request each, sent together. +async function fetchRowContext(force = false) { + const readId = ++latestRowContextRead; + const teamIds = [ + ...new Set(responses.value.map((r) => r.team_id).filter((id): id is string => !!id)), + ]; const open = responses.value .filter((r) => isEscalating(r.state)) .sort((a, b) => a.opened_at - b.opened_at); - + const wanted = open.slice(0, ESCALATION_DETAIL_LIMIT).map((r) => r.id); + const teamChunks: string[][] = []; + for (let i = 0; i < teamIds.length; i += ESCALATION_DETAIL_LIMIT) { + teamChunks.push(teamIds.slice(i, i + ESCALATION_DETAIL_LIMIT)); + } + if (!teamChunks.length) teamChunks.push([]); + const [context, progress] = await Promise.allSettled([ + Promise.allSettled( + teamChunks.map((ids, i) => + read(teamContextQuery(orgId.value, ids, i === 0), force), + ), + ).then((reads) => reads.flatMap((r) => (r.status === "fulfilled" ? r.value : []))), + wanted.length + ? read(responsesProgressQuery(orgId.value, wanted), force) + : Promise.resolve([]), + ]); + if (readId !== latestRowContextRead) return; + applyTeamContext(context.status === "fulfilled" ? context.value : []); escalationCapped.value = open.length > ESCALATION_DETAIL_LIMIT; - const wanted = open.slice(0, ESCALATION_DETAIL_LIMIT); + applyProgress(progress.status === "fulfilled" ? progress.value : []); +} - const results = await Promise.allSettled( - wanted.map((r) => - read(responseProgressQuery(orgId.value, r.id), force), - ), - ); +/// Replaced wholesale, so a team no longer on screen drops out of the maps. +function applyTeamContext(context: OnCallTeamContext[]) { + const policies: Record = {}; + const slots: Record = {}; + const schedules: Record = {}; + for (const team of context) { + if (team.policy) policies[team.team_id] = team.policy; + // An unreadable rotation is left OUT, not empty: it is not the same fact as a coverage gap. + if (team.on_call) slots[team.team_id] = team.on_call; + if (team.schedule) schedules[team.team_id] = team.schedule; + } + policyByTeam.value = policies; + positionsByTeam.value = slots; + scheduleByTeam.value = schedules; +} +/// Replaced wholesale, so a record that resolved since the last read drops a countdown that will never fire. +function applyProgress(entries: EscalationProgressEntry[]) { const next: Record = {}; - const answered = new Set(); - wanted.forEach((record, index) => { - const result = results[index]; - // An empty body is no answer, so the row stays unknown rather than reading "Not paged yet". - if (result.status !== "fulfilled" || !result.value) return; - answered.add(record.id); - next[record.id] = result.value; - }); - // Replaced wholesale so a record that resolved since the last poll drops its - // stale ladder instead of keeping a countdown that will never fire. + for (const { response_id, ...progress } of entries) next[response_id] = progress; progressById.value = next; - progressAnswered.value = answered; + // Only the ids the server answered, so an unread row never reads "Not paged yet". + progressAnswered.value = new Set(Object.keys(next)); } /// The expanded row's timeline. @@ -2160,9 +2106,8 @@ async function loadAll(force = false) { // The probe answered "not here" — every further call would 404/403 the // same way, so stop asking. if (unavailable.value) return; + // Team and ladder reads were started by fetchResponses. await fetchContext(force); - await fetchTeamContext(force); - await fetchEscalationProgress(force); // The open timeline is part of what the reader sees, so a refresh reaches it too. const open = expandedResponseId(); if (open) await fetchExpandedEvents(open, force); From 70dcb67537b5011b3e1eb0a90b206ad3bc889be4 Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Wed, 4 Nov 2026 07:40:09 +0000 Subject: [PATCH 02/12] ci: run chosen Playwright shards on any branch (#16369) --- .github/scripts/build-ci-matrix.js | 22 +++++++++++++++++++++- .github/scripts/build-ci-matrix.test.js | 17 +++++++++++++++++ .github/workflows/playwright.yml | 24 +++++++++++++++++++++--- 3 files changed, 59 insertions(+), 4 deletions(-) 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 From 6f7f4a5d2cbdbc461968447cdefcbbac7391dcf7 Mon Sep 17 00:00:00 2001 From: Neha P <144308844+neha00290@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:37:36 +0000 Subject: [PATCH 03/12] fix(dashboards): lock the import folder while importing and keep the home pin's folder current after a move (#16295) Co-authored-by: ktx-vaidehi Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> --- .../dashboards/SelectFolderDropdown.spec.ts | 21 ++ .../dashboards/SelectFolderDropdown.vue | 6 + web/src/test/unit/HomeView.spec.ts | 188 +++++++++++++++--- web/src/test/unit/PinnedDashboardTab.spec.ts | 4 +- web/src/views/Dashboards/Dashboards.spec.ts | 43 ++++ web/src/views/Dashboards/Dashboards.vue | 13 +- .../views/Dashboards/ImportDashboard.spec.ts | 91 +++++++++ web/src/views/Dashboards/ImportDashboard.vue | 17 +- web/src/views/HomeView.vue | 79 ++++++-- web/src/views/PinnedDashboardTab.vue | 6 +- 10 files changed, 418 insertions(+), 50 deletions(-) diff --git a/web/src/components/dashboards/SelectFolderDropdown.spec.ts b/web/src/components/dashboards/SelectFolderDropdown.spec.ts index 3717026c9b..a5eb995f49 100644 --- a/web/src/components/dashboards/SelectFolderDropdown.spec.ts +++ b/web/src/components/dashboards/SelectFolderDropdown.spec.ts @@ -16,6 +16,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { mount } from "@vue/test-utils"; import SelectFolderDropdown from "./SelectFolderDropdown.vue"; +import OSelect from "@/lib/forms/Select/OSelect.vue"; import i18n from "@/locales"; import { createStore } from "vuex"; import { createRouter, createWebHistory } from "vue-router"; @@ -341,6 +342,26 @@ describe("SelectFolderDropdown", () => { }); }); + describe("disabled prop", () => { + it("locks the folder select and the new-folder button when disabled", () => { + const wrapper = createWrapper({ disabled: true }, store, router); + + expect(wrapper.findComponent(OSelect).props("disabled")).toBe(true); + expect( + wrapper.find('[data-test="dashboard-folder-move-new-add"]').attributes("disabled"), + ).toBeDefined(); + }); + + it("leaves both controls enabled by default", () => { + const wrapper = createWrapper({}, store, router); + + expect(wrapper.findComponent(OSelect).props("disabled")).toBe(false); + expect( + wrapper.find('[data-test="dashboard-folder-move-new-add"]').attributes("disabled"), + ).toBeUndefined(); + }); + }); + describe("Reactive folder list", () => { it("should refresh selectedFolder when the store folders list changes and current id no longer exists", async () => { const wrapper = createWrapper({ activeFolderId: "folder1" }, store, router); diff --git a/web/src/components/dashboards/SelectFolderDropdown.vue b/web/src/components/dashboards/SelectFolderDropdown.vue index 3bd442a7a0..99059a241a 100644 --- a/web/src/components/dashboards/SelectFolderDropdown.vue +++ b/web/src/components/dashboards/SelectFolderDropdown.vue @@ -24,6 +24,7 @@ along with this program. If not, see . :options="folderOptions" data-test="index-dropdown-stream_type" labelKey="label" + :disabled="disabled" class="flex-1" >