mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
fix(alerts): drop dead trigger_by_name and clean_complete, saturate max retries, stop cloning into batches (#15468)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot]
parent
96b5747fe2
commit
e7ffc8c453
@@ -2383,7 +2383,6 @@
|
||||
"evidence": "03867aea70acaf4c",
|
||||
"filterAll": "a52ace420f2175d0",
|
||||
"findingsHeading": "e171c2ff25b55e5a",
|
||||
"lagImpact": "e058e98635dc1d23",
|
||||
"loadFailed": "d209798dda2325be",
|
||||
"noRecords": "d3b98b3f340b534b",
|
||||
"noneFoundDeclined": "2366f8dc1f762210",
|
||||
|
||||
@@ -2384,115 +2384,6 @@ pub async fn trigger_by_id<C: ConnectionTrait>(
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn trigger_by_name(
|
||||
org_id: &str,
|
||||
stream_type: StreamType,
|
||||
stream_name: &str,
|
||||
name: &str,
|
||||
) -> Result<ManualTrigger, AlertError> {
|
||||
let (_folder, alert) =
|
||||
match db::alerts::alert::get_by_name_with_folder(org_id, stream_type, stream_name, name)
|
||||
.await
|
||||
{
|
||||
Ok(Some(found)) => found,
|
||||
_ => {
|
||||
return Err(AlertError::AlertNotFound);
|
||||
}
|
||||
};
|
||||
let now = Utc::now().timestamp_micros();
|
||||
#[cfg(feature = "enterprise")]
|
||||
if let Some(downtime) = suppressed_manual_trigger(&alert, &_folder.folder_id, now).await {
|
||||
return Ok(ManualTrigger::Suppressed(downtime));
|
||||
}
|
||||
|
||||
// For creates_incident=true alerts the incident correlation path handles
|
||||
// the notification. For all other cases send the direct notification.
|
||||
#[cfg(feature = "enterprise")]
|
||||
let (incident_notified, incident_notify_error) = if alert.creates_incident
|
||||
&& o2_enterprise::enterprise::common::config::get_config()
|
||||
.incidents
|
||||
.enabled
|
||||
{
|
||||
let synthetic_row = manual_trigger_row(&alert);
|
||||
let notify = std::slice::from_ref(&synthetic_row);
|
||||
|
||||
match crate::alerts::incidents::correlate_alert_to_incident(
|
||||
&alert,
|
||||
&synthetic_row,
|
||||
notify,
|
||||
now,
|
||||
// Manual triggers evaluate nothing; no level to map.
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(correlated)) => {
|
||||
log::info!(
|
||||
"Manual trigger for alert {org_id}/{} correlated to incident {} (service: {})",
|
||||
alert.name,
|
||||
correlated.outcome.incident_id(),
|
||||
correlated.outcome.service_name(),
|
||||
);
|
||||
(
|
||||
incident_path_notified(&correlated.outcome),
|
||||
correlated.notify_error,
|
||||
)
|
||||
}
|
||||
Ok(None) => {
|
||||
log::debug!(
|
||||
"No incident correlation for manually triggered alert {org_id}/{}",
|
||||
alert.name
|
||||
);
|
||||
(false, None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Error correlating manual trigger to incident, falling back to direct notification: {e}"
|
||||
);
|
||||
(false, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(false, None)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let (incident_notified, incident_notify_error) = (false, None);
|
||||
|
||||
let trace_id = config::ider::generate_trace_id();
|
||||
let trace_id = format!("trig_name_{trace_id}");
|
||||
// Always sent: alert-attached workflows dispatch only from here. When the
|
||||
// incident path already notified the destinations, they are skipped so the
|
||||
// same manual trigger cannot page them twice.
|
||||
let skip_destinations: &[String] = if incident_notified {
|
||||
&alert.destinations
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let rows = vec![manual_trigger_row(&alert)];
|
||||
let outcome = alert
|
||||
.send_notification(
|
||||
&trace_id,
|
||||
&rows,
|
||||
now,
|
||||
None,
|
||||
now,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
skip_destinations,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let error_message = with_incident_notify_error(outcome.error_message, incident_notify_error);
|
||||
|
||||
Ok(ManualTrigger::Sent {
|
||||
success_message: outcome.success_message,
|
||||
error_message,
|
||||
})
|
||||
}
|
||||
|
||||
/// A manual trigger inside a window is suppressed like a scheduled run: no page, no un-mute.
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn suppressed_manual_trigger(
|
||||
@@ -2678,8 +2569,8 @@ impl AlertExt for Alert {
|
||||
// every surface below (each destination's template, and the workflow
|
||||
// trigger metadata). An SLO alert reports its SLO where an ordinary
|
||||
// alert reports its stream, and the SLO's NAME is on the evaluation
|
||||
// row rather than on the alert — so the rowless paths
|
||||
// (`trigger_by_id`, `trigger_by_name`) need one primary-key read to
|
||||
// row rather than on the alert — so the rowless path
|
||||
// (`trigger_by_id`) needs one primary-key read to
|
||||
// say the same thing a firing says. Resolving it here rather than
|
||||
// inside the renderer is what keeps that at one read per notification
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use config::{
|
||||
},
|
||||
utils::json,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use dashmap::{DashMap, mapref::entry::Entry};
|
||||
|
||||
/// Keyed by [`batch_key`]: a fingerprint carries no org, so it alone would merge two orgs' batches.
|
||||
static PENDING_BATCHES: Lazy<Arc<DashMap<String, PendingBatch>>> =
|
||||
@@ -188,36 +188,38 @@ pub fn add_to_batch(
|
||||
// fingerprint — so this is set once, when the batch is created.
|
||||
group_labels: Option<std::collections::BTreeMap<String, String>>,
|
||||
) -> BatchAdmission {
|
||||
let mut refused = false;
|
||||
let full = PENDING_BATCHES
|
||||
.entry(batch_key(&org_id, &fingerprint))
|
||||
.and_modify(|batch| {
|
||||
if batch.add_alert(alert.clone(), rows.clone(), trigger_data.clone()) {
|
||||
log::debug!(
|
||||
"[grouping] Added alert '{}' to existing batch {} (count: {}/{})",
|
||||
alert.name,
|
||||
fingerprint,
|
||||
batch.alerts.len(),
|
||||
batch.max_group_size
|
||||
);
|
||||
if batch.is_full() {
|
||||
log::info!(
|
||||
"[grouping] Batch {} reached max size ({}), ready to send",
|
||||
fingerprint,
|
||||
batch.max_group_size
|
||||
);
|
||||
}
|
||||
} else {
|
||||
refused = true;
|
||||
match PENDING_BATCHES.entry(batch_key(&org_id, &fingerprint)) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let batch = entry.get_mut();
|
||||
let alert_name = alert.name.clone();
|
||||
if !batch.add_alert(alert, rows, trigger_data) {
|
||||
log::warn!(
|
||||
"[grouping] Failed to add alert '{}' to batch {} (already full), org_id: {}",
|
||||
alert.name,
|
||||
alert_name,
|
||||
fingerprint,
|
||||
org_id
|
||||
);
|
||||
return BatchAdmission::Refused;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
log::debug!(
|
||||
"[grouping] Added alert '{}' to existing batch {} (count: {}/{})",
|
||||
alert_name,
|
||||
fingerprint,
|
||||
batch.alerts.len(),
|
||||
batch.max_group_size
|
||||
);
|
||||
if batch.is_full() {
|
||||
log::info!(
|
||||
"[grouping] Batch {} reached max size ({}), ready to send",
|
||||
fingerprint,
|
||||
batch.max_group_size
|
||||
);
|
||||
BatchAdmission::Ready
|
||||
} else {
|
||||
BatchAdmission::Queued
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
log::info!(
|
||||
"[grouping] Created new batch for fingerprint {}, alert: '{}', org: {}, wait_seconds: {}, max_size: {}",
|
||||
fingerprint,
|
||||
@@ -226,8 +228,8 @@ pub fn add_to_batch(
|
||||
group_wait_seconds,
|
||||
max_group_size
|
||||
);
|
||||
PendingBatch::new(
|
||||
fingerprint.clone(),
|
||||
let batch = entry.insert(PendingBatch::new(
|
||||
fingerprint,
|
||||
org_id,
|
||||
alert,
|
||||
rows,
|
||||
@@ -236,15 +238,13 @@ pub fn add_to_batch(
|
||||
max_group_size,
|
||||
level,
|
||||
group_labels,
|
||||
)
|
||||
})
|
||||
.is_full();
|
||||
if refused {
|
||||
BatchAdmission::Refused
|
||||
} else if full {
|
||||
BatchAdmission::Ready
|
||||
} else {
|
||||
BatchAdmission::Queued
|
||||
));
|
||||
if batch.is_full() {
|
||||
BatchAdmission::Ready
|
||||
} else {
|
||||
BatchAdmission::Queued
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(super) async fn handle_triggers(
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let started_at = now_micros();
|
||||
let read = read_config(&trigger).await;
|
||||
let (_, max_retries) = get_scheduler_max_retries();
|
||||
let max_retries = get_scheduler_max_retries();
|
||||
let pass = Pass::decide(read.as_ref(), trigger.retries, max_retries);
|
||||
let analysis = match &pass {
|
||||
Pass::Due(config) => {
|
||||
|
||||
@@ -2878,7 +2878,7 @@ async fn handle_alert_triggers(
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let query_trace_id = ider::generate_trace_id();
|
||||
let scheduler_trace_id = format!("{trace_id}/{query_trace_id}");
|
||||
let (_, max_retries) = get_scheduler_max_retries();
|
||||
let max_retries = get_scheduler_max_retries();
|
||||
log::debug!(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] Inside handle_alert_triggers: processing trigger: {}",
|
||||
trigger.module_key
|
||||
@@ -4531,7 +4531,7 @@ async fn handle_report_triggers(
|
||||
let conn = get_orm_client_rw().await;
|
||||
let query_trace_id = ider::generate_trace_id();
|
||||
let scheduler_trace_id = format!("{trace_id}/{query_trace_id}");
|
||||
let (_, max_retries) = get_scheduler_max_retries();
|
||||
let max_retries = get_scheduler_max_retries();
|
||||
log::debug!(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] Inside handle_report_trigger,org: {}, module_key: {}",
|
||||
trigger.org,
|
||||
@@ -4867,7 +4867,7 @@ async fn handle_derived_stream_triggers(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] Inside handle_derived_stream_triggers processing trigger: {}",
|
||||
trigger.module_key
|
||||
);
|
||||
let (_, max_retries) = get_scheduler_max_retries();
|
||||
let max_retries = get_scheduler_max_retries();
|
||||
|
||||
// module_key format: stream_type/org_id/pipeline_name/pipeline_id
|
||||
let (org_id, stream_type, pipeline_name, pipeline_id) = match get_pipeline_info_from_module_key(
|
||||
@@ -5250,10 +5250,7 @@ async fn handle_derived_stream_triggers(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// In case the scheduler background job (watch_timeout) updates the trigger retries
|
||||
// (not through this handler), we need to skip to the next run at but with the same
|
||||
// trigger start time. If we don't handle here, in that case, the `clean_complete`
|
||||
// background job will clear this job as it has reached max retries.
|
||||
// watch_timeout can bump retries outside this handler; past the cap, skip to the next run.
|
||||
if trigger.retries >= max_retries {
|
||||
log::info!(
|
||||
"[SCHEDULER trace_id {scheduler_trace_id}] DerivedStream trigger: {}/{} has reached maximum possible retries. Skipping to next run",
|
||||
@@ -5612,7 +5609,7 @@ async fn handle_backfill_triggers(
|
||||
triggers::{DeletionStatus, ScheduledTriggerData},
|
||||
};
|
||||
|
||||
let (_, max_retries) = get_scheduler_max_retries();
|
||||
let max_retries = get_scheduler_max_retries();
|
||||
let job_id = trigger.module_key.clone();
|
||||
let org_id = trigger.org.clone();
|
||||
let query_trace_id = ider::generate_trace_id();
|
||||
|
||||
@@ -88,7 +88,6 @@ pub trait Scheduler: Sync + Send + 'static {
|
||||
async fn get(&self, org: &str, module: TriggerModule, key: &str) -> Result<Trigger>;
|
||||
async fn list(&self, module: Option<TriggerModule>) -> Result<Vec<Trigger>>;
|
||||
async fn list_by_org(&self, org: &str, module: Option<TriggerModule>) -> Result<Vec<Trigger>>;
|
||||
async fn clean_complete(&self) -> Result<()>;
|
||||
async fn watch_timeout(&self) -> Result<()>;
|
||||
async fn len_module(&self, module: TriggerModule) -> usize;
|
||||
async fn len(&self) -> usize;
|
||||
@@ -255,13 +254,6 @@ pub async fn get(org: &str, module: TriggerModule, key: &str) -> Result<Trigger>
|
||||
CLIENT.get(org, module, key).await
|
||||
}
|
||||
|
||||
/// Background job that frequently (with the given interval) cleans "Completed" jobs
|
||||
/// or jobs with retries >= scheduler_max_retries set through environment config
|
||||
#[inline]
|
||||
pub async fn clean_complete() -> Result<()> {
|
||||
CLIENT.clean_complete().await
|
||||
}
|
||||
|
||||
/// Background job that watches for timeout of a job
|
||||
/// Steps:
|
||||
/// - Select all the records with status = "Processing"
|
||||
@@ -306,11 +298,12 @@ pub async fn clear() -> Result<()> {
|
||||
CLIENT.clear().await
|
||||
}
|
||||
|
||||
/// Returns the scheduler_max_retries set through environment config
|
||||
/// The bool element in the tuple indicates if the max retries value is included
|
||||
pub fn get_scheduler_max_retries() -> (bool, i32) {
|
||||
let max_retries = config::get_config().limit.scheduler_max_retries;
|
||||
(max_retries > 0, max_retries.unsigned_abs() as i32)
|
||||
/// `i32::MIN` has no positive magnitude, so `abs` would wrap it back to a negative cap.
|
||||
pub fn get_scheduler_max_retries() -> i32 {
|
||||
config::get_config()
|
||||
.limit
|
||||
.scheduler_max_retries
|
||||
.saturating_abs()
|
||||
}
|
||||
|
||||
/// Realtime alert triggers are cached on every node; the trigger rides in the
|
||||
@@ -407,10 +400,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_scheduler_max_retries_returns_tuple() {
|
||||
let (has_max, _max) = get_scheduler_max_retries();
|
||||
// when config is default (0), should return false and abs(0) = 0
|
||||
let max_retries = config::get_config().limit.scheduler_max_retries;
|
||||
assert_eq!(has_max, max_retries > 0);
|
||||
fn test_get_scheduler_max_retries_is_never_negative() {
|
||||
assert!(get_scheduler_max_retries() >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use config::{
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{TRIGGERS_KEY, Trigger, TriggerModule, TriggerStatus, get_scheduler_max_retries};
|
||||
use super::{TRIGGERS_KEY, Trigger, TriggerModule, TriggerStatus};
|
||||
use crate::{
|
||||
db::{
|
||||
self, IndexStatement,
|
||||
@@ -870,30 +870,6 @@ WHERE org = $1 AND module = $2 AND module_key = $3;"#;
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
/// Background job that frequently (30 secs interval) cleans "Completed" jobs or jobs with
|
||||
/// retries >= threshold set through environment
|
||||
async fn clean_complete(&self) -> Result<()> {
|
||||
let pool = CLIENT_RW.clone();
|
||||
log::debug!("[SCHEDULER] cleaning completed jobs");
|
||||
let (include_max, mut max_retries) = get_scheduler_max_retries();
|
||||
if include_max {
|
||||
max_retries += 1;
|
||||
}
|
||||
DB_QUERY_NUMS
|
||||
.with_label_values(&["delete", "scheduled_jobs"])
|
||||
.inc();
|
||||
// Since alert scheduled_jobs contain last_satisfied_at field, we should not delete them
|
||||
sqlx::query(
|
||||
r#"DELETE FROM scheduled_jobs WHERE (status = $1 OR retries >= $2) AND module != $3;"#,
|
||||
)
|
||||
.bind(TriggerStatus::Completed)
|
||||
.bind(max_retries)
|
||||
.bind(TriggerModule::Alert)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Background job that watches for timeout of a job
|
||||
/// Steps:
|
||||
/// - Select records with status = "Processing" whose `end_time` has passed
|
||||
|
||||
@@ -18,7 +18,7 @@ use chrono::Duration;
|
||||
use config::utils::time::now_micros;
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{TRIGGERS_KEY, Trigger, TriggerModule, TriggerStatus, get_scheduler_max_retries};
|
||||
use super::{TRIGGERS_KEY, Trigger, TriggerModule, TriggerStatus};
|
||||
use crate::{
|
||||
db::{
|
||||
self, IndexStatement,
|
||||
@@ -550,26 +550,6 @@ WHERE org = $1 AND module = $2 AND module_key = $3;"#;
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
/// Background job that frequently (30 secs interval) cleans "Completed" jobs or jobs with
|
||||
/// retries >= threshold set through environment
|
||||
async fn clean_complete(&self) -> Result<()> {
|
||||
let client = CLIENT_RW.clone();
|
||||
let (include_max, mut max_retries) = get_scheduler_max_retries();
|
||||
if include_max {
|
||||
max_retries += 1;
|
||||
}
|
||||
// Since alert scheduled_jobs contain last_satisfied_at field, we should not delete them
|
||||
sqlx::query(
|
||||
r#"DELETE FROM scheduled_jobs WHERE (status = $1 OR retries >= $2) AND module != $3;"#,
|
||||
)
|
||||
.bind(TriggerStatus::Completed)
|
||||
.bind(max_retries)
|
||||
.bind(TriggerModule::Alert)
|
||||
.execute(&client)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Background job that watches for timeout of a job
|
||||
/// Steps:
|
||||
/// - Select all the records with status = "Processing"
|
||||
|
||||
@@ -189,7 +189,7 @@ pub async fn list_overlapping(
|
||||
range_start_us: i64,
|
||||
range_end_us: i64,
|
||||
) -> Result<Vec<AlertEvalInterval>, errors::Error> {
|
||||
let client = get_orm_client_rw().await;
|
||||
let client = get_orm_client_ro().await;
|
||||
list_overlapping_with(client, alert_id, range_start_us, range_end_us).await
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,18 @@ describe("AlertCalibrationFindingCard", () => {
|
||||
it("lists a cluster finding's worst orgs in place of a single alert", () => {
|
||||
const wrapper = mountCard(finding());
|
||||
const rows = wrapper.findAll('[data-test="alert-calibration-finding-lag-row"]');
|
||||
expect(rows.map((row) => row.text())).toEqual([
|
||||
"globex3 skipped, 0 of 100 late",
|
||||
"acme0 skipped, 40 of 100 late",
|
||||
expect(
|
||||
rows.map((row) =>
|
||||
row.findAll('[data-test="alert-calibration-finding-lag-cell"]').map((cell) => cell.text()),
|
||||
),
|
||||
).toEqual([
|
||||
["globex", "3", "0", "100"],
|
||||
["acme", "0", "40", "100"],
|
||||
]);
|
||||
expect(wrapper.text()).toContain("Most affected orgs");
|
||||
const list = wrapper.find('[data-test="alert-calibration-finding-org_id-list"]');
|
||||
expect(list.text()).toContain("Most affected orgs");
|
||||
expect(list.text()).toContain("Skipped windows");
|
||||
expect(list.text()).toContain("Late evaluations");
|
||||
expect(
|
||||
wrapper.find('[data-test="alert-calibration-finding-hour_start_micros-list"]').exists(),
|
||||
).toBe(false);
|
||||
|
||||
@@ -53,49 +53,60 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<OSeparator />
|
||||
|
||||
<OCardSection role="body" class="flex flex-col gap-2 pt-3">
|
||||
<dl class="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<OCardSection role="body" class="flex flex-col gap-3 pt-3">
|
||||
<dl class="flex flex-wrap gap-x-8 gap-y-2">
|
||||
<div
|
||||
v-for="row in evidence"
|
||||
:key="row.column"
|
||||
class="flex min-w-0 items-baseline gap-1.5"
|
||||
class="flex min-w-0 flex-col"
|
||||
:class="row.wide ? 'basis-full' : ''"
|
||||
>
|
||||
<dt class="text-text-secondary shrink-0 text-xs">{{ row.label }}</dt>
|
||||
<OTruncatedText as="dd" class="text-text-body min-w-0 text-xs font-medium tabular-nums">
|
||||
<dt class="text-text-secondary text-xs">{{ row.label }}</dt>
|
||||
<OTruncatedText as="dd" class="text-text-body min-w-0 text-sm font-medium tabular-nums">
|
||||
{{ row.value }}
|
||||
</OTruncatedText>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-for="list in lagLists"
|
||||
:key="list.column"
|
||||
class="flex flex-col gap-1.5"
|
||||
:data-test="`alert-calibration-finding-${list.key}-list`"
|
||||
>
|
||||
<OText variant="label" as="div">{{ list.label }}</OText>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
<div v-if="lagLists.length" class="grid gap-3 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="list in lagLists"
|
||||
:key="list.column"
|
||||
class="border-border-default rounded-default grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-x-4 gap-y-1.5 border px-3 py-2"
|
||||
:data-test="`alert-calibration-finding-${list.key}-list`"
|
||||
>
|
||||
<OText variant="label" as="div" class="min-w-0">{{ list.label }}</OText>
|
||||
<OText
|
||||
v-for="column in LAG_COLUMNS"
|
||||
:key="column.field"
|
||||
variant="label"
|
||||
as="div"
|
||||
class="text-end"
|
||||
>
|
||||
{{ t(column.labelKey) }}
|
||||
</OText>
|
||||
<div
|
||||
v-for="row in list.rows"
|
||||
:key="row.key"
|
||||
class="flex min-w-0 items-baseline justify-between gap-3"
|
||||
class="contents"
|
||||
data-test="alert-calibration-finding-lag-row"
|
||||
>
|
||||
<OTruncatedText class="text-text-body min-w-0 text-xs font-medium">
|
||||
<OTruncatedText
|
||||
class="text-text-body min-w-0 text-xs font-medium"
|
||||
data-test="alert-calibration-finding-lag-cell"
|
||||
>
|
||||
{{ row.label }}
|
||||
</OTruncatedText>
|
||||
<span class="text-text-secondary shrink-0 text-xs tabular-nums">
|
||||
{{
|
||||
t("alertCalibration.detail.lagImpact", {
|
||||
skipped: formatEvidence(t, row.skipped_windows, "count"),
|
||||
late: formatEvidence(t, row.late_evaluations, "count"),
|
||||
evaluations: formatEvidence(t, row.evaluations, "count"),
|
||||
})
|
||||
}}
|
||||
<span
|
||||
v-for="column in LAG_COLUMNS"
|
||||
:key="column.field"
|
||||
class="text-text-body text-end text-xs tabular-nums"
|
||||
data-test="alert-calibration-finding-lag-cell"
|
||||
>
|
||||
{{ formatEvidence(t, row[column.field], "count") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -130,8 +141,8 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import { useOrgId } from "@/composables/query";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import { parseLagRows, type FindingRecord } from "./digests";
|
||||
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
|
||||
import { parseLagRows, type FindingRecord, type LagRow } from "./digests";
|
||||
import { ACTION_META, isProposedAction, RULE_META } from "./rules";
|
||||
import {
|
||||
CONFIDENCE_LABEL,
|
||||
@@ -143,6 +154,12 @@ import {
|
||||
|
||||
const props = defineProps<{ finding: FindingRecord }>();
|
||||
|
||||
const LAG_COLUMNS: { field: keyof LagRow; labelKey: I18nKey }[] = [
|
||||
{ field: "skipped_windows", labelKey: "alertCalibration.evidence.skippedWindows" },
|
||||
{ field: "late_evaluations", labelKey: "alertCalibration.evidence.lateEvaluations" },
|
||||
{ field: "evaluations", labelKey: "alertCalibration.evidence.evaluations" },
|
||||
];
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const router = useRouter();
|
||||
const orgId = useOrgId();
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "تنبيه",
|
||||
"topOrgs": "المؤسسات الأكثر تأثرًا",
|
||||
"worstHours": "أسوأ الساعات",
|
||||
"lagImpact": "{skipped} متخطاة، و{late} من {evaluations} متأخرة",
|
||||
"proposedActions": "الإجراءات المقترحة",
|
||||
"openAlert": "فتح التنبيه"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Alarm",
|
||||
"topOrgs": "Am stärksten betroffene Organisationen",
|
||||
"worstHours": "Schlimmste Stunden",
|
||||
"lagImpact": "{skipped} übersprungen, {late} von {evaluations} verspätet",
|
||||
"proposedActions": "Vorgeschlagene Aktionen",
|
||||
"openAlert": "Alarm öffnen"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Alert",
|
||||
"topOrgs": "Most affected orgs",
|
||||
"worstHours": "Worst hours",
|
||||
"lagImpact": "{skipped} skipped, {late} of {evaluations} late",
|
||||
"proposedActions": "Proposed actions",
|
||||
"openAlert": "Open alert"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Alerta",
|
||||
"topOrgs": "Organizaciones más afectadas",
|
||||
"worstHours": "Peores horas",
|
||||
"lagImpact": "{skipped} omitidas, {late} de {evaluations} tardías",
|
||||
"proposedActions": "Acciones propuestas",
|
||||
"openAlert": "Abrir alerta"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Alerte",
|
||||
"topOrgs": "Organisations les plus affectées",
|
||||
"worstHours": "Heures les plus critiques",
|
||||
"lagImpact": "{skipped} ignorées, {late} sur {evaluations} tardives",
|
||||
"proposedActions": "Actions proposées",
|
||||
"openAlert": "Ouvrir l'alerte"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Avviso",
|
||||
"topOrgs": "Organizzazioni più interessate",
|
||||
"worstHours": "Ore peggiori",
|
||||
"lagImpact": "{skipped} saltate, {late} di {evaluations} in ritardo",
|
||||
"proposedActions": "Azioni proposte",
|
||||
"openAlert": "Apri avviso"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "アラート",
|
||||
"topOrgs": "最も影響を受けた組織",
|
||||
"worstHours": "最も深刻な時間帯",
|
||||
"lagImpact": "{skipped} 件スキップ、{evaluations} 件中 {late} 件が遅延",
|
||||
"proposedActions": "提案されたアクション",
|
||||
"openAlert": "アラートを開く"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "알림",
|
||||
"topOrgs": "영향을 가장 많이 받은 조직",
|
||||
"worstHours": "최악의 시간대",
|
||||
"lagImpact": "{skipped}건 건너뜀, {evaluations}건 중 {late}건 지연",
|
||||
"proposedActions": "제안된 작업",
|
||||
"openAlert": "알림 열기"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Alarm",
|
||||
"topOrgs": "Meest getroffen organisaties",
|
||||
"worstHours": "Slechtste uren",
|
||||
"lagImpact": "{skipped} overgeslagen, {late} van {evaluations} te laat",
|
||||
"proposedActions": "Voorgestelde acties",
|
||||
"openAlert": "Alarm openen"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Alert",
|
||||
"topOrgs": "Najbardziej dotknięte organizacje",
|
||||
"worstHours": "Najgorsze godziny",
|
||||
"lagImpact": "{skipped} pominiętych, {late} z {evaluations} opóźnionych",
|
||||
"proposedActions": "Proponowane działania",
|
||||
"openAlert": "Otwórz alert"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Alerta",
|
||||
"topOrgs": "Organizações mais afetadas",
|
||||
"worstHours": "Piores horas",
|
||||
"lagImpact": "{skipped} ignoradas, {late} de {evaluations} atrasadas",
|
||||
"proposedActions": "Ações propostas",
|
||||
"openAlert": "Abrir alerta"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Оповещение",
|
||||
"topOrgs": "Наиболее затронутые организации",
|
||||
"worstHours": "Худшие часы",
|
||||
"lagImpact": "{skipped} пропущено, {late} из {evaluations} с задержкой",
|
||||
"proposedActions": "Предлагаемые действия",
|
||||
"openAlert": "Открыть оповещение"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Uyarı",
|
||||
"topOrgs": "En çok etkilenen kuruluşlar",
|
||||
"worstHours": "En kötü saatler",
|
||||
"lagImpact": "{skipped} atlandı, {evaluations} değerlendirmenin {late} tanesi geç",
|
||||
"proposedActions": "Önerilen eylemler",
|
||||
"openAlert": "Uyarıyı aç"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "Cảnh báo",
|
||||
"topOrgs": "Các tổ chức bị ảnh hưởng nhiều nhất",
|
||||
"worstHours": "Khung giờ tệ nhất",
|
||||
"lagImpact": "{skipped} bị bỏ qua, {late} trên {evaluations} bị trễ",
|
||||
"proposedActions": "Hành động đề xuất",
|
||||
"openAlert": "Mở cảnh báo"
|
||||
},
|
||||
|
||||
@@ -8207,7 +8207,6 @@
|
||||
"alert": "告警",
|
||||
"topOrgs": "受影响最严重的组织",
|
||||
"worstHours": "最糟糕的时段",
|
||||
"lagImpact": "{skipped} 个跳过,{evaluations} 个中 {late} 个延迟",
|
||||
"proposedActions": "建议的操作",
|
||||
"openAlert": "打开告警"
|
||||
},
|
||||
|
||||
@@ -8202,7 +8202,6 @@
|
||||
"alert": "警示",
|
||||
"topOrgs": "受影響最嚴重的組織",
|
||||
"worstHours": "最嚴重的時段",
|
||||
"lagImpact": "略過 {skipped},{evaluations} 次中有 {late} 次延遲",
|
||||
"proposedActions": "建議動作",
|
||||
"openAlert": "開啟警示"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user