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:
Hengfei Yang
2026-10-16 14:52:31 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent 96b5747fe2
commit e7ffc8c453
27 changed files with 111 additions and 270 deletions
@@ -2383,7 +2383,6 @@
"evidence": "03867aea70acaf4c",
"filterAll": "a52ace420f2175d0",
"findingsHeading": "e171c2ff25b55e5a",
"lagImpact": "e058e98635dc1d23",
"loadFailed": "d209798dda2325be",
"noRecords": "d3b98b3f340b534b",
"noneFoundDeclined": "2366f8dc1f762210",
+2 -111
View File
@@ -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
+36 -36
View File
@@ -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
}
}
}
}
+1 -1
View File
@@ -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) => {
+5 -8
View File
@@ -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();
+8 -18
View File
@@ -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);
}
}
+1 -25
View File
@@ -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
+1 -21
View File
@@ -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"
+1 -1
View File
@@ -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();
-1
View File
@@ -8207,7 +8207,6 @@
"alert": "تنبيه",
"topOrgs": "المؤسسات الأكثر تأثرًا",
"worstHours": "أسوأ الساعات",
"lagImpact": "{skipped} متخطاة، و{late} من {evaluations} متأخرة",
"proposedActions": "الإجراءات المقترحة",
"openAlert": "فتح التنبيه"
},
-1
View File
@@ -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"
},
-1
View File
@@ -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"
},
-1
View File
@@ -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"
},
-1
View File
@@ -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"
},
-1
View File
@@ -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"
},
-1
View File
@@ -8207,7 +8207,6 @@
"alert": "アラート",
"topOrgs": "最も影響を受けた組織",
"worstHours": "最も深刻な時間帯",
"lagImpact": "{skipped} 件スキップ、{evaluations} 件中 {late} 件が遅延",
"proposedActions": "提案されたアクション",
"openAlert": "アラートを開く"
},
-1
View File
@@ -8207,7 +8207,6 @@
"alert": "알림",
"topOrgs": "영향을 가장 많이 받은 조직",
"worstHours": "최악의 시간대",
"lagImpact": "{skipped}건 건너뜀, {evaluations}건 중 {late}건 지연",
"proposedActions": "제안된 작업",
"openAlert": "알림 열기"
},
-1
View File
@@ -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"
},
-1
View File
@@ -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"
},
-1
View File
@@ -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"
},
-1
View File
@@ -8207,7 +8207,6 @@
"alert": "Оповещение",
"topOrgs": "Наиболее затронутые организации",
"worstHours": "Худшие часы",
"lagImpact": "{skipped} пропущено, {late} из {evaluations} с задержкой",
"proposedActions": "Предлагаемые действия",
"openAlert": "Открыть оповещение"
},
-1
View File
@@ -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ç"
},
-1
View File
@@ -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"
},
-1
View File
@@ -8207,7 +8207,6 @@
"alert": "告警",
"topOrgs": "受影响最严重的组织",
"worstHours": "最糟糕的时段",
"lagImpact": "{skipped} 个跳过,{evaluations} 个中 {late} 个延迟",
"proposedActions": "建议的操作",
"openAlert": "打开告警"
},
-1
View File
@@ -8202,7 +8202,6 @@
"alert": "警示",
"topOrgs": "受影響最嚴重的組織",
"worstHours": "最嚴重的時段",
"lagImpact": "略過 {skipped},{evaluations} 次中有 {late} 次延遲",
"proposedActions": "建議動作",
"openAlert": "開啟警示"
},