Merge branch 'main' into hf/like-index-pushdown

This commit is contained in:
Hengfei Yang
2026-11-02 22:10:46 +08:00
committed by GitHub
122 changed files with 7307 additions and 2239 deletions
+73 -16
View File
@@ -375,6 +375,8 @@
"copied": "ea61bc15688d1e48",
"copyDetails": "ec7ee2826c849564",
"denied": {
"actions": "ad5a1de1c7b8b675",
"askAdmin": "01ecb087fe129ce1",
"create": "e64bc3593a603832",
"delete": "c346b3c06e9303ed",
"edit": "6649052d1364e44e",
@@ -382,57 +384,120 @@
"missingIntro": "475cc73919dd1e9d",
"more": "5f4f590acbda8ee3",
"parts": "8b82b8f1aed3dad5",
"resources": "cd3b36d4f31da787",
"showLess": "94ea9b1d33a02975",
"view": "27e9f709ef02acf1"
},
"resources": {
"ai": "11fb682be0a0233d",
"alertFolders": "a6e2acee332f6d23",
"alerts": "f62e9267d196c761",
"billingGroup": "3a0cc47e1a82ca70",
"cipherKeys": "9d5e36b817bf496e",
"dashboardFolders": "62ec1a77e94fbcb4",
"dashboards": "a53bcafb67d960df",
"databaseMonitoring": "8f614378c817c981",
"destinations": "72eb63f032e47e34",
"downtimeFolders": "8d359247ac73379d",
"downtimes": "5d9972eaed771890",
"enrichmentTables": "271f9c6c3357a0b6",
"evalJobs": "e24b61f0397c85fd",
"fleetConfigs": "affc3f6b5f45623a",
"fleetManagement": "734376ce61f58376",
"functions": "75e942e5b366227e",
"groups": "39bbb719fa2b9d22",
"incidentIntegrations": "28df794d2558a7c3",
"incidents": "bfe868931580fb3b",
"llmModelPricing": "794f915f46e1cc64",
"llmProviders": "437dbe28117905aa",
"metrics": "a58da793c7250f1b",
"onCall": "3004e1251d8890eb",
"onCallResponses": "a330bac7444925ef",
"organizations": "2730183d6b776b40",
"passcode": "df5f51e2e793f3c9",
"pipelines": "d1a8fffeb935847e",
"promptFolders": "f92c9201b9ff5bb1",
"publicDashboards": "b4bee918655d4e11",
"regexPatterns": "36e205848147c947",
"remoteTasks": "10d46c5392001dce",
"reportFolders": "515a865d77faf262",
"reports": "dacca3cba3f346a4",
"roles": "c25337055464f8d3",
"rumTokens": "d7da49dc65e2dbf8",
"savedviews": "81184cb517eda985",
"scoreConfigs": "7d19a0ae2c76ced2",
"scorers": "d8598427499abb65",
"searchJobs": "989ecb5d07fd4c76",
"serviceAccounts": "1642d9e225e63659",
"serviceStreams": "e3c04b59334f2bd1",
"settings": "74a883a037bc227f",
"sourceMaps": "b95b75c0ca503146",
"statusPages": "8fe24747e79435f9",
"streams": "bfd297afbf9e7430",
"syntheticChecks": "fe7120bc3b396719",
"syntheticEnvironments": "a4b9982963b2f048",
"syntheticFolders": "48bdc3235b5ea3ea",
"templates": "56b564b75c7fdb48",
"users": "6b0cc904d081a8c1"
"traces": "998741a58ed2b411",
"users": "6b0cc904d081a8c1",
"workflowFolders": "8e6af10f9d904da1",
"workflows": "e2073645cb0dbbdd"
},
"resourcesInline": {
"ai": "11fb682be0a0233d",
"alertFolders": "d2a7365a5fcea5bd",
"alerts": "deb3e366c077c228",
"billingGroup": "988507f87cfea865",
"cipherKeys": "a4ec922695af251c",
"dashboardFolders": "b3505579443c0ff4",
"dashboards": "21b2e2095fd38f7d",
"databaseMonitoring": "d70b640c5e7e70cb",
"destinations": "1af0500e4d2be663",
"downtimeFolders": "20ee5e166973b24f",
"downtimes": "573ef711d75684dd",
"enrichmentTables": "2523e745479b9694",
"evalJobs": "3fcc2bfe3200a445",
"fleetConfigs": "fa54fb3d9e25631c",
"fleetManagement": "a3ff8c3b237fa163",
"functions": "ef7d009c7e812da1",
"groups": "4ed379d418bb8629",
"incidentIntegrations": "c04904d8df7467f6",
"incidents": "a6d1765ab64d82a7",
"llmModelPricing": "794f915f46e1cc64",
"llmProviders": "437dbe28117905aa",
"metrics": "177a7ea3611fe6b1",
"onCall": "3004e1251d8890eb",
"onCallResponses": "a330bac7444925ef",
"organizations": "f8a665a97769f1d2",
"passcode": "583375d251142f0d",
"pipelines": "8719e4b9771d340e",
"promptFolders": "ac11560dd26704d4",
"publicDashboards": "55ff9bd4571fa4cb",
"regexPatterns": "f2677e20964b4314",
"remoteTasks": "63d70d9b7b8e801d",
"reportFolders": "ac9ce060cfb53c7b",
"reports": "7f26104f77a7ce47",
"roles": "66cf5513b37462be",
"rumTokens": "d7da49dc65e2dbf8",
"savedviews": "1b46920dbc530fb7",
"scoreConfigs": "189e152b76d63e8a",
"scorers": "2b0f61e1cf254296",
"searchJobs": "6722117cf5ce7eda",
"serviceAccounts": "7c40134810b0f1e1",
"serviceStreams": "8d5894bc8ffa2192",
"settings": "cde0fb0dec1400c5",
"sourceMaps": "08cd5f94f175b0d2",
"statusPages": "449ab3614f7fecf1",
"streams": "d5a08b6893ecd1aa",
"syntheticChecks": "6e01d2051842b5a1",
"syntheticEnvironments": "a902961a1e4e24a3",
"syntheticFolders": "b8fe6356a3da347a",
"templates": "9c6641c1f9cfb0c6",
"traces": "b5ac69fc467c01f3",
"unnamed": "644ebf22501f57b5",
"users": "7dfb4cf67742cb06"
"users": "7dfb4cf67742cb06",
"workflowFolders": "9c0544bd232b9a33",
"workflows": "45b34f16db605a06"
}
},
"aiAssistant": {
@@ -2883,6 +2948,9 @@
"alertTypeAll": "a52ace420f2175d0",
"alertTypeAnomaly": "79622828d2d8d988",
"alertTypeThreshold": "0da627ada476c179",
"card": {
"customizeTooltip": "c54bbc1df678bf48"
},
"categories": "292c06f0045a45d0",
"clearFilters": "7179ea0035fca783",
"clearGroupSelection": "65a102085dc79c05",
@@ -2935,32 +3003,21 @@
"anomalyUnavailable": "6e0d80036514c77d",
"back": "76900f1bfd16c8d4",
"cancel": "19766ed6ccb2f4a3",
"clear": "83b12c2216efb4fd",
"confirmLarge": "e0e1d5cc504b8f92",
"confirmLargeNoDestination": "c20eb3ff81f4d2fb",
"destinationIntro": "bf8cdafc7243e855",
"destinationLabel": "293d404a500f5f9a",
"destinationsLoadFailed": "4eeeed3a003c5cf3",
"destinationsNoneBody": "cdef20b5bf7cd337",
"done": "11a6767d5674c7e4",
"folderIntro": "b4e0c86b9e450e0f",
"frequency": "24947227759d0aca",
"minutes": "1f6fa6f69d185e60",
"next": "1ff57a29d7c9d11b",
"openDestinations": "c56a8a631d92885b",
"retry": "d8b8392e2c542950",
"retryFailed": "32780945b5f6a491",
"run": "bd467a2684768a3d",
"selectAll": "1fc9a387654d410f",
"selectedCount": "30ae395b1b4c7b5c",
"silence": "6ce6a4db3720f473",
"someFailed": "e0ff076899de444d",
"stepAlerts": "f62e9267d196c761",
"stepDestination": "293d404a500f5f9a",
"stepFolder": "74ccd43303847f26",
"stepInstall": "9fd728c66c9a256b",
"stepTune": "22c58d84f08e3a4e",
"subtitle": "f0269d0053de4642",
"subtitle": "a1658f4d1f84e24b",
"summaryTuningNone": "cda6ba311e7559b0",
"summaryTuningOverride": "4618a77d0da6425f",
"title": "4cc111be1713bc4e",
@@ -2976,7 +3033,6 @@
"noCollectorTitle": "f2c2ba2f9dafa112",
"noMatchesDescription": "e09a161e41ec40f8",
"noMatchesTitle": "0106064dcab637bc",
"notIngested": "ba8613ab3f202cb7",
"notIngestedHint": "12e99d7888e6aa47",
"retry": "942087cc2d41e013",
"searchCategories": "d03ec37c5f391830",
@@ -3310,6 +3366,7 @@
"alertIfColumn": "598de24632dc3693",
"alertIfNoOfEvents": "4e93cac2049f2acc",
"alertIfValueIs": "d93436b1c28cdb91",
"alertLibrary": "4c4995d1987d3b7b",
"alertName": "60a6c1eb5253724b",
"alertNamePlaceholder": "1f84a161ed86d648",
"alertRules": "98a30442b31e77d6",
@@ -4477,6 +4534,7 @@
"v2GroupConditionsArray": "e7eb3aa890d8d2bb",
"vrlFunctionString": "c8fdb15f9cc29aba"
},
"importAlert": "33cc28980e082bc3",
"importAlertTitle": "8003b4ed4b9e3e5b",
"importedFromPanel": "4e66f1ce917a8daf",
"incidents": {
@@ -23324,7 +23382,6 @@
"searchField": "7909dc4e707ddddb",
"searchHistory": "66d7e4a1c738f0b1",
"searchInspect": "bac72e15bee2bc8a",
"searchJobNoPermission": "e0038dd68dcec8eb",
"searchOperationCancelled": "2429bc389adabe7f",
"searchQueryEmptyOrInvalid": "7682ef6f589ed1ea",
"searchSavedFunction": "49c266baaaa70981",
+1 -1
View File
@@ -2604,7 +2604,7 @@ fn fleet_router() -> Router {
.route_layer(middleware::from_fn(fleet_gate))
}
/// §17: with `O2_FLEET_ENABLED` off, OpAMP answers 503 and management reads work but writes do not.
/// §17: with `O2_FLEET_ENABLED` off, auth refuses fleet tokens first (401); writes get 503.
#[cfg(feature = "enterprise")]
async fn fleet_gate(request: Request, next: Next) -> Response {
if openobserve_core::fleet::enabled() || request.method() == Method::GET && !is_opamp(&request)
@@ -94,6 +94,7 @@ use crate::{
openobserve_api_search::promql::remote_write,
openobserve_api_search::promql::query_get,
openobserve_api_search::promql::query_range_get,
openobserve_api_search::promql::query_exemplars_get,
openobserve_api_search::promql::metadata,
openobserve_api_search::promql::series_get,
openobserve_api_search::promql::labels_get,
@@ -1714,6 +1715,19 @@ mod tests {
}
}
#[test]
fn promql_exemplars_is_published_with_its_error_codes() {
let spec = serde_json::to_value(ApiDoc::openapi()).unwrap();
let op = &spec["paths"]["/api/{org_id}/prometheus/api/v1/query_exemplars"]["get"];
assert_eq!(op["operationId"], "PrometheusQueryExemplars");
for status in ["200", "400", "403", "429", "500"] {
assert!(
op["responses"].get(status).is_some(),
"query_exemplars {status}"
);
}
}
#[test]
fn recent_rejections_path_and_schemas_are_registered() {
let spec = serde_json::to_value(ApiDoc::openapi()).unwrap();
@@ -20,13 +20,15 @@ use axum::{
#[cfg(feature = "enterprise")]
use openobserve_api_common::extractors::Headers;
#[cfg(feature = "enterprise")]
use openobserve_core::auth::UserEmail;
use openobserve_core::auth::{UserEmail, UserReads};
#[cfg(test)]
use openobserve_core::llm_evaluations::eval_jobs::EvalJobError;
use openobserve_core::llm_evaluations::eval_jobs::{
self, ManualEvalJobRequestBody, ManualEvalJobResponseBody,
};
#[cfg(feature = "enterprise")]
use crate::request::scorers::{MAX_SCORERS_PER_REQUEST, authorize_references};
use crate::{
common::meta::http::HttpResponse as MetaHttpResponse,
models::eval_jobs::{
@@ -118,6 +120,10 @@ pub async fn create_eval_job(
Err(err) => return MetaHttpResponse::bad_request(err),
};
#[cfg(feature = "enterprise")]
if let Err(resp) = limit_scorers(&job.scorers) {
return resp;
}
#[cfg(feature = "enterprise")]
let job = match eval_jobs::prepare_new_job(&org_id, job).await {
Ok(job) => job,
Err(err) => return err.into(),
@@ -127,6 +133,10 @@ pub async fn create_eval_job(
return resp;
}
#[cfg(feature = "enterprise")]
if let Err(resp) = guard_scorers(&org_id, &user_email.user_id, &job.scorers).await {
return resp;
}
#[cfg(feature = "enterprise")]
let created = eval_jobs::insert_job(&org_id, job).await;
#[cfg(not(feature = "enterprise"))]
let created = eval_jobs::create_job(&org_id, job).await;
@@ -205,6 +215,15 @@ pub async fn update_eval_job(
Err(err) => return MetaHttpResponse::bad_request(err),
};
#[cfg(feature = "enterprise")]
let scorers_changed = match eval_jobs::get_job(&org_id, &job_id).await {
Ok(existing) => existing.scorers != job.scorers,
Err(_) => true,
};
#[cfg(feature = "enterprise")]
if scorers_changed && let Err(resp) = limit_scorers(&job.scorers) {
return resp;
}
#[cfg(feature = "enterprise")]
let job = match eval_jobs::prepare_job_update(&org_id, &job_id, job).await {
Ok(job) => job,
Err(err) => return err.into(),
@@ -214,6 +233,12 @@ pub async fn update_eval_job(
return resp;
}
#[cfg(feature = "enterprise")]
if scorers_changed
&& let Err(resp) = guard_scorers(&org_id, &user_email.user_id, &job.scorers).await
{
return resp;
}
#[cfg(feature = "enterprise")]
let updated = eval_jobs::store_job_update(job).await;
#[cfg(not(feature = "enterprise"))]
let updated = eval_jobs::update_job(&org_id, &job_id, job).await;
@@ -485,6 +510,36 @@ async fn guard_job(
openobserve_core::background_access::guard_write(org_id, user_id, &sources).await
}
#[cfg(feature = "enterprise")]
fn limit_scorers(
scorers: &[config::meta::pipeline::components::ScorerRef],
) -> Result<(), Response> {
if scorers.len() > MAX_SCORERS_PER_REQUEST {
return Err(MetaHttpResponse::bad_request(format!(
"At most {MAX_SCORERS_PER_REQUEST} scorers can be named in one request"
)));
}
Ok(())
}
#[cfg(feature = "enterprise")]
async fn guard_scorers(
org_id: &str,
user_id: &str,
scorers: &[config::meta::pipeline::components::ScorerRef],
) -> Result<(), Response> {
let objects: Vec<_> = scorers
.iter()
.map(|scorer| ("scorers", scorer.id.as_str()))
.collect();
let definitions = async {
eval_jobs::scorer_definitions(org_id, scorers)
.await
.map_err(Response::from)
};
authorize_references(&objects, &UserReads { org_id, user_id }, definitions).await
}
/// The transition's own read and checks answer first, so a bad job is never reported as refused.
#[cfg(feature = "enterprise")]
async fn guard_activation(org_id: &str, user_id: &str, job_id: &str) -> Result<(), Response> {
@@ -497,7 +552,8 @@ async fn guard_activation(org_id: &str, user_id: &str, job_id: &str) -> Result<(
eval_jobs::check_transition(org_id, &job, "active")
.await
.map_err(Response::from)?;
guard_job(org_id, user_id, &job).await
guard_job(org_id, user_id, &job).await?;
guard_scorers(org_id, user_id, &job.scorers).await
}
/// The evaluation's own read and checks answer first, so a bad request is never refused.
@@ -517,7 +573,8 @@ async fn guard_manual(
eval_jobs::check_manual_evaluate(org_id, &job, body)
.await
.map_err(Response::from)?;
guard_job(org_id, user_id, &job).await
guard_job(org_id, user_id, &job).await?;
guard_scorers(org_id, user_id, &job.scorers).await
}
#[cfg(test)]
@@ -636,4 +693,31 @@ mod tests {
.await
.unwrap();
}
#[cfg(feature = "enterprise")]
fn scorer_refs(count: usize) -> Vec<config::meta::pipeline::components::ScorerRef> {
(0..count)
.map(|i| config::meta::pipeline::components::ScorerRef {
id: format!("scorer-{i}"),
version: None,
})
.collect()
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn a_job_naming_a_scorer_the_caller_cannot_read_is_refused() {
let denied = guard_scorers("org1", &denied_user().0.user_id, &scorer_refs(1))
.await
.unwrap_err();
assert_eq!(denied.status().as_u16(), 403);
}
#[cfg(feature = "enterprise")]
#[test]
fn a_job_naming_more_scorers_than_the_maximum_is_refused() {
assert!(limit_scorers(&scorer_refs(MAX_SCORERS_PER_REQUEST)).is_ok());
let resp = limit_scorers(&scorer_refs(MAX_SCORERS_PER_REQUEST + 1)).unwrap_err();
assert_eq!(resp.status().as_u16(), 400);
}
}
+343 -114
View File
@@ -20,13 +20,15 @@ use axum::{
response::{IntoResponse, Response},
};
use db::authz::{remove_ownership, set_ownership};
use infra::table::scorers::Scorer;
use openobserve_api_common::extractors::Headers;
use openobserve_core::{
auth::{UserEmail, is_ofga_object_visible},
auth::{UserEmail, UserReads, is_ofga_object_visible},
llm_evaluations::{
datasets,
experiments::{
self, ExperimentError, PinnedExperimentScorer, baseline,
self, Experiment, ExperimentError, ExperimentScorerRef, ExperimentTaskConfig,
PinnedExperimentScorer, baseline,
dispersion::{self, NormalizationSpans, RowDispersion},
ingest::{self, IngestError},
results::{self, ExperimentResultSlot, ExperimentSlotStatus},
@@ -53,14 +55,41 @@ use crate::{
ExperimentResultRowSortBody, ExperimentResultsResponseBody,
ExperimentRowDetailResponseBody, ExperimentRowNavigationBody,
ExperimentRowSnapshotBody, ExperimentScoreSummaryBody, ExperimentSlotPageQuery,
ExperimentSlotPageResponseBody, ExperimentSummaryResponseBody, ExperimentTaskBody,
ExperimentSlotPageResponseBody, ExperimentSummaryResponseBody,
ExperimentViewResponseBody, ListExperimentsResponseBody,
RetryExperimentSlotRequestBody, SubmitExperimentRecordsRequestBody,
SubmitExperimentRecordsResponseBody,
},
},
request::scorers::{MAX_SCORERS_PER_REQUEST, authorize_references},
};
/// The scorers an Experiment pins: named by a request, or kept from a stored Experiment.
enum ScorerSelection<'a> {
Requested(&'a [ExperimentScorerRef]),
Pinned(&'a Experiment),
}
impl ScorerSelection<'_> {
fn ids(&self) -> Vec<&str> {
match self {
Self::Requested(refs) => refs.iter().map(|scorer| scorer.id.as_str()).collect(),
Self::Pinned(experiment) => experiment
.scorers
.iter()
.map(|scorer| scorer.id.as_str())
.collect(),
}
}
async fn definitions(&self, org_id: &str) -> Result<Vec<Scorer>, ExperimentError> {
match self {
Self::Requested(refs) => Ok(experiments::pin_scorers(org_id, refs).await?.1),
Self::Pinned(experiment) => experiments::scorer_definitions(org_id, experiment).await,
}
}
}
fn experiment_error_response(error: ExperimentError) -> Response {
match error {
ExperimentError::Database(error) => {
@@ -406,51 +435,84 @@ async fn require_experiment_visibility(
Ok(())
}
/// The Remote Task reference is the one place an Experiment reaches outside
/// itself, so the caller must be able to see the Task it names.
///
/// Without this, a caller who knows a hidden `name@version` can make the
/// platform place that Task's credentialed outbound call and can read its output
/// back through the Experiment results. The Experiment routes authorize the
/// Experiment alone, so nothing else covers this.
async fn require_remote_task_visibility(
org_id: &str,
user_id: &str,
task_ref: &str,
) -> Result<(), Response> {
// The OpenFGA object is the head's `entity_id`, not the `name@version` an
// Experiment carries, so the reference has to be resolved first. An
// unresolvable reference is answered as a denial rather than a 404, so that
// a caller cannot use the difference to probe for hidden Tasks.
let task = remote_tasks::resolve_task_ref(org_id, task_ref)
.await
.map_err(|_| MetaHttpResponse::forbidden("Unauthorized Access"))?;
let permitted_objects = openobserve_api_common::auth::validator::list_objects_for_user(
org_id,
user_id,
"GET",
"remote_task",
)
.await
.map_err(|error| MetaHttpResponse::forbidden(error.to_string()))?;
if !is_ofga_object_visible(
org_id,
"remote_task",
&task.entity_id,
permitted_objects.as_deref(),
) {
return Err(MetaHttpResponse::forbidden("Unauthorized Access"));
/// The objects an Experiment reads besides itself and its Remote Task, as `(resource, id)`.
fn referenced_objects<'a>(
dataset_id: &'a str,
scorer_ids: &[&'a str],
task: &'a ExperimentTaskConfig,
) -> Vec<(&'static str, &'a str)> {
let mut objects = vec![("datasets", dataset_id)];
objects.extend(scorer_ids.iter().map(|id| ("scorers", *id)));
if let Some((prompt_id, ..)) = task.prompt_ref() {
objects.push(("prompts", prompt_id));
}
Ok(())
if let Some(provider_id) = task.provider_id() {
objects.push(("providers", provider_id));
}
objects
}
/// The `task_ref` of a Remote Task request, or `None` for the task types that
/// never leave the platform.
fn remote_task_ref(task: &ExperimentTaskBody) -> Option<&str> {
match task {
ExperimentTaskBody::Remote { task_ref, .. } => Some(task_ref.as_str()),
_ => None,
async fn require_reference_access(
org_id: &str,
user_id: &str,
dataset_id: &str,
scorers: ScorerSelection<'_>,
task: &ExperimentTaskConfig,
) -> Result<(), Response> {
let scorer_ids = scorers.ids();
if matches!(scorers, ScorerSelection::Requested(_))
&& scorer_ids.len() > MAX_SCORERS_PER_REQUEST
{
return Err(MetaHttpResponse::bad_request(format!(
"At most {MAX_SCORERS_PER_REQUEST} scorers can be named in one request"
)));
}
// Unresolvable is a denial, not a 404, so hidden Tasks cannot be probed.
let remote_task = match task.remote_task() {
Some((task_ref, _)) => Some(
remote_tasks::resolve_task_ref(org_id, task_ref)
.await
.map_err(|_| MetaHttpResponse::forbidden("Unauthorized Access"))?,
),
None => None,
};
let mut objects = referenced_objects(dataset_id, &scorer_ids, task);
if let Some(remote_task) = &remote_task {
objects.push(("remote_tasks", remote_task.entity_id.as_str()));
}
let definitions = async {
scorers
.definitions(org_id)
.await
.map_err(experiment_error_response)
};
authorize_references(&objects, &UserReads { org_id, user_id }, definitions).await
}
async fn require_pinned_reference_access(
org_id: &str,
user_id: &str,
experiment: &Experiment,
) -> Result<(), Response> {
require_reference_access(
org_id,
user_id,
&experiment.dataset_id,
ScorerSelection::Pinned(experiment),
&experiment.task,
)
.await
}
async fn require_stored_reference_access(
org_id: &str,
experiment_id: &str,
user_id: &str,
) -> Result<(), Response> {
let experiment = experiments::get(org_id, experiment_id)
.await
.map_err(experiment_error_response)?;
require_pinned_reference_access(org_id, user_id, &experiment).await
}
#[utoipa::path(
@@ -467,6 +529,7 @@ fn remote_task_ref(task: &ExperimentTaskBody) -> Option<&str> {
responses(
(status = 200, body = inline(ExperimentPreviewResponseBody)),
(status = 400, description = "Invalid Experiment definition", body = ()),
(status = 403, description = "A referenced Dataset, Scorer, Prompt, Provider or Remote Task is not accessible", body = ()),
(status = 404, description = "Dataset or scorer not found", body = ()),
),
)]
@@ -476,13 +539,19 @@ pub async fn preview_experiment(
Headers(user): Headers<UserEmail>,
axum::Json(body): axum::Json<CreateExperimentRequestBody>,
) -> Response {
if let Some(task_ref) = remote_task_ref(&body.task)
&& let Err(response) =
require_remote_task_visibility(&org_id, &user.user_id, task_ref).await
let input: experiments::CreateExperiment = body.into();
if let Err(response) = require_reference_access(
&org_id,
&user.user_id,
&input.dataset_id,
ScorerSelection::Requested(&input.scorers),
&input.task,
)
.await
{
return response;
}
match experiments::preview(&org_id, body.into(), query.sample_size).await {
match experiments::preview(&org_id, input, query.sample_size).await {
Ok(preview) => MetaHttpResponse::json(ExperimentPreviewResponseBody::from(preview)),
Err(error) => experiment_error_response(error),
}
@@ -499,6 +568,7 @@ pub async fn preview_experiment(
responses(
(status = 200, body = inline(CreateExperimentResponseBody)),
(status = 400, description = "Invalid Experiment definition", body = ()),
(status = 403, description = "A referenced Dataset, Scorer, Prompt, Provider or Remote Task is not accessible", body = ()),
(status = 409, description = "Idempotency conflict", body = ()),
(status = 412, description = "Cost estimate is above the warning threshold and was not confirmed", body = ()),
),
@@ -508,12 +578,6 @@ pub async fn create_experiment(
Headers(user): Headers<UserEmail>,
axum::Json(body): axum::Json<CreateExperimentRequestBody>,
) -> Response {
if let Some(task_ref) = remote_task_ref(&body.task)
&& let Err(response) =
require_remote_task_visibility(&org_id, &user.user_id, task_ref).await
{
return response;
}
// Re-serialized rather than hashed from the raw bytes so two encodings of
// the same request agree on their canonical hash.
let canonical_request = match serde_json::to_value(&body) {
@@ -523,7 +587,19 @@ pub async fn create_experiment(
return MetaHttpResponse::internal_error("Internal server error");
}
};
match experiments::create(&org_id, &user.user_id, body.into(), &canonical_request).await {
let input: experiments::CreateExperiment = body.into();
if let Err(response) = require_reference_access(
&org_id,
&user.user_id,
&input.dataset_id,
ScorerSelection::Requested(&input.scorers),
&input.task,
)
.await
{
return response;
}
match experiments::create(&org_id, &user.user_id, input, &canonical_request).await {
Ok(result) => {
set_ownership(&org_id, "experiments", Authz::new(&result.experiment.id)).await;
MetaHttpResponse::json(CreateExperimentResponseBody::from(result))
@@ -1337,11 +1413,20 @@ pub async fn delete_experiment(
),
responses(
(status = 200, description = "Failed Experiment returned to running", body = ExperimentResponseBody),
(status = 403, description = "A referenced Dataset, Scorer, Prompt, Provider or Remote Task is not accessible"),
(status = 404, description = "Experiment not found"),
(status = 409, description = "Invalid or concurrent lifecycle transition"),
)
)]
pub async fn retry_experiment(Path((org_id, experiment_id)): Path<(String, String)>) -> Response {
pub async fn retry_experiment(
Path((org_id, experiment_id)): Path<(String, String)>,
Headers(user): Headers<UserEmail>,
) -> Response {
if let Err(response) =
require_stored_reference_access(&org_id, &experiment_id, &user.user_id).await
{
return response;
}
match openobserve_core::llm_evaluations::experiments::runner::retry_failed_experiment(
&org_id,
&experiment_id,
@@ -1371,7 +1456,7 @@ pub async fn retry_experiment(Path((org_id, experiment_id)): Path<(String, Strin
responses(
(status = 202, description = "Selected slot retry queued"),
(status = 400, description = "Invalid idempotency key"),
(status = 403, description = "Experiment is not accessible"),
(status = 403, description = "Experiment or one of its references is not accessible"),
(status = 404, description = "Experiment, row, or trial not found"),
(status = 409, description = "Experiment lifecycle, active slot retry, or idempotency conflict"),
)
@@ -1388,6 +1473,11 @@ pub async fn retry_experiment_slot(
{
return response;
}
if let Err(response) =
require_stored_reference_access(&org_id, &experiment_id, &user.user_id).await
{
return response;
}
match openobserve_core::llm_evaluations::experiments::runner::queue_slot_retry(
&org_id,
@@ -1435,6 +1525,7 @@ pub async fn retry_experiment_slot(
responses(
(status = 200, description = "Pending clone created", body = ExperimentResponseBody),
(status = 400, description = "Invalid Experiment definition after overrides", body = ()),
(status = 403, description = "A referenced Dataset, Scorer, Prompt, Provider or Remote Task is not accessible", body = ()),
(status = 404, description = "Experiment, Dataset or scorer not found"),
(status = 409, description = "Only sealed Experiments can be cloned, or the idempotency key describes a different clone"),
(status = 412, description = "Cost estimate is above the warning threshold and was not confirmed", body = ()),
@@ -1445,24 +1536,31 @@ pub async fn clone_experiment(
Headers(user): Headers<UserEmail>,
axum::Json(body): axum::Json<CloneExperimentRequestBody>,
) -> Response {
// The clone reaches whichever Remote Task it ends up pinning — the source's
// when the request overrides nothing, the override's when it does.
// Checking only at create would leave clone as the way around that check.
// Overrides fall back to the source's pins, so check what the clone will actually reference.
let source = match experiments::get(&org_id, &experiment_id).await {
Ok(source) => source,
Err(error) => return experiment_error_response(error),
};
let effective_task_ref = match body.task.as_ref() {
Some(task) => remote_task_ref(task),
None => source.task.remote_task().map(|(task_ref, _)| task_ref),
let overrides: experiments::CloneExperimentOverrides = body.into();
let scorers = match &overrides.scorers {
Some(refs) => ScorerSelection::Requested(refs),
None => ScorerSelection::Pinned(&source),
};
if let Some(task_ref) = effective_task_ref
&& let Err(response) =
require_remote_task_visibility(&org_id, &user.user_id, task_ref).await
if let Err(response) = require_reference_access(
&org_id,
&user.user_id,
overrides
.dataset_id
.as_deref()
.unwrap_or(&source.dataset_id),
scorers,
overrides.task.as_ref().unwrap_or(&source.task),
)
.await
{
return response;
}
match experiments::clone_sealed(&org_id, &experiment_id, &user.user_id, body.into()).await {
match experiments::clone_sealed(&org_id, &experiment_id, &user.user_id, overrides).await {
Ok(experiment) => {
set_ownership(&org_id, "experiments", Authz::new(&experiment.id)).await;
MetaHttpResponse::json(ExperimentResponseBody::from(experiment))
@@ -1768,63 +1866,194 @@ mod tests {
));
}
/// The guard that decides whether the Remote Task check runs at all. If this
/// ever returned `None` for a Remote Task, `create_experiment` would go back
/// to reaching any Task in the org without a permission check, and no other
/// test would notice.
/// Dropping an object from this list would let an Experiment pin it unchecked.
#[test]
fn only_a_remote_task_carries_a_reference_that_needs_checking() {
fn every_dataset_scorer_prompt_and_provider_an_experiment_reads_is_checked() {
let prompt_ref = ExperimentTaskConfig::PromptRef {
id: "prompt-1".to_string(),
version: 2,
provider_id: "provider-1".to_string(),
params_overrides: None,
};
assert_eq!(
remote_task_ref(&ExperimentTaskBody::Remote {
task_ref: "grader@3".to_string(),
overrides: None,
}),
Some("grader@3")
referenced_objects("dataset-1", &["scorer-1", "scorer-2"], &prompt_ref),
vec![
("datasets", "dataset-1"),
("scorers", "scorer-1"),
("scorers", "scorer-2"),
("prompts", "prompt-1"),
("providers", "provider-1"),
]
);
let inline = ExperimentTaskConfig::InlinePrompt {
messages: vec![],
provider_id: "provider-1".to_string(),
model: None,
params: None,
};
assert_eq!(
remote_task_ref(&ExperimentTaskBody::Sdk {
task_fingerprint: "abc".to_string(),
config: serde_json::json!({}),
}),
None
referenced_objects("dataset-1", &[], &inline),
vec![("datasets", "dataset-1"), ("providers", "provider-1")]
);
let remote = ExperimentTaskConfig::Remote {
task_ref: "grader@3".to_string(),
overrides: None,
};
assert_eq!(
remote_task_ref(&ExperimentTaskBody::InlinePrompt {
messages: vec![],
provider_id: "provider-1".to_string(),
model: None,
params: None,
}),
None
referenced_objects("dataset-1", &[], &remote),
vec![("datasets", "dataset-1")]
);
let sdk = ExperimentTaskConfig::Sdk {
task_fingerprint: "sha256:abc".to_string(),
config: serde_json::json!({}),
};
assert_eq!(
referenced_objects("dataset-1", &[], &sdk),
vec![("datasets", "dataset-1")]
);
}
/// The Remote Task check keys on the head `entity_id`, so it has to accept
/// the same grant shapes the Remote Task list already accepts.
#[test]
fn remote_task_visibility_accepts_exact_or_org_wildcard_and_rejects_other_tasks() {
let exact = vec!["remote_task:task-1".to_string()];
let wildcard = vec!["remote_task:_all_acme".to_string()];
let other = vec!["remote_task:task-2".to_string()];
fn judge_scorer(provider_id: &str) -> Scorer {
let body: crate::models::scorers::ScorerRequestBody =
serde_json::from_value(serde_json::json!({
"name": "judge",
"scorer": {"type": "llm_judge", "template": "t", "params": {"provider_id": provider_id}}
}))
.unwrap();
body.into()
}
assert!(is_ofga_object_visible(
"acme",
"remote_task",
"task-1",
Some(&exact)
));
assert!(is_ofga_object_visible(
"acme",
"remote_task",
"task-1",
Some(&wildcard)
));
assert!(!is_ofga_object_visible(
"acme",
"remote_task",
"task-1",
Some(&other)
));
fn unknown_user() -> String {
format!("{}@example.com", config::ider::uuid())
}
fn inline_task() -> ExperimentTaskConfig {
ExperimentTaskConfig::InlinePrompt {
messages: vec![],
provider_id: "provider-1".to_string(),
model: None,
params: None,
}
}
struct DeniesResource(&'static str);
impl openobserve_core::auth::ReadCheck for DeniesResource {
async fn can_read(&self, resource: &str, _: &str) -> bool {
resource != self.0
}
}
#[tokio::test]
async fn an_llm_judge_scorer_s_own_provider_must_be_readable() {
let definitions = vec![judge_scorer("judge-provider")];
let denied = authorize_references(
&[("datasets", "dataset-1"), ("scorers", "scorer-1")],
&DeniesResource("providers"),
async { Ok(definitions.clone()) },
)
.await
.unwrap_err();
assert_eq!(denied.status().as_u16(), 403);
assert!(
authorize_references(
&[("datasets", "dataset-1")],
&DeniesResource("none"),
async { Ok(definitions.clone()) },
)
.await
.is_ok()
);
}
#[tokio::test]
async fn a_scorer_the_caller_cannot_read_is_never_loaded() {
let loaded = std::cell::Cell::new(false);
let denied = authorize_references(
&[("scorers", "scorer-1")],
&DeniesResource("scorers"),
async {
loaded.set(true);
Ok(vec![judge_scorer("judge-provider")])
},
)
.await
.unwrap_err();
assert_eq!(denied.status().as_u16(), 403);
assert!(!loaded.get());
}
#[tokio::test]
async fn naming_more_scorers_than_the_maximum_is_refused_before_any_check() {
let refs: Vec<ExperimentScorerRef> = (0..=MAX_SCORERS_PER_REQUEST)
.map(|i| ExperimentScorerRef {
id: format!("scorer-{i}"),
version: None,
})
.collect();
let task = inline_task();
let user = unknown_user();
let too_many = require_reference_access(
"org1",
&user,
"dataset-1",
ScorerSelection::Requested(&refs),
&task,
)
.await
.unwrap_err();
assert_eq!(too_many.status().as_u16(), 400);
let at_limit = require_reference_access(
"org1",
&user,
"dataset-1",
ScorerSelection::Requested(&refs[..MAX_SCORERS_PER_REQUEST]),
&task,
)
.await
.unwrap_err();
assert_eq!(at_limit.status().as_u16(), 403);
}
#[tokio::test]
async fn a_retry_rechecks_the_stored_experiment_s_references() {
let experiment = Experiment {
id: "experiment-1".to_string(),
org_id: "org1".to_string(),
name: "e".to_string(),
description: None,
dataset_id: "dataset-1".to_string(),
dataset_version: 1,
dataset_filter: None,
task: inline_task(),
scorers: vec![PinnedExperimentScorer {
id: "scorer-1".to_string(),
version: 1,
}],
trial_count: 1,
metadata: None,
status: experiments::ExperimentStatus::Failed,
status_reason: None,
deadline_at: 0,
completed_at: None,
lifecycle_version: 0,
retry_count: 0,
scores_settled_at: None,
idempotency_key: None,
is_baseline: false,
prompt_id: None,
prompt_name: None,
prompt_version: None,
prompt_content_hash: None,
created_by: "owner@example.com".to_string(),
created_at: 0,
};
let denied = require_pinned_reference_access("org1", &unknown_user(), &experiment)
.await
.unwrap_err();
assert_eq!(denied.status().as_u16(), 403);
}
#[test]
+79 -53
View File
@@ -26,7 +26,7 @@ use db::authz::set_ownership;
use futures::StreamExt;
use openobserve_api_common::extractors::Headers;
use openobserve_core::{
auth::{UserEmail, is_ofga_object_visible},
auth::{ReadCheck, UserEmail, UserReads, first_unreadable, is_ofga_object_visible},
llm_evaluations::{
experiments::runner::render_prompt,
playground::{self, PlaygroundError},
@@ -46,6 +46,7 @@ use crate::{
PlaygroundScoreResponseBody, PlaygroundSnapshotResponseBody,
SharePlaygroundSnapshotRequestBody,
},
request::scorers::{MAX_SCORERS_PER_REQUEST, judge_provider_ids},
};
/// FGA object type for everything under `/playground`.
@@ -135,21 +136,15 @@ async fn require_provider_visibility(
provider_id: &str,
user_id: &str,
) -> Result<(), Response> {
match openobserve_api_common::auth::validator::list_objects_for_user(
org_id, user_id, "GET", "provider",
)
.await
if (UserReads { org_id, user_id })
.can_read("providers", provider_id)
.await
{
Ok(permitted) => {
if is_ofga_object_visible(org_id, "provider", provider_id, permitted.as_deref()) {
Ok(())
} else {
Err(MetaHttpResponse::forbidden(
"Not allowed to use this provider",
))
}
}
Err(error) => Err(MetaHttpResponse::forbidden(error.to_string())),
Ok(())
} else {
Err(MetaHttpResponse::forbidden(
"Not allowed to use this provider",
))
}
}
@@ -160,52 +155,38 @@ async fn require_scorer_visibility(
scorer_ids: &[String],
user_id: &str,
) -> Result<(), Response> {
let permitted = openobserve_api_common::auth::validator::list_objects_for_user(
org_id, user_id, "GET", "scorer",
)
.await
.map_err(|error| MetaHttpResponse::forbidden(error.to_string()))?;
if let Some(hidden_id) = scorer_ids.iter().find(|scorer_id| {
!is_ofga_object_visible(org_id, "scorer", scorer_id, permitted.as_deref())
}) {
return Err(MetaHttpResponse::not_found(format!(
"Scorer '{hidden_id}' not found"
if scorer_ids.len() > MAX_SCORERS_PER_REQUEST {
return Err(MetaHttpResponse::bad_request(format!(
"At most {MAX_SCORERS_PER_REQUEST} scorers can be named in one request"
)));
}
Ok(())
let scorers: Vec<_> = scorer_ids
.iter()
.map(|id| ("scorers", id.as_str()))
.collect();
match first_unreadable(&scorers, &UserReads { org_id, user_id }).await {
Some((_, hidden_id)) => Err(MetaHttpResponse::not_found(format!(
"Scorer '{hidden_id}' not found"
))),
None => Ok(()),
}
}
/// Confirm the caller may use every provider referenced by an LLM judge
/// scorer. The permission lookup is shared across the whole batch.
/// Confirm the caller may use every provider referenced by an LLM judge scorer.
async fn require_scorer_provider_visibility(
org_id: &str,
scorers: &[infra::table::scorers::Scorer],
user_id: &str,
) -> Result<(), Response> {
let provider_ids = scorers
.iter()
.filter(|scorer| scorer.scorer_type == infra::table::scorers::ScorerType::LlmJudge)
.filter_map(|scorer| scorer.params.get("provider_id").and_then(Value::as_str))
.collect::<std::collections::HashSet<_>>();
if provider_ids.is_empty() {
return Ok(());
}
let permitted = openobserve_api_common::auth::validator::list_objects_for_user(
org_id, user_id, "GET", "provider",
)
.await
.map_err(|error| MetaHttpResponse::forbidden(error.to_string()))?;
if provider_ids.iter().any(|provider_id| {
!is_ofga_object_visible(org_id, "provider", provider_id, permitted.as_deref())
}) {
return Err(MetaHttpResponse::forbidden(
let providers: Vec<_> = judge_provider_ids(scorers)
.map(|id| ("providers", id))
.collect();
match first_unreadable(&providers, &UserReads { org_id, user_id }).await {
Some(_) => Err(MetaHttpResponse::forbidden(
"Not allowed to use a provider configured by this scorer",
));
)),
None => Ok(()),
}
Ok(())
}
// ---------------------------------------------------------------------------
@@ -291,11 +272,11 @@ fn sse(payload: &Value) -> bytes::Bytes {
/// Resolve a provider that belongs to this organization.
async fn load_provider(org_id: &str, provider_id: &str) -> Result<PreparedProvider, Response> {
let provider = match infra::table::providers::get(provider_id).await {
Ok(Some(provider)) if provider.org_id == org_id => provider,
let provider = match infra::table::providers::get(org_id, provider_id).await {
Ok(Some(provider)) => provider,
// A provider in another organization is reported as absent rather than
// forbidden, so its existence cannot be probed.
Ok(_) => return Err(MetaHttpResponse::not_found("Provider not found")),
Ok(None) => return Err(MetaHttpResponse::not_found("Provider not found")),
Err(error) => {
log::error!("[Playground] failed to load provider {provider_id}: {error}");
return Err(MetaHttpResponse::internal_error("Internal server error"));
@@ -613,6 +594,7 @@ pub async fn run_playground_cell(
request_body(content = inline(PlaygroundScoreRequestBody)),
responses(
(status = 200, body = inline(PlaygroundScoreResponseBody)),
(status = 400, description = "Too many scorers named", body = ()),
(status = 403, description = "A scorer provider is not visible to the caller", body = ()),
(status = 404, description = "A referenced scorer does not exist", body = ()),
(status = 500, description = "Scorer resolution failed", body = ()),
@@ -749,4 +731,48 @@ mod tests {
);
assert_eq!(rendered[1].tool_call_id.as_deref(), Some("call_1"));
}
fn unknown_user() -> String {
format!("{}@example.com", config::ider::uuid())
}
#[tokio::test]
async fn providers_and_scorers_are_checked_per_object_whatever_list_filtering_says() {
let user = unknown_user();
let denied = require_provider_visibility("org1", "provider-1", &user)
.await
.unwrap_err();
assert_eq!(denied.status().as_u16(), 403);
let hidden = require_scorer_visibility("org1", &["scorer-1".to_string()], &user)
.await
.unwrap_err();
assert_eq!(hidden.status().as_u16(), 404);
let body: crate::models::scorers::ScorerRequestBody = serde_json::from_value(json!({
"name": "judge",
"scorer": {"type": "llm_judge", "template": "t", "params": {"provider_id": "provider-1"}}
}))
.unwrap();
let denied = require_scorer_provider_visibility("org1", &[body.into()], &user)
.await
.unwrap_err();
assert_eq!(denied.status().as_u16(), 403);
}
#[tokio::test]
async fn naming_more_scorers_than_the_maximum_is_refused_before_any_check() {
let ids: Vec<String> = (0..=MAX_SCORERS_PER_REQUEST)
.map(|i| format!("scorer-{i}"))
.collect();
let resp = require_scorer_visibility("org1", &ids, &unknown_user())
.await
.unwrap_err();
assert_eq!(resp.status().as_u16(), 400);
let resp =
require_scorer_visibility("org1", &ids[..MAX_SCORERS_PER_REQUEST], &unknown_user())
.await
.unwrap_err();
assert_eq!(resp.status().as_u16(), 404);
}
}
@@ -17,7 +17,7 @@ use axum::{extract::Path, response::Response};
#[cfg(feature = "enterprise")]
use openobserve_api_common::extractors::Headers;
#[cfg(feature = "enterprise")]
use openobserve_core::auth::{UserEmail, is_ofga_object_visible};
use openobserve_core::auth::{ReadCheck, UserEmail, UserReads};
use crate::{
common::meta::http::HttpResponse as MetaHttpResponse,
@@ -282,15 +282,10 @@ async fn require_provider_visibility(
provider_id: &str,
user_id: &str,
) -> Result<(), Response> {
let permitted = openobserve_api_common::auth::validator::list_objects_for_user(
org_id, user_id, "GET", "provider",
)
.await
.map_err(|error| {
log::warn!("[Provider] failed to load provider permissions: {error}");
MetaHttpResponse::forbidden("Not allowed to use this provider")
})?;
if is_ofga_object_visible(org_id, "provider", provider_id, permitted.as_deref()) {
if (UserReads { org_id, user_id })
.can_read("providers", provider_id)
.await
{
Ok(())
} else {
Err(MetaHttpResponse::forbidden(
+177 -3
View File
@@ -21,7 +21,9 @@ use db::authz::{remove_ownership, set_ownership};
#[cfg(feature = "enterprise")]
use openobserve_api_common::extractors::Headers;
#[cfg(feature = "enterprise")]
use openobserve_core::auth::{UserEmail, is_ofga_object_visible};
use openobserve_core::auth::{
ReadCheck, UserEmail, UserReads, first_unreadable, is_ofga_object_visible,
};
use crate::{
common::meta::{authz::Authz, http::HttpResponse as MetaHttpResponse},
@@ -37,6 +39,9 @@ use crate::{
},
};
/// Most scorers one request may name; each costs a permission check before anything runs.
pub(crate) const MAX_SCORERS_PER_REQUEST: usize = 50;
fn scorer_error_response(value: ScorerError) -> Response {
match value {
ScorerError::InfraError(err) => {
@@ -45,6 +50,8 @@ fn scorer_error_response(value: ScorerError) -> Response {
}
ScorerError::MissingName => MetaHttpResponse::bad_request("Scorer name cannot be empty"),
ScorerError::NotFound => MetaHttpResponse::not_found("Scorer not found"),
ScorerError::ProviderNotFound(_) => MetaHttpResponse::not_found(value),
ScorerError::ProviderNotReadable(_) => MetaHttpResponse::forbidden(value),
ScorerError::InvalidScorerType(_)
| ScorerError::ScorerTypeImmutable
| ScorerError::ProducesScoreConfigIdImmutable
@@ -131,6 +138,8 @@ pub async fn list_scorers(
responses(
(status = 200, body = inline(ScorerResponseBody)),
(status = 400, description = "Bad Request", body = ()),
(status = 403, description = "Provider not accessible", body = ()),
(status = 404, description = "Provider not found", body = ()),
(status = 409, description = "Conflict", body = ()),
),
extensions(
@@ -139,9 +148,14 @@ pub async fn list_scorers(
)]
pub async fn create_scorer(
Path(org_id): Path<String>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
axum::Json(body): axum::Json<ScorerRequestBody>,
) -> Response {
let scorer: infra::table::scorers::Scorer = body.into();
#[cfg(feature = "enterprise")]
if let Err(err) = require_provider_read(&org_id, &user_email.user_id, &scorer).await {
return scorer_error_response(err);
}
match scorers::save_scorer(&org_id, scorer).await {
Ok(s) => {
set_ownership(&org_id, "scorers", Authz::new(&s.entity_id)).await;
@@ -232,7 +246,8 @@ pub async fn list_scorer_versions(Path((org_id, entity_id)): Path<(String, Strin
request_body(content = inline(ScorerUpdateRequestBody), description = "Scorer payload"),
responses(
(status = 200, body = inline(ScorerResponseBody)),
(status = 404, description = "Not Found", body = ()),
(status = 403, description = "Provider not accessible", body = ()),
(status = 404, description = "Scorer or provider not found", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Scorers", "operation": "update"})),
@@ -240,9 +255,16 @@ pub async fn list_scorer_versions(Path((org_id, entity_id)): Path<(String, Strin
)]
pub async fn update_scorer(
Path((org_id, entity_id)): Path<(String, String)>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
axum::Json(body): axum::Json<ScorerUpdateRequestBody>,
) -> Response {
let scorer: infra::table::scorers::Scorer = body.into();
#[cfg(feature = "enterprise")]
if let Err(err) =
require_changed_provider_read(&org_id, &user_email.user_id, &entity_id, &scorer).await
{
return scorer_error_response(err);
}
match scorers::update_scorer(&org_id, &entity_id, scorer).await {
Ok(s) => {
let resp: ScorerResponseBody = s.into();
@@ -300,6 +322,7 @@ pub async fn delete_scorer(Path((org_id, entity_id)): Path<(String, String)>) ->
responses(
(status = 200, body = inline(ScorerTestResponseBody)),
(status = 400, description = "Bad Request", body = ()),
(status = 403, description = "Provider not accessible", body = ()),
(status = 404, description = "Not Found", body = ()),
),
extensions(
@@ -308,9 +331,10 @@ pub async fn delete_scorer(Path((org_id, entity_id)): Path<(String, String)>) ->
)]
pub async fn test_scorer(
Path(org_id): Path<String>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
axum::Json(body): axum::Json<ScorerTestRequestBody>,
) -> Response {
match run_scorer_test(&org_id, &body).await {
match run_scorer_test(&org_id, &user_email.user_id, &body).await {
Ok(resp) => MetaHttpResponse::json(resp),
Err(err) => scorer_error_response(err),
}
@@ -348,6 +372,76 @@ pub async fn preview_llm_judge_output_schema(
}
}
/// The provider each LLM-judge scorer calls.
pub(crate) fn judge_provider_ids(
scorers: &[infra::table::scorers::Scorer],
) -> impl Iterator<Item = &str> {
scorers
.iter()
.filter(|scorer| scorer.scorer_type == infra::table::scorers::ScorerType::LlmJudge)
.filter_map(|scorer| scorer.params.get("provider_id")?.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())
}
/// Scorers are loaded only after every named object is readable, then their providers are checked.
#[cfg(feature = "enterprise")]
pub(crate) async fn authorize_references(
objects: &[(&'static str, &str)],
reads: &impl ReadCheck,
scorer_definitions: impl Future<Output = Result<Vec<infra::table::scorers::Scorer>, Response>>,
) -> Result<(), Response> {
if first_unreadable(objects, reads).await.is_some() {
return Err(MetaHttpResponse::forbidden("Unauthorized Access"));
}
let definitions = scorer_definitions.await?;
let providers: Vec<_> = judge_provider_ids(&definitions)
.map(|id| ("providers", id))
.collect();
if first_unreadable(&providers, reads).await.is_some() {
return Err(MetaHttpResponse::forbidden("Unauthorized Access"));
}
Ok(())
}
#[cfg(feature = "enterprise")]
async fn require_provider_read(
org_id: &str,
user_id: &str,
scorer: &infra::table::scorers::Scorer,
) -> Result<(), ScorerError> {
let providers: Vec<_> = judge_provider_ids(std::slice::from_ref(scorer))
.map(|id| ("providers", id))
.collect();
match first_unreadable(&providers, &UserReads { org_id, user_id }).await {
Some((_, id)) => Err(ScorerError::ProviderNotReadable(id.to_string())),
None => Ok(()),
}
}
/// A new version that keeps its provider needs no read on it; rebinding does.
#[cfg(feature = "enterprise")]
async fn require_changed_provider_read(
org_id: &str,
user_id: &str,
entity_id: &str,
incoming: &infra::table::scorers::Scorer,
) -> Result<(), ScorerError> {
let existing = match scorers::get_scorer(org_id, entity_id).await {
Ok(existing) => Some(existing),
Err(ScorerError::NotFound) => None,
Err(err) => return Err(err),
};
let unchanged = existing.as_ref().is_some_and(|existing| {
judge_provider_ids(std::slice::from_ref(existing))
.eq(judge_provider_ids(std::slice::from_ref(incoming)))
});
if unchanged {
return Ok(());
}
require_provider_read(org_id, user_id, incoming).await
}
async fn derive_llm_judge_output_schema(
org_id: &str,
body: &LlmJudgeOutputSchemaRequestBody,
@@ -442,11 +536,13 @@ async fn score_config_info_for_test_scorer(
async fn run_scorer_test(
org_id: &str,
user_id: &str,
body: &ScorerTestRequestBody,
) -> Result<ScorerTestResponseBody, ScorerError> {
use std::collections::HashMap;
let mut scorer = scorer_for_test(org_id, body)?;
require_provider_read(org_id, user_id, &scorer).await?;
let score_config_info = score_config_info_for_test_scorer(org_id, &mut scorer).await?;
let mut attrs: HashMap<String, serde_json::Value> = HashMap::new();
@@ -460,6 +556,8 @@ async fn run_scorer_test(
match &scorer.scorer_type {
infra::table::scorers::ScorerType::LlmJudge => {
// Admins pass the read check, so this is what keeps another org's provider out.
scorers::validate_llm_judge_provider_model(org_id, &scorer).await?;
let prepared =
openobserve_core::llm_evaluations::prepared_scorers::PreparedLlmJudgeScorer::prepare_with_score_config_info(
org_id,
@@ -558,6 +656,8 @@ mod tests {
(ScorerError::ProducesScoreConfigIdImmutable, 400),
(ScorerError::InvalidOutputSchema("bad".to_string()), 400),
(ScorerError::InvalidProviderConfig("bad".to_string()), 400),
(ScorerError::ProviderNotFound("p".to_string()), 404),
(ScorerError::ProviderNotReadable("p".to_string()), 403),
(ScorerError::DuplicateName, 409),
(ScorerError::InUseByEvalJob, 409),
];
@@ -625,4 +725,78 @@ mod tests {
let err = scorer_for_test("org1", &body).unwrap_err();
assert!(matches!(err, ScorerError::MissingName));
}
fn denied_user() -> Headers<UserEmail> {
Headers(UserEmail {
user_id: format!("{}@example.com", config::ider::uuid()),
})
}
fn judge(provider_id: &str) -> serde_json::Value {
serde_json::json!({
"type": "llm_judge",
"template": "Rate {{output}}",
"params": {"provider_id": provider_id}
})
}
#[tokio::test]
async fn testing_a_judge_on_an_unreadable_provider_is_refused() {
let body: ScorerTestRequestBody = serde_json::from_value(serde_json::json!({
"name": "probe",
"scorer": judge("provider-1"),
"inputVariables": {"output": "hello"}
}))
.unwrap();
let resp = test_scorer(Path("org1".to_string()), denied_user(), axum::Json(body)).await;
assert_eq!(resp.status().as_u16(), 403);
}
#[tokio::test]
async fn creating_a_judge_on_an_unreadable_provider_is_refused() {
let body: ScorerRequestBody = serde_json::from_value(
serde_json::json!({"name": "judge", "scorer": judge("provider-1")}),
)
.unwrap();
let resp = create_scorer(Path("org1".to_string()), denied_user(), axum::Json(body)).await;
assert_eq!(resp.status().as_u16(), 403);
}
#[tokio::test]
async fn rebinding_a_judge_needs_read_on_the_new_provider_but_keeping_it_does_not() {
infra::table::scorers::create_table().await.unwrap();
let org_id = format!("rebind_{}", config::ider::uuid());
let body: ScorerRequestBody = serde_json::from_value(
serde_json::json!({"name": "judge", "scorer": judge("provider-a")}),
)
.unwrap();
let mut existing: infra::table::scorers::Scorer = body.into();
existing.id = config::ider::uuid();
existing.entity_id = config::ider::uuid();
existing.org_id = org_id.clone();
existing.version = 1;
existing.is_active = true;
infra::table::scorers::add(&existing).await.unwrap();
let user = denied_user().0.user_id;
let kept = existing.clone();
assert!(
require_changed_provider_read(&org_id, &user, &existing.entity_id, &kept)
.await
.is_ok()
);
let rebound: ScorerUpdateRequestBody =
serde_json::from_value(serde_json::json!({"scorer": judge("provider-b")})).unwrap();
let resp = update_scorer(
Path((org_id.clone(), existing.entity_id.clone())),
denied_user(),
axum::Json(rebound),
)
.await;
assert_eq!(resp.status().as_u16(), 403);
infra::table::scorers::delete_all_by_org(&org_id)
.await
.unwrap();
}
}
+18
View File
@@ -60,6 +60,7 @@ use crate::{
request_body(content = String, description = "prometheus WriteRequest", content_type = "application/x-protobuf"),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object, example = json!({"code": 200})),
(status = 400, description = "Not a protobuf body or the payload cannot be ingested", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -132,6 +133,9 @@ pub async fn remote_write(
]
}
})),
(status = 400, description = "Invalid query or time", content_type = "application/json", body = ()),
(status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()),
(status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -304,6 +308,9 @@ async fn query(
]
}
})),
(status = 400, description = "Invalid query, time range, step or search type", content_type = "application/json", body = ()),
(status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()),
(status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -398,6 +405,9 @@ pub async fn query_range_post(
}
]
})),
(status = 400, description = "Invalid query, time range, step or search type", content_type = "application/json", body = ()),
(status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()),
(status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -626,6 +636,7 @@ async fn query_range(
]
}
})),
(status = 403, description = "No permission to read metrics", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -750,6 +761,9 @@ pub async fn metadata(
}
]
})),
(status = 400, description = "Invalid match[] or time range", content_type = "application/json", body = ()),
(status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()),
(status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -883,6 +897,8 @@ async fn series(
"version"
]
})),
(status = 400, description = "Invalid match[] or time range", content_type = "application/json", body = ()),
(status = 403, description = "No permission to read metrics", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -1024,6 +1040,7 @@ async fn labels(
]
})),
(status = 400, description = "Invalid parameters or match[] does not identify a metric", content_type = "application/json", body = ()),
(status = 403, description = "No permission to read metrics", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -1246,6 +1263,7 @@ fn validate_range_window(start: i64, end: i64) -> Result<(), String> {
"status" : "success",
"data" : "foo / bar"
})),
(status = 400, description = "The query does not parse", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
+26 -1
View File
@@ -206,6 +206,15 @@ pub async fn dump(job: &DumpJob) -> Result<(), anyhow::Error> {
job.offset
);
let dump_stream_name = generate_dump_stream_name(job.stream_type, &job.stream_name);
// the dump key outgrows the source's by 10, so a source near the limit cannot be dumped
if infra_file_list::stream_key_violation(&job.org_id, StreamType::Filelist, &dump_stream_name)
.is_some()
{
infra_file_list::set_job_dumped_status(&[job.job_id], true).await?;
return Ok(());
}
// check offset
let partition_time_level = get_partition_time_level(job.stream_type);
let offset_time: DateTime<Utc> = Utc.timestamp_nanos(job.offset * 1000);
@@ -259,7 +268,6 @@ pub async fn dump(job: &DumpJob) -> Result<(), anyhow::Error> {
}
// check if the schema need to be updated
let dump_stream_name = generate_dump_stream_name(job.stream_type, &job.stream_name);
let dump_stream_key = format!(
"{}/{}/{}",
job.org_id,
@@ -820,6 +828,23 @@ mod tests {
use super::*;
#[test]
fn test_dump_key_of_a_source_at_the_limit_cannot_be_stored() {
let org_id = "org";
let source =
"s".repeat(infra::file_list::STREAM_KEY_MAX_LEN - org_id.len() - "/logs/".len());
assert!(infra_file_list::stream_key_violation(org_id, StreamType::Logs, &source).is_none());
let bounded =
infra_file_list::stream_key_violation(org_id, StreamType::Logs, &format!("{source}s"))
.is_some();
let dump_stream = generate_dump_stream_name(StreamType::Logs, &source);
assert_eq!(
infra_file_list::stream_key_violation(org_id, StreamType::Filelist, &dump_stream)
.is_some(),
bounded
);
}
// ---- DumpJob ----
#[test]
+11
View File
@@ -152,6 +152,12 @@ pub async fn run_generate_job(job_type: CompactionJobType) -> Result<(), anyhow:
);
continue;
}
// a stream created before over-long keys were rejected can never record a job
if infra_file_list::stream_key_violation(&org_id, stream_type, &stream_name)
.is_some()
{
continue;
}
match job_type {
CompactionJobType::Current => {
@@ -245,6 +251,11 @@ pub async fn run_generate_downsampling_job() -> Result<(), anyhow::Error> {
);
continue;
}
if infra_file_list::stream_key_violation(&org_id, stream_type, &stream_name)
.is_some()
{
continue;
}
if let Err(e) = merge::generate_downsampling_job_by_stream_and_rule(
&org_id,
+6 -2
View File
@@ -149,7 +149,11 @@ impl StreamType {
pub fn support_index(&self) -> bool {
matches!(
*self,
StreamType::Logs | StreamType::Metrics | StreamType::Traces | StreamType::Metadata
StreamType::Logs
| StreamType::Metrics
| StreamType::Traces
| StreamType::Profiles
| StreamType::Metadata
)
}
@@ -1716,7 +1720,7 @@ mod tests {
assert!(StreamType::Logs.support_index());
assert!(StreamType::Metrics.support_index());
assert!(StreamType::Traces.support_index());
assert!(!StreamType::Profiles.support_index());
assert!(StreamType::Profiles.support_index());
assert!(StreamType::Metadata.support_index());
assert!(!StreamType::EnrichmentTables.support_index());
assert!(!StreamType::Filelist.support_index());
+9 -2
View File
@@ -35,6 +35,7 @@ pub fn to_tantivy_name(from: &str) -> Option<String> {
"logs" => StreamType::Logs,
"metrics" => StreamType::Metrics,
"traces" => StreamType::Traces,
"profiles" => StreamType::Profiles,
"metadata" => StreamType::Metadata,
_ => return None,
};
@@ -87,6 +88,13 @@ mod tests {
.to_string(),
),
),
(
"files/default/profiles/quickstart1/2024/02/16/16/7164299619311026293.parquet",
Some(
"files/default/index/quickstart1_profiles/2024/02/16/16/7164299619311026293.ttv"
.to_string(),
),
),
(
"files/default/metadata/quickstart1/2024/02/16/16/7164299619311026293.parquet",
Some(
@@ -129,8 +137,7 @@ mod tests {
#[test]
fn test_convert_returns_none_for_unknown_stream_type() {
// service_graph / enrichment_tables / file_list are not handled by this
// converter — only logs/metrics/traces/metadata map to a tantivy index.
// only logs/metrics/traces/profiles/metadata map to a tantivy index
let input = "files/default/service_graph/x/2024/02/16/16/1.parquet";
assert_eq!(to_tantivy_name(input), None);
+47
View File
@@ -224,6 +224,37 @@ pub struct UserEmail {
pub user_id: String,
}
/// A per-object GET check, taken as a trait so tests can stand in for OpenFGA.
#[cfg(feature = "enterprise")]
pub trait ReadCheck {
fn can_read(&self, resource: &str, object_id: &str) -> impl Future<Output = bool> + Send;
}
/// One user's reads in one org, checked as the `GET /{org}/{resource}/{id}` routes check them.
#[cfg(feature = "enterprise")]
pub struct UserReads<'a> {
pub org_id: &'a str,
pub user_id: &'a str,
}
#[cfg(feature = "enterprise")]
impl ReadCheck for UserReads<'_> {
async fn can_read(&self, resource: &str, object_id: &str) -> bool {
check_permissions(
object_id,
self.org_id,
self.user_id,
resource,
"GET",
None,
false,
false,
true,
)
.await
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct AuthExtractor {
pub auth: String,
@@ -771,6 +802,22 @@ pub async fn check_permissions(
true
}
/// The first object `reads` refuses, asking once per distinct trimmed id.
#[cfg(feature = "enterprise")]
pub async fn first_unreadable<'a>(
objects: &[(&'static str, &'a str)],
reads: &impl ReadCheck,
) -> Option<(&'static str, &'a str)> {
let mut asked = std::collections::HashSet::new();
for &(resource, id) in objects {
let id = id.trim();
if asked.insert((resource, id)) && !reads.can_read(resource, id).await {
return Some((resource, id));
}
}
None
}
// OSS has no folder boundary to cross, so this allows where the `check_permissions`
// stub above denies: there is no folder ACL to consult, not a failed lookup.
#[cfg(not(feature = "enterprise"))]
+14 -2
View File
@@ -167,6 +167,18 @@ async fn validate_online_scorers(
org_id: &str,
scorer_refs: &[config::meta::pipeline::components::ScorerRef],
) -> Result<(), EvalJobError> {
for scorer in scorer_definitions(org_id, scorer_refs).await? {
ensure_scorer_is_online_compatible(&scorer)?;
}
Ok(())
}
/// The scorer versions a job's references resolve to, in reference order.
pub async fn scorer_definitions(
org_id: &str,
scorer_refs: &[config::meta::pipeline::components::ScorerRef],
) -> Result<Vec<table::scorers::Scorer>, EvalJobError> {
let mut definitions = Vec::with_capacity(scorer_refs.len());
for scorer_ref in scorer_refs {
let scorer = match scorer_ref.version {
Some(version) => {
@@ -178,9 +190,9 @@ async fn validate_online_scorers(
.ok_or_else(|| {
EvalJobError::InvalidJob(format!("Scorer '{}' was not found", scorer_ref.id))
})?;
ensure_scorer_is_online_compatible(&scorer)?;
definitions.push(scorer);
}
Ok(())
Ok(definitions)
}
#[tracing::instrument(skip(job))]
+4 -14
View File
@@ -137,12 +137,9 @@ pub async fn update_provider(
return Err(ProviderError::MissingName);
}
let existing = table::providers::get(provider_id)
let existing = table::providers::get(org_id, provider_id)
.await?
.ok_or(ProviderError::NotFound)?;
if existing.org_id != org_id {
return Err(ProviderError::NotFound);
}
provider.auth_config =
resolve_updated_auth_config(&provider.auth_config, &existing.auth_config);
@@ -190,23 +187,16 @@ pub async fn get_provider(
org_id: &str,
provider_id: &str,
) -> Result<table::providers::Provider, ProviderError> {
let provider = table::providers::get(provider_id)
table::providers::get(org_id, provider_id)
.await?
.ok_or(ProviderError::NotFound)?;
if provider.org_id != org_id {
return Err(ProviderError::NotFound);
}
Ok(provider)
.ok_or(ProviderError::NotFound)
}
#[tracing::instrument()]
pub async fn delete_provider(org_id: &str, provider_id: &str) -> Result<(), ProviderError> {
let provider = table::providers::get(provider_id)
table::providers::get(org_id, provider_id)
.await?
.ok_or(ProviderError::NotFound)?;
if provider.org_id != org_id {
return Err(ProviderError::NotFound);
}
let dependent_scorers: Vec<_> = table::scorers::get_all_by_org(org_id)
.await?
+7 -2
View File
@@ -174,10 +174,15 @@ async fn provider_active_model(provider: &Provider) -> Result<ActiveModel, error
})
}
pub async fn get(id: &str) -> Result<Option<Provider>, errors::Error> {
/// A provider in another organization is reported as absent.
pub async fn get(org_id: &str, id: &str) -> Result<Option<Provider>, errors::Error> {
let client = get_orm_client_ro().await;
let model = Entity::find().filter(Column::Id.eq(id)).one(client).await?;
let model = Entity::find()
.filter(Column::OrgId.eq(org_id))
.filter(Column::Id.eq(id))
.one(client)
.await?;
match model {
Some(m) => {
@@ -1,16 +1,6 @@
/**
* AlertLibraryPage - Page object for the Alert Library (browse / filter / preview / install).
*
* Covers the gallery page, the left rail (severity + category facets), the
* readiness stat strip, cross-surface selection, the detail drawer and the
* 5-step install wizard. Selectors are the feature's own data-test attributes.
*
* Reuses: OSelect dropdown helper (reka trigger gotcha) and the O2 OInput
* `-field` inner-input gotcha for text/number inputs.
*/
// Selectors and expects for the Alert Library page; specs never inline them.
import { expect } from '@playwright/test';
import { openOSelectDropdown } from './oselectHelpers.js';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
@@ -55,26 +45,21 @@ export class AlertLibraryPage {
drawerCustomize: '[data-test="alert-library-drawer-customize"]',
// install wizard
dialog: '[data-test="alert-library-install-dialog"]',
destinationStep: '[data-test="alert-library-install-destination-step"]',
destination: '[data-test="alert-library-install-destination"]',
destinationsEmpty: '[data-test="alert-library-install-destinations-empty"]',
destinationsFailed: '[data-test="alert-library-install-destinations-failed"]',
destinationsRetry: '[data-test="alert-library-install-destinations-retry"]',
openDestinations: '[data-test="alert-library-install-open-destinations"]',
installSelectAll: '[data-test="alert-library-install-select-all"]',
installClear: '[data-test="alert-library-install-clear"]',
installCount: '[data-test="alert-library-install-count"]',
folderStep: '[data-test="alert-library-install-folder-step"]',
tuneStep: '[data-test="alert-library-install-tune-step"]',
reviewStep: '[data-test="alert-library-install-review-step"]',
tuneToggle: '[data-test="alert-library-install-tune-toggle"]',
tuneFrequency: '[data-test="alert-library-install-tune-frequency"]',
tuneSilence: '[data-test="alert-library-install-tune-silence"]',
summary: '[data-test="alert-library-install-summary"]',
confirmLarge: '[data-test="alert-library-install-confirm-large"]',
next: '[data-test="alert-library-install-next"]',
back: '[data-test="alert-library-install-back"]',
run: '[data-test="alert-library-install-run"]',
retry: '[data-test="alert-library-install-retry"]',
done: '[data-test="alert-library-install-done"]',
cancel: '[data-test="alert-library-install-cancel"]',
// alert editor (the Customize hand-off target)
editorAlertName: '[data-test="add-alert-name-input-value"]',
};
}
@@ -143,6 +128,8 @@ export class AlertLibraryPage {
// ── cards / selection ─────────────────────────────────────────────────────
card(id) { return this.page.locator(`[data-test="alert-library-card-${id}"]`); }
cardTitle(id) { return this.page.locator(`[data-test="alert-library-card-title-${id}"]`); }
cardAdd(id) { return this.page.locator(`[data-test="alert-library-card-add-${id}"]`); }
cardCustomize(id) { return this.page.locator(`[data-test="alert-library-card-customize-${id}"]`); }
needsDataChip(id) {
return this.card(id).locator('[data-test="alert-library-card-needs-data"]');
}
@@ -154,6 +141,13 @@ export class AlertLibraryPage {
return dt.replace('alert-library-card-title-', '');
}
async addFromCard(id) {
await this.cardAdd(id).click();
await expect(this.page.locator(this.l.dialog)).toBeVisible({ timeout: 15000 });
}
async customizeFromCard(id) { await this.cardCustomize(id).click(); }
async selectCard(id) {
await this.page.locator(`[data-test="alert-library-select-${id}"]`).click();
}
@@ -211,28 +205,7 @@ export class AlertLibraryPage {
}
// ── install wizard ────────────────────────────────────────────────────────
async pickDestination(name) {
const root = this.page.locator(this.l.destination);
await root.waitFor({ state: 'visible', timeout: 10000 });
await openOSelectDropdown(this.page, root);
await this.page.waitForTimeout(500);
// Options are virtualized; narrow via the popover search box (the -search
// data-test IS the input) so the target row is actually rendered.
const search = this.page.locator('[data-test="alert-library-install-destination-search"]');
if (await search.isVisible({ timeout: 2000 }).catch(() => false)) {
await search.fill(name, { force: true });
await this.page.waitForTimeout(400);
}
const options = this.page.locator('[data-test$="-popover"] [data-test$="-option"]');
await expect(options.first()).toBeVisible({ timeout: 8000 });
// Fail loudly if the named destination is absent — never silently pick another.
const match = options.filter({ hasText: name }).first();
await expect(match, `destination "${name}" not in the wizard's list`).toBeVisible({ timeout: 8000 });
await match.click();
}
async next() { await this.page.locator(this.l.next).click(); }
async back() { await this.page.locator(this.l.back).click(); }
async enableTune() {
const toggle = this.page.locator(this.l.tuneToggle);
@@ -249,11 +222,6 @@ export class AlertLibraryPage {
async run() { await this.page.locator(this.l.run).click(); }
async retryFailed() { await this.page.locator(this.l.retry).click(); }
async clearInDialog() { await this.page.locator(this.l.installClear).click(); }
async selectAllInDialog() { await this.page.locator(this.l.installSelectAll).click(); }
async toggleAlertInDialog(id) {
await this.page.locator(`[data-test="alert-library-install-alert-${id}"]`).click();
}
async waitResult(id, status = 'installed', timeout = 30000) {
await expect(this.page.locator(`[data-test="alert-library-install-result-${id}"]`))
@@ -303,11 +271,33 @@ export class AlertLibraryPage {
await expect(this.page.locator(this.l.drawer)).toBeHidden();
}
async expectSummaryDestination(text) {
await expect(this.page.locator(this.l.summary).locator('dd').first()).toHaveText(text);
async expectEditorAlertName(name) {
await expect(this.page.locator(this.l.editorAlertName).first()).toHaveText(name, { timeout: 15000 });
}
async expectReadyFacetActive() {
await expect(this.page.locator(this.l.statReady)).toHaveAttribute('aria-pressed', 'true');
}
async expectCardActions(id, { customize = true } = {}) {
await expect(this.cardAdd(id)).toBeVisible();
await expect(this.cardCustomize(id)).toHaveCount(customize ? 1 : 0);
}
async expectFolderStep() { await expect(this.page.locator(this.l.folderStep)).toBeVisible(); }
async expectTuneStep() { await expect(this.page.locator(this.l.tuneStep)).toBeVisible(); }
async expectReviewStep() { await expect(this.page.locator(this.l.reviewStep)).toBeVisible(); }
async expectNoDestinationAsked() {
const dialog = this.page.locator(this.l.dialog);
for (const removed of [
'alert-library-install-destination-step',
'alert-library-install-destination',
'alert-library-install-alerts-step',
'alert-library-install-count',
'alert-library-install-select-all',
]) {
await expect(dialog.locator(`[data-test="${removed}"]`)).toHaveCount(0);
}
await expect(dialog.locator('[data-test^="alert-library-install-alert-"]')).toHaveCount(0);
}
async expectNextDisabled() { await expect(this.page.locator(this.l.next)).toBeDisabled(); }
async expectNextEnabled() { await expect(this.page.locator(this.l.next)).toBeEnabled(); }
async expectRunDisabled() { await expect(this.page.locator(this.l.run)).toBeDisabled(); }
async expectRunEnabled() { await expect(this.page.locator(this.l.run)).toBeEnabled(); }
async expectLargeConfirmVisible() { await expect(this.page.locator(this.l.confirmLarge)).toBeVisible(); }
@@ -330,6 +320,9 @@ export class AlertLibraryPage {
async expectRailCategoryVisible(id) {
await expect(this.page.locator(`[data-test="alert-library-rail-category-${id}"]`)).toBeVisible();
}
async expectRailPackHeading(pack) {
await expect(this.page.locator(`[data-test="alert-library-rail-pack-heading-${pack}"]`)).toBeVisible();
}
async expectRailCategoryAbsent(id) {
await expect(this.page.locator(`[data-test="alert-library-rail-category-${id}"]`)).toHaveCount(0);
}
@@ -351,12 +344,6 @@ export class AlertLibraryPage {
}
async expectRetryVisible() { await expect(this.page.locator(this.l.retry)).toBeVisible(); }
async expectDestinationsFailed() { await expect(this.page.locator(this.l.destinationsFailed)).toBeVisible(); }
async expectDestinationsRetryVisible() { await expect(this.page.locator(this.l.destinationsRetry)).toBeVisible(); }
async clickDestinationsRetry() { await this.page.locator(this.l.destinationsRetry).click(); }
async expectDestinationsEmpty() { await expect(this.page.locator(this.l.destinationsEmpty)).toBeVisible(); }
async expectOpenDestinationsVisible() { await expect(this.page.locator(this.l.openDestinations)).toBeVisible(); }
// ── internals ─────────────────────────────────────────────────────────────
async _fillField(rootSelector, value) {
// O2 OInput: the data-test is on a wrapper; the real input is the inner node.
@@ -21,8 +21,6 @@ test.describe('Alert Library', () => {
let lib;
let readyStream;
let missingStream;
let templateName;
let destinationName;
let entries;
test.beforeEach(async ({ page }, testInfo) => {
@@ -33,20 +31,11 @@ test.describe('Alert Library', () => {
const rand = pm.alertsPage.generateRandomString().toLowerCase();
readyStream = `pw_lib_ready_${rand}`.toLowerCase();
missingStream = `pw_lib_absent_${rand}`.toLowerCase(); // never created → "missing"
templateName = `pw_lib_tmpl_${rand}`;
destinationName = `pw_lib_dest_${rand}`;
// A stream with data → its cards read "ready" / fresh.
await pm.commonActions.initializeAlertTestStream(readyStream);
await pm.commonActions.queryStream(readyStream).catch(() => {}); // let it become searchable
// The install picks a real destination (template first). example.com avoids
// the SSRF guard that blocks self-host URLs.
await pm.alertTemplatesPage.ensureTemplateExists(templateName);
await pm.alertDestinationsPage.ensureDestinationExists(
destinationName, 'http://example.com/webhook', templateName,
);
// All installable names share the pw_lib_ prefix so cleanup.spec.js sweeps
// the alerts they create (see apiCleanup alertPrefixes).
const ready = makeEntry(1, {
@@ -93,9 +82,13 @@ test.describe('Alert Library', () => {
const ready = entries[0];
const missing = entries[1];
// Gallery renders from the mocked manifest.
// Gallery renders from the mocked manifest, opening on "Ready to use".
await lib.openViaUrl();
await lib.expectGalleryVisible();
await lib.expectReadyFacetActive();
await lib.expectCardVisible(ready.id);
await lib.expectCardAbsent(missing.id);
await lib.filterAll();
// Readiness: missing card shows the "not ingested" chip, ready card doesn't.
await lib.expectCardNeedsData(missing.id);
@@ -115,18 +108,18 @@ test.describe('Alert Library', () => {
await lib.openCard(ready.id);
await lib.expectDrawerPreviewVisible();
// Install wizard: destination → alerts → folder(default) → tune → run.
// Install wizard: folder(default) → tune → add. No destination, no checklist.
await lib.installFromDrawer();
await lib.expectNextEnabled(); // F2: the destination is optional
await lib.pickDestination(destinationName);
await lib.expectNextEnabled();
await lib.next(); // → alerts
await lib.next(); // → folder
await lib.expectFolderStep();
await lib.expectNoDestinationAsked();
await lib.next(); // → tune
await lib.expectTuneStep();
await lib.enableTune();
await lib.setFrequency(15);
await lib.setSilence(20);
await lib.next(); // → review
await lib.next(); // → add
await lib.expectReviewStep();
await lib.expectNoDestinationAsked();
await lib.run();
await lib.waitResult(ready.id, 'installed');
await lib.done();
@@ -134,8 +127,8 @@ test.describe('Alert Library', () => {
// Verify via API the transformations the UI never shows.
const created = await lib.getInstalledAlert(ready.name);
expect(created, 'installed alert should be readable via API').toBeTruthy();
// I2 destination overridden (author's o2_to_slack replaced)
expect(created.destinations).toContain(destinationName);
// I2 the author's destinations (o2_to_slack) are cleared, none added
expect(created.destinations).toEqual([]);
// I3 provenance stamped
expect(JSON.stringify(created.tags || [])).toContain(`pack:${ready.pack}`);
expect(created.context_attributes?.library_id).toBe(ready.id);
@@ -149,17 +142,15 @@ test.describe('Alert Library', () => {
expect(created.trigger_condition?.silence).toBe(20);
});
test('install with no destination adds the alert without one', async () => {
test('a card adds its alert directly, through Folder, Tune and Add', async () => {
const ready = entries[0];
await lib.openViaUrl();
await lib.openCard(ready.id);
await lib.installFromDrawer();
await lib.next(); // → alerts, with no destination picked
await lib.next(); // → folder
await lib.expectCardActions(ready.id);
await lib.addFromCard(ready.id);
await lib.expectFolderStep();
await lib.next(); // → tune
await lib.next(); // → review
await lib.expectSummaryDestination('None');
await lib.next(); // → add
await lib.run();
await lib.waitResult(ready.id, 'installed');
await lib.done();
@@ -171,35 +162,21 @@ test.describe('Alert Library', () => {
test('bulk select via select-all-in-view triggers the >50 large-batch guard', async () => {
await lib.openViaUrl();
await lib.filterAll();
// Select every card in view (1 ready + 1 missing + 51 bulk + 1 malformed = 54 > 50).
await lib.selectAllInViewToggle();
expect(await lib.selectedCountInBar()).toBeGreaterThan(50);
await lib.addSelected();
await lib.pickDestination(destinationName);
await lib.next(); // alerts
await lib.next(); // folder
await lib.next(); // tune
await lib.next(); // review
await lib.next(); // add
// Guard: confirm checkbox present, Run disabled until it is ticked.
await lib.expectLargeConfirmVisible();
await lib.expectRunDisabled();
await lib.confirmLargeBatch();
await lib.expectRunEnabled();
// H5: the confirmation auto-resets when the run's shape changes (here, the
// alert selection), so it always describes the run about to happen.
await lib.back(); // → tune
await lib.back(); // → folder
await lib.back(); // → alerts
await lib.clearInDialog();
await lib.selectAllInDialog();
await lib.next(); // folder
await lib.next(); // tune
await lib.next(); // review
await lib.expectRunDisabled();
});
test('readiness surfaces on the card and in the drawer (fresh vs missing)', async () => {
@@ -207,6 +184,7 @@ test.describe('Alert Library', () => {
const missing = entries[1];
await lib.openViaUrl();
await lib.filterAll();
// Card: missing → "not ingested" chip; fresh → none.
await lib.expectCardNeedsData(missing.id);
@@ -306,12 +284,32 @@ test.describe('Alert Library', () => {
await expect(page).toHaveURL(/prefill=library/, { timeout: 15000 });
});
test('Customize on a card opens the editor without opening the drawer', async ({ page }) => {
const ready = entries[0];
await lib.openViaUrl();
await lib.customizeFromCard(ready.id);
await expect(page).toHaveURL(/prefill=library/, { timeout: 15000 });
await lib.expectEditorAlertName(ready.name);
});
test('Customize on a card whose stream was never ingested still prefills the editor', async ({ page }) => {
const missing = entries[1];
await lib.openViaUrl();
await lib.filterAll();
await lib.customizeFromCard(missing.id);
await expect(page).toHaveURL(/prefill=library/, { timeout: 15000 });
await lib.expectEditorAlertName(missing.name);
});
test('the rail filters the gallery by severity and by category', async () => {
const ready = entries[0]; // critical, category ready-signals
const missing = entries[1]; // info, category absent-signals
const bulk = entries[2]; // warning, category bulk-signals
await lib.openViaUrl();
await lib.filterAll();
// Severity is single-select: critical → only the critical card.
await lib.selectSeverity('critical');
@@ -326,6 +324,14 @@ test.describe('Alert Library', () => {
await lib.expectRailCategoryAbsent('absent-signals');
await lib.searchCategories('');
// Categories sit under their pack heading, and a pack name finds all of its categories.
await lib.expectRailPackHeading('infrastructure');
await lib.expectRailPackHeading('observability');
await lib.searchCategories('infrastructure');
await lib.expectRailCategoryVisible('bulk-signals');
await lib.expectRailCategoryAbsent('absent-signals');
await lib.searchCategories('');
// Category multi-select filters the gallery; clear resets it.
await lib.toggleCategory('bulk-signals');
await lib.expectCardVisible(bulk.id);
@@ -346,17 +352,8 @@ test.describe('Alert Library', () => {
expect(await lib.selectedCountInBar()).toBe(picks.length);
await lib.addSelected();
await lib.pickDestination(destinationName);
await lib.next(); // alerts
// F5: clearing the selection blocks advancing until at least one is re-picked
// (re-check the three specific rows — select-all would pull the whole gallery).
await lib.clearInDialog();
await lib.expectNextDisabled();
for (const e of picks) await lib.toggleAlertInDialog(e.id);
await lib.expectNextEnabled();
await lib.next(); // folder
await lib.next(); // tune
await lib.next(); // review
await lib.next(); // add
await lib.expectLargeConfirmAbsent(); // <= 50, so no confirm required
await lib.run();
for (const e of picks) await lib.waitResult(e.id, 'installed');
@@ -365,7 +362,7 @@ test.describe('Alert Library', () => {
// Spot-check one installed alert via API.
const created = await lib.getInstalledAlert(picks[0].name);
expect(created, 'a bulk-installed alert should be readable via API').toBeTruthy();
expect(created.destinations).toContain(destinationName);
expect(created.destinations).toEqual([]);
expect(created.context_attributes?.library_id).toBe(picks[0].id);
});
@@ -426,11 +423,8 @@ test.describe('Alert Library', () => {
await lib.selectCard(a.id);
await lib.selectCard(b.id);
await lib.addSelected();
await lib.pickDestination(destinationName);
await lib.next(); // alerts
await lib.next(); // folder
await lib.next(); // tune
await lib.next(); // review
await lib.next(); // add
await lib.run();
// H6: exactly one installed, one failed, with the server message shown.
@@ -443,29 +437,4 @@ test.describe('Alert Library', () => {
await lib.retryFailed();
await lib.expectInstallResultCount('failed', 1);
});
test('install explains when the org has no usable destinations', async ({ page }) => {
const ready = entries[0];
// F4: destinations fail to load → failed banner + retry.
await page.route('**/api/*/alerts/destinations**', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }),
);
await lib.openViaUrl();
await lib.openCard(ready.id);
await lib.installFromDrawer();
await lib.expectDestinationsFailed();
await lib.expectDestinationsRetryVisible();
// F3: retry against an empty list → the no-destinations banner + open-destinations.
await page.unroute('**/api/*/alerts/destinations**');
await page.route('**/api/*/alerts/destinations**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }),
);
await lib.clickDestinationsRetry();
await lib.expectDestinationsEmpty();
await lib.expectOpenDestinationsVisible();
// Not a blocker: the alerts install without a destination.
await lib.expectNextEnabled();
});
});
@@ -0,0 +1,188 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { shallowRef, type ShallowRef } from "vue";
import i18n from "@/locales";
import OAccessActionPopover from "@/components/access/OAccessActionPopover.vue";
import { viewDenial, type AccessActionNotice } from "@/composables/useAccessStore";
const state = vi.hoisted(() => ({
notice: null as unknown as ShallowRef<AccessActionNotice | null>,
dismissAction: null as unknown as () => void,
}));
vi.mock("@/composables/useAccessStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useAccessStore")>();
return {
...actual,
useAccessBoundary: () => ({ actionNotice: state.notice, dismissAction: state.dismissAction }),
};
});
let wrapper: VueWrapper | null = null;
let container: HTMLElement;
let anchor: HTMLButtonElement;
const card = () => document.querySelector<HTMLElement>('[data-test="access-action-error"]');
const settle = async () => {
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 20));
};
const press = (el: Element, key: string, init: KeyboardEventInit = {}) =>
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
async function openCard(
denials = [viewDenial("alerts", "delete", ["DELETE /api/default/alerts/a1"])],
): Promise<void> {
wrapper = mount(OAccessActionPopover, { attachTo: document.body, global: { plugins: [i18n] } });
state.notice.value = {
el: anchor,
permission: denials[0].permission,
method: "DELETE",
resource: denials[0].resource,
denials,
routeKey: "alerts",
fallbacks: [],
};
await settle();
}
beforeEach(() => {
state.notice = shallowRef(null);
state.dismissAction = vi.fn(() => (state.notice.value = null));
container = document.createElement("div");
anchor = document.createElement("button");
anchor.textContent = "Delete";
container.appendChild(anchor);
document.body.appendChild(container);
anchor.focus();
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
container.remove();
});
describe("OAccessActionPopover", () => {
it("keeps focus on the pressed control when the card opens", async () => {
await openCard();
expect(card()).not.toBeNull();
expect(document.activeElement).toBe(anchor);
});
it("names every refused write the one action sent", async () => {
await openCard([
viewDenial("roles", "update", ["PUT /api/default/roles/viewer"]),
viewDenial("groups", "update", ["PUT /api/default/groups/ops"]),
]);
const text = card()!.textContent ?? "";
expect(text).toContain("Roles");
expect(text).toContain("Groups");
});
it("moves Tab from the pressed control into the card instead of closing it", async () => {
await openCard();
press(anchor, "Tab");
await settle();
expect(state.dismissAction).not.toHaveBeenCalled();
expect(card()?.contains(document.activeElement)).toBe(true);
});
it("stays open when focus returns to the pressed control", async () => {
await openCard();
card()!.focus();
anchor.focus();
await settle();
expect(state.dismissAction).not.toHaveBeenCalled();
expect(card()).not.toBeNull();
});
it("returns to the pressed control when Tab leaves either end of the card", async () => {
await openCard();
card()!.focus();
press(card()!, "Tab", { shiftKey: true });
expect(document.activeElement).toBe(anchor);
expect(state.dismissAction).not.toHaveBeenCalled();
const copy = card()!.querySelector<HTMLElement>('[data-test="access-denied-copy"]')!;
copy.focus();
press(copy, "Tab");
await settle();
expect(document.activeElement).toBe(anchor);
expect(state.dismissAction).toHaveBeenCalledTimes(1);
});
it("returns Shift+Tab from the first card button to the pressed control", async () => {
await openCard();
const copy = card()!.querySelector<HTMLElement>('[data-test="access-denied-copy"]')!;
copy.focus();
const event = new KeyboardEvent("keydown", {
key: "Tab",
shiftKey: true,
bubbles: true,
cancelable: true,
});
copy.dispatchEvent(event);
await settle();
expect(event.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(anchor);
expect(state.dismissAction).not.toHaveBeenCalled();
});
it("returns Shift+Tab from the first of several card buttons, and leaves the others to the browser", async () => {
await openCard(
["dashboards", "alerts", "streams", "functions", "pipelines", "destinations"].map((key) =>
viewDenial(key, "delete", [`DELETE /api/default/${key}/x`]),
),
);
const buttons = [...card()!.querySelectorAll<HTMLElement>("button")];
expect(buttons[0].dataset.test).toBe("access-denied-more");
expect(buttons.length).toBeGreaterThan(1);
buttons[1].focus();
const inner = new KeyboardEvent("keydown", {
key: "Tab",
shiftKey: true,
bubbles: true,
cancelable: true,
});
buttons[1].dispatchEvent(inner);
expect(inner.defaultPrevented).toBe(false);
buttons[0].focus();
press(buttons[0], "Tab", { shiftKey: true });
await settle();
expect(document.activeElement).toBe(anchor);
expect(state.dismissAction).not.toHaveBeenCalled();
});
it("closes on Escape and gives focus back to the pressed control", async () => {
await openCard();
card()!.focus();
press(card()!, "Escape");
await settle();
expect(state.dismissAction).toHaveBeenCalled();
expect(document.activeElement).toBe(anchor);
});
it("stays open while the page under it scrolls", async () => {
await openCard();
container.dispatchEvent(new Event("scroll"));
await settle();
expect(state.dismissAction).not.toHaveBeenCalled();
expect(card()).not.toBeNull();
});
});
@@ -22,13 +22,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
align="end"
flip-align
arrow
:close-on-scroll="false"
:side-offset="-1"
:collision-padding="8"
content-class="border-0!"
@update:open="onOpenChange"
@open-auto-focus="keepFocus"
@focus-outside="keepWhileOnAnchor"
>
<OAccessDeniedCard :denials="denials" role="alert" data-test="access-action-error" />
<OAccessDeniedCard
ref="card"
:denials="denials"
role="alert"
data-test="access-action-error"
@keydown="onCardKeydown"
/>
<template #arrow>
<path class="fill-dropdown-bg" d="M0 0L6 6L12 0Z" />
<path class="fill-banner-error-soft-bg" d="M0 0L6 6L12 0Z" />
@@ -38,26 +46,70 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OAccessDeniedCard from "@/components/access/OAccessDeniedCard.vue";
import { useAccessBoundary, type AccessDenial } from "@/composables/useAccessStore";
const { actionNotice, dismissAction } = useAccessBoundary();
const card = ref<{ focus: () => void; $el?: Element } | null>(null);
const denials = computed<AccessDenial[]>(() => {
const notice = actionNotice.value;
if (!notice) return [];
const request = `${notice.method} ${notice.resource.path}`;
return [{ resource: notice.resource, permission: notice.permission, requests: [request] }];
});
const denials = computed<AccessDenial[]>(() => actionNotice.value?.denials ?? []);
const anchorEl = (): HTMLElement | null => {
const el = actionNotice.value?.el;
return el instanceof HTMLElement ? el : null;
};
const cardEl = (): Element | null => card.value?.$el ?? null;
const focusInCard = () => !!cardEl()?.contains(document.activeElement);
function onOpenChange(open: boolean): void {
if (!open) dismissAction();
if (open || !actionNotice.value) return;
const anchor = anchorEl();
// The card lives in a portal, so closing it from inside would drop focus to the page start.
if (focusInCard()) anchor?.focus();
dismissAction();
}
// The control the user just pressed keeps focus; the error is announced through role="alert".
function keepFocus(event: Event): void {
event.preventDefault();
}
function keepWhileOnAnchor(event: Event): void {
const target = (event as CustomEvent<{ originalEvent?: Event }>).detail?.originalEvent?.target;
if (target instanceof Node && anchorEl()?.contains(target)) event.preventDefault();
}
// The portal sits at the end of the page, so Tab from the pressed control would never reach the card.
function onAnchorKeydown(event: KeyboardEvent): void {
if (event.key !== "Tab" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
return;
event.preventDefault();
card.value?.focus();
}
watch(
() => actionNotice.value?.el,
(el, _old, onCleanup) => {
if (!(el instanceof HTMLElement)) return;
el.addEventListener("keydown", onAnchorKeydown);
onCleanup(() => el.removeEventListener("keydown", onAnchorKeydown));
},
{ immediate: true },
);
// Tabbing out of either end of the card returns to the pressed control, which keeps the user's place on the page.
function onCardKeydown(event: KeyboardEvent): void {
const root = cardEl();
if (event.key !== "Tab" || !root) return;
const buttons = [...root.querySelectorAll<HTMLElement>("button:not([disabled])")];
const leavingStart =
event.shiftKey && (document.activeElement === root || document.activeElement === buttons[0]);
const leavingEnd = !event.shiftKey && document.activeElement === buttons[buttons.length - 1];
if (!leavingStart && !leavingEnd) return;
event.preventDefault();
anchorEl()?.focus();
if (leavingEnd) dismissAction();
}
</script>
@@ -0,0 +1,107 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { computed, defineComponent, h, ref, shallowRef } from "vue";
import { createMemoryHistory, createRouter } from "vue-router";
import i18n from "@/locales";
import OAccessBoundary from "@/components/access/OAccessBoundary.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import { viewDenial, type AccessSectionEntry } from "@/composables/useAccessStore";
const state = vi.hoisted(() => ({ entries: [] as AccessSectionEntry[] }));
vi.mock("@/composables/useAccessStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useAccessStore")>();
return {
...actual,
useAccessBoundary: () => ({
pageDenied: computed(() => false),
pageDenial: computed(() => null),
pageRequests: () => [],
sectionEntries: computed(() => state.entries),
deepestBoundary: computed(() => -1),
actionNotice: shallowRef(null),
dismissAction: vi.fn(),
}),
};
});
let wrapper: VueWrapper | null = null;
async function mountTable(entries: AccessSectionEntry[], forbiddenResource?: string) {
state.entries = entries;
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", component: { render: () => null } }],
});
await router.push("/");
const Page = defineComponent({
setup: () => () =>
h(OTable, {
data: [],
columns: [{ id: "name", header: "Name", accessorKey: "name" }],
forbidden: true,
forbiddenResource,
loading: ref(false).value,
}),
});
wrapper = mount(OAccessBoundary, {
slots: { default: () => h(Page) },
global: { plugins: [i18n, router] },
});
await flushPromises();
return wrapper.get('[data-test="o2-table-forbidden"]');
}
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
describe("OAccessBoundary table no-access state", () => {
it("shows the shared lock naming the table's refused list", async () => {
const forbidden = await mountTable(
[
viewDenial("templates", "list", ["GET /api/default/alerts/templates"]),
viewDenial("alert_folders", "list", ["GET /api/v2/default/alerts"]),
],
"alert_folders",
);
expect(forbidden.find('[data-test="access-lock-icon"]').exists()).toBe(true);
expect(forbidden.text()).toContain("You can't view alert folders");
expect(forbidden.text()).toContain(
"Your role is missing the List permission on Alert folders.",
);
expect(forbidden.text()).toContain("Copy details");
});
it("keeps the shared lock with the general wording when it cannot tell which list was refused", async () => {
const forbidden = await mountTable([
viewDenial("templates", "list"),
viewDenial("destinations", "list"),
]);
expect(forbidden.find('[data-test="access-lock-icon"]').exists()).toBe(true);
expect(forbidden.text()).toContain("You don't have access");
expect(forbidden.text()).not.toContain("Copy details");
});
it("does not name the page's only refused list on a table that names no resource", async () => {
const forbidden = await mountTable([viewDenial("templates", "list")]);
expect(forbidden.text()).toContain("You don't have access");
expect(forbidden.text()).not.toContain("templates");
});
});
+18 -2
View File
@@ -37,12 +37,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, provide, toRaw } from "vue";
import {
computed,
h,
inject,
onBeforeUnmount,
provide,
toRaw,
type FunctionalComponent,
} from "vue";
import { matchedRouteKey, useRoute } from "vue-router";
import { providePageHeaderStatus } from "@/lib/core/PageHeader/pageHeaderStatus";
import { provideTableForbiddenState } from "@/lib/core/Table/tableForbiddenState";
import OAccessActionPopover from "@/components/access/OAccessActionPopover.vue";
import OAccessHeaderChip from "@/components/access/OAccessHeaderChip.vue";
import OAccessLock from "@/components/access/OAccessLock.vue";
import { tableDenials, withoutCovered } from "@/components/access/surfaceDenials";
import {
accessBoundaryKey,
registerAccessBoundary,
@@ -76,12 +86,18 @@ const showsChip = computed(
depth.value >= 0 &&
depth.value === deepestBoundary.value &&
!pageDenied.value &&
sectionEntries.value.length > 0,
withoutCovered(sectionEntries.value).length > 0,
);
providePageHeaderStatus(
() => (showsChip.value ? OAccessHeaderChip : null),
() => route.fullPath,
);
// A refused table list shows the same lock and wording as a refused page; the chip keeps listing it beside a create button that may still work.
const TableAccessLock: FunctionalComponent<{ resource?: string }> = ({ resource }) =>
h(OAccessLock, { denials: tableDenials(sectionEntries.value, resource), size: "hero" });
TableAccessLock.props = ["resource"];
provideTableForbiddenState(TableAccessLock);
const lockDenials = computed<AccessDenial[]>(() => (pageDenial.value ? [pageDenial.value] : []));
</script>
@@ -16,7 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
v-if="message.groups.length"
v-if="message.count"
ref="root"
tabindex="-1"
:role="role"
@@ -37,7 +37,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<ResourceList :resources="message.groups[0].resources" />
</template>
</i18n-t>
<template v-else>
<template v-else-if="message.groups.length > 1">
<p class="text-text-body">{{ t("accessBoundary.denied.missingIntro") }}</p>
<dl
class="text-text-body grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1"
@@ -51,6 +51,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</dl>
</template>
<p v-if="message.askAdmin" class="text-text-body" data-test="access-denied-ask-admin">
{{ t("accessBoundary.denied.askAdmin") }}
</p>
<div class="flex flex-wrap items-center gap-1">
<OButton
variant="ghost-primary"
@@ -0,0 +1,121 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { computed, shallowRef, type ShallowRef } from "vue";
import i18n from "@/locales";
import OAccessHeaderChip from "@/components/access/OAccessHeaderChip.vue";
import { viewDenial, type AccessSectionEntry } from "@/composables/useAccessStore";
const state = vi.hoisted(() => ({
entries: null as unknown as ShallowRef<AccessSectionEntry[]>,
}));
const screen = vi.hoisted(() => ({ lgUp: true }));
vi.mock("@/composables/useBreakpoint", () => ({
default: () => ({ lgUp: computed(() => screen.lgUp) }),
}));
vi.mock("@/composables/useAccessStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useAccessStore")>();
return { ...actual, useAccessBoundary: () => ({ sectionEntries: state.entries }) };
});
let wrapper: VueWrapper | null = null;
const announcer = () => wrapper!.find('[data-test="access-header-chip-announcer"]');
const chipName = () => wrapper!.find('[data-test="access-header-chip"]').attributes("aria-label");
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
screen.lgUp = true;
state.entries = shallowRef([viewDenial("templates", "list", ["GET /api/default/templates"])]);
wrapper = mount(OAccessHeaderChip, { attachTo: document.body, global: { plugins: [i18n] } });
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
describe("OAccessHeaderChip", () => {
it("announces the chip once through a polite live region", async () => {
const region = announcer();
expect(region.attributes("aria-live")).toBe("polite");
expect(region.attributes("role")).toBe("status");
expect(region.text()).toBe("");
await wait(400);
expect(region.text()).toBe(chipName());
expect(region.text()).toContain("templates");
});
it("does not re-announce when the same denials render again", async () => {
await wait(400);
const changes: MutationRecord[] = [];
const observer = new MutationObserver((records) => changes.push(...records));
observer.observe(announcer().element, { childList: true, characterData: true, subtree: true });
state.entries.value = [viewDenial("templates", "list", ["GET /api/default/templates"])];
await flushPromises();
await wait(400);
observer.disconnect();
expect(changes).toHaveLength(0);
});
it("announces again when the denials change", async () => {
await wait(400);
state.entries.value = [
viewDenial("templates", "list", ["GET /api/default/templates"]),
viewDenial("destinations", "list", ["GET /api/default/destinations"]),
];
await flushPromises();
expect(announcer().text()).toBe(chipName());
expect(announcer().text()).not.toContain("templates");
});
it("asks for an administrator, naming no permission, for a read no role grant lifts", async () => {
state.entries.value = [
{
resource: {
key: "_unnamed/synthetics",
labelKey: "accessBoundary.resourcesInline.unnamed",
path: "/api/default/synthetics/variables",
grantable: false,
},
permission: "list",
requests: ["GET /api/default/synthetics/variables"],
},
];
await flushPromises();
await wrapper!.find('[data-test="access-header-chip"]').trigger("click");
await flushPromises();
const card = document.querySelector('[data-test="access-header-popover"]')!;
expect(card.querySelector('[data-test="access-denied-title"]')?.textContent).toBe(
"You can't view this resource",
);
expect(card.querySelector('[data-test="access-denied-ask-admin"]')?.textContent).toBe(
"Ask your administrator for access.",
);
expect(card.textContent).not.toContain("permission");
});
it("is no taller than the title it sits beside below lg", async () => {
wrapper!.unmount();
screen.lgUp = false;
wrapper = mount(OAccessHeaderChip, { attachTo: document.body, global: { plugins: [i18n] } });
const chip = wrapper.find('[data-test="access-header-chip"]');
expect(chip.classes()).toContain("h-6");
});
});
@@ -30,13 +30,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #trigger>
<OButton
variant="ghost-destructive"
size="sm"
:size="compactChip ? 'chip' : 'sm'"
icon-left="lock"
:aria-label="t('accessBoundary.chip.ariaLabel', { title: message.title })"
:aria-label="chipName"
data-test="access-header-chip"
>
<span class="max-md:hidden">{{ t("accessBoundary.chip.label") }}</span>
<OBadge variant="error-soft" size="sm">{{ message.resourceCount }}</OBadge>
<span v-if="!compactChip">{{ t("accessBoundary.chip.label") }}</span>
<OBadge variant="error-soft" size="sm">{{ message.count }}</OBadge>
</OButton>
</template>
<OAccessDeniedCard
@@ -51,24 +51,54 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<path class="stroke-banner-error-soft-border fill-none" d="M0 0L6 6L12 0" />
</template>
</OPopover>
<span role="status" aria-live="polite" class="sr-only" data-test="access-header-chip-announcer">{{
announcement
}}</span>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OAccessDeniedCard from "@/components/access/OAccessDeniedCard.vue";
import useBreakpoint from "@/composables/useBreakpoint";
import { withoutCovered } from "@/components/access/surfaceDenials";
import { accessDeniedMessage, sortDenials, useAccessBoundary } from "@/composables/useAccessStore";
import { useI18nTyped } from "@/types/i18n";
import { useI18nTyped, type I18nText } from "@/types/i18n";
// Screen readers skip text that arrives together with its live region, so the first announcement waits for the region to register.
const ANNOUNCE_DELAY_MS = 150;
const props = withDefaults(
defineProps<{
/** Set by a host that knows whether its own row wraps; other hosts follow the viewport. */
compact?: boolean;
}>(),
{ compact: undefined },
);
const { t } = useI18nTyped();
const { sectionEntries } = useAccessBoundary();
const { lgUp } = useBreakpoint();
const compactChip = computed(() => props.compact ?? !lgUp.value);
const open = ref(false);
const denials = computed(() => sortDenials(sectionEntries.value, t));
const denials = computed(() => sortDenials(withoutCovered(sectionEntries.value), t));
const message = computed(() => accessDeniedMessage(denials.value, t));
const card = ref<{ focus: () => void } | null>(null);
const chipName = computed(() => t("accessBoundary.chip.ariaLabel", { title: message.value.title }));
// Set only when the name changes, so a re-render with the same denials leaves the region's text, and the reader, alone.
const announcement = ref<I18nText | null>(null);
let announceTimer: ReturnType<typeof setTimeout> | undefined;
onMounted(() => {
announceTimer = setTimeout(() => (announcement.value = chipName.value), ANNOUNCE_DELAY_MS);
});
watch(chipName, (name) => {
if (announcement.value !== null) announcement.value = name;
});
onBeforeUnmount(() => clearTimeout(announceTimer));
// The card takes focus instead of its first button, so a mouse open shows no focus ring.
function focusCard(event: Event): void {
+20 -8
View File
@@ -18,11 +18,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OEmptyState
preset="no-access"
:size="size"
:title="message.title"
:title="named ? message.title : undefined"
:description="description"
:action-label="actionLabel ?? copyLabel"
:action-label="actionLabel ?? (named ? copyLabel : undefined)"
:action-icon="actionLabel ? actionIcon : 'content-copy'"
:secondary-action-label="actionLabel ? copyLabel : undefined"
:secondary-action-label="actionLabel && named ? copyLabel : undefined"
@action="actionLabel ? emit('action') : copyDetails()"
@secondary-action="copyDetails"
>
@@ -43,6 +43,7 @@ import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import { useAccessCopy } from "@/components/access/useAccessCopy";
import { coverDenials } from "@/components/access/surfaceDenials";
import {
accessDeniedMessage,
accessDetails,
@@ -53,7 +54,7 @@ import { useI18nTyped, type I18nText } from "@/types/i18n";
const props = withDefaults(
defineProps<{
/** What the lock names; one denial gives the "You can't ..." title and its missing-permission line. */
/** What the lock names; one denial gives the "You can't ..." title and its missing-permission line, none the general no-access wording. */
denials: AccessDenial[];
/** Read when Copy details is pressed, since a page's denied requests keep arriving after it locks. */
requests?: () => string[];
@@ -61,16 +62,27 @@ const props = withDefaults(
/** The view's own next step; Copy details then moves to the secondary button. */
actionLabel?: I18nText;
actionIcon?: IconName;
/** The access chip leaves these denials out while the lock shows, for a lock whose page would otherwise list them a second time. */
covers?: boolean;
}>(),
{ size: "block", requests: undefined, actionLabel: undefined, actionIcon: undefined },
{
size: "block",
requests: undefined,
actionLabel: undefined,
actionIcon: undefined,
covers: false,
},
);
const emit = defineEmits<{ (e: "action"): void }>();
const { t } = useI18nTyped();
if (props.covers) coverDenials(() => props.denials);
const named = computed(() => props.denials.length > 0);
const message = computed(() => accessDeniedMessage(props.denials, t));
const description = computed(() =>
message.value.groups[0] ? accessReason(message.value.groups[0], t) : undefined,
);
const description = computed(() => {
if (message.value.groups[0]) return accessReason(message.value.groups[0], t);
return message.value.askAdmin ? t("accessBoundary.denied.askAdmin") : undefined;
});
const { copied, copyDetails } = useAccessCopy(() => {
const requests = props.requests;
return accessDetails(
@@ -0,0 +1,77 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { defineComponent, h, KeepAlive, nextTick, ref } from "vue";
import { mount } from "@vue/test-utils";
import { viewDenial } from "@/composables/useAccessStore";
import { coverDenials, tableDenials, withoutCovered } from "@/components/access/surfaceDenials";
const keys = (denials: { resource: { key: string } }[]) => denials.map((d) => d.resource.key);
describe("tableDenials", () => {
const alertsList = viewDenial("alert_folders", "list", ["GET /api/v2/default/alerts"]);
const templatesList = viewDenial("templates", "list", ["GET /api/default/alerts/templates"]);
const alertDelete = viewDenial("alerts", "delete", ["DELETE /api/v2/default/alerts/a1"]);
it("names the table's own refused list, with its requests", () => {
const denials = tableDenials([templatesList, alertsList], "alert_folders");
expect(keys(denials)).toEqual(["alert_folders"]);
expect(denials[0].requests).toEqual(["GET /api/v2/default/alerts"]);
});
it("names the table's resource even before the page's entry arrives", () => {
expect(tableDenials([], "functions")).toEqual([viewDenial("functions", "list")]);
});
it("names nothing when the table names no resource, even with one refused list on the page", () => {
expect(tableDenials([alertDelete, templatesList])).toEqual([]);
});
it("names nothing when several lists were refused and the table names no resource", () => {
expect(tableDenials([alertsList, templatesList])).toEqual([]);
});
});
describe("coverDenials", () => {
it("stops covering a denial while its cached page is off screen, and covers it again on return", async () => {
const denial = viewDenial("streams", "list", ["GET /api/default/streams?type=traces"]);
const showTraces = ref(true);
const CachedTraces = defineComponent({
name: "AppTraces",
setup() {
coverDenials(() => [denial]);
return () => h("div", "Traces");
},
});
const OtherPage = defineComponent({ render: () => h("div", "Other page") });
const wrapper = mount(
defineComponent({
setup: () => () =>
h(KeepAlive, null, { default: () => h(showTraces.value ? CachedTraces : OtherPage) }),
}),
);
expect(withoutCovered([denial])).toHaveLength(0);
showTraces.value = false;
await nextTick();
expect(wrapper.text()).toBe("Other page");
expect(withoutCovered([denial])).toHaveLength(1);
showTraces.value = true;
await nextTick();
expect(withoutCovered([denial])).toHaveLength(0);
wrapper.unmount();
expect(withoutCovered([denial])).toHaveLength(1);
});
});
@@ -0,0 +1,57 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
onActivated,
onBeforeUnmount,
onDeactivated,
ref,
shallowReactive,
watchEffect,
} from "vue";
import { viewDenial, type AccessDenial } from "@/composables/useAccessStore";
// What each mounted inline lock names, by lock; the chip leaves these out so one 403 reads once.
const coveredByLock = shallowReactive(new Map<symbol, Set<string>>());
const denialId = (d: AccessDenial) => `${d.resource.key}|${d.permission}`;
/** Keeps `denials` out of the access chip while the calling component is mounted and active. */
export function coverDenials(denials: () => AccessDenial[]): void {
const lock = Symbol("accessLock");
// A KeepAlive-cached page stays mounted while another page is on screen; its lock must not hide that page's denials.
const active = ref(true);
watchEffect(() => {
if (active.value) coveredByLock.set(lock, new Set(denials().map(denialId)));
else coveredByLock.delete(lock);
});
onActivated(() => (active.value = true));
onDeactivated(() => (active.value = false));
onBeforeUnmount(() => coveredByLock.delete(lock));
}
/** `entries` without the ones an inline lock on screen already names. */
export function withoutCovered<T extends AccessDenial>(entries: T[]): T[] {
if (coveredByLock.size === 0) return entries;
const covered = new Set([...coveredByLock.values()].flatMap((ids) => [...ids]));
return entries.filter((e) => !covered.has(denialId(e)));
}
/** What a forbidden table's lock names: its own refused list, or none when the table does not say which resource it lists. */
export function tableDenials(entries: AccessDenial[], resource?: string): AccessDenial[] {
if (!resource) return [];
const own = entries.filter((e) => e.permission === "list" && e.resource.key === resource);
return own.length ? own : [viewDenial(resource, "list")];
}
@@ -77,8 +77,8 @@ describe("LibraryCard", () => {
const wrapper = mountCard({ ready: false });
const note = wrapper.find('[data-test="alert-library-card-needs-data"]');
expect(note.exists()).toBe(true);
expect(note.text()).toContain("Not ingested");
expect(note.text()).toContain("kube_pod_container_status_last_terminated_reason");
expect(note.text()).toBe("Not ingested");
expect(note.attributes("title")).toContain("kube_pod_container_status_last_terminated_reason");
expect(note.attributes("title")).toContain("would never fire");
});
@@ -130,15 +130,41 @@ describe("LibraryCard", () => {
expect(dashed(false)).toBe(dashed(true));
});
it("gives the note its own row, so a long stream name is not truncated away", () => {
// Stream names run to 40+ characters. Sharing the footer row with the
// query-language tag left roughly a word's worth of space, so the string
// the message exists to name would ellipsis into nothing.
it("puts the note at the end of the stream line, beside the stream it names", () => {
const wrapper = mountCard({ ready: false });
const note = wrapper.find('[data-test="alert-library-card-needs-data"]').element;
const tag = wrapper.find('[data-test="alert-library-card-query-type"]').element;
// Not flex siblings: the note is on a line the tag does not share.
expect(note.parentElement).not.toBe(tag.parentElement);
const line = wrapper.find('[data-test="alert-library-card-stream"]');
expect(line.find('[data-test="alert-library-card-needs-data"]').exists()).toBe(true);
expect(line.text()).toContain("kube_pod_container_status_last_terminated_reason");
expect(line.element.lastElementChild?.getAttribute("data-test")).toBe(
"alert-library-card-needs-data",
);
});
it("shows the stream on its own line with a database icon, under the description", () => {
const wrapper = mountCard();
const line = wrapper.find('[data-test="alert-library-card-stream"]');
expect(line.exists()).toBe(true);
expect(line.text()).toBe("kube_pod_container_status_last_terminated_reason");
expect(line.findComponent({ name: "OIcon" }).props("name")).toBe("database");
});
it("keeps the stream name out of the chip row", () => {
const tag = mountCard().find('[data-test="alert-library-card-query-type"]').element;
expect(tag.parentElement?.textContent).not.toContain(
"kube_pod_container_status_last_terminated_reason",
);
});
it("renders no stream line for an entry without a stream", () => {
const wrapper = mountCard({ entry: { ...entry, stream: "" } });
expect(wrapper.find('[data-test="alert-library-card-stream"]').exists()).toBe(false);
});
it("still flags a not-ingested entry that has no stream", () => {
const wrapper = mountCard({ entry: { ...entry, stream: "" }, ready: false });
const tag = wrapper.find('[data-test="alert-library-card-needs-data"]');
expect(tag.exists()).toBe(true);
expect(tag.attributes("title")).toBeUndefined();
});
it("does not dim the card, so its description stays readable", () => {
@@ -342,4 +368,94 @@ describe("LibraryCard", () => {
expect(open.find('[data-test="alert-library-card-enterprise"]').exists()).toBe(false);
});
});
describe("actions", () => {
const ADD_ID = `alert-library-card-add-${entry.id}`;
const CUSTOMIZE_ID = `alert-library-card-customize-${entry.id}`;
const ADD = `[data-test="${ADD_ID}"]`;
const CUSTOMIZE = `[data-test="${CUSTOMIZE_ID}"]`;
const anomaly: AlertLibraryEntry = {
...entry,
query_type: "sql",
alert_type: "anomaly_detection",
};
it("offers a short Add label and an icon-only Customize on a scheduled card", () => {
const wrapper = mountCard();
expect(wrapper.find(ADD).text()).toBe("Add");
const customize = wrapper.find(CUSTOMIZE);
expect(customize.text()).toBe("");
expect(customize.attributes("aria-label")).toBe("Customize in editor");
const button = wrapper
.findAllComponents({ name: "OButton" })
.find((b) => b.attributes("data-test") === CUSTOMIZE_ID);
expect(button?.props("variant")).toBe("outline");
expect(button?.props("size")).toBe("icon-sm");
expect(button?.props("iconLeft")).toBe("edit");
});
it("explains Customize in a tooltip and spells out Add on hover", () => {
const wrapper = mountCard();
expect(wrapper.find(ADD).attributes("title")).toBe("Add to alerts");
const tip = wrapper.find(CUSTOMIZE).findComponent({ name: "OTooltip" });
expect(tip.props("content")).toBe("Opens the alert editor prefilled");
});
it("puts Customize before Add, at the end of the chip row", () => {
const wrapper = mountCard();
const row = wrapper.find('[data-test="alert-library-card-query-type"]').element.parentElement;
const ids = Array.from(row?.querySelectorAll("[data-test]") ?? []).map((n) =>
n.getAttribute("data-test"),
);
expect(ids.indexOf(CUSTOMIZE_ID)).toBeLessThan(ids.indexOf(ADD_ID));
expect(ids.indexOf(CUSTOMIZE_ID)).toBeGreaterThan(-1);
});
it("offers Customize on a SQL card as well as a PromQL one", () => {
const wrapper = mountCard({ entry: { ...entry, query_type: "sql" } });
expect(wrapper.find(CUSTOMIZE).exists()).toBe(true);
});
it("emits install from Add without opening the card", async () => {
const wrapper = mountCard();
await wrapper.find(ADD).trigger("click");
expect(wrapper.emitted("install")).toHaveLength(1);
expect(wrapper.emitted("open")).toBeUndefined();
});
it("emits customize from Customize without opening the card", async () => {
const wrapper = mountCard();
await wrapper.find(CUSTOMIZE).trigger("click");
expect(wrapper.emitted("customize")).toHaveLength(1);
expect(wrapper.emitted("open")).toBeUndefined();
});
it("offers Add but not Customize on an anomaly card, which has no editor prefill", () => {
const wrapper = mountCard({ entry: anomaly });
expect(wrapper.find(ADD).exists()).toBe(true);
expect(wrapper.find(CUSTOMIZE).exists()).toBe(false);
});
it("ties both buttons to their own card's title, since every card repeats the labels", () => {
const wrapper = mountCard();
const title = wrapper.find(`[data-test="alert-library-card-title-${entry.id}"]`);
const titleId = title.attributes("id");
expect(titleId).toBeTruthy();
expect(title.text()).toBe("Pod OOM Killed");
expect(wrapper.find(ADD).attributes("aria-describedby")).toBe(titleId);
expect(wrapper.find(CUSTOMIZE).attributes("aria-describedby")).toBe(titleId);
const other = mountCard({ entry: { ...entry, id: "k8s/node-disk-pressure" } });
const otherTitle = other.find(
'[data-test="alert-library-card-title-k8s/node-disk-pressure"]',
);
expect(otherTitle.attributes("id")).not.toBe(titleId);
});
it("offers neither on a locked card, whose click is the upsell", () => {
const wrapper = mountCard({ entry: anomaly, locked: true });
expect(wrapper.find(ADD).exists()).toBe(false);
expect(wrapper.find(CUSTOMIZE).exists()).toBe(false);
});
});
});
+78 -50
View File
@@ -29,8 +29,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
different?") and answered it nowhere, so the difference read as arbitrary.
Now ONE signal carries the meaning: a warning-toned chip, shaped like the
query-language chip beside it, reading "Not ingested: <stream>". It gets a row
of its own so the stream name survives at card width. The dashed border and
query-language chip, reading "Not ingested" at the end of the stream line it
refers to, so the stream it names is the one printed beside it. The dashed border and
the opacity dim are both gone — the border only ever meant anything by
comparison with a solid one, which is precisely what made it unreadable, and
the dim faded the description people need in order to judge the alert. Neither
@@ -64,6 +64,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<h3 class="min-w-0 flex-1 text-sm leading-snug font-medium">
<button
:id="titleId"
type="button"
class="text-text-heading focus-visible:ring-accent/40 rounded-default cursor-pointer text-left outline-none focus-visible:ring-2"
:data-test="`alert-library-card-title-${entry.id}`"
@@ -85,56 +86,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ entry.description }}
</OTruncatedText>
<div class="mt-auto flex flex-col gap-1 pt-1">
<div class="flex items-center gap-1.5">
<!-- Neutral because most alerts are PromQL: colouring the query language
would colour the norm. -->
<OTag
variant="default-soft"
:label="queryTypeLabelText"
data-test="alert-library-card-query-type"
/>
<OTag
v-if="isAnomaly"
type="alertType"
value="anomaly_detection"
:label="t('alert_library.alertTypeAnomaly')"
data-test="alert-library-card-anomaly"
/>
<OTag
v-if="locked"
variant="default-soft"
icon="lock"
:label="t('alert_library.enterprise')"
:title="t('alert_library.enterpriseHint')"
data-test="alert-library-card-enterprise"
/>
<!-- Available: the stream is quiet provenance, tucked opposite the tag. -->
<OTruncatedText
v-if="ready"
class="text-text-secondary text-2xs flex-1 text-right font-mono"
>{{ entry.stream }}</OTruncatedText
>
</div>
<!-- Unavailable: the same chip shape as the query language, on a row of
its own — stream names run past 40 characters, and squeezed beside
the other chip this one would truncate to nothing, naming the very
thing it exists to name.
The CHIP is neutral and only its icon carries the warning colour.
A filled amber chip was right in principle and wrong in practice:
959 of 1242 alerts are not ingested on a typical org, so twelve amber
blocks landed on every screen and the colour became the page's
background rather than a signal — loudest in dark mode, where the
warning hue sits at full chroma against near-black. One small amber
mark per card says the same thing and lets the text stay readable. -->
<div
v-if="entry.stream || !ready"
class="flex min-w-0 items-center gap-1.5"
data-test="alert-library-card-stream"
>
<template v-if="entry.stream">
<OIcon name="database" size="xs" class="text-text-secondary shrink-0" />
<OTruncatedText class="text-text-secondary text-2xs min-w-0 flex-1 font-mono">{{
entry.stream
}}</OTruncatedText>
</template>
<!-- The CHIP is neutral and only its icon carries the warning colour: most alerts are not ingested on a typical org. -->
<OTag
v-if="!ready"
variant="warning-quiet"
class="max-w-full min-w-0 self-start"
:title="t('alert_library.notIngestedHint', { stream: entry.stream })"
class="shrink-0"
:title="
entry.stream ? t('alert_library.notIngestedHint', { stream: entry.stream }) : undefined
"
data-test="alert-library-card-needs-data"
>
<template #icon>
@@ -145,9 +115,61 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-library-card-needs-data-mark"
/>
</template>
<span class="truncate">{{ t("alert_library.notIngested", { stream: entry.stream }) }}</span>
{{ t("alert_library.statNeedsData") }}
</OTag>
</div>
<div class="mt-auto flex items-center gap-1.5 pt-1">
<!-- Neutral because most alerts are PromQL: colouring the query language would colour the norm. -->
<OTag
variant="default-soft"
:label="queryTypeLabelText"
data-test="alert-library-card-query-type"
/>
<OTag
v-if="isAnomaly"
type="alertType"
value="anomaly_detection"
:label="t('alert_library.alertTypeAnomaly')"
data-test="alert-library-card-anomaly"
/>
<OTag
v-if="locked"
variant="default-soft"
icon="lock"
:label="t('alert_library.enterprise')"
:title="t('alert_library.enterpriseHint')"
data-test="alert-library-card-enterprise"
/>
<!-- Locked cards open the upsell instead, so they offer nothing to act on here. -->
<div v-if="!locked" class="ms-auto flex shrink-0 items-center gap-2">
<!-- The editor prefill speaks the scheduled alert shape only. -->
<OButton
v-if="!isAnomaly"
variant="outline"
size="icon-sm"
icon-left="edit"
:aria-label="t('alert_library.drawer.customize')"
:aria-describedby="titleId"
:data-test="`alert-library-card-customize-${entry.id}`"
@click.stop="emit('customize')"
>
<OTooltip :content="t('alert_library.card.customizeTooltip')" />
</OButton>
<OButton
variant="outline"
size="sm"
icon-left="add"
:title="t('alert_library.drawer.install')"
:aria-describedby="titleId"
:data-test="`alert-library-card-add-${entry.id}`"
@click.stop="emit('install')"
>
{{ t("common.add") }}
</OButton>
</div>
</div>
</article>
</template>
@@ -155,9 +177,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed } from "vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { AlertLibraryEntry } from "@/types/alertLibrary";
import { useI18nTyped } from "@/types/i18n";
@@ -176,6 +200,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "open"): void;
(e: "update:selected", value: boolean): void;
(e: "install"): void;
(e: "customize"): void;
}>();
const { t } = useI18nTyped();
@@ -185,4 +211,6 @@ const severityText = computed(() => severityLabel(t, props.entry.severity));
// A query language, not prose — one correct form worldwide.
const queryTypeLabelText = computed(() => queryTypeLabel(props.entry.query_type));
const isAnomaly = computed(() => alertTypeOf(props.entry) === "anomaly");
// Every card repeats the same two button labels, so each is described by its own card's title.
const titleId = computed(() => `alert-library-card-title-id-${props.entry.id}`);
</script>
@@ -240,19 +240,16 @@ import { computed, nextTick, ref, watch } from "vue";
import AlertQueryPreview from "@/components/alerts/AlertQueryPreview.vue";
import { ANOMALY_DIRECTION_KEYS } from "@/components/anomaly_detection/steps/AnomalyDetectionConfig.schema";
import PreviewAlert from "@/components/alerts/PreviewAlert.vue";
import { useAlertCreation } from "@/composables/alerts/useAlertCreation";
import { useAlertLibrary } from "@/composables/alerts/useAlertLibrary";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import type { AlertLibraryEntry, AlertLibraryFile, StreamDataState } from "@/types/alertLibrary";
import { formatDistanceToNowStrict } from "date-fns";
import { raw, useI18nTyped, type I18nKey } from "@/types/i18n";
import { buildPrefillFromLibrary } from "@/utils/alerts/prefill/fromLibrary";
import {
alertTypeOf,
@@ -262,7 +259,8 @@ import {
severityBadgeValue,
severityLabel,
} from "./libraryFacets";
import { readTunables, applyTunables } from "./libraryTunables";
import { readTunables, applyTunables, fileForInstall } from "./libraryTunables";
import { useLibraryCustomize } from "./useLibraryCustomize";
const props = defineProps<{
open: boolean;
@@ -284,7 +282,7 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
const { loadAlertFile } = useAlertLibrary();
const { openAlertCreation } = useAlertCreation();
const { customizeInEditor } = useLibraryCustomize();
const file = ref<AlertLibraryFile | null>(null);
const isLoading = ref(false);
@@ -548,27 +546,13 @@ watch(
);
// ── actions ────────────────────────────────────────────────────────────────
const customize = () => {
const customize = async () => {
if (!props.entry || !file.value) return;
const opened = openAlertCreation(
buildPrefillFromLibrary({ entry: props.entry, file: file.value }),
);
if (!opened) {
toast({ variant: "error", message: t("alert_library.drawer.customizeFailed") });
return;
}
emit("update:open", false);
if (await customizeInEditor(props.entry, file.value)) emit("update:open", false);
};
const install = () => {
if (!props.entry || !file.value) return;
// The threshold floors would write a trigger_condition into a body that must not have one.
if (isAnomaly.value) {
emit("install", { entry: props.entry, file: file.value });
return;
}
if (!tunedFile.value) return;
emit("install", { entry: props.entry, file: tunedFile.value });
emit("install", { entry: props.entry, file: fileForInstall(props.entry, file.value) });
};
</script>
File diff suppressed because it is too large Load Diff
@@ -14,19 +14,7 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<!--
LibraryInstallDialog — the five answers an install needs, in the order the
answer stops being obvious: Destination -> Alerts -> Folder -> Tune -> Install.
Destination is optional: alerts installed without one evaluate and record their history.
The batch arrives as `seed` from one alert's drawer or `preselect` from the
gallery's selection. The seed wins.
Installs run SEQUENTIALLY and one failure never stops the rest — a duplicate
name in the middle of a batch of twelve must not cost the other eleven. Each
alert gets its own row and the failures get a retry.
-->
<!-- Every alert passed in is added, with no destination: the page already chose them. -->
<template>
<ODialog
:open="open"
@@ -51,164 +39,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OStep
:name="1"
:title="t('alert_library.install.stepDestination')"
icon="send"
:done="step > 1"
>
<div class="flex flex-col gap-3 pt-2" data-test="alert-library-install-destination-step">
<p class="text-text-secondary text-sm">
{{ t("alert_library.install.destinationIntro") }}
</p>
<OBanner
v-if="destinationsFailed"
variant="warning"
dense
icon="warning"
data-test="alert-library-install-destinations-failed"
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm">{{ t("alert_library.install.destinationsLoadFailed") }}</span>
<OButton
variant="outline"
size="sm"
icon-left="refresh"
:loading="isLoadingDestinations"
data-test="alert-library-install-destinations-retry"
@click="loadDestinations()"
>
{{ t("alert_library.install.retry") }}
</OButton>
</div>
</OBanner>
<!-- No destinations yet. Not an error: the alerts install without one. -->
<template v-if="!isLoadingDestinations && !destinationsFailed && !hasDestinations">
<OBanner
variant="info"
icon="info"
data-test="alert-library-install-destinations-empty"
>
<div class="flex flex-col gap-2">
<span class="text-sm">{{ t("alert_library.install.destinationsNoneBody") }}</span>
<div>
<OButton
variant="outline"
size="sm"
icon-left="open-in-new"
data-test="alert-library-install-open-destinations"
@click="openDestinationsPage"
>
{{ t("alert_library.install.openDestinations") }}
</OButton>
</div>
</div>
</OBanner>
</template>
<template v-else>
<OSelect
:model-value="chosenDestination"
:options="destinationOptions"
size="sm"
:label="t('alert_library.install.destinationLabel')"
:placeholder="t('alerts.summary.noDestination')"
clearable
data-test="alert-library-install-destination"
@update:model-value="onDestinationPicked"
/>
<div>
<OButton
variant="ghost"
size="sm"
icon-left="open-in-new"
data-test="alert-library-install-open-destinations"
@click="openDestinationsPage"
>
{{ t("alert_library.install.openDestinations") }}
</OButton>
</div>
</template>
</div>
</OStep>
<OStep
:name="2"
:title="t('alert_library.install.stepAlerts')"
icon="checklist"
:done="step > 2"
>
<div class="flex flex-col gap-3 pt-2" data-test="alert-library-install-alerts-step">
<div class="flex items-center justify-between gap-2">
<span
class="text-text-secondary text-sm"
data-test="alert-library-install-count"
:data-selected="selectedIds.length"
:data-total="candidates.length"
>
{{
t("alert_library.install.selectedCount", {
count: selectedIds.length,
total: candidates.length,
})
}}
</span>
<div class="flex items-center gap-1">
<OButton
variant="ghost"
size="sm"
data-test="alert-library-install-select-all"
@click="selectAll"
>
{{ t("alert_library.install.selectAll") }}
</OButton>
<OButton
variant="ghost"
size="sm"
data-test="alert-library-install-clear"
@click="clearSelection"
>
{{ t("alert_library.install.clear") }}
</OButton>
</div>
</div>
<ul
class="border-border-default rounded-default divide-border-default max-h-80 divide-y overflow-y-auto border"
>
<li
v-for="entry in candidates"
:key="entry.id"
class="flex items-center gap-3 px-3 py-2"
>
<OCheckbox
:model-value="isSelected(entry.id)"
size="sm"
:label="raw(entry.title)"
class="min-w-0 flex-1"
:data-test="`alert-library-install-alert-${entry.id}`"
@update:model-value="toggleEntry(entry.id, $event)"
/>
<span class="text-text-secondary hidden shrink-0 truncate text-xs sm:inline">{{
entry.id
}}</span>
<OTag
type="severity"
size="xs"
:value="severityBadgeValue(entry.severity)"
:label="severityLabel(t, entry.severity)"
class="shrink-0"
/>
</li>
</ul>
</div>
</OStep>
<OStep
:name="3"
:title="t('alert_library.install.stepFolder')"
icon="folder"
:done="step > 3"
:done="step > 1"
>
<div class="flex flex-col gap-3 pt-2" data-test="alert-library-install-folder-step">
<p class="text-text-secondary text-sm">{{ t("alert_library.install.folderIntro") }}</p>
@@ -230,10 +63,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Frequency and silence are scheduled-alert fields; an anomaly-only batch skips the step. -->
<OStep
v-if="batchHasScheduled"
:name="4"
:name="2"
:title="t('alert_library.install.stepTune')"
icon="tune"
:done="step > 4"
:done="step > 2"
>
<div class="flex flex-col gap-3 pt-2" data-test="alert-library-install-tune-step">
<p
@@ -283,20 +116,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</OStep>
<OStep :name="5" :title="t('alert_library.install.stepInstall')" icon="download">
<OStep :name="3" :title="t('alert_library.install.stepInstall')" icon="download">
<div class="flex flex-col gap-3 pt-2" data-test="alert-library-install-review-step">
<dl
class="border-border-default rounded-default grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 border p-3 text-sm"
data-test="alert-library-install-summary"
>
<dt class="text-text-secondary">{{ t("alert_library.install.stepDestination") }}</dt>
<dd class="text-text-heading">
{{ chosenDestination || t("common.none") }}
</dd>
<dt class="text-text-secondary">{{ t("alert_library.install.stepFolder") }}</dt>
<dd class="text-text-heading">{{ folderLabel }}</dd>
<dt class="text-text-secondary">{{ t("alert_library.install.stepAlerts") }}</dt>
<dd class="text-text-heading">{{ selectedIds.length }}</dd>
<dd class="text-text-heading">{{ candidates.length }}</dd>
<template v-if="batchHasScheduled">
<dt class="text-text-secondary">{{ t("alert_library.install.stepTune") }}</dt>
<dd class="text-text-heading">{{ tuningSummary }}</dd>
@@ -380,28 +209,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("alert_library.install.back") }}
</OButton>
<OButton
v-if="step < 5"
v-if="step < 3"
variant="primary"
size="sm-action"
:disabled="!canAdvance"
data-test="alert-library-install-next"
@click="goNext"
>
{{ t("alert_library.install.next") }}
</OButton>
<OButton
v-if="step === 5 && !hasRun"
v-if="step === 3 && !hasRun"
variant="primary"
size="sm-action"
:loading="isInstalling"
:disabled="selectedIds.length === 0 || (needsLargeBatchConfirm && !largeBatchConfirmed)"
:disabled="candidates.length === 0 || (needsLargeBatchConfirm && !largeBatchConfirmed)"
data-test="alert-library-install-run"
@click="install(selectedIds)"
@click="install(candidates.map((entry) => entry.id))"
>
{{ t("alert_library.install.run", { count: selectedIds.length }, selectedIds.length) }}
{{ t("alert_library.install.run", { count: candidates.length }, candidates.length) }}
</OButton>
<OButton
v-if="step === 5 && hasRun && failedIds.length > 0"
v-if="step === 3 && hasRun && failedIds.length > 0"
variant="primary"
size="sm-action"
:loading="isInstalling"
@@ -414,7 +242,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
}}
</OButton>
<OButton
v-if="step === 5 && hasRun"
v-if="step === 3 && hasRun"
:variant="failedIds.length > 0 ? 'outline' : 'primary'"
size="sm-action"
:disabled="isInstalling"
@@ -430,12 +258,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import SelectFolderDropDown from "@/components/common/sidebar/SelectFolderDropDown.vue";
import { useAlertLibrary, useAnomalyLibraryAccess } from "@/composables/alerts/useAlertLibrary";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
@@ -444,7 +270,6 @@ import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OStep from "@/lib/navigation/Stepper/OStep.vue";
import OStepper from "@/lib/navigation/Stepper/OStepper.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
@@ -452,28 +277,25 @@ import { saveAlertMutation } from "@/services/alerts.queries";
import { alertKeys } from "@/services/alerts.querykeys";
import { saveAnomalyConfigMutation } from "@/services/anomaly_detection.queries";
import { useMutation } from "@tanstack/vue-query";
import destinationService from "@/services/alert_destination";
import analytics from "@/services/product_analytics";
import type { AlertLibraryEntry, AlertLibraryFile } from "@/types/alertLibrary";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { getFoldersListByType } from "@/utils/commons";
import { alertTypeOf, severityBadgeValue, severityLabel } from "./libraryFacets";
import { alertTypeOf } from "./libraryFacets";
import {
buildAnomalyInstallPayload,
buildInstallPayload,
InstallPayloadError,
type InstallOverrides,
} from "./libraryInstall";
import { coerceTunable, readTunables, DEFAULT_TUNABLES } from "./libraryTunables";
import { coerceTunable, fileForInstall, readTunables, DEFAULT_TUNABLES } from "./libraryTunables";
const props = defineProps<{
open: boolean;
/** What the Alerts step offers — the gallery's current, filtered view. */
entries: AlertLibraryEntry[];
/** The drawer's alert and the file it already tuned. */
/** The drawer's or card's alert and the file it already tuned. */
seed: { entry: AlertLibraryEntry; file: AlertLibraryFile } | null;
/** The gallery's frozen batch — entries, not ids, since it may hold alerts `entries` excludes. */
/** The gallery's frozen batch — entries, not ids, since it may hold alerts the filters now hide. */
preselect: AlertLibraryEntry[];
}>();
@@ -495,7 +317,6 @@ const LARGE_BATCH = 50;
const { t } = useI18nTyped();
const store = useStore();
const router = useRouter();
const { loadAlertFile } = useAlertLibrary();
const anomalyAccess = useAnomalyLibraryAccess();
@@ -514,14 +335,7 @@ interface InstallResult {
const step = ref(1);
// ── destination ────────────────────────────────────────────────────────────
const existingDestinations = ref<string[]>([]);
const destinationsFailed = ref(false);
const chosenDestination = ref("");
const isLoadingDestinations = ref(false);
// ── selection, folder, tuning ──────────────────────────────────────────────
const selectedIds = ref<string[]>([]);
// ── folder, tuning ─────────────────────────────────────────────────────────
const folderId = ref("default");
const folderName = ref("");
const foldersLoading = ref(false);
@@ -531,43 +345,23 @@ const tuneFrequency = ref(DEFAULT_TUNABLES.frequency);
const tuneSilence = ref(DEFAULT_TUNABLES.silence);
// ── run ────────────────────────────────────────────────────────────────────
/**
* The alert set this run reports on, frozen when it starts.
*
* `candidates` derives from `props.entries`, which the gallery recomputes as
* its async stream load settles. Rebuilding the result rows from it mid-run
* would drop rows the user has not read yet.
*/
// Frozen at run start: the gallery can prune `preselect` mid-run, which would drop unread result rows.
const roster = ref<AlertLibraryEntry[]>([]);
const results = ref<InstallResult[]>([]);
const isInstalling = ref(false);
const hasRun = ref(false);
const anomalyInstalled = ref(false);
// Locked anomaly cards reach `entries` too, since the gallery shows them; this keeps them out of the batch.
// A locked anomaly alert stays out of the batch even if a caller hands one in.
const canInstallKind = (entry: AlertLibraryEntry): boolean =>
alertTypeOf(entry) !== "anomaly" || anomalyAccess.value === "enabled";
const minutesSuffix = computed(() => t("alert_library.install.minutes"));
/**
* What the Alerts step offers, and the only list install ever walks.
*
* `entries` is the gallery's FILTERED view, so the alert the drawer was opened
* on may not be in it — the user can narrow the filters after opening the
* drawer. Dropping the seed there would install nothing while showing a
* selected count of one.
*/
/** Every alert this open adds, and the only list install ever walks. */
const candidates = computed<AlertLibraryEntry[]>(() => {
const seedEntry = props.seed?.entry;
const visible = new Set(props.entries.map((entry) => entry.id));
// The batch outlives the filters, so dropping these would count alerts the list cannot offer.
const offView = props.preselect.filter((entry) => !visible.has(entry.id));
// Seed, then the batch the filters hide, then the visible set; the dedupe drops the repeat.
const offered = [...(seedEntry ? [seedEntry] : []), ...offView, ...props.entries];
// Deduped by id: the install walks this list, so a manifest that repeats an
// entry would create that alert twice.
const offered = props.seed ? [props.seed.entry] : props.preselect;
// Deduped by id: a repeated entry would otherwise be created twice.
const seen = new Set<string>();
return offered.filter((entry) => {
if (!canInstallKind(entry)) return false;
@@ -577,30 +371,13 @@ const candidates = computed<AlertLibraryEntry[]>(() => {
});
});
// Anomaly access can turn off while the dialog is open; a selection it no longer offers must not be counted.
watch(candidates, (offered) => {
const eligible = new Set(offered.map((entry) => entry.id));
if (selectedIds.value.some((id) => !eligible.has(id))) {
selectedIds.value = selectedIds.value.filter((id) => eligible.has(id));
}
});
const selectedEntries = computed(() =>
candidates.value.filter((entry) => selectedIds.value.includes(entry.id)),
);
const batchHasScheduled = computed(() =>
selectedEntries.value.some((entry) => alertTypeOf(entry) === "scheduled"),
candidates.value.some((entry) => alertTypeOf(entry) === "scheduled"),
);
const batchHasAnomaly = computed(() =>
selectedEntries.value.some((entry) => alertTypeOf(entry) === "anomaly"),
candidates.value.some((entry) => alertTypeOf(entry) === "anomaly"),
);
const destinationOptions = computed(() =>
existingDestinations.value.map((name) => ({ label: raw(name), value: name })),
);
const hasDestinations = computed(() => existingDestinations.value.length > 0);
const folderLabel = computed<I18nText>(() => raw(folderName.value || folderId.value));
const tuningSummary = computed<I18nText>(() =>
@@ -612,24 +389,17 @@ const tuningSummary = computed<I18nText>(() =>
: t("alert_library.install.summaryTuningNone"),
);
const needsLargeBatchConfirm = computed(() => selectedIds.value.length > LARGE_BATCH);
const needsLargeBatchConfirm = computed(() => candidates.value.length > LARGE_BATCH);
const largeBatchPrompt = computed(() =>
chosenDestination.value
? t("alert_library.install.confirmLarge", {
count: selectedIds.value.length,
folder: folderLabel.value,
destination: chosenDestination.value,
})
: t("alert_library.install.confirmLargeNoDestination", {
count: selectedIds.value.length,
folder: folderLabel.value,
}),
t("alert_library.install.confirmLargeNoDestination", {
count: candidates.value.length,
folder: folderLabel.value,
}),
);
// The tick is a statement about THIS batch, folder and destination; any of the three changing
// makes it an acknowledgement of a run that is no longer the one about to happen.
watch([selectedIds, folderId, chosenDestination], () => {
// The tick acknowledges THIS batch in THIS folder; a change to either re-arms it.
watch([candidates, folderId], () => {
largeBatchConfirmed.value = false;
});
@@ -637,20 +407,11 @@ const failedIds = computed(() =>
results.value.filter((result) => result.status === "failed").map((result) => result.id),
);
const canAdvance = computed(() => {
if (step.value === 2) return selectedIds.value.length > 0;
return true;
});
// ── lifecycle ──────────────────────────────────────────────────────────────
const reset = () => {
sessionToken += 1;
step.value = 1;
isLoadingDestinations.value = false;
foldersLoading.value = false;
existingDestinations.value = [];
destinationsFailed.value = false;
chosenDestination.value = "";
folderId.value = "default";
folderName.value = "";
tuneEnabled.value = false;
@@ -659,9 +420,6 @@ const reset = () => {
hasRun.value = false;
isInstalling.value = false;
anomalyInstalled.value = false;
const opened = props.seed ? [props.seed.entry] : props.preselect;
selectedIds.value = opened.filter(canInstallKind).map((entry) => entry.id);
largeBatchConfirmed.value = false;
// Seed the bulk pair from the alert the user was just looking at, so the
// fields open on values that mean something rather than on a global default.
@@ -670,65 +428,11 @@ const reset = () => {
tuneSilence.value = tunables.silence;
};
/**
* Only the newest open may write the destination state.
*
* Close-and-reopen is fast, the list request is not, and a reply from the
* previous open landing after `reset()` would repopulate the picker — or flip
* it into create mode — behind the current one. Same guard the drawer uses.
*/
let destinationsToken = 0;
/**
* Bumped by every `reset()`. Anything that awaits must re-check it afterwards:
* a create started before a close-and-reopen would otherwise finish against the
* NEW session, writing a destination name the user never chose.
*/
// Bumped by reset(), so a folder fetch from a previous open cannot clear this open's loading state.
let sessionToken = 0;
const loadDestinations = async () => {
const token = ++destinationsToken;
isLoadingDestinations.value = true;
let names: string[] = [];
let failed = false;
try {
const response = await destinationService.list({
org_identifier: orgIdentifier(),
page_num: 1,
page_size: 100000,
sort_by: "name",
desc: false,
module: "alert",
});
const list = Array.isArray(response?.data) ? response.data : [];
names = list
.map((destination: { name?: string }) => destination?.name)
.filter((name: unknown): name is string => typeof name === "string" && name !== "");
} catch {
failed = true;
}
if (token !== destinationsToken) return;
destinationsFailed.value = failed;
existingDestinations.value = names;
isLoadingDestinations.value = false;
};
watch(
() => props.open,
(open) => {
if (!open) return;
reset();
void loadDestinations();
},
{ immediate: true },
);
// The folder picker reads the store and only refreshes itself on `onActivated`,
// which a dialog never fires. Fetch when the step is reached.
watch(step, (value) => {
if (value !== 3) return;
// The folder picker refreshes itself only on `onActivated`, which a dialog never fires.
const loadFolders = () => {
const token = sessionToken;
foldersLoading.value = true;
void getFoldersListByType(store, "alerts")
@@ -738,18 +442,23 @@ watch(step, (value) => {
.finally(() => {
if (token === sessionToken) foldersLoading.value = false;
});
});
};
watch(
() => props.open,
(open) => {
if (!open) return;
reset();
loadFolders();
},
{ immediate: true },
);
const onOpenChange = (value: boolean) => {
if (!value && isInstalling.value) return;
emit("update:open", value);
};
// ── destination step ───────────────────────────────────────────────────────
const onDestinationPicked = (value: unknown) => {
chosenDestination.value = typeof value === "string" ? value : "";
};
const errorText = (error: unknown): I18nText => {
// The builder refuses a file it cannot read by CODE, because it has no `t`.
// Resolving it here is what keeps that refusal translatable instead of
@@ -767,42 +476,13 @@ const errorText = (error: unknown): I18nText => {
);
};
const openDestinationsPage = () => {
const url = router.resolve({
name: "alertDestinations",
query: {
action: "add",
org_identifier: orgIdentifier(),
},
}).href;
window.open(url, "_blank", "noopener");
};
// Tune exists only for scheduled alerts, so an anomaly-only batch steps over it.
const goNext = () => {
step.value = step.value === 3 && !batchHasScheduled.value ? 5 : step.value + 1;
step.value = step.value === 1 && !batchHasScheduled.value ? 3 : step.value + 1;
};
const goBack = () => {
step.value = step.value === 5 && !batchHasScheduled.value ? 3 : step.value - 1;
};
// ── alerts step ────────────────────────────────────────────────────────────
const isSelected = (id: string) => selectedIds.value.includes(id);
const toggleEntry = (id: string, checked: unknown) => {
if (checked === true) {
if (!isSelected(id)) selectedIds.value = [...selectedIds.value, id];
} else {
selectedIds.value = selectedIds.value.filter((selected) => selected !== id);
}
};
const selectAll = () => {
selectedIds.value = candidates.value.map((entry) => entry.id);
};
const clearSelection = () => {
selectedIds.value = [];
step.value = step.value === 3 && !batchHasScheduled.value ? 1 : step.value - 1;
};
// ── folder step ────────────────────────────────────────────────────────────
@@ -849,7 +529,9 @@ const installOne = async (
overrides: InstallOverrides | undefined,
): Promise<boolean> => {
const file =
props.seed && props.seed.entry.id === entry.id ? props.seed.file : await loadAlertFile(entry);
props.seed && props.seed.entry.id === entry.id
? props.seed.file
: fileForInstall(entry, await loadAlertFile(entry));
// The file is checked too: on OSS an anomaly body is stored as a broken scheduled alert.
const isAnomaly = alertTypeOf(entry) === "anomaly" || file.alert_type === "anomaly_detection";
@@ -859,7 +541,6 @@ const installOne = async (
entry,
file,
folderId: folderId.value,
destination: chosenDestination.value,
});
await installAnomaly.mutateAsync({ payload, folderId: folderId.value });
return true;
@@ -869,7 +550,6 @@ const installOne = async (
entry,
file,
folderId: folderId.value,
destination: chosenDestination.value,
owner: store.state.userInfo?.email ?? "",
timezone: store.state.timezone,
overrides,
@@ -883,7 +563,7 @@ const install = async (ids: string[]) => {
// Freeze the roster on the FIRST run; a retry reports on the same set.
if (!hasRun.value) {
roster.value = candidates.value.filter((entry) => selectedIds.value.includes(entry.id));
roster.value = [...candidates.value];
}
const batch = roster.value.filter((entry) => ids.includes(entry.id));
@@ -17,17 +17,17 @@ import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import LibraryRail from "./LibraryRail.vue";
import type { LibraryFacet } from "./libraryFacets";
import type { LibraryCategoryFacet, LibraryFacet } from "./libraryFacets";
import i18n from "@/locales";
import { raw } from "@/types/i18n";
const categories: LibraryFacet[] = [
{ id: "kafka", label: raw("Kafka"), count: 12 },
{ id: "clickhouse", label: raw("Clickhouse"), count: 23 },
const categories: LibraryCategoryFacet[] = [
{ id: "kafka", label: raw("Kafka"), count: 12, pack: "message-brokers" },
{ id: "clickhouse", label: raw("Clickhouse"), count: 23, pack: "databases" },
// id-vs-label discriminator: "cert-m" is in the id, not in "Cert Manager".
{ id: "cert-manager", label: raw("Cert Manager"), count: 8 },
{ id: "cert-manager", label: raw("Cert Manager"), count: 8, pack: "k8s" },
// The dead end.
{ id: "retired", label: raw("Retired"), count: 0 },
{ id: "retired", label: raw("Retired"), count: 0, pack: "databases" },
];
const severities: LibraryFacet[] = [
@@ -91,6 +91,63 @@ describe("LibraryRail", () => {
expect(wrapper.find('[data-test="alert-library-rail-count-kafka"]').text()).toBe("12");
});
// ── pack headings ────────────────────────────────────────────────────────
it("lists each technology under its pack heading, packs in label order", () => {
const wrapper = mountRail();
const headings = wrapper.findAll('[data-test^="alert-library-rail-pack-heading-"]');
expect(headings.map((heading) => heading.text())).toEqual([
"Databases",
"Kubernetes",
"Message brokers",
]);
const group = wrapper.find('[data-test="alert-library-rail-pack-message-brokers"]');
expect(group.find('[data-test="alert-library-rail-category-kafka"]').exists()).toBe(true);
expect(group.find('[data-test="alert-library-rail-category-clickhouse"]').exists()).toBe(false);
// The group is named by its heading for assistive tech.
expect(group.attributes("role")).toBe("group");
expect(group.attributes("aria-labelledby")).toBe(
"alert-library-rail-pack-heading-message-brokers",
);
});
it("makes the pack heading a label, not a select-all", async () => {
const wrapper = mountRail();
const heading = wrapper.find('[data-test="alert-library-rail-pack-heading-databases"]');
expect(heading.element.tagName).toBe("H3");
expect(heading.find("button, [role='checkbox']").exists()).toBe(false);
await heading.trigger("click");
expect(wrapper.emitted("update:selectedCategories")).toBeUndefined();
});
it("drops a pack heading once none of its technologies are listed", async () => {
const wrapper = mountRail();
await type(wrapper, "kafka");
expect(wrapper.find('[data-test="alert-library-rail-pack-heading-databases"]').exists()).toBe(
false,
);
expect(
wrapper.find('[data-test="alert-library-rail-pack-heading-message-brokers"]').exists(),
).toBe(true);
});
it("shows every technology of a pack whose name matches the search", async () => {
const wrapper = mountRail();
await type(wrapper, "message");
expect(wrapper.find('[data-test="alert-library-rail-category-kafka"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-library-rail-category-clickhouse"]').exists()).toBe(
false,
);
});
it("still hides a dead-end row when its pack name matches", async () => {
const wrapper = mountRail();
await type(wrapper, "databases");
expect(wrapper.find('[data-test="alert-library-rail-category-clickhouse"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-test="alert-library-rail-category-retired"]').exists()).toBe(false);
});
// ── severity ─────────────────────────────────────────────────────────────
it("puts severity above the category list, outside the scrolling area", () => {
// Below the list it collided with the rows and scrolled out of reach after
+70 -41
View File
@@ -20,8 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ONE list, no axis switcher. Packs used to be a second axis, but a pack is a
coarse bucket ("databases", "observability") and the category is the thing
people actually come looking for — Kafka, Clickhouse, Cilium. Two axes over
the same catalogue meant a tab to reach the useful one; the pack still names
itself in the gallery's group headings, which is where it earns its keep.
the same catalogue meant a tab to reach the useful one.
Categories are MULTI-SELECT on the metrics-explorer rail pattern: a search
box over the list, tick boxes with counts, and a "N selected / Clear" footer.
@@ -107,7 +106,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="min-h-0 flex-1 overflow-y-auto pb-3">
<OEmptyState
v-if="visibleItems.length === 0"
v-if="visibleGroups.length === 0"
size="inline"
class="px-2"
illustration="no-results"
@@ -117,33 +116,51 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<div
v-for="item in visibleItems"
:key="item.id"
class="rounded-default hover:bg-surface-subtle flex items-center justify-between gap-2 px-2 py-1"
:class="selectedCategories.includes(item.id) ? 'bg-surface-subtle' : ''"
v-for="group in visibleGroups"
:key="group.pack"
role="group"
:aria-labelledby="group.label ? `alert-library-rail-pack-heading-${group.pack}` : undefined"
class="flex flex-col pt-2 first:pt-0"
:data-test="`alert-library-rail-pack-${group.pack}`"
>
<OCheckbox
size="sm"
class="min-w-0 flex-1"
:model-value="selectedCategories.includes(item.id)"
:aria-label="t('alert_library.facetAria', { label: item.label, count: item.count })"
:data-active="String(selectedCategories.includes(item.id))"
:data-test="`alert-library-rail-category-${item.id}`"
@update:model-value="toggleCategory(item.id)"
<!-- A label only: a select-all here would tick technologies the user never looked at. -->
<h3
v-if="group.label"
:id="`alert-library-rail-pack-heading-${group.pack}`"
class="text-text-secondary text-2xs px-2 pb-0.5 font-semibold"
:data-test="`alert-library-rail-pack-heading-${group.pack}`"
>
<template #label>
<OTruncatedText class="block text-xs">{{ item.label }}</OTruncatedText>
</template>
</OCheckbox>
{{ group.label }}
</h3>
<div
v-for="item in group.items"
:key="item.id"
class="rounded-default hover:bg-surface-subtle flex items-center justify-between gap-2 px-2 py-1"
:class="selectedCategories.includes(item.id) ? 'bg-surface-subtle' : ''"
>
<OCheckbox
size="sm"
class="min-w-0 flex-1"
:model-value="selectedCategories.includes(item.id)"
:aria-label="t('alert_library.facetAria', { label: item.label, count: item.count })"
:data-active="String(selectedCategories.includes(item.id))"
:data-test="`alert-library-rail-category-${item.id}`"
@update:model-value="toggleCategory(item.id)"
>
<template #label>
<OTruncatedText class="block text-xs">{{ item.label }}</OTruncatedText>
</template>
</OCheckbox>
<OTag
type="countChip"
value="neutral"
size="xs"
shape="rounded"
:data-test="`alert-library-rail-count-${item.id}`"
>{{ item.count }}</OTag
>
<OTag
type="countChip"
value="neutral"
size="xs"
shape="rounded"
:data-test="`alert-library-rail-count-${item.id}`"
>{{ item.count }}</OTag
>
</div>
</div>
</div>
</nav>
@@ -163,10 +180,10 @@ import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { useI18nTyped } from "@/types/i18n";
import type { LibraryFacet } from "./libraryFacets";
import { packLabel, type LibraryCategoryFacet, type LibraryFacet } from "./libraryFacets";
const props = defineProps<{
categories: LibraryFacet[];
categories: LibraryCategoryFacet[];
/** Empty means every category — the rail opens unfiltered. */
selectedCategories: string[];
/** Includes the "all" pseudo-facet, built by the caller. */
@@ -195,19 +212,31 @@ const { t } = useI18nTyped();
* A zero-count row is a dead end and is dropped — except when it is selected,
* since hiding it would strand the user with a filter they cannot lift.
*/
const visibleItems = computed(() => {
const isVisible = (item: LibraryCategoryFacet, needle: string): boolean => {
// A ticked row is never hidden, or its filter would stay on with no control to lift it.
if (props.selectedCategories.includes(item.id)) return true;
// A zero-count row filters to nothing, so offering it wastes a click.
if (item.count === 0) return false;
if (!needle) return true;
// The pack heading is on screen too, so a query naming a pack keeps all of its rows.
return [item.label, packLabel(item.pack)].some((text) =>
String(text).toLowerCase().includes(needle),
);
};
/** Pack headings in label order; technologies keep the order the caller gave them. */
const visibleGroups = computed(() => {
const needle = props.search.trim().toLowerCase();
return props.categories.filter((item) => {
// A ticked row is never hidden — not by the search, not by a zero count.
// Both used to remove it: typing "redis" while "kafka" was ticked took the
// kafka row off screen while the grid stayed filtered to kafka, leaving a
// filter you could see the effect of but not the control for.
if (props.selectedCategories.includes(item.id)) return true;
if (needle && !String(item.label).toLowerCase().includes(needle)) return false;
// A zero-count row filters to nothing, so offering it wastes a click.
if (item.count === 0) return false;
return true;
});
const groups = new Map<string, LibraryCategoryFacet[]>();
for (const item of props.categories) {
if (!isVisible(item, needle)) continue;
const bucket = groups.get(item.pack);
if (bucket) bucket.push(item);
else groups.set(item.pack, [item]);
}
return [...groups.entries()]
.map(([pack, items]) => ({ pack, label: packLabel(pack), items }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)));
});
const toggleCategory = (id: string) => {
@@ -28,6 +28,11 @@ export interface LibraryFacet {
count: number;
}
/** A category rail entry, listed under the pack heading it belongs to. */
export interface LibraryCategoryFacet extends LibraryFacet {
pack: string;
}
/** The rail's alert-type facet; the manifest's `scheduled` reads as Threshold. */
export type LibraryAlertType = "scheduled" | "anomaly";
@@ -62,7 +62,6 @@ const build = (over: Record<string, unknown> = {}) =>
entry: entry(),
file: file(),
folderId: "folder-7",
destination: "ops-slack",
owner: "someone@example.com",
timezone: "Asia/Kolkata",
...over,
@@ -70,24 +69,20 @@ const build = (over: Record<string, unknown> = {}) =>
describe("buildInstallPayload", () => {
describe("library-specific steps", () => {
it("overwrites the library's destinations — the author's never exist in this org", () => {
it("clears the library's destinations — the author's never exist in this org", () => {
// "k8s_alert" is hardcoded in every k8s pack file and would be rejected.
expect(build().destinations).toEqual(["ops-slack"]);
expect(build().destinations).toEqual([]);
});
it("overwrites even when the file names several destinations", () => {
it("clears them even when the file names several destinations", () => {
const payload = build({ file: file({ destinations: ["k8s_alert", "o2_to_slack"] }) });
expect(payload.destinations).toEqual(["ops-slack"]);
expect(payload.destinations).toEqual([]);
});
it("gives a destination to a file that carries none", () => {
it("sends an empty list for a file that carries none, never omitting the field", () => {
const bare = file();
delete bare.destinations;
expect(build({ file: bare }).destinations).toEqual(["ops-slack"]);
});
it("installs with no destination when none is chosen, never the author's", () => {
expect(build({ destination: "" }).destinations).toEqual([]);
expect(build({ file: bare }).destinations).toEqual([]);
});
it("stamps the library id and content hash without dropping the file's own attributes", () => {
@@ -555,7 +550,6 @@ describe("buildAnomalyInstallPayload", () => {
entry: anomalyEntry(),
file: anomalyFile(),
folderId: "folder-7",
destination: "ops-slack",
...over,
});
@@ -582,9 +576,10 @@ describe("buildAnomalyInstallPayload", () => {
expect(buildAnomaly({ file: unnamed }).name).toBe("Server error ratio is unusually high");
});
it("sends the chosen destination, or none", () => {
expect(buildAnomaly().destinations).toEqual(["ops-slack"]);
expect(buildAnomaly({ destination: "" }).destinations).toEqual([]);
it("sends no destination, whatever the file names", () => {
expect(
buildAnomaly({ file: { ...anomalyFile(), destinations: ["o2_to_slack"] } }).destinations,
).toEqual([]);
});
it("files the alert in the chosen folder, matching ?folder=", () => {
@@ -23,9 +23,7 @@
// 1. The same normalization ImportAlert does (`createAlert`, ImportAlert.vue)
// — that is the only place in the app that has ever turned a stored alert
// document back into a create, and divergence here is a bug by definition.
// 2. Three things only the library needs: the org's destination (or none)
// replaces the author's, provenance is stamped so Phase 5 can recognise the alert
// later, and the library's severity becomes the product's priority.
// 2. Library-only: drop the author's destinations, stamp provenance, map severity to priority.
//
// Pure and file-in/payload-out, so the wizard stays markup and this stays
// tested — same split as libraryTunables.ts.
@@ -55,8 +53,6 @@ export interface InstallPayloadInput {
/** The tuned file, straight from the drawer, or freshly fetched. */
file: AlertLibraryFile;
folderId: string;
/** A destination NAME that exists in this org, or "" to install with none. */
destination: string;
/** Installing user's email — becomes owner and last editor. */
owner: string;
/** Org timezone, used only when the file carries none. */
@@ -68,8 +64,6 @@ export interface AnomalyInstallPayloadInput {
entry: AlertLibraryEntry;
file: AlertLibraryFile;
folderId: string;
/** A destination NAME that exists in this org, or "" to install with none. */
destination: string;
}
const asRecord = (value: unknown): Record<string, unknown> =>
@@ -162,7 +156,7 @@ const withPackTag = (tags: unknown, pack: string): unknown[] => {
};
export function buildInstallPayload(input: InstallPayloadInput): Record<string, unknown> {
const { entry, file, folderId, destination, owner, timezone, overrides } = input;
const { entry, file, folderId, owner, timezone, overrides } = input;
// A library file is JSON by construction, so a JSON round-trip is a complete
// clone. The drawer keeps rendering the object it handed us, and install
@@ -191,9 +185,8 @@ export function buildInstallPayload(input: InstallPayloadInput): Record<string,
}
// ── library-specific ─────────────────────────────────────────────────────
// Overwrite, never merge: the packs hardcode "k8s_alert"/"o2_to_slack",
// which no customer org has, and the alert API rejects an unknown name.
payload.destinations = destination ? [destination] : [];
// Cleared: the packs hardcode "k8s_alert"/"o2_to_slack", which the alert API rejects here.
payload.destinations = [];
// Phase 5 input, recorded here because this is where the promise is made:
// `context_attributes` is USER-EDITABLE — the alert form renders it as
@@ -224,7 +217,7 @@ export function buildInstallPayload(input: InstallPayloadInput): Record<string,
export function buildAnomalyInstallPayload(
input: AnomalyInstallPayloadInput,
): Record<string, unknown> {
const { entry, file, folderId, destination } = input;
const { entry, file, folderId } = input;
const payload = JSON.parse(JSON.stringify(file)) as Record<string, unknown>;
if (typeof payload.name !== "string" || payload.name === "") {
@@ -234,7 +227,7 @@ export function buildAnomalyInstallPayload(
// The backend routes on this field; without it the body becomes a scheduled alert.
payload.alert_type = "anomaly_detection";
payload.destinations = destination ? [destination] : [];
payload.destinations = [];
// The backend 400s when this disagrees with ?folder=.
payload.folder_id = folderId;
// Absent means disabled, and a disabled config never trains.
@@ -15,8 +15,8 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_TUNABLES, coerceTunable, readTunables } from "./libraryTunables";
import type { AlertLibraryFile } from "@/types/alertLibrary";
import { DEFAULT_TUNABLES, coerceTunable, fileForInstall, readTunables } from "./libraryTunables";
import type { AlertLibraryEntry, AlertLibraryFile } from "@/types/alertLibrary";
// Trimmed from the real library files (verified against the live bucket).
const promqlFile = (): AlertLibraryFile => ({
@@ -154,3 +154,20 @@ describe("coerceTunable", () => {
expect(tunables.silence).toBe(0);
});
});
describe("fileForInstall", () => {
const entry = { id: "k8s/x", alert_type: "scheduled" } as AlertLibraryEntry;
it("floors a scheduled file so it cannot be added as an alert that never fires", () => {
const zeroed = promqlFile();
(zeroed.trigger_condition as Record<string, unknown>).period = 0;
const tuned = fileForInstall(entry, zeroed);
expect((tuned.trigger_condition as Record<string, unknown>).period).toBeGreaterThan(0);
});
it("hands an anomaly body over untouched, so it never gains a trigger_condition", () => {
const anomaly = { alert_type: "anomaly_detection", anomaly_config: {} } as AlertLibraryFile;
expect(fileForInstall({ ...entry, alert_type: "anomaly_detection" }, anomaly)).toBe(anomaly);
expect(fileForInstall(entry, anomaly)).toBe(anomaly);
});
});
@@ -31,7 +31,9 @@
// Pure and file-in/file-out, so the install wizard stays markup and this stays
// tested.
import type { AlertLibraryFile } from "@/types/alertLibrary";
import type { AlertLibraryEntry, AlertLibraryFile } from "@/types/alertLibrary";
import { alertTypeOf } from "./libraryFacets";
/** The structured knobs the install wizard edits, per alert. */
export interface LibraryTunables {
@@ -174,3 +176,10 @@ export function applyTunables(file: AlertLibraryFile, tunables: LibraryTunables)
return next;
}
/** The file an add receives: floored when scheduled, untouched when it is an anomaly body. */
export function fileForInstall(entry: AlertLibraryEntry, file: AlertLibraryFile): AlertLibraryFile {
// The threshold floors would write a trigger_condition into a body that must not have one.
const isAnomaly = alertTypeOf(entry) === "anomaly" || file.alert_type === "anomaly_detection";
return isAnomaly ? file : applyTunables(file, readTunables(file));
}
@@ -0,0 +1,49 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useAlertCreation } from "@/composables/alerts/useAlertCreation";
import { useAlertLibrary } from "@/composables/alerts/useAlertLibrary";
import { toast } from "@/lib/feedback/Toast/useToast";
import type { AlertLibraryEntry, AlertLibraryFile } from "@/types/alertLibrary";
import { useI18nTyped } from "@/types/i18n";
import { buildPrefillFromLibrary } from "@/utils/alerts/prefill/fromLibrary";
import { fileForInstall } from "./libraryTunables";
/** "Customize in editor" for a scheduled library alert, shared by the drawer and the card. */
export const useLibraryCustomize = () => {
const { t } = useI18nTyped();
const { loadAlertFile } = useAlertLibrary();
const { openAlertCreation } = useAlertCreation();
/** Resolves false, after telling the user, when the editor could not be opened. */
const customizeInEditor = async (
entry: AlertLibraryEntry,
file?: AlertLibraryFile | null,
): Promise<boolean> => {
const source = file ?? (await loadAlertFile(entry).catch(() => null));
// The prefill speaks the scheduled shape only; an anomaly body would open a broken form.
const prefillable = source !== null && source.alert_type !== "anomaly_detection";
if (
prefillable &&
openAlertCreation(buildPrefillFromLibrary({ entry, file: fileForInstall(entry, source) }))
) {
return true;
}
toast({ variant: "error", message: t("alert_library.drawer.customizeFailed") });
return false;
};
return { customizeInEditor };
};
@@ -25,7 +25,7 @@
// assertions are about the FORM, not the library.
import { flushPromises, mount } from "@vue/test-utils";
import { describe, it, expect, vi, afterEach } from "vitest";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import AddAlert from "@/components/alerts/AddAlert.vue";
import store from "@/test/unit/helpers/store";
import i18n from "@/locales";
@@ -34,15 +34,18 @@ import { normalizePrefill } from "@/utils/alerts/alertPrefill";
import { ALERT_PREFILL_KEY } from "@/utils/alerts/alertPrefillStorage";
import type { AlertLibraryEntry, AlertLibraryFile } from "@/types/alertLibrary";
const streamSchema = {
schema: [
{ name: "event_count", type: "int" },
{ name: "k8s_cluster", type: "string" },
{ name: "value", type: "float" },
],
};
const getStreamMock = vi.hoisted(() => vi.fn());
vi.mock("@/composables/useStreams", () => ({
default: () => ({
getStream: vi.fn().mockResolvedValue({
schema: [
{ name: "event_count", type: "int" },
{ name: "k8s_cluster", type: "string" },
{ name: "value", type: "float" },
],
}),
getStream: getStreamMock,
getStreams: vi
.fn()
.mockResolvedValue({ list: [{ name: "k8s_events" }, { name: "eth_rpc_status" }] }),
@@ -249,6 +252,10 @@ const previewConfig = (wrapper: any) =>
describe("AddAlert — a prefill seeds the form", () => {
let wrapper: any;
beforeEach(() => {
getStreamMock.mockResolvedValue(streamSchema);
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
@@ -256,6 +263,19 @@ describe("AddAlert — a prefill seeds the form", () => {
vi.clearAllMocks();
});
describe("a source whose stream this org has not ingested yet", () => {
it("still applies the name, query and trigger when the stream lookup fails", async () => {
getStreamMock.mockRejectedValue(new Error("stream not found"));
wrapper = await openWithPrefill(sqlEntry(), sqlFile());
const data = wrapper.vm.formData;
expect(data.name).toBe("high_warning_event_rate");
expect(data.stream_name).toBe("k8s_events");
expect(data.query_condition.sql).toBe(sqlFile().query_condition!.sql);
expect(data.trigger_condition.silence).toBe(30);
});
});
describe("a SQL alert whose source carries a real trigger", () => {
it("adopts the source's trigger rather than the form's defaults", async () => {
wrapper = await openWithPrefill(sqlEntry(), sqlFile());
@@ -324,6 +324,35 @@ describe("AddDestination - custom path schema gating + payload parity", () => {
});
});
describe("AddDestination - refused save", () => {
beforeEach(() => vi.clearAllMocks());
it.each([
["create", undefined],
["update", { name: "dest1", url: "https://example.com/webhook", method: "post", type: "http" }],
])("closes its loading toast when a %s is refused with a 403", async (op, destination) => {
const dismissLoading = vi.fn();
vi.mocked(toast).mockReturnValueOnce(dismissLoading);
const service = destinationService as unknown as Record<string, ReturnType<typeof vi.fn>>;
service[op].mockRejectedValueOnce({ response: { status: 403 } });
wrapper = mountComp({ isAlerts: true, ...(destination ? { destination } : {}) });
await flushPromises();
const form = destination ? getForm(wrapper) : await toCustomHttp(wrapper);
form.setFieldValue("name", "dest1");
form.setFieldValue("url", "https://example.com/webhook");
form.setFieldValue("method", "post");
form.setFieldValue("template", "tmpl1");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(service[op]).toHaveBeenCalledTimes(1);
expect(dismissLoading).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast).mock.calls.map((c) => c[0].variant)).toEqual(["loading"]);
});
});
describe("AddDestination - pipeline (!isAlerts) branch", () => {
beforeEach(() => vi.clearAllMocks());
+2 -2
View File
@@ -1497,10 +1497,10 @@ function saveCustomDestination(value: AddDestinationForm) {
});
})
.catch((err: any) => {
dismiss();
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
@@ -1522,10 +1522,10 @@ function saveCustomDestination(value: AddDestinationForm) {
});
})
.catch((err: any) => {
dismiss();
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
@@ -462,6 +462,29 @@ describe("AddTemplate - http JSON-validity toast (Rule ④ side-effect)", () =>
});
});
describe("AddTemplate - refused save", () => {
beforeEach(() => vi.clearAllMocks());
it("closes its loading toast when the save is refused with a 403", async () => {
const dismissLoading = vi.fn();
vi.mocked(toast).mockReturnValueOnce(dismissLoading);
vi.mocked(templateService.create).mockRejectedValueOnce({ response: { status: 403 } });
const w = await mountComp();
await selectCustomMode(w);
const form = getForm(w);
form.setFieldValue("name", "email-template");
form.setFieldValue("type", "email");
form.setFieldValue("title", "Subject");
form.setFieldValue("body", "plain text");
await submit(w);
expect(templateService.create).toHaveBeenCalledTimes(1);
expect(dismissLoading).toHaveBeenCalledTimes(1);
expect(toast).toHaveBeenCalledTimes(1);
});
});
describe("AddTemplate - cancel", () => {
beforeEach(() => vi.clearAllMocks());
+1 -1
View File
@@ -837,10 +837,10 @@ async function saveTemplate(value: AddTemplateForm) {
};
const onError = (err: any) => {
dismiss();
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
+266
View File
@@ -800,6 +800,107 @@ describe("AlertList - row actions", () => {
expect(alertsDB.length).toBeGreaterThan(6);
});
it("names its own list to the table's no-access lock", async () => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
const table = wrapper
.findAllComponents({ name: "OTable" })
.find((c: any) => "forbidden" in c.props());
expect(table.props("forbiddenResource")).toBe("alert_folders");
});
it("closes the clone's loading toast when the clone is refused with a 403", async () => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
const row = wrapper.vm.filteredResults[0];
await wrapper.find(`[data-test="alert-list-${row.name}-clone-alert"]`).trigger("click");
await flushPromises();
wrapper.vm.toBeCloneAlertName = `${row.name} - Copy`;
wrapper.vm.toBeClonestreamType = "logs";
wrapper.vm.toBeClonestreamName = "default";
wrapper.vm.folderIdToBeCloned = "default";
(alertsSvc.create_by_alert_id as any).mockRejectedValueOnce({ response: { status: 403 } });
const dismissLoading = vi.fn();
vi.mocked(toast).mockClear();
vi.mocked(toast).mockImplementationOnce(() => dismissLoading);
await wrapper.vm.submitForm();
await flushPromises();
expect(dismissLoading).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast).mock.calls.map((c) => c[0].variant)).toEqual(["loading"]);
});
it.each([
["an anomaly", { toBeClonedIsAnomaly: true, toBeClonedIsComposite: false }],
["a composite", { toBeClonedIsAnomaly: false, toBeClonedIsComposite: true }],
])(
"closes its loading toast when cloning %s alert is refused with a 403",
async (_kind, flags) => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
Object.assign(wrapper.vm, flags, {
toBeClonedID: "a1",
toBeCloneAlertName: "Copy",
toBeClonestreamType: "logs",
toBeClonestreamName: "default",
folderIdToBeCloned: "default",
showForm: true,
});
const clone = vi
.spyOn(alertsSvc, "clone_by_id")
.mockRejectedValueOnce({ response: { status: 403, data: { message: "denied" } } });
const dismissLoading = vi.fn();
vi.mocked(toast).mockClear();
vi.mocked(toast).mockImplementationOnce(() => dismissLoading);
await wrapper.vm.submitForm();
await flushPromises();
clone.mockRestore();
expect(dismissLoading).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast).mock.calls.map((c) => c[0].variant)).toEqual(["loading"]);
expect(wrapper.vm.showForm).toBe(false);
expect(wrapper.vm.isSubmitting).toBe(false);
},
);
it.each([
["exporting an alert", "export_by_id", (vm: any, row: any) => vm.exportAlert(row)],
["triggering an alert", "trigger_alert", (vm: any, row: any) => vm.triggerAlert(row)],
["retraining an anomaly", "retrain_by_id", (vm: any, row: any) => vm.retrainAnomaly(row)],
[
"exporting selected alerts",
"export_by_id",
(vm: any, row: any) => {
vm.selectedAlertIds = [row.alert_id];
return vm.multipleExportAlert();
},
],
[
"pausing or resuming selected alerts",
"bulkToggleState",
(vm: any, row: any) => {
vm.selectedAlertIds = [row.alert_id];
return vm.bulkToggleAlerts(row.enabled ? "pause" : "resume");
},
],
])("raises no error toast when %s is refused with a 403", async (_action, method, run) => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
const row = wrapper.vm.filteredResults[0];
const refused = vi
.spyOn(alertsSvc, method)
.mockRejectedValue({ response: { status: 403, data: { message: "denied" } } });
vi.mocked(toast).mockClear();
await run(wrapper.vm, row);
await flushPromises();
refused.mockRestore();
expect(vi.mocked(toast).mock.calls.map((c) => c[0].variant)).not.toContain("error");
});
it("deletes an alert after confirming dialog", async () => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
@@ -2318,6 +2419,18 @@ describe("AlertList - editor drafts", () => {
);
});
it("raises no error toast when its edit link is refused with a 403", async () => {
const { vm } = await mountDraftList();
alertsSvc.get_by_alert_id.mockRejectedValueOnce({ response: { status: 403 } });
vm.router.currentRoute.value.query = { action: "update", alert_id: "denied-1" };
vi.mocked(toast).mockClear();
await vm.handleActionQuery("update");
await flushPromises();
expect(vi.mocked(toast).mock.calls.map((c) => c[0].variant)).not.toContain("error");
});
it("refreshes without closing the editor when the leave dialog saved", async () => {
const { vm } = await mountDraftList();
vm.router.currentRoute.value.query = { action: "add" };
@@ -2496,3 +2609,156 @@ describe("AlertList — Acknowledge and alert-scoped Mute (MK-3)", () => {
);
});
}, 15000);
describe("AlertList - first run and the alert library", () => {
let originalHideMenus: unknown;
beforeEach(() => {
originalHideMenus = (store.state as any).zoConfig.custom_hide_menus;
});
afterEach(() => {
(store.state as any).zoConfig.custom_hide_menus = originalHideMenus;
});
// A stale ?action= from an earlier spec's navigation can swap the whole page for the editor.
const mountList = async () => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
await wrapper.vm.handleActionQuery(undefined);
await flushPromises();
return wrapper;
};
/** A loaded org with no alerts at all, so the table shows its empty slot. */
const mountEmpty = async () => {
const wrapper: any = await mountList();
wrapper.vm.allAlerts = [];
wrapper.vm.filteredResults = [];
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 80));
await flushPromises();
return wrapper;
};
const emptyState = (wrapper: any) =>
wrapper
.findAllComponents({ name: "OEmptyState" })
.find((c: any) => c.props("preset") === "no-alerts");
it("puts an Alert library button in the header that opens the library", async () => {
const wrapper: any = await mountList();
const push = vi.spyOn(wrapper.vm.router, "push").mockResolvedValue(undefined);
const button = wrapper.find('[data-test="alert-list-alert-library-btn"]');
expect(button.text()).toContain("Alert library");
await button.trigger("click");
expect(push).toHaveBeenCalledWith({
name: "alertLibrary",
query: { org_identifier: store.state.selectedOrganization.identifier },
});
});
it("shows the first-run empty state at block size with New alert, Alert library and Import", async () => {
const wrapper = await mountEmpty();
const state = emptyState(wrapper);
expect(state.props("size")).toBe("block");
expect(wrapper.find('[data-test="alert-list-empty-new-alert"]').text()).toContain("New alert");
expect(wrapper.find('[data-test="alert-list-empty-alert-library"]').text()).toContain(
"Alert library",
);
expect(wrapper.find('[data-test="alert-list-empty-import"]').text()).toContain("Import alert");
});
const emptyActions = (wrapper: any) =>
emptyState(wrapper)
.findAllComponents({ name: "OButton" })
.filter((b: any) => (b.attributes("data-test") ?? "").startsWith("alert-list-empty-"))
.map((b: any) => ({
id: b.attributes("data-test"),
variant: b.props("variant"),
size: b.props("size"),
}));
it("leads the first-run actions with the library, at lg size, when the library is shown", async () => {
const wrapper = await mountEmpty();
expect(emptyActions(wrapper)).toEqual([
{ id: "alert-list-empty-alert-library", variant: "primary", size: "lg" },
{ id: "alert-list-empty-import", variant: "outline", size: "lg" },
{ id: "alert-list-empty-new-alert", variant: "outline", size: "lg" },
]);
});
it("leads with New alert as the primary action when the library is hidden", async () => {
(store.state as any).zoConfig.custom_hide_menus = "alertLibrary";
const wrapper = await mountEmpty();
expect(emptyActions(wrapper)).toEqual([
{ id: "alert-list-empty-new-alert", variant: "primary", size: "lg" },
{ id: "alert-list-empty-import", variant: "outline", size: "lg" },
]);
});
it("starts a new alert from the empty state, as the header's New alert does", async () => {
const wrapper = await mountEmpty();
const push = vi.spyOn(wrapper.vm.router, "push").mockResolvedValue(undefined);
await wrapper.find('[data-test="alert-list-empty-new-alert"]').trigger("click");
await flushPromises();
expect(push).toHaveBeenCalledWith(
expect.objectContaining({
name: "alertList",
query: expect.objectContaining({ action: "add" }),
}),
);
});
it("opens the library from the empty state", async () => {
const wrapper = await mountEmpty();
const push = vi.spyOn(wrapper.vm.router, "push").mockResolvedValue(undefined);
await wrapper.find('[data-test="alert-list-empty-alert-library"]').trigger("click");
expect(push).toHaveBeenCalledWith({
name: "alertLibrary",
query: { org_identifier: store.state.selectedOrganization.identifier },
});
});
it("opens the import dialog from the empty state, as the header's Import does", async () => {
const wrapper = await mountEmpty();
const push = vi.spyOn(wrapper.vm.router, "push").mockResolvedValue(undefined);
await wrapper.find('[data-test="alert-list-empty-import"]').trigger("click");
await flushPromises();
expect(wrapper.vm.showImportAlertDialog).toBe(true);
expect(push).toHaveBeenCalledWith(
expect.objectContaining({ query: expect.objectContaining({ action: "import" }) }),
);
});
it("keeps the filtered empty state to Clear filters alone", async () => {
const wrapper = await mountEmpty();
wrapper.vm.filterQuery = "nothing-matches";
await flushPromises();
expect(emptyState(wrapper).props("filtered")).toBe(true);
expect(wrapper.find('[data-test="alert-list-empty-new-alert"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-list-empty-alert-library"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-list-empty-import"]').exists()).toBe(false);
expect(emptyState(wrapper).text()).toContain("Clear filters");
emptyState(wrapper).vm.$emit("action", "clear-filters");
await flushPromises();
expect(wrapper.vm.filterQuery).toBe("");
});
it("hides both library entry points when custom_hide_menus lists alertLibrary", async () => {
(store.state as any).zoConfig.custom_hide_menus = "billings,alertLibrary";
const wrapper = await mountEmpty();
expect(wrapper.find('[data-test="alert-list-alert-library-btn"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-list-empty-alert-library"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-list-empty-new-alert"]').exists()).toBe(true);
});
it("renders the summary strip compact, so its six tiles share one row", async () => {
const wrapper: any = await mountList();
expect(wrapper.findComponent({ name: "OStatStrip" }).props("compact")).toBe(true);
});
});
+93 -9
View File
@@ -88,6 +88,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
shortcut-id="alertsImport"
/>
</OButton>
<OButton
v-if="showAlertLibrary"
:class="isCompactToolbar ? 'min-w-0! px-2! py-0!' : ''"
variant="outline"
size="sm"
icon-left="menu-book"
data-test="alert-list-alert-library-btn"
@click="goToAlertLibrary"
>
<template v-if="!isCompactToolbar">{{ t("alerts.alertLibrary") }}</template>
<OTooltip v-if="isCompactToolbar" :content="t('alerts.alertLibrary')" side="bottom" />
</OButton>
</template>
<div data-test="alert-list-splitter" class="flex min-h-0 flex-1 max-md:flex-col">
@@ -117,6 +129,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:loading="loading"
:loading-reset-key="`${activeFolderId}:${activeTab}`"
:forbidden="forbidden"
forbidden-resource="alert_folders"
pagination="client"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
@@ -149,6 +162,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OStatStrip
:items="summaryStats"
:loading="loading"
compact
selectable
:selected-key="stateFilter"
default-key="total"
@@ -818,16 +832,58 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<OEmptyState
v-else
size="hero"
size="block"
preset="no-alerts"
:filtered="!!(filterQuery || searchQuery || stateFilter)"
@action="
(id) =>
id === 'clear-filters'
? ((filterQuery = ''), (searchQuery = ''), (stateFilter = null))
: showAddUpdateFn({})
id === 'clear-filters' &&
((filterQuery = ''), (searchQuery = ''), (stateFilter = null))
"
/>
>
<!-- First run only: the filtered state keeps its single Clear filters action. -->
<template v-if="!(filterQuery || searchQuery || stateFilter)" #actions>
<OButton
v-if="showAlertLibrary"
variant="primary"
size="lg"
icon-left="menu-book"
data-test="alert-list-empty-alert-library"
@click="goToAlertLibrary"
>
{{ t("alerts.alertLibrary") }}
</OButton>
<OButton
v-if="!showAlertLibrary"
variant="primary"
size="lg"
icon-left="add"
data-test="alert-list-empty-new-alert"
@click="showAddUpdateFn({})"
>
{{ t("emptyState.noAlerts.action") }}
</OButton>
<OButton
variant="outline"
size="lg"
icon-left="upload-file"
data-test="alert-list-empty-import"
@click="importAlert"
>
{{ t("alerts.importAlert") }}
</OButton>
<OButton
v-if="showAlertLibrary"
variant="outline"
size="lg"
icon-left="add"
data-test="alert-list-empty-new-alert"
@click="showAddUpdateFn({})"
>
{{ t("emptyState.noAlerts.action") }}
</OButton>
</template>
</OEmptyState>
</template>
<template #selection-actions>
@@ -2744,8 +2800,9 @@ export default defineComponent({
if (!(await divertSloAlert(alert))) {
showAddUpdateFn({ row: alert });
}
} catch (error) {
} catch (error: any) {
console.error("AlertList: Failed to load alert", error);
if (error?.response?.status === 403) return;
const draftDropped =
isNotFoundError(error) &&
dropDraftOfMissingAlert({
@@ -2962,6 +3019,10 @@ export default defineComponent({
activeFolderId.value = folderIdToBeCloned.value;
} catch (e: any) {
dismiss();
if (e?.response?.status === 403) {
showForm.value = false;
return;
}
toast({
variant: "error",
message: e?.response?.data?.message || t("alerts.messages.cloneAnomalyFailed"),
@@ -3005,6 +3066,10 @@ export default defineComponent({
activeFolderId.value = folderIdToBeCloned.value;
} catch (e: any) {
dismiss();
if (e?.response?.status === 403) {
showForm.value = false;
return;
}
toast({
variant: "error",
message: e?.response?.data?.message,
@@ -3083,12 +3148,12 @@ export default defineComponent({
}
})
.catch((e: any) => {
dismiss();
if (e.response?.status == 403) {
showForm.value = false;
isSubmitting.value = false;
return;
}
dismiss();
toast({
variant: "error",
message: e.response.data.message,
@@ -3409,6 +3474,18 @@ export default defineComponent({
});
};
// Raw split (no trim), the same rule AlertSectionTabs hides the Library tab by.
const showAlertLibrary = computed(
() => !(store.state.zoConfig?.custom_hide_menus ?? "").split(",").includes("alertLibrary"),
);
const goToAlertLibrary = () => {
router.push({
name: "alertLibrary",
query: { org_identifier: store.state.selectedOrganization.identifier },
});
};
const goToAlertInsights = () => {
router.push({
name: "alertInsights",
@@ -3467,6 +3544,7 @@ export default defineComponent({
alertsToExport.value = [alert];
showExportDialog.value = true;
} catch (error: any) {
if (error?.response?.status === 403) return;
toast({
variant: "error",
message:
@@ -3509,6 +3587,7 @@ export default defineComponent({
await getAlertsFn(store, activeFolderId.value, "", true, "", true);
}
} catch (error: any) {
if (error?.response?.status === 403) return;
toast({
variant: "error",
message: error?.response?.data?.message || t("alerts.messages.triggerAlertFailed"),
@@ -3527,6 +3606,7 @@ export default defineComponent({
message: t("toastMessages.alerts.retrainingTriggered"),
});
} catch (error: any) {
if (error?.response?.status === 403) return;
toast({
variant: "error",
message: error?.response?.data?.message || t("alerts.messages.triggerRetrainingFailed"),
@@ -3821,8 +3901,9 @@ export default defineComponent({
}),
});
}
} catch (error) {
} catch (error: any) {
console.error("Error exporting alerts:", error);
if (error?.response?.status === 403) return;
toast({
variant: "error",
message: t("toastMessages.alerts.errorExportingAlertsPleaseTryAgain"),
@@ -3956,9 +4037,10 @@ export default defineComponent({
if (filterQuery.value) {
filterAlertsByQuery(filterQuery.value);
}
} catch (error) {
} catch (error: any) {
dismiss();
console.error(`Error ${action}ing alerts:`, error);
if (error?.response?.status === 403) return;
toast({
variant: "error",
message:
@@ -4246,6 +4328,8 @@ export default defineComponent({
refreshDestination,
showImportAlertDialog,
importAlert,
showAlertLibrary,
goToAlertLibrary,
goToAlertInsights,
goToAlertHistory,
getTemplates,
@@ -15,6 +15,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { inject } from "vue";
import type { useVariablesManager } from "@/composables/dashboard/useVariablesManager";
import ViewPanel from "./ViewPanel.vue";
import config from "@/aws-exports";
import PanelErrorButtons from "@/components/dashboards/PanelErrorButtons.vue";
@@ -469,6 +471,59 @@ describe("ViewPanel", () => {
});
});
describe("variable values passed from the dashboard", () => {
const levelVariable = {
type: "query_values",
name: "level",
label: "Level",
query_data: { stream_type: "logs", stream: "app_logs", field: "level" },
value: "",
options: [],
};
const mountWithPassed = async (passed: Record<string, unknown>) => {
mockGetDashboard.mockResolvedValue({
title: "Test Dashboard",
tabs: [{ tabId: "default", panels: [] }],
variables: { list: [levelVariable], showDynamicFilters: false },
});
let manager: ReturnType<typeof useVariablesManager> | undefined;
wrapper = createWrapper(
{ initialVariableValues: { values: [{ ...levelVariable, ...passed }] } },
{
VariablesValueSelector: {
template: "<div />",
setup: () => {
manager = inject<ReturnType<typeof useVariablesManager>>("variablesManager");
return {};
},
},
},
);
await flushPromises();
return manager?.variablesData.global.find((v) => v.name === "level");
};
it("loads a variable itself when the dashboard had not loaded it yet", async () => {
const level = await mountWithPassed({
value: null,
isLoading: true,
isVariablePartialLoaded: false,
});
expect(level?.isVariablePartialLoaded).toBe(false);
});
it("reuses a value the dashboard already loaded", async () => {
const level = await mountWithPassed({
value: "debug",
isLoading: false,
isVariableLoadingPending: false,
isVariablePartialLoaded: true,
});
expect(level?.value).toBe("debug");
expect(level?.isVariablePartialLoaded).toBe(true);
});
});
describe("Component Initialization", () => {
it("should display panel title", () => {
wrapper = createWrapper();
@@ -764,20 +764,7 @@ export default defineComponent({
props.initialVariableValues.values &&
props.initialVariableValues.values.length > 0
) {
// Update variablesManager with passed values
props.initialVariableValues.values.forEach((passedVar: any) => {
// Find and update the variable in the manager (global scope)
const globalVar = variablesManager.variablesData.global.find(
(v: any) => v.name === passedVar.name,
);
if (globalVar) {
globalVar.value = passedVar.value;
globalVar.isVariablePartialLoaded = true;
globalVar.isLoading = false;
// Set pending to false to prevent API call
globalVar.isVariableLoadingPending = false;
}
});
variablesManager.adoptLoadedValues(props.initialVariableValues.values);
// Also populate currentVariablesDataRef with passed values
Object.assign(currentVariablesDataRef, props.initialVariableValues);
@@ -69,6 +69,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
row-key="name"
:loading="loading"
:forbidden="forbidden"
forbidden-resource="functions"
pagination="client"
:current-page="currentPage"
@update:current-page="onPageChange"
@@ -59,6 +59,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
row-key="group_name"
:loading="loading"
:forbidden="forbidden"
forbidden-resource="groups"
:selected-ids="selectedGroupNames"
v-model:global-filter="filterQuery"
:show-global-filter="false"
@@ -56,6 +56,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data="rows"
:loading="loading"
:forbidden="forbidden"
forbidden-resource="roles"
:action-loading="bulkDeleteLoading"
v-model:global-filter="filterQuery"
v-model:page-size="pageSize"
@@ -43,6 +43,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
row-key="email"
:loading="loading"
:forbidden="forbidden"
forbidden-resource="service_accounts"
pagination="client"
:page-size="20"
:page-size-options="[20, 50, 100, 250, 500]"
+1
View File
@@ -49,6 +49,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
row-key="email"
:loading="loading"
:forbidden="forbidden"
forbidden-resource="users"
:selected-ids="selectedUserIds"
v-model:global-filter="filterQuery"
:show-global-filter="false"
@@ -28,6 +28,8 @@ import type {
FirstEventState,
} from "@/composables/firstEvent/useFirstEventWatch";
import FirstDataPanel from "./FirstDataPanel.vue";
import { viewDenial } from "@/composables/useAccessStore";
import { withoutCovered } from "@/components/access/surfaceDenials";
const fake = {
state: ref<FirstEventState>("waiting"),
@@ -330,6 +332,17 @@ describe("FirstDataPanel — full panel on Logs, Traces and Metrics", () => {
expect(watchArgs).not.toHaveBeenCalled();
});
it("keeps the stream denial its lock names out of the access chip while the lock shows", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
await failList("logs", 403);
const entries = [viewDenial("streams", "list"), viewDenial("templates", "list")];
await mountPanel({ signal: "logs", variant: "full" });
expect(q("first-data-panel-no-access").exists()).toBe(true);
expect(withoutCovered(entries).map((e) => e.resource.key)).toEqual(["templates"]);
wrapper.unmount();
expect(withoutCovered(entries)).toEqual(entries);
});
it("keeps the panel when the list failed for another reason", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
await failList("logs", 500);
@@ -427,6 +427,7 @@ const StatusLine = defineComponent({
v-if="forbidden && variant === 'full'"
:denials="streamDenials"
size="hero"
covers
data-test="first-data-panel-no-access"
/>
<template v-else>
@@ -21,6 +21,13 @@ import { createStore } from "vuex";
import destinationService from "@/services/alert_destination";
import i18n from "@/locales";
import { nextTick } from "vue";
import { toast } from "@/lib/feedback/Toast/useToast";
// Passthrough spy: real toasts still render, and specs can read what was raised.
vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feedback/Toast/useToast")>();
return { ...actual, toast: vi.fn((...args: unknown[]) => actual.toast(...(args as [never]))) };
});
// Mock the destination service
vi.mock("@/services/alert_destination", async (importOriginal) => {
@@ -630,6 +637,37 @@ describe("CreateDestinationForm", () => {
expect(wrapper.emitted("created")![0]).toEqual(["My Dest"]);
});
it.each(["create", "update"] as const)(
"closes its loading toast when a %s is refused with a 403",
async (op) => {
if (op === "update") {
wrapper.unmount();
wrapper = createWrapper({
destination: {
name: "My Dest",
url: "https://example.com",
method: "post",
type: "http",
},
});
await flushPromises();
}
const dismissLoading = vi.fn();
vi.mocked(toast).mockImplementationOnce(() => dismissLoading);
(destinationService[op] as any).mockRejectedValueOnce({ response: { status: 403 } });
setFormField(wrapper, "name", "My Dest");
setFormField(wrapper, "url", "https://example.com");
setFormField(wrapper, "method", "post");
await wrapper.vm.createDestination();
await flushPromises();
expect(destinationService[op]).toHaveBeenCalledTimes(1);
expect(dismissLoading).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast).mock.calls.map((c) => c[0].variant)).toEqual(["loading"]);
},
);
it("does not call destinationService.create when name is empty", async () => {
setFormField(wrapper, "name", "");
setFormField(wrapper, "url", "");
@@ -1164,10 +1164,10 @@ const createDestination = (value?: DestinationForm) => {
emit("updated", name);
})
.catch((err: any) => {
dismiss();
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
@@ -1182,10 +1182,10 @@ const createDestination = (value?: DestinationForm) => {
emit("created", name);
})
.catch((err: any) => {
dismiss();
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
@@ -345,4 +345,23 @@ describe("OrgStorageEditor", () => {
});
});
});
it("closes its loading toast when the save is refused with a 403", async () => {
const dismissLoading = vi.fn();
mockToast.mockReturnValueOnce(dismissLoading);
mockCreate.mockRejectedValueOnce({ response: { status: 403 } });
const wrapper = createWrapper("add");
await selectProvider(wrapper, "AwsCredentials");
const form = getForm(wrapper);
form.setFieldValue("bucket_name", "my-bucket");
form.setFieldValue("region", "us-east-1");
form.setFieldValue("access_key", "AKIA123");
form.setFieldValue("secret_key", "secret123");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(dismissLoading).toHaveBeenCalledTimes(1);
expect(mockToast).toHaveBeenCalledTimes(1);
});
});
@@ -562,8 +562,8 @@ async function submitStorage(value: OrgStorageEditorForm) {
}
emit("saved");
} catch (err: any) {
if (err.response?.status === 403) return;
dismiss();
if (err.response?.status === 403) return;
toast({
variant: "error",
message:
@@ -1260,6 +1260,40 @@ describe("useVariablesManager live apply and commit-once", () => {
expect(pod.isVariableLoadingPending).toBe(true);
});
describe("adoptLoadedValues", () => {
const adopt = async (passed: Partial<VariableConfig> & Record<string, unknown>) => {
const manager = useVariablesManager(gt);
await manager.initialize(queryChainConfig(), {});
manager.adoptLoadedValues([{ name: "ns", type: "query_values", ...passed }]);
return manager.variablesData.global;
};
it("keeps a loaded value and schedules its children", async () => {
const [ns, pod] = await adopt({ value: "a", isVariablePartialLoaded: true });
expect(ns.value).toBe("a");
expect(ns.isVariableLoadingPending).toBe(false);
expect(pod.isVariableLoadingPending).toBe(true);
});
it("keeps a loaded value that is only being refreshed", async () => {
const [ns] = await adopt({ value: "a", isVariablePartialLoaded: true, isLoading: true });
expect(ns.value).toBe("a");
});
it.each([
["still loading", { value: null, isVariablePartialLoaded: false, isLoading: true }],
[
"waiting to reload",
{ value: "a", isVariablePartialLoaded: true, isVariableLoadingPending: true },
],
])("leaves a value that is %s to its own fetch", async (_case, passed) => {
const [ns, pod] = await adopt(passed);
expect(ns.value).toBe(null);
expect(ns.isVariableLoadingPending).toBe(true);
expect(pod.isVariableLoadingPending).toBe(false);
});
});
it("an aborted dependent load releases the commit", async () => {
const { manager, service, commits } = await setupSettled();
manager.setLiveMode(true);
@@ -1112,6 +1112,24 @@ export const useVariablesManager = (t: TranslateFn) => {
scheduleChildrenOfRestored(restoredKeys);
};
/** Takes global values another view already loaded, so they are not fetched again, and starts their children's loads. */
const adoptLoadedValues = (passed: Partial<VariableRuntimeState>[]) => {
const adopted = passed.flatMap((p) => {
// A query value mid-fetch or due to reload after its parent changed is not a value to keep.
if (p.type === "query_values" && (!p.isVariablePartialLoaded || p.isVariableLoadingPending))
return [];
const v = variablesData.global.find((g) => g.name === p.name);
if (!v) return [];
v.value = p.value;
v.isVariablePartialLoaded = true;
v.isLoading = false;
v.isVariableLoadingPending = false;
return [v];
});
recordLoadedAgainst(adopted);
scheduleChildrenOfRestored(adopted.map((v) => getVariableKey(v.name, v.scope)));
};
interface ParsedUrlKey {
name: string;
scope: "global" | "tabs" | "panels";
@@ -1262,6 +1280,7 @@ export const useVariablesManager = (t: TranslateFn) => {
isVariableReady,
getDependentVariables,
loadFromUrl,
adoptLoadedValues,
getUrlParams,
};
};
+3
View File
@@ -708,6 +708,7 @@ const useRoutes = () => {
props: true,
meta: {
titleKey: "routeTitles.viewDashboard",
accessScopeQuery: ["dashboard"],
},
beforeEnter(to: any, from: any, next: any) {
// A playlist link to the dashboard view is played by the TV player, except its own page fallback.
@@ -765,6 +766,7 @@ const useRoutes = () => {
props: true,
meta: {
titleKey: "panel.addPanel",
accessScopeQuery: ["dashboard"],
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
@@ -836,6 +838,7 @@ const useRoutes = () => {
path: "edit",
name: "pipelineEditor",
component: PipelineEditor,
meta: { accessScopeQuery: ["id"] },
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
@@ -441,7 +441,7 @@ const useEnterpriseRoutes = () => {
path: "edit",
name: "workflowEditor",
component: WorkflowEditor,
meta: { titleKey: "workflow.editMode" },
meta: { titleKey: "workflow.editMode", accessScopeQuery: ["id"] },
beforeEnter: withFeatureGate("workflows", workflowsRouteGuard),
},
{
@@ -451,7 +451,7 @@ const useEnterpriseRoutes = () => {
path: "runs",
name: "workflowRuns",
component: WorkflowRuns,
meta: { titleKey: "workflow.runs.title" },
meta: { titleKey: "workflow.runs.title", accessScopeQuery: ["id"] },
beforeEnter: withFeatureGate("workflows", workflowsRouteGuard),
},
],
@@ -134,6 +134,7 @@ const useManagementRoutes = () => {
name: "modelPricingEditor",
meta: {
titleKey: "routeTitles.modelPricingEditor",
accessScopeQuery: ["id"],
},
component: () => import("@/components/settings/ModelPricingEditor.vue"),
beforeEnter: withFeatureGate("modelPricing", routeGuard),
File diff suppressed because it is too large Load Diff
+113 -3
View File
@@ -32,12 +32,21 @@ const t = createI18n({ legacy: false, locale: "en", messages: { en } }).global
const deny = (key: string, permission: AccessPermission = "list") =>
viewDenial(key, permission, [`GET /api/default/${key}`]);
const unnamed = (area: string) => ({
resource: {
key: `_unnamed/${area}`,
labelKey: "accessBoundary.resourcesInline.unnamed" as const,
path: `/api/acme/${area}`,
},
permission: "create" as const,
requests: [`POST /api/acme/${area}`],
});
describe("access denial grouping", () => {
it("names a single denial in the title and one sentence", () => {
const message = accessDeniedMessage([deny("alerts")], t);
expect(message.title).toBe("You can't view alerts");
expect(message.resourceCount).toBe(1);
expect(message.count).toBe(1);
expect(message.groups).toHaveLength(1);
expect(accessReason(message.groups[0], t)).toBe(
"Your role is missing the List permission on Alerts.",
@@ -71,8 +80,8 @@ describe("access denial grouping", () => {
deny("roles"),
];
const message = accessDeniedMessage(denials, t);
expect(message.title).toBe("You can't view 10 parts of this page");
expect(message.resourceCount).toBe(10);
expect(message.title).toBe("10 actions on this page are blocked");
expect(message.count).toBe(10);
expect(message.groups.map((g) => [g.label, g.resources.length])).toEqual([
["List", 8],
["Delete", 2],
@@ -89,6 +98,107 @@ describe("access denial grouping", () => {
]);
});
it("does not title a refused delete as a view when the entries mix verbs", () => {
expect(accessDeniedMessage([deny("alerts", "delete"), deny("templates")], t).title).toBe(
"2 actions on this page are blocked",
);
expect(accessDeniedMessage([deny("alerts"), deny("alerts", "delete")], t).title).toBe(
"2 actions on this page are blocked",
);
});
it("names every resource in a title that shares one write verb, folding after four", () => {
expect(
accessDeniedMessage([deny("alerts", "delete"), deny("reports", "delete")], t).title,
).toBe("You can't delete alerts and reports");
const many = ["alerts", "reports", "roles", "groups", "users", "functions"].map((k) =>
deny(k, "delete"),
);
expect(accessDeniedMessage(many, t).title).toBe(
"You can't delete alerts, reports, roles, groups, and 2 more",
);
});
it("prints request lines, never an internal key, for resources with no friendly name", () => {
const unnamed = {
resource: {
key: "_unnamed",
labelKey: "accessBoundary.resourcesInline.unnamed" as const,
path: "/api/acme/tasks",
},
permission: "create" as const,
requests: ["POST /api/acme/tasks"],
};
const lines = accessDetails([unnamed, deny("alerts", "create")])
.split("\n")
.filter((l) => l.startsWith("denied"));
expect(lines).toEqual([
"denied create: alerts (POST /api/acme/tasks, GET /api/default/alerts)",
]);
expect(accessDetails([unnamed])).toContain("denied create: POST /api/acme/tasks");
expect(accessDetails([unnamed])).not.toContain("_unnamed");
});
it("counts the same unit in the title and the chip's badge", () => {
const listAndDelete = accessDeniedMessage([deny("alerts"), deny("alerts", "delete")], t);
expect(listAndDelete.count).toBe(2);
expect(listAndDelete.title).toBe("2 actions on this page are blocked");
const views = accessDeniedMessage(
[deny("alerts"), deny("alerts", "get"), deny("templates")],
t,
);
expect(views.count).toBe(3);
expect(views.title).toBe("You can't view 3 parts of this page");
const oneResource = accessDeniedMessage([deny("alerts"), deny("alerts", "get")], t);
expect(oneResource.count).toBe(2);
expect(oneResource.title).toBe("You can't view alerts");
});
it.each([
[0, "", ""],
[1, "You can't create this resource", "this resource"],
[2, "You can't create 2 resources", "2 resources"],
[3, "You can't create 3 resources", "3 resources"],
[12, "You can't create 12 resources", "12 resources"],
])("words %i refused unnamed writes once, counted", (n, title, resources) => {
const denials = Array.from({ length: n }, (_, i) => unnamed(`area${i}`));
const message = accessDeniedMessage(denials, t);
expect(message.count).toBe(n);
if (n === 0) {
expect(message.groups).toEqual([]);
return;
}
expect(message.title).toBe(title);
expect(accessReason(message.groups[0], t)).toBe(
`Your role is missing the Create permission on ${resources}.`,
);
});
it("names the named resources and counts the unnamed ones in one sentence", () => {
const message = accessDeniedMessage(
[deny("alerts", "create"), unnamed("a"), unnamed("b"), deny("reports", "create")],
t,
);
expect(message.count).toBe(4);
expect(message.title).toBe("You can't create alerts, reports, and 2 resources");
expect(accessReason(message.groups[0], t)).toBe(
"Your role is missing the Create permission on Alerts, Reports, and 2 resources.",
);
});
it("names the permission for the grantable denials and asks for an administrator for the rest", () => {
const bypassed = { ...unnamed("synthetics"), permission: "list" as const };
bypassed.resource = { ...bypassed.resource, grantable: false };
const message = accessDeniedMessage([deny("alerts"), bypassed], t);
expect(message.title).toBe("You can't view 2 parts of this page");
expect(message.count).toBe(2);
expect(message.askAdmin).toBe(true);
expect(message.groups.map((g) => accessReason(g, t))).toEqual([
"Your role is missing the List permission on Alerts.",
]);
expect(accessDetails([deny("alerts"), bypassed])).toContain("POST /api/acme/synthetics");
});
it("folds a list longer than four into an expandable 'N more' part", () => {
const resources = accessDeniedMessage(
["dashboards", "streams", "functions", "users", "roles", "pipelines"].map((k) => deny(k)),
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1829,7 +1829,8 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
// schema (it reads them off the form); resetForm below re-seeds anyway.
setF("stream_type", data.stream_type);
setF("stream_name", data.stream_name);
await updateStreams(false);
// A stream this org has not ingested yet has no schema; the rest of the prefill still applies.
await Promise.resolve(updateStreams(false)).catch(() => undefined);
}
if (prefill.queryType === "promql") {
@@ -169,11 +169,14 @@ describe("useHomeDashboard", () => {
updateLabel("org1", "abc", "Renamed");
expect(homeDashboard.value?.label).toBe("Renamed");
// Persisted so the fresh label survives the next load().
// Background sync the user never asked for, so its 403 must not reach the access boundary.
expect(settings.setOrgSetting).toHaveBeenCalledWith(
"org1",
"home_dashboard",
{ ...D, label: "Renamed" },
"ui",
undefined,
{ silentForbidden: true },
);
});
+3 -1
View File
@@ -149,7 +149,9 @@ export function useHomeDashboard(t: TranslateFn) {
homeDashboard.value = updated;
if (org) {
settings
.setOrgSetting(org, SETTING_KEY, updated, SETTING_CATEGORY)
.setOrgSetting(org, SETTING_KEY, updated, SETTING_CATEGORY, undefined, {
silentForbidden: true,
})
.then(() => queryClient.setQueryData(settingKeys.one(org, SETTING_KEY), updated))
.catch(() => {
/* label persist is best-effort; ref already shows the fresh label */
@@ -0,0 +1,205 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { computed, defineComponent, h, ref, shallowRef, type Component } from "vue";
import { createMemoryHistory, createRouter } from "vue-router";
import i18n from "@/locales";
import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue";
import { providePageHeaderStatus } from "@/lib/core/PageHeader/pageHeaderStatus";
import { raw } from "@/types/i18n";
import OAccessHeaderChip from "@/components/access/OAccessHeaderChip.vue";
import { viewDenial } from "@/composables/useAccessStore";
const lgUp = ref(true);
vi.mock("@/composables/useBreakpoint", () => ({
default: () => ({ isMobile: computed(() => false), lgUp: computed(() => lgUp.value) }),
}));
const entries = shallowRef([viewDenial("templates", "list", ["GET /api/default/templates"])]);
vi.mock("@/composables/useAccessStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useAccessStore")>();
return { ...actual, useAccessBoundary: () => ({ sectionEntries: entries }) };
});
const Status = defineComponent({ setup: () => () => h("button", { "data-test": "status" }) });
const ROW_FLOORS = ["md:max-lg:min-w-40", "max-md:min-w-24"];
let wrapper: VueWrapper | null = null;
let resizeCallbacks: ResizeObserverCallback[] = [];
class MockResizeObserver {
constructor(cb: ResizeObserverCallback) {
resizeCallbacks.push(cb);
}
observe = vi.fn();
disconnect = vi.fn();
}
// Mounts inside a page container of `pageWidth` px when given, as OPageLayout provides one.
async function mountHeader(
withStatus: boolean,
desktop: boolean,
pageWidth?: number,
status: Component = Status,
) {
lgUp.value = desktop;
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", component: { render: () => null } }],
});
const Host = defineComponent({
setup() {
providePageHeaderStatus(
() => (withStatus ? status : null),
() => "/",
);
return () => {
const header = h(
OPageHeader,
{ title: raw("Alerts") },
{ actions: () => h("button", "New alert") },
);
return pageWidth === undefined
? header
: h("div", { class: "@container/page", ref: setPageWidth }, [header]);
};
},
});
const setPageWidth = (el: unknown) => {
if (el instanceof HTMLElement)
Object.defineProperty(el, "clientWidth", { configurable: true, value: pageWidth });
};
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [i18n, router] } });
await flushPromises();
}
const titleBlock = () => wrapper!.find("h1").element.closest(".flex-1")!;
// The status is one element in the title block; on one row CSS order moves it beside the actions.
const statusInTitleRow = () => {
const status = titleBlock().querySelector('[data-test="status"]');
return status !== null && !status.parentElement!.classList.contains("order-last");
};
const pageEl = () =>
wrapper!.element.parentElement!.querySelector<HTMLElement>('[class~="@container/page"]')!;
async function resizePage(width: number) {
Object.defineProperty(pageEl(), "clientWidth", { configurable: true, value: width });
for (const cb of resizeCallbacks) cb([], {} as ResizeObserver);
await flushPromises();
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Classes this header adds only for a status; a chipless header must render as it did before.
const STATUS_ONLY = /@max-4xl\/page:min-w-|\border-last\b|-me-2/;
beforeEach(() => {
resizeCallbacks = [];
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
vi.unstubAllGlobals();
});
describe("OPageHeader with a status", () => {
it("shows the status with the actions on desktop", async () => {
await mountHeader(true, true);
expect(wrapper!.findAll('[data-test="status"]')).toHaveLength(1);
expect(statusInTitleRow()).toBe(false);
});
it("puts the status beside the title below lg, keeping the row's wrap floors", async () => {
await mountHeader(true, false);
expect(wrapper!.findAll('[data-test="status"]')).toHaveLength(1);
expect(statusInTitleRow()).toBe(true);
for (const floor of ROW_FLOORS) expect(titleBlock().className).toContain(floor);
expect(wrapper!.html()).not.toContain("md:max-lg:min-w-32");
});
it("puts the status beside the title when a narrow page container wraps the row on a wide viewport", async () => {
await mountHeader(true, true, 700);
expect(wrapper!.findAll('[data-test="status"]')).toHaveLength(1);
expect(statusInTitleRow()).toBe(true);
// The container floor is what makes the actions wrap instead of the title collapsing under the status.
expect(titleBlock().className).toContain("md:@max-4xl/page:min-w-40");
});
it("moves the status back to the actions once the page container widens", async () => {
await mountHeader(true, true, 700);
const page = wrapper!.element.parentElement!.querySelector<HTMLElement>(
'[class~="@container/page"]',
)!;
Object.defineProperty(page, "clientWidth", { configurable: true, value: 1200 });
for (const cb of resizeCallbacks) cb([], {} as ResizeObserver);
await flushPromises();
expect(wrapper!.findAll('[data-test="status"]')).toHaveLength(1);
expect(statusInTitleRow()).toBe(false);
});
it("keeps the status with the actions in a wide page container", async () => {
await mountHeader(true, true, 1200);
expect(statusInTitleRow()).toBe(false);
});
it("keeps the title's own floor when no status is shown", async () => {
await mountHeader(false, false);
expect(wrapper!.html()).toContain("md:max-lg:min-w-32");
});
it("adds no status class to a chipless header in a narrow page container", async () => {
await mountHeader(false, true, 700);
expect(wrapper!.html()).not.toMatch(STATUS_ONLY);
expect(titleBlock().className).not.toContain("md:@max-4xl/page:min-w-40");
});
});
describe("OPageHeader with the access chip", () => {
const chip = () => document.querySelector<HTMLElement>('[data-test="access-header-chip"]')!;
const card = () => document.querySelector('[data-test="access-header-popover"]');
const announcer = () =>
document.querySelector<HTMLElement>('[data-test="access-header-chip-announcer"]')!;
it("sizes the chip by the row's wrap, not the viewport", async () => {
await mountHeader(true, true, 700, OAccessHeaderChip);
expect(chip().className).toContain("h-6");
expect(chip().textContent).not.toContain("Access denied");
await resizePage(1200);
expect(chip().className).not.toContain("h-6");
expect(chip().textContent).toContain("Access denied");
});
it("moves one chip when the row's wrap flips, keeping its card open and its announcement", async () => {
await mountHeader(true, true, 1200, OAccessHeaderChip);
await wait(400);
const before = { chip: chip(), announcer: announcer(), text: announcer().textContent };
chip().click();
await flushPromises();
expect(card()).not.toBeNull();
const changes: MutationRecord[] = [];
const observer = new MutationObserver((records) => changes.push(...records));
observer.observe(announcer(), { childList: true, characterData: true, subtree: true });
await resizePage(700);
await wait(400);
observer.disconnect();
expect(titleBlock().contains(chip())).toBe(true);
expect(chip()).toBe(before.chip);
expect(announcer()).toBe(before.announcer);
expect(announcer().textContent).toBe(before.text);
expect(changes).toHaveLength(0);
expect(card()).not.toBeNull();
});
});
+36 -4
View File
@@ -65,6 +65,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="flex h-full min-w-0 flex-1 items-center gap-3.25 max-lg:h-auto max-lg:self-stretch max-md:gap-2 md:max-lg:min-w-40 @max-4xl/page:h-auto @max-4xl/page:self-stretch"
:class="[
statusComponent ? 'md:@max-4xl/page:min-w-40' : '',
hasBack() ? 'max-md:min-w-40' : 'max-md:min-w-24',
hasTabs() && !tabsBelow ? 'max-md:flex-wrap' : 'max-md:flex-nowrap',
]"
@@ -119,7 +120,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
? ''
: hasTabs() && !tabsBelow
? 'shrink-0 max-md:shrink'
: 'md:max-lg:min-w-32'
: statusComponent
? ''
: 'md:max-lg:min-w-32'
"
>
<h1
@@ -159,6 +162,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
input on create pages). Renders between title text and tabs/actions. -->
<slot name="title-trail" />
<!-- A status arrives after paint; while the row wraps it takes width from the title, so the row never wraps or grows for it. One element moved by order keeps an open card open. -->
<span v-if="statusComponent" :class="statusClass()">
<component :is="statusComponent" :compact="rowWraps" />
</span>
<!-- Module tabs (Level-2 nav), inline to the right of the title.
Two-row mode renders them as a full-width strip below instead. -->
<div
@@ -171,10 +179,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- max-w-full + shrink let the group wrap within its row (shrink-0 alone runs it off-screen); phone date pickers go icon-only to share the title row. -->
<div
v-if="statusComponent || hasActions() || hasActionsOverflow()"
v-if="hasActions() || hasActionsOverflow()"
class="flex shrink-0 items-center gap-2 max-lg:ms-auto max-md:max-w-full max-md:shrink max-md:flex-wrap max-md:justify-end @max-4xl/page:ms-auto @max-4xl/page:max-w-full @max-4xl/page:shrink @max-4xl/page:flex-wrap @max-4xl/page:justify-end max-md:[&_.date-time-label]:hidden"
>
<component :is="statusComponent" v-if="statusComponent" />
<slot v-if="hasActionsOverflow() && !isMobile && overflowFirst" name="actions-overflow" />
<slot name="actions" />
@@ -280,11 +287,13 @@ const props = withDefaults(
// The @page half repeats the max-lg half against the page container: a wide viewport whose content column is narrow collapses the same way.
const ROW_CLASS =
"flex h-15 items-center justify-between gap-4 max-lg:h-auto max-lg:min-h-15 max-lg:flex-wrap max-lg:gap-y-1 max-lg:py-1.5 @max-4xl/page:h-auto @max-4xl/page:min-h-15 @max-4xl/page:flex-wrap @max-4xl/page:gap-y-1 @max-4xl/page:py-1.5";
// Tailwind's `4xl` container width, the `@max-4xl/page` threshold above.
const PAGE_WRAP_REM = 56;
const router = useRouter();
const slots = useSlots();
const { t } = useI18nTyped();
const { isMobile } = useBreakpoint();
const { isMobile, lgUp } = useBreakpoint();
// The first header inside a sidebar layout carries its phone trigger, saving the layout a row.
const layoutSidebar = inject(PAGE_LAYOUT_SIDEBAR_KEY, null);
@@ -306,6 +315,23 @@ const ownsSidebarTrigger = computed(
const headerEl = ref<HTMLElement | null>(null);
const statusComponent = usePageHeaderStatusHost(headerEl);
// The status follows the row's own wrap condition (viewport or page container), so it never wraps onto a line of its own.
const pageNarrow = ref(false);
let pageObserver: ResizeObserver | null = null;
onMounted(() => {
const page = headerEl.value?.closest<HTMLElement>('[class~="@container/page"]');
if (!page || typeof ResizeObserver === "undefined") return;
const measure = () => {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
pageNarrow.value = page.clientWidth < PAGE_WRAP_REM * rem;
};
measure();
pageObserver = new ResizeObserver(measure);
pageObserver.observe(page);
});
onBeforeUnmount(() => pageObserver?.disconnect());
const rowWraps = computed(() => !lgUp.value || pageNarrow.value);
// A slot passed with an always-present <template> but a falsy inner v-if still
// yields a comment placeholder node — so checking `$slots.x` is truthy even
// when there's nothing to show. Detect *real* content instead, so we never
@@ -331,6 +357,12 @@ const hasTabs = (): boolean => slotHasContent("tabs");
const hasActions = (): boolean => slotHasContent("actions");
const hasActionsOverflow = (): boolean => slotHasContent("actions-overflow");
const hasBack = (): boolean => Boolean(props.back) || slotHasContent("back");
// On one row the status sits last in the title block, at the actions' own gap from them.
const statusClass = (): string[] => [
"flex shrink-0 items-center",
rowWraps.value ? "" : "order-last ms-auto",
!rowWraps.value && (hasActions() || hasActionsOverflow()) ? "-me-2" : "",
];
const backLabel = computed(() =>
props.back?.label ? t("common.backTo", { label: props.back.label }) : t("common.back"),
);
+21
View File
@@ -29,6 +29,7 @@ beforeAll(() => {
import { defineComponent, h, nextTick, reactive } from "vue";
import OTable from "./OTable.vue";
import { TABLE_FORBIDDEN_STATE_KEY } from "./tableForbiddenState";
import OTableHeader from "./sub-components/OTableHeader.vue";
import OTableBody from "./sub-components/OTableBody.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
@@ -1273,6 +1274,26 @@ describe("OTable", () => {
expect(isPinned(wrapper)).toBe(true);
});
it("shows the generic no-access state when no app state is provided", () => {
wrapper = mount(OTable, { props: { data: [], columns: makeColumns(), forbidden: true } });
const forbidden = wrapper.find('[data-test="o2-table-forbidden"]');
expect(forbidden.findComponent({ name: "OEmptyState" }).props("preset")).toBe("no-access");
});
it("renders the app's no-access state with the table's resource when one is provided", () => {
const AppLock = defineComponent({
props: { resource: { type: String, default: undefined } },
setup: (props) => () => h("div", { "data-test": "app-lock" }, props.resource ?? "none"),
});
wrapper = mount(OTable, {
props: { data: [], columns: makeColumns(), forbidden: true, forbiddenResource: "alerts" },
global: { provide: { [TABLE_FORBIDDEN_STATE_KEY as symbol]: AppLock } },
});
const forbidden = wrapper.find('[data-test="o2-table-forbidden"]');
expect(forbidden.find('[data-test="app-lock"]').text()).toBe("alerts");
expect(forbidden.findComponent({ name: "OEmptyState" }).exists()).toBe(false);
});
it("leaves pinning to the <thead> while rows show", () => {
wrapper = mount(OTable, { props: { data: makeRows(5), columns: makeColumns() } });
expect(isPinned(wrapper)).toBe(false);
+2
View File
@@ -482,6 +482,8 @@ export interface OTableProps<TData = any> {
* user to create something they are not allowed to create.
*/
forbidden?: boolean;
/** What the forbidden table lists, as the app's access state names it (an IAM resource key). */
forbiddenResource?: string;
/** Text shown when data is empty and not loading */
emptyMessage?: I18nText;
dense?: boolean;
+9 -1
View File
@@ -4,6 +4,7 @@
import {
computed,
getCurrentInstance,
inject,
nextTick,
onBeforeUnmount,
onMounted,
@@ -63,6 +64,7 @@ import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTableEmpty from "./sub-components/OTableEmpty.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import { TABLE_FORBIDDEN_STATE_KEY } from "./tableForbiddenState";
import OTableLoading from "./sub-components/OTableLoading.vue";
import OTableError from "./sub-components/OTableError.vue";
import OTableOverflowTooltip from "./sub-components/OTableOverflowTooltip.vue";
@@ -1317,6 +1319,7 @@ useTableRowShortcuts(scrollContainerRef);
// itself was not empty.
// A denied fetch and a genuinely empty list are both zero rows; only the caller
// knows which, so `forbidden` decides and takes precedence over the empty state.
const forbiddenState = inject(TABLE_FORBIDDEN_STATE_KEY, null);
const showForbidden = computed(
() => !heldLoading.value && !!props.forbidden && displayRows.value.length === 0,
);
@@ -1874,7 +1877,12 @@ defineExpose({
<!-- ── Access Denied State ───────────────────────────── -->
<OTableEmpty v-if="showForbidden" :floor="!props.fillHeight" data-test="o2-table-forbidden">
<OEmptyState size="hero" preset="no-access" />
<component
:is="forbiddenState"
v-if="forbiddenState"
:resource="props.forbiddenResource"
/>
<OEmptyState v-else size="hero" preset="no-access" />
</OTableEmpty>
<!-- ── Empty State ───────────────────────────────────── -->
@@ -0,0 +1,24 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { provide, type Component, type InjectionKey } from "vue";
/** The app's no-access state for a forbidden table; it receives the table's `forbiddenResource` as `resource`. */
export const TABLE_FORBIDDEN_STATE_KEY: InjectionKey<Component> = Symbol("tableForbiddenState");
/** Renders `component` in place of the generic no-access state in every table below. */
export function provideTableForbiddenState(component: Component): void {
provide(TABLE_FORBIDDEN_STATE_KEY, component);
}
@@ -83,6 +83,22 @@ describe("OPopover", () => {
wrapper.unmount();
});
it("stays open on an outside scroll when closeOnScroll is false", async () => {
const wrapper = mount(OPopover, {
attachTo: document.body,
props: { open: false, closeOnScroll: false },
slots: {
trigger: "<button>Open</button>",
default: '<div data-testid="content">Body</div>',
},
});
await wrapper.setProps({ open: true });
document.dispatchEvent(new Event("scroll"));
const emitted = wrapper.emitted("update:open") ?? [];
expect(emitted.some((args) => args[0] === false)).toBe(false);
wrapper.unmount();
});
it("ignores scrolls inside its own content", async () => {
const wrapper = mount(OPopover, {
attachTo: document.body,
+6 -3
View File
@@ -9,7 +9,7 @@
Shares ODropdown's overlay coordination so descendant overlays (OSelect /
OCombobox / nested dropdowns) opening INSIDE the popover never dismiss it.
Props: open (v-model) | anchor | side | align | flipAlign | sideOffset | arrow | hideWhenDetached | modal | ariaLabel | contentClass | collisionPadding
Props: open (v-model) | anchor | side | align | flipAlign | sideOffset | arrow | hideWhenDetached | closeOnScroll | modal | ariaLabel | contentClass | collisionPadding
Slots: trigger (unused when `anchor` is set) | default | arrow (paths in a 12x6 viewBox)
Emits: update:open | open-auto-focus
-->
@@ -75,6 +75,8 @@ const props = withDefaults(
arrow?: boolean;
/** Hide the content when the trigger scrolls out of view (Floating UI). */
hideWhenDetached?: boolean;
/** Close when an ancestor of the trigger scrolls; false keeps it open, following the trigger and hidden while detached. */
closeOnScroll?: boolean;
ariaLabel?: I18nText;
/** Content stacking order. Default sits above the app header/drawer (2000/3000). */
zIndex?: number;
@@ -89,6 +91,7 @@ const props = withDefaults(
align: "start",
sideOffset: 4,
hideWhenDetached: true,
closeOnScroll: true,
zIndex: 6000,
},
);
@@ -217,11 +220,11 @@ function handleFocusOutside(event: Event) {
const sidebarScrollTick = inject<Ref<number> | null>("sidebarScrollTick", null);
if (sidebarScrollTick) {
watch(sidebarScrollTick, () => {
if (internalOpen.value) handleOpenChange(false);
if (internalOpen.value && props.closeOnScroll) handleOpenChange(false);
});
}
function handleViewportScroll(event: Event) {
if (!internalOpen.value) return;
if (!internalOpen.value || !props.closeOnScroll) return;
const target = event.target as (Element & Node) | Document | null;
if (!target) return;
// Ignore scrolls inside our own or a nested overlay's content.
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "المجموعات",
"serviceAccounts": "حسابات الخدمة",
"cipherKeys": "مفاتيح التشفير",
"regexPatterns": "أنماط Regex"
"regexPatterns": "أنماط Regex",
"evalJobs": "مهام التقييم عبر الإنترنت",
"billingGroup": "مجموعة الفوترة",
"syntheticChecks": "فحوصات المراقبة الاصطناعية",
"metrics": "المقاييس",
"databaseMonitoring": "مراقبة قواعد البيانات",
"workflowFolders": "مجلدات سير العمل",
"promptFolders": "مجلدات المطالبات",
"syntheticEnvironments": "بيئات المراقبة الاصطناعية",
"sourceMaps": "خرائط المصدر",
"alertFolders": "مجلدات التنبيهات",
"serviceStreams": "تدفقات الخدمات",
"fleetManagement": "إدارة الأسطول",
"onCallResponses": "استجابات المناوبة",
"statusPages": "صفحات الحالة",
"onCall": "المناوبة",
"incidentIntegrations": "تكاملات الحوادث",
"incidents": "الحوادث",
"scorers": "أدوات التقييم",
"searchJobs": "مهام البحث",
"enrichmentTables": "جداول الإثراء",
"rumTokens": "رموز RUM",
"traces": "التتبعات",
"remoteTasks": "المهام عن بعد",
"reportFolders": "مجلدات التقارير",
"llmProviders": "مزودو LLM",
"settings": "الإعدادات",
"dashboardFolders": "مجلدات لوحات المعلومات",
"scoreConfigs": "إعدادات النقاط",
"workflows": "سير العمل",
"syntheticFolders": "مجلدات المراقبة الاصطناعية",
"downtimeFolders": "مجلدات فترات الصيانة"
},
"resourcesInline": {
"dashboards": "لوحات المعلومات",
@@ -663,7 +694,38 @@
"serviceAccounts": "حسابات الخدمة",
"cipherKeys": "مفاتيح التشفير",
"regexPatterns": "أنماط Regex",
"unnamed": "هذا المورد"
"unnamed": "هذا المورد",
"onCall": "المناوبة",
"incidentIntegrations": "تكاملات الحوادث",
"scorers": "أدوات التقييم",
"llmProviders": "مزودو LLM",
"enrichmentTables": "جداول الإثراء",
"rumTokens": "رموز RUM",
"settings": "الإعدادات",
"traces": "التتبعات",
"remoteTasks": "المهام عن بعد",
"reportFolders": "مجلدات التقارير",
"dashboardFolders": "مجلدات لوحات المعلومات",
"scoreConfigs": "إعدادات النقاط",
"downtimeFolders": "مجلدات فترات الصيانة",
"workflows": "سير العمل",
"syntheticFolders": "مجلدات المراقبة الاصطناعية",
"evalJobs": "مهام التقييم عبر الإنترنت",
"metrics": "المقاييس",
"databaseMonitoring": "مراقبة قواعد البيانات",
"billingGroup": "مجموعة الفوترة",
"syntheticChecks": "فحوصات المراقبة الاصطناعية",
"workflowFolders": "مجلدات سير العمل",
"promptFolders": "مجلدات المطالبات",
"syntheticEnvironments": "بيئات المراقبة الاصطناعية",
"serviceStreams": "تدفقات الخدمات",
"sourceMaps": "خرائط المصدر",
"statusPages": "صفحات الحالة",
"alertFolders": "مجلدات التنبيهات",
"fleetManagement": "إدارة الأسطول",
"incidents": "الحوادث",
"onCallResponses": "استجابات المناوبة",
"searchJobs": "مهام البحث"
},
"denied": {
"view": "لا يمكنك عرض {resource}",
@@ -674,7 +736,10 @@
"parts": "لا يمكنك عرض {count} جزء من هذه الصفحة | لا يمكنك عرض أجزاء من هذه الصفحة ({count})",
"missingIntro": "يفتقد دورك إلى هذه الصلاحيات:",
"more": "{count} أخرى",
"showLess": "عرض أقل"
"showLess": "عرض أقل",
"actions": "تم حظر {count} إجراء في هذه الصفحة | تم حظر إجراءات في هذه الصفحة ({count})",
"askAdmin": "اطلب الوصول من المسؤول.",
"resources": "{count} مورد | الموارد ({count})"
},
"chip": {
"label": "تم رفض الوصول",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "تضييق النطاق إلى {period} (~{size})",
"autoRunGuardSearchJob": "التشغيل كمهمة بحث",
"autoRunGuardSelectStream": "اختر تدفقًا",
"searchJobNoPermission": "ليس لديك إذن لإنشاء مهام البحث",
"missingStreamAndNoFts": "حقل تصفية واحد أو أكثر غير موجود في \"{missingStreams}\"، لذلك لا يتم إجراء أي بحث في التدفق المذكور.\n لم يتم البحث: {noFtsStreams} (لا توجد حقول نص كامل).",
"freeTextChooseFirst": "اختر طريقة البحث في النص أولاً",
"freeTextScanLogsOnly": "يعمل وضع المسح في عرض السجلات فقط؛ عيّن حقل نص كامل لاستخدام البحث النصي هنا",
@@ -10631,6 +10695,8 @@
"legendNoData": "لا توجد بيانات"
},
"importAlertTitle": "استيراد تنبيه",
"importAlert": "استيراد التنبيه",
"alertLibrary": "مكتبة التنبيهات",
"exportDialogTitle": "تصدير تنبيهات | تصدير تنبيهات {count}",
"exportDialogSubtitle": "راجع التعريف، ثم قم بتحميله ك JSON أو Terraform لمزود OpenObserve.",
"jsonFilesOnlyHint": ".json الملفات فقط",
@@ -13075,7 +13141,6 @@
"statReady": "جاهز للاستخدام",
"statNeedsData": "لم يتم استيعابه",
"statAll": "الجميع",
"notIngested": "لم يتم استيعابه: {stream}",
"notIngestedHint": "هذه المؤسسة لا تتلقى {stream}، لذا لن يتم تفعيل هذا التنبيه أبدا. أضفه على أي حال وسيبدأ العمل بمجرد وصول البيانات.",
"alertCount": "تنبيه {count} تنبيهات | {count}",
"noCollectorTitle": "لا شيء هنا يطابق بياناتك حتى الآن",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "شذوذ",
"enterprise": "Enterprise",
"enterpriseHint": "تتطلب تنبيهات اكتشاف الشذوذ OpenObserve Enterprise.",
"card": {
"customizeTooltip": "يفتح محرر التنبيهات مملوءًا مسبقًا"
},
"drawer": {
"dataAvailable": "البيانات متاحة",
"streamNotFound": "التدفق غير موجود",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "إضافة تنبيهات من المكتبة",
"subtitle": "اختر التنبيهات التي تريد إضافتها، وأين تُحفظ، واختياريًا أين تُعلم.",
"stepDestination": "الوجهة",
"subtitle": "اختر مكان حفظ هذه التنبيهات، ثم أضفها.",
"stepAlerts": "تنبيهات",
"stepFolder": "المجلد",
"stepTune": "لحن",
"stepInstall": "إضافة",
"destinationIntro": "كل تنبيه في هذه الدفعة يُعلم الوجهة التي تختارها هنا. تسمي ملفات المكتبة وجهات المؤسسة التي تم تصديرها منها، والتي لا وجود لها في مؤسستك. هذه الخطوة اختيارية: تخطاها وتُضاف التنبيهات بدونها.",
"destinationLabel": "الوجهة",
"destinationsLoadFailed": "لم يكن بالإمكان تحميل وجهاتك الحالية.",
"retry": "حاول مرة أخرى",
"destinationsNoneBody": "لا توجد وجهات تنبيه لهذه المؤسسة بعد. أنشئ واحدة في صفحة الوجهات متى كنت مستعدًا.",
"openDestinations": "فتح الوجهات",
"selectAll": "تحديد الكل",
"clear": "مسح",
"selectedCount": "{count} من {total} المختارين",
"folderIntro": "يتم حفظ التنبيهات المضافة هنا. أنشئ مجلدا من زر + إذا كانت هذه الدفعة تستحق مجلدها الخاص.",
"tuneToggle": "اضبط هذه البطاقات لكل تنبيه في هذه الدفعة",
"tuneHint": "يمكن ضبط «{frequency}» و«{silence}» فقط لدفعة كاملة. تبقى العتبات خاصة بكل تنبيه — لتغيير إحداها، افتح ذلك التنبيه وأضفه منفردا.",
@@ -13165,7 +13223,6 @@
"next": "التالي",
"cancel": "إلغاء",
"done": "تم",
"confirmLarge": "أفهم أن هذا سينشئ في {folder} تنبيهات عددها {count}، وسيرسل كل منها إشعارا إلى {destination}.",
"tuneThresholdOnly": "ينطبق على تنبيهات الحد فقط.",
"anomalyTrainingNote": "تبدأ تنبيهات الشذوذ في الإطلاق بعد أن تتدرب على قدر كافٍ من سجل التدفق.",
"anomalyUnavailable": "تتطلب تنبيهات الشذوذ OpenObserve Enterprise مع تفعيل اكتشاف الشذوذ، لذا تم تخطي هذا التنبيه.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Gruppen",
"serviceAccounts": "Dienstkonten",
"cipherKeys": "Verschlüsselungsschlüssel",
"regexPatterns": "Regex-Muster"
"regexPatterns": "Regex-Muster",
"evalJobs": "Online-Eval-Jobs",
"billingGroup": "Abrechnungsgruppe",
"syntheticChecks": "Synthetics-Checks",
"metrics": "Metriken",
"databaseMonitoring": "Datenbanküberwachung",
"workflowFolders": "Workflow-Ordner",
"promptFolders": "Prompt-Ordner",
"syntheticEnvironments": "Synthetics-Umgebungen",
"sourceMaps": "Source Maps",
"alertFolders": "Alarmordner",
"serviceStreams": "Service-Streams",
"fleetManagement": "Fleet-Verwaltung",
"onCallResponses": "On-Call-Antworten",
"statusPages": "Statusseiten",
"onCall": "On-Call",
"incidentIntegrations": "Vorfall-Integrationen",
"incidents": "Vorfälle",
"scorers": "Scorer",
"searchJobs": "Suchaufträge",
"enrichmentTables": "Anreicherungstabellen",
"rumTokens": "RUM-Tokens",
"traces": "Traces",
"remoteTasks": "Remote-Tasks",
"reportFolders": "Berichtsordner",
"llmProviders": "LLM-Anbieter",
"settings": "Einstellungen",
"dashboardFolders": "Dashboard-Ordner",
"scoreConfigs": "Score-Konfigurationen",
"workflows": "Workflows",
"syntheticFolders": "Synthetics-Ordner",
"downtimeFolders": "Wartungsfenster-Ordner"
},
"resourcesInline": {
"dashboards": "Dashboards",
@@ -663,7 +694,38 @@
"serviceAccounts": "Dienstkonten",
"cipherKeys": "Verschlüsselungsschlüssel",
"regexPatterns": "Regex-Muster",
"unnamed": "diese Ressource"
"unnamed": "diese Ressource",
"onCall": "On-Call",
"incidentIntegrations": "Vorfall-Integrationen",
"scorers": "Scorer",
"llmProviders": "LLM-Anbieter",
"enrichmentTables": "Anreicherungstabellen",
"rumTokens": "RUM-Tokens",
"settings": "Einstellungen",
"traces": "Traces",
"remoteTasks": "Remote-Tasks",
"reportFolders": "Berichtsordner",
"dashboardFolders": "Dashboard-Ordner",
"scoreConfigs": "Score-Konfigurationen",
"downtimeFolders": "Wartungsfenster-Ordner",
"workflows": "Workflows",
"syntheticFolders": "Synthetics-Ordner",
"evalJobs": "Online-Eval-Jobs",
"metrics": "Metriken",
"databaseMonitoring": "Datenbanküberwachung",
"billingGroup": "Abrechnungsgruppe",
"syntheticChecks": "Synthetics-Checks",
"workflowFolders": "Workflow-Ordner",
"promptFolders": "Prompt-Ordner",
"syntheticEnvironments": "Synthetics-Umgebungen",
"serviceStreams": "Service-Streams",
"sourceMaps": "Source Maps",
"statusPages": "Statusseiten",
"alertFolders": "Alarmordner",
"fleetManagement": "Fleet-Verwaltung",
"incidents": "Vorfälle",
"onCallResponses": "On-Call-Antworten",
"searchJobs": "Suchaufträge"
},
"denied": {
"view": "Sie können {resource} nicht anzeigen",
@@ -674,7 +736,10 @@
"parts": "Sie können {count} Teil dieser Seite nicht anzeigen | Sie können {count} Teile dieser Seite nicht anzeigen",
"missingIntro": "Ihrer Rolle fehlen diese Berechtigungen:",
"more": "{count} weitere",
"showLess": "Weniger anzeigen"
"showLess": "Weniger anzeigen",
"actions": "{count} Aktion auf dieser Seite ist gesperrt | {count} Aktionen auf dieser Seite sind gesperrt",
"askAdmin": "Bitten Sie Ihren Administrator um Zugriff.",
"resources": "{count} Ressource | {count} Ressourcen"
},
"chip": {
"label": "Zugriff verweigert",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Auf {period} eingrenzen (~{size})",
"autoRunGuardSearchJob": "Als Suchjob ausführen",
"autoRunGuardSelectStream": "Stream auswählen",
"searchJobNoPermission": "Sie haben keine Berechtigung, Suchjobs zu erstellen",
"missingStreamAndNoFts": "Ein oder mehrere Filterfelder existieren nicht in \"{missingStreams}\", daher wird im genannten Stream keine Suche durchgeführt.\n Nicht durchsucht: {noFtsStreams} (keine Volltextfelder).",
"freeTextChooseFirst": "Wählen Sie zuerst, wie Text durchsucht werden soll",
"freeTextScanLogsOnly": "Der Scan-Modus funktioniert nur in der Logs-Ansicht; legen Sie ein Volltextfeld fest, um hier die Textsuche zu verwenden",
@@ -10631,6 +10695,8 @@
"legendNoData": "Keine Daten"
},
"importAlertTitle": "Alarm importieren",
"importAlert": "Alarm importieren",
"alertLibrary": "Alarmbibliothek",
"exportDialogTitle": "Alarm exportieren | {count} Alarme exportieren",
"exportDialogSubtitle": "Überprüfen Sie die Definition und laden Sie sie dann als JSON oder als Terraform für den OpenObserve-Provider herunter.",
"jsonFilesOnlyHint": "Nur .json-Dateien",
@@ -13075,7 +13141,6 @@
"statReady": "Einsatzbereit",
"statNeedsData": "Nicht erfasst",
"statAll": "Alle",
"notIngested": "Nicht erfasst: {stream}",
"notIngestedHint": "Diese Organisation empfängt {stream} nicht, daher würde dieser Alarm nie ausgelöst. Fügen Sie ihn trotzdem hinzu, und er funktioniert, sobald die Daten eintreffen.",
"alertCount": "{count} Alarm | {count} Alarme",
"noCollectorTitle": "Hier passt noch nichts zu Ihren Daten.",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomalie",
"enterprise": "Enterprise",
"enterpriseHint": "Alarme zur Anomalieerkennung erfordern OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Öffnet den Alarm-Editor vorausgefüllt"
},
"drawer": {
"dataAvailable": "Daten verfügbar",
"streamNotFound": "Stream nicht gefunden",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Alarme aus der Bibliothek hinzufügen",
"subtitle": "Wählen Sie aus, welche Alarme hinzugefügt werden, wo sie abgelegt werden und optional, wohin sie benachrichtigen.",
"stepDestination": "Ziel",
"subtitle": "Wählen Sie, wo diese Alarme abgelegt werden, und fügen Sie sie dann hinzu.",
"stepAlerts": "Alarme",
"stepFolder": "Ordner",
"stepTune": "Anpassen",
"stepInstall": "Hinzufügen",
"destinationIntro": "Jeder Alarm in diesem Stapel benachrichtigt das Ziel, das Sie hier auswählen. Bibliotheksdateien nennen die Ziele der Organisation, aus der sie exportiert wurden – diese existieren in Ihrer nicht. Dieser Schritt ist optional: Überspringen Sie ihn, und die Alarme werden ohne Ziel hinzugefügt.",
"destinationLabel": "Ziel",
"destinationsLoadFailed": "Ihre vorhandenen Ziele konnten nicht geladen werden.",
"retry": "Erneut versuchen",
"destinationsNoneBody": "Diese Organisation hat noch keine Alarm-Ziele. Erstellen Sie eines auf der Seite Ziele, sobald Sie bereit sind.",
"openDestinations": "Ziele öffnen",
"selectAll": "Alle auswählen",
"clear": "Löschen",
"selectedCount": "{count} von {total} ausgewählt",
"folderIntro": "Hinzugefügte Alarme werden hier abgelegt. Erstellen Sie über die +-Schaltfläche einen Ordner, wenn dieser Stapel einen eigenen verdient.",
"tuneToggle": "Diese für jeden Alarm in diesem Stapel festlegen",
"tuneHint": "Nur „{frequency}“ und „{silence}“ können sicher für einen ganzen Stapel festgelegt werden. Schwellenwerte bleiben pro Alarm – ändern Sie einen, indem Sie den Alarm öffnen und ihn einzeln hinzufügen.",
@@ -13165,7 +13223,6 @@
"next": "Weiter",
"cancel": "Abbrechen",
"done": "Fertig",
"confirmLarge": "Mir ist klar, dass dies {count} Alarme in {folder} erstellt, die jeweils {destination} benachrichtigen.",
"tuneThresholdOnly": "Gilt nur für Schwellenwert-Alarme.",
"anomalyTrainingNote": "Anomalie-Alarme lösen erst aus, wenn sie mit genügend Verlauf des Streams trainiert wurden.",
"anomalyUnavailable": "Anomalie-Alarme erfordern OpenObserve Enterprise mit aktivierter Anomalieerkennung, daher wurde dieser übersprungen.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Groups",
"serviceAccounts": "Service accounts",
"cipherKeys": "Cipher keys",
"regexPatterns": "Regex patterns"
"regexPatterns": "Regex patterns",
"evalJobs": "Online eval jobs",
"billingGroup": "Billing group",
"syntheticChecks": "Synthetic checks",
"metrics": "Metrics",
"databaseMonitoring": "Database monitoring",
"workflowFolders": "Workflow folders",
"promptFolders": "Prompt folders",
"syntheticEnvironments": "Synthetic environments",
"sourceMaps": "Source maps",
"alertFolders": "Alert folders",
"serviceStreams": "Service streams",
"fleetManagement": "Fleet management",
"onCallResponses": "On-Call responses",
"statusPages": "Status pages",
"onCall": "On-Call",
"incidentIntegrations": "Incident integrations",
"incidents": "Incidents",
"scorers": "Scorers",
"searchJobs": "Search jobs",
"enrichmentTables": "Enrichment tables",
"rumTokens": "RUM tokens",
"traces": "Traces",
"remoteTasks": "Remote tasks",
"reportFolders": "Report folders",
"llmProviders": "LLM providers",
"settings": "Settings",
"dashboardFolders": "Dashboard folders",
"scoreConfigs": "Score configs",
"workflows": "Workflows",
"syntheticFolders": "Synthetic folders",
"downtimeFolders": "Downtime folders"
},
"resourcesInline": {
"dashboards": "dashboards",
@@ -663,7 +694,38 @@
"serviceAccounts": "service accounts",
"cipherKeys": "cipher keys",
"regexPatterns": "regex patterns",
"unnamed": "this resource"
"unnamed": "this resource",
"onCall": "On-Call",
"incidentIntegrations": "incident integrations",
"scorers": "scorers",
"llmProviders": "LLM providers",
"enrichmentTables": "enrichment tables",
"rumTokens": "RUM tokens",
"settings": "settings",
"traces": "traces",
"remoteTasks": "remote tasks",
"reportFolders": "report folders",
"dashboardFolders": "dashboard folders",
"scoreConfigs": "score configs",
"downtimeFolders": "downtime folders",
"workflows": "workflows",
"syntheticFolders": "synthetic folders",
"evalJobs": "online eval jobs",
"metrics": "metrics",
"databaseMonitoring": "database monitoring",
"billingGroup": "billing group",
"syntheticChecks": "synthetic checks",
"workflowFolders": "workflow folders",
"promptFolders": "prompt folders",
"syntheticEnvironments": "synthetic environments",
"serviceStreams": "service streams",
"sourceMaps": "source maps",
"statusPages": "status pages",
"alertFolders": "alert folders",
"fleetManagement": "fleet management",
"incidents": "incidents",
"onCallResponses": "On-Call responses",
"searchJobs": "search jobs"
},
"denied": {
"view": "You can't view {resource}",
@@ -674,7 +736,10 @@
"parts": "You can't view {count} part of this page | You can't view {count} parts of this page",
"missingIntro": "Your role is missing these permissions:",
"more": "{count} more",
"showLess": "Show less"
"showLess": "Show less",
"actions": "{count} action on this page is blocked | {count} actions on this page are blocked",
"askAdmin": "Ask your administrator for access.",
"resources": "{count} resource | {count} resources"
},
"chip": {
"label": "Access denied",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Narrow to {period} (~{size})",
"autoRunGuardSearchJob": "Run as search job",
"autoRunGuardSelectStream": "Select a stream",
"searchJobNoPermission": "You do not have permission to create search jobs",
"missingStreamAndNoFts": "One or more filter fields do not exist in \"{missingStreams}\", hence no search is performed in the mentioned stream.\n Not searched: {noFtsStreams} (no full-text fields).",
"freeTextChooseFirst": "Choose how to search text first",
"freeTextScanLogsOnly": "Scan mode works in the logs view only; set a full-text field to use text search here",
@@ -10629,6 +10693,8 @@
"legendNoData": "No data"
},
"importAlertTitle": "Import Alert",
"importAlert": "Import alert",
"alertLibrary": "Alert library",
"exportDialogTitle": "Export alert | Export {count} alerts",
"exportDialogSubtitle": "Review the definition, then download it as JSON or as Terraform for the OpenObserve provider.",
"jsonFilesOnlyHint": ".json files only",
@@ -13073,7 +13139,6 @@
"statReady": "Ready to use",
"statNeedsData": "Not ingested",
"statAll": "All",
"notIngested": "Not ingested: {stream}",
"notIngestedHint": "This organization is not receiving {stream}, so this alert would never fire. Add it anyway and it will start working once the data arrives.",
"alertCount": "{count} alert | {count} alerts",
"noCollectorTitle": "Nothing here matches your data yet",
@@ -13100,6 +13165,9 @@
"alertTypeAnomaly": "Anomaly",
"enterprise": "Enterprise",
"enterpriseHint": "Anomaly detection alerts need OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Opens the alert editor prefilled"
},
"drawer": {
"dataAvailable": "Data available",
"streamNotFound": "Stream not found",
@@ -13130,21 +13198,11 @@
},
"install": {
"title": "Add alerts from the library",
"subtitle": "Choose which alerts to add, where they are filed, and optionally where they notify.",
"stepDestination": "Destination",
"subtitle": "Choose where these alerts are filed, then add them.",
"stepAlerts": "Alerts",
"stepFolder": "Folder",
"stepTune": "Tune",
"stepInstall": "Add",
"destinationIntro": "Every alert in this batch notifies the destination you choose here. Library files name the destinations of the organization they were exported from, which do not exist in yours. This step is optional: skip it and the alerts are added without one.",
"destinationLabel": "Destination",
"destinationsLoadFailed": "Your existing destinations could not be loaded.",
"retry": "Try again",
"destinationsNoneBody": "This organization has no alert destinations yet. Create one on the Destinations page whenever you are ready.",
"openDestinations": "Open Destinations",
"selectAll": "Select all",
"clear": "Clear",
"selectedCount": "{count} of {total} selected",
"folderIntro": "Added alerts are filed here. Create a folder from the + button if this batch deserves its own.",
"tuneToggle": "Set these for every alert in this batch",
"tuneHint": "Only “{frequency}” and “{silence}” are safe to set for a whole batch. Thresholds stay per-alert — change one by opening that alert and adding it on its own.",
@@ -13163,7 +13221,6 @@
"next": "Next",
"cancel": "Cancel",
"done": "Done",
"confirmLarge": "I understand this creates {count} alerts in {folder}, each notifying {destination}.",
"tuneThresholdOnly": "Applies to threshold alerts only.",
"anomalyTrainingNote": "Anomaly alerts start alerting once they have trained on enough of the stream's history.",
"anomalyUnavailable": "Anomaly alerts need OpenObserve Enterprise with anomaly detection turned on, so this one was skipped.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Grupos",
"serviceAccounts": "Cuentas de servicio",
"cipherKeys": "Claves de cifrado",
"regexPatterns": "Patrones de expresiones regulares"
"regexPatterns": "Patrones de expresiones regulares",
"evalJobs": "Trabajos de evaluación en línea",
"billingGroup": "Grupo de facturación",
"syntheticChecks": "Verificaciones sintéticas",
"metrics": "Métricas",
"databaseMonitoring": "Monitorización de bases de datos",
"workflowFolders": "Carpetas de flujos de trabajo",
"promptFolders": "Carpetas de prompts",
"syntheticEnvironments": "Entornos de sintéticos",
"sourceMaps": "Source maps",
"alertFolders": "Carpetas de alertas",
"serviceStreams": "Flujos de servicio",
"fleetManagement": "Gestión de flotas",
"onCallResponses": "Respuestas de guardia",
"statusPages": "Páginas de estado",
"onCall": "Guardia",
"incidentIntegrations": "Integraciones de incidentes",
"incidents": "Incidentes",
"scorers": "Evaluadores",
"searchJobs": "Trabajos de búsqueda",
"enrichmentTables": "Tablas de enriquecimiento",
"rumTokens": "Tokens de RUM",
"traces": "Trazas",
"remoteTasks": "Tareas remotas",
"reportFolders": "Carpetas de informes",
"llmProviders": "Proveedores de LLM",
"settings": "Ajustes",
"dashboardFolders": "Carpetas de dashboards",
"scoreConfigs": "Configuraciones de puntuación",
"workflows": "Flujos de trabajo",
"syntheticFolders": "Carpetas de sintéticos",
"downtimeFolders": "Carpetas de periodos de mantenimiento"
},
"resourcesInline": {
"dashboards": "dashboards",
@@ -663,7 +694,38 @@
"serviceAccounts": "cuentas de servicio",
"cipherKeys": "claves de cifrado",
"regexPatterns": "patrones de expresiones regulares",
"unnamed": "este recurso"
"unnamed": "este recurso",
"onCall": "guardia",
"incidentIntegrations": "integraciones de incidentes",
"scorers": "evaluadores",
"llmProviders": "proveedores de LLM",
"enrichmentTables": "tablas de enriquecimiento",
"rumTokens": "tokens de RUM",
"settings": "ajustes",
"traces": "trazas",
"remoteTasks": "tareas remotas",
"reportFolders": "carpetas de informes",
"dashboardFolders": "carpetas de dashboards",
"scoreConfigs": "configuraciones de puntuación",
"downtimeFolders": "carpetas de periodos de mantenimiento",
"workflows": "flujos de trabajo",
"syntheticFolders": "carpetas de sintéticos",
"evalJobs": "trabajos de evaluación en línea",
"metrics": "métricas",
"databaseMonitoring": "monitorización de bases de datos",
"billingGroup": "grupo de facturación",
"syntheticChecks": "verificaciones sintéticas",
"workflowFolders": "carpetas de flujos de trabajo",
"promptFolders": "carpetas de prompts",
"syntheticEnvironments": "entornos de sintéticos",
"serviceStreams": "flujos de servicio",
"sourceMaps": "source maps",
"statusPages": "páginas de estado",
"alertFolders": "carpetas de alertas",
"fleetManagement": "gestión de flotas",
"incidents": "incidentes",
"onCallResponses": "respuestas de guardia",
"searchJobs": "trabajos de búsqueda"
},
"denied": {
"view": "No puedes ver {resource}",
@@ -674,7 +736,10 @@
"parts": "No puedes ver {count} parte de esta página | No puedes ver {count} partes de esta página",
"missingIntro": "A tu rol le faltan estos permisos:",
"more": "{count} más",
"showLess": "Mostrar menos"
"showLess": "Mostrar menos",
"actions": "{count} acción de esta página está bloqueada | {count} acciones de esta página están bloqueadas",
"askAdmin": "Pide acceso a tu administrador.",
"resources": "{count} recurso | {count} recursos"
},
"chip": {
"label": "Acceso denegado",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Acotar a {period} (~{size})",
"autoRunGuardSearchJob": "Ejecutar como trabajo de búsqueda",
"autoRunGuardSelectStream": "Selecciona un stream",
"searchJobNoPermission": "No tienes permiso para crear trabajos de búsqueda",
"missingStreamAndNoFts": "Uno o más campos de filtro no existen en \"{missingStreams}\", por lo que no se realiza ninguna búsqueda en el stream mencionado.\n No se buscó en: {noFtsStreams} (sin campos de texto completo).",
"freeTextChooseFirst": "Elige primero cómo buscar texto",
"freeTextScanLogsOnly": "El modo de escaneo solo funciona en la vista de logs; configura un campo de texto completo para usar la búsqueda de texto aquí",
@@ -10631,6 +10695,8 @@
"legendNoData": "Sin datos"
},
"importAlertTitle": "Importar alerta",
"importAlert": "Importar alerta",
"alertLibrary": "Biblioteca de alertas",
"exportDialogTitle": "Exportar alerta | Exportar {count} alertas",
"exportDialogSubtitle": "Revisa la definición y luego descárgala como JSON o como Terraform para el proveedor de OpenObserve.",
"jsonFilesOnlyHint": "Solo archivos .json",
@@ -13075,7 +13141,6 @@
"statReady": "Lista para usar",
"statNeedsData": "Sin ingesta",
"statAll": "Todas",
"notIngested": "Sin ingesta: {stream}",
"notIngestedHint": "Esta organización no está recibiendo {stream}, por lo que esta alerta nunca se activaría. Añádela de todos modos y empezará a funcionar cuando lleguen los datos.",
"alertCount": "{count} alerta | {count} alertas",
"noCollectorTitle": "Nada aquí coincide con sus datos todavía",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomalía",
"enterprise": "Enterprise",
"enterpriseHint": "Las alertas de detección de anomalías requieren OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Abre el editor de alertas con los datos rellenados"
},
"drawer": {
"dataAvailable": "Datos disponibles",
"streamNotFound": "Stream no encontrado",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Añadir alertas de la biblioteca",
"subtitle": "Elige qué alertas agregar, dónde se archivan y, opcionalmente, a dónde notifican.",
"stepDestination": "Destino",
"subtitle": "Elige dónde se archivan estas alertas y, a continuación, añádelas.",
"stepAlerts": "Alertas",
"stepFolder": "Carpeta",
"stepTune": "Ajustar",
"stepInstall": "Añadir",
"destinationIntro": "Cada alerta de este lote notifica al destino que elijas aquí. Los archivos de la biblioteca nombran los destinos de la organización desde la que se exportaron, que no existen en la tuya. Este paso es opcional: omítelo y las alertas se agregarán sin destino.",
"destinationLabel": "Destino",
"destinationsLoadFailed": "No se pudieron cargar tus destinos existentes.",
"retry": "Inténtalo de nuevo",
"destinationsNoneBody": "Esta organización aún no tiene destinos de alertas. Crea uno en la página Destinos cuando quieras.",
"openDestinations": "Abrir Destinos",
"selectAll": "Seleccionar todo",
"clear": "Limpiar",
"selectedCount": "{count} de {total} seleccionados",
"folderIntro": "Las alertas añadidas se archivan aquí. Crea una carpeta desde el botón + si este lote merece una propia.",
"tuneToggle": "Establecer estos para cada alerta de este lote",
"tuneHint": "Solo “{frequency}” y “{silence}” se pueden establecer de forma segura para todo un lote. Los umbrales se mantienen por alerta: cambia uno abriendo esa alerta y añadiéndola por separado.",
@@ -13165,7 +13223,6 @@
"next": "Siguiente",
"cancel": "Cancelar",
"done": "Listo",
"confirmLarge": "Entiendo que esto crea {count} alertas en {folder}, cada una notificando a {destination}.",
"tuneThresholdOnly": "Solo se aplica a las alertas de umbral.",
"anomalyTrainingNote": "Las alertas de anomalías empiezan a avisar cuando se han entrenado con suficiente historial del flujo.",
"anomalyUnavailable": "Las alertas de anomalías requieren OpenObserve Enterprise con la detección de anomalías activada, así que esta se ha omitido.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Groupes",
"serviceAccounts": "Comptes de service",
"cipherKeys": "Clés de chiffrement",
"regexPatterns": "Motifs regex"
"regexPatterns": "Motifs regex",
"evalJobs": "Tâches d'évaluation en ligne",
"billingGroup": "Groupe de facturation",
"syntheticChecks": "Vérifications synthétiques",
"metrics": "Métriques",
"databaseMonitoring": "Surveillance des bases de données",
"workflowFolders": "Dossiers de workflows",
"promptFolders": "Dossiers de prompts",
"syntheticEnvironments": "Environnements synthétiques",
"sourceMaps": "Source maps",
"alertFolders": "Dossiers d'alertes",
"serviceStreams": "Flux de services",
"fleetManagement": "Gestion de flotte",
"onCallResponses": "Réponses d'astreinte",
"statusPages": "Pages de statut",
"onCall": "Astreinte",
"incidentIntegrations": "Intégrations d'incidents",
"incidents": "Incidents",
"scorers": "Évaluateurs",
"searchJobs": "Tâches de recherche",
"enrichmentTables": "Tableaux d'enrichissement",
"rumTokens": "Jetons RUM",
"traces": "Traces",
"remoteTasks": "Tâches distantes",
"reportFolders": "Dossiers de rapports",
"llmProviders": "Fournisseurs de LLM",
"settings": "Réglages",
"dashboardFolders": "Dossiers de tableaux de bord",
"scoreConfigs": "Configurations de score",
"workflows": "Workflows",
"syntheticFolders": "Dossiers synthétiques",
"downtimeFolders": "Dossiers de plages de maintenance"
},
"resourcesInline": {
"dashboards": "tableaux de bord",
@@ -663,7 +694,38 @@
"serviceAccounts": "comptes de service",
"cipherKeys": "clés de chiffrement",
"regexPatterns": "motifs regex",
"unnamed": "cette ressource"
"unnamed": "cette ressource",
"onCall": "astreinte",
"incidentIntegrations": "intégrations d'incidents",
"scorers": "évaluateurs",
"llmProviders": "fournisseurs de LLM",
"enrichmentTables": "tableaux d'enrichissement",
"rumTokens": "jetons RUM",
"settings": "réglages",
"traces": "traces",
"remoteTasks": "tâches distantes",
"reportFolders": "dossiers de rapports",
"dashboardFolders": "dossiers de tableaux de bord",
"scoreConfigs": "configurations de score",
"downtimeFolders": "dossiers de plages de maintenance",
"workflows": "workflows",
"syntheticFolders": "dossiers synthétiques",
"evalJobs": "tâches d'évaluation en ligne",
"metrics": "métriques",
"databaseMonitoring": "surveillance des bases de données",
"billingGroup": "groupe de facturation",
"syntheticChecks": "vérifications synthétiques",
"workflowFolders": "dossiers de workflows",
"promptFolders": "dossiers de prompts",
"syntheticEnvironments": "environnements synthétiques",
"serviceStreams": "flux de services",
"sourceMaps": "source maps",
"statusPages": "pages de statut",
"alertFolders": "dossiers d'alertes",
"fleetManagement": "gestion de flotte",
"incidents": "incidents",
"onCallResponses": "réponses d'astreinte",
"searchJobs": "tâches de recherche"
},
"denied": {
"view": "Vous ne pouvez pas afficher {resource}",
@@ -674,7 +736,10 @@
"parts": "Vous ne pouvez pas afficher {count} partie de cette page | Vous ne pouvez pas afficher {count} parties de cette page",
"missingIntro": "Il manque à votre rôle ces autorisations :",
"more": "{count} de plus",
"showLess": "Afficher moins"
"showLess": "Afficher moins",
"actions": "{count} action sur cette page est bloquée | {count} actions sur cette page sont bloquées",
"askAdmin": "Demandez l'accès à votre administrateur.",
"resources": "{count} ressource | {count} ressources"
},
"chip": {
"label": "Accès refusé",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Restreindre à {period} (~{size})",
"autoRunGuardSearchJob": "Exécuter comme tâche de recherche",
"autoRunGuardSelectStream": "Sélectionnez un flux",
"searchJobNoPermission": "Vous n'avez pas la permission de créer des tâches de recherche",
"missingStreamAndNoFts": "Un ou plusieurs champs de filtre n'existent pas dans « {missingStreams} », aucune recherche n'est donc effectuée dans le flux mentionné.\n Non recherché : {noFtsStreams} (aucun champ en texte intégral).",
"freeTextChooseFirst": "Choisissez d'abord comment rechercher le texte",
"freeTextScanLogsOnly": "Le mode scan ne fonctionne que dans la vue des logs ; définissez un champ en texte intégral pour utiliser la recherche textuelle ici",
@@ -10631,6 +10695,8 @@
"legendNoData": "Aucune donnée"
},
"importAlertTitle": "Importer une alerte",
"importAlert": "Importer une alerte",
"alertLibrary": "Bibliothèque d'alertes",
"exportDialogTitle": "Exporter l'alerte | Exporter {count} alertes",
"exportDialogSubtitle": "Examinez la définition, puis téléchargez-la au format JSON ou Terraform pour le fournisseur OpenObserve.",
"jsonFilesOnlyHint": "Fichiers .json uniquement",
@@ -13075,7 +13141,6 @@
"statReady": "Prêt à l'emploi",
"statNeedsData": "Non ingéré",
"statAll": "Tous",
"notIngested": "Non ingéré : {stream}",
"notIngestedHint": "Cette organisation ne reçoit pas {stream}, donc cette alerte ne se déclencherait jamais. Ajoutez-la quand même et elle commencera à fonctionner dès que les données arriveront.",
"alertCount": "{count} alerte | {count} alertes",
"noCollectorTitle": "Rien ici ne correspond encore à vos données",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomalie",
"enterprise": "Enterprise",
"enterpriseHint": "Les alertes de détection d'anomalies nécessitent OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Ouvre l'éditeur d'alertes prérempli"
},
"drawer": {
"dataAvailable": "Données disponibles",
"streamNotFound": "Flux introuvable",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Ajouter des alertes depuis la bibliothèque",
"subtitle": "Choisissez les alertes à ajouter, où elles sont classées et, en option, où elles notifient.",
"stepDestination": "Destination",
"subtitle": "Choisissez où ces alertes sont classées, puis ajoutez-les.",
"stepAlerts": "Alertes",
"stepFolder": "Dossier",
"stepTune": "Réglage",
"stepInstall": "Ajouter",
"destinationIntro": "Chaque alerte de ce lot notifie la destination que vous choisissez ici. Les fichiers de la bibliothèque nomment les destinations de l'organisation d'où ils ont été exportés, lesquelles n'existent pas dans la vôtre. Cette étape est facultative : passez-la et les alertes sont ajoutées sans destination.",
"destinationLabel": "Destination",
"destinationsLoadFailed": "Vos destinations existantes n'ont pas pu être chargées.",
"retry": "Réessayer",
"destinationsNoneBody": "Cette organisation n'a pas encore de destination d'alerte. Créez-en une sur la page Destinations quand vous le souhaitez.",
"openDestinations": "Ouvrir Destinations",
"selectAll": "Tout sélectionner",
"clear": "Effacer",
"selectedCount": "{count} sur {total} sélectionnés",
"folderIntro": "Les alertes ajoutées sont classées ici. Créez un dossier à partir du bouton + si ce lot mérite un dossier dédié.",
"tuneToggle": "Définir ces paramètres pour chaque alerte de ce lot",
"tuneHint": "Seuls « {frequency} » et « {silence} » peuvent être définis pour tout un lot. Les seuils restent spécifiques à chaque alerte — modifiez-en un en ouvrant cette alerte et en l'ajoutant séparément.",
@@ -13165,7 +13223,6 @@
"next": "Suivant",
"cancel": "Annuler",
"done": "Terminé",
"confirmLarge": "Je comprends que cela crée {count} alertes dans {folder}, chacune notifiant {destination}.",
"tuneThresholdOnly": "S'applique uniquement aux alertes à seuil.",
"anomalyTrainingNote": "Les alertes d'anomalie commencent à se déclencher une fois entraînées sur un historique suffisant du flux.",
"anomalyUnavailable": "Les alertes d'anomalie nécessitent OpenObserve Enterprise avec la détection d'anomalies activée ; celle-ci a donc été ignorée.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Gruppi",
"serviceAccounts": "Account di servizio",
"cipherKeys": "Chiavi di cifratura",
"regexPatterns": "Pattern regex"
"regexPatterns": "Pattern regex",
"evalJobs": "Job di valutazione online",
"billingGroup": "Gruppo di fatturazione",
"syntheticChecks": "Controlli Synthetics",
"metrics": "Metriche",
"databaseMonitoring": "Monitoraggio dei database",
"workflowFolders": "Cartelle dei workflow",
"promptFolders": "Cartelle dei prompt",
"syntheticEnvironments": "Ambienti Synthetics",
"sourceMaps": "Source map",
"alertFolders": "Cartelle degli avvisi",
"serviceStreams": "Stream dei servizi",
"fleetManagement": "Gestione della flotta",
"onCallResponses": "Risposte di reperibilità",
"statusPages": "Pagine di stato",
"onCall": "Reperibilità",
"incidentIntegrations": "Integrazioni degli incidenti",
"incidents": "Incidenti",
"scorers": "Valutatori",
"searchJobs": "Job di ricerca",
"enrichmentTables": "Tabelle di arricchimento",
"rumTokens": "Token RUM",
"traces": "Tracce",
"remoteTasks": "Attività remote",
"reportFolders": "Cartelle dei report",
"llmProviders": "Provider LLM",
"settings": "Impostazioni",
"dashboardFolders": "Cartelle delle dashboard",
"scoreConfigs": "Configurazioni dei punteggi",
"workflows": "Workflow",
"syntheticFolders": "Cartelle Synthetics",
"downtimeFolders": "Cartelle dei downtime"
},
"resourcesInline": {
"dashboards": "dashboard",
@@ -663,7 +694,38 @@
"serviceAccounts": "account di servizio",
"cipherKeys": "chiavi di cifratura",
"regexPatterns": "pattern regex",
"unnamed": "questa risorsa"
"unnamed": "questa risorsa",
"onCall": "reperibilità",
"incidentIntegrations": "integrazioni degli incidenti",
"scorers": "valutatori",
"llmProviders": "provider LLM",
"enrichmentTables": "tabelle di arricchimento",
"rumTokens": "token RUM",
"settings": "impostazioni",
"traces": "tracce",
"remoteTasks": "attività remote",
"reportFolders": "cartelle dei report",
"dashboardFolders": "cartelle delle dashboard",
"scoreConfigs": "configurazioni dei punteggi",
"downtimeFolders": "cartelle dei downtime",
"workflows": "workflow",
"syntheticFolders": "cartelle Synthetics",
"evalJobs": "job di valutazione online",
"metrics": "metriche",
"databaseMonitoring": "monitoraggio dei database",
"billingGroup": "gruppo di fatturazione",
"syntheticChecks": "controlli Synthetics",
"workflowFolders": "cartelle dei workflow",
"promptFolders": "cartelle dei prompt",
"syntheticEnvironments": "ambienti Synthetics",
"serviceStreams": "stream dei servizi",
"sourceMaps": "source map",
"statusPages": "pagine di stato",
"alertFolders": "cartelle degli avvisi",
"fleetManagement": "gestione della flotta",
"incidents": "incidenti",
"onCallResponses": "risposte di reperibilità",
"searchJobs": "job di ricerca"
},
"denied": {
"view": "Non puoi visualizzare {resource}",
@@ -674,7 +736,10 @@
"parts": "Non puoi visualizzare {count} parte di questa pagina | Non puoi visualizzare {count} parti di questa pagina",
"missingIntro": "Al tuo ruolo mancano questi permessi:",
"more": "altri {count}",
"showLess": "Mostra meno"
"showLess": "Mostra meno",
"actions": "{count} azione su questa pagina è bloccata | {count} azioni su questa pagina sono bloccate",
"askAdmin": "Chiedi l'accesso al tuo amministratore.",
"resources": "{count} risorsa | {count} risorse"
},
"chip": {
"label": "Accesso negato",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Restringi a {period} (~{size})",
"autoRunGuardSearchJob": "Esegui come job di ricerca",
"autoRunGuardSelectStream": "Seleziona uno stream",
"searchJobNoPermission": "Non hai l'autorizzazione per creare job di ricerca",
"missingStreamAndNoFts": "Uno o più campi filtro non esistono in \"{missingStreams}\", quindi non viene eseguita alcuna ricerca nello stream indicato.\n Non cercati: {noFtsStreams} (nessun campo full-text).",
"freeTextChooseFirst": "Scegli prima come cercare il testo",
"freeTextScanLogsOnly": "La modalità scansione funziona solo nella vista dei log; imposta un campo full-text per usare qui la ricerca testuale",
@@ -10631,6 +10695,8 @@
"legendNoData": "Nessun dato"
},
"importAlertTitle": "Importa avviso",
"importAlert": "Importa avviso",
"alertLibrary": "Libreria avvisi",
"exportDialogTitle": "Esporta avviso | Esporta {count} avvisi",
"exportDialogSubtitle": "Rivedi la definizione, poi scaricala come JSON o come Terraform per il provider OpenObserve.",
"jsonFilesOnlyHint": "Solo file .json",
@@ -13075,7 +13141,6 @@
"statReady": "Pronti all'uso",
"statNeedsData": "Non ingerito",
"statAll": "Tutti",
"notIngested": "Non ingerito: {stream}",
"notIngestedHint": "Questa organizzazione non riceve {stream}, quindi questo avviso non scatterà mai. Aggiungilo comunque e inizierà a funzionare quando i dati arriveranno.",
"alertCount": "{count} avviso | {count} avvisi",
"noCollectorTitle": "Niente qui corrisponde ancora ai tuoi dati",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomalia",
"enterprise": "Enterprise",
"enterpriseHint": "Gli avvisi di rilevamento anomalie richiedono OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Apre l'editor degli avvisi precompilato"
},
"drawer": {
"dataAvailable": "Dati disponibili",
"streamNotFound": "Stream non trovato",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Aggiungi avvisi dalla libreria",
"subtitle": "Scegli quali avvisi aggiungere, dove archiviarli e, facoltativamente, dove notificarli.",
"stepDestination": "Destinazione",
"subtitle": "Scegli dove archiviare questi avvisi, poi aggiungili.",
"stepAlerts": "Avvisi",
"stepFolder": "Cartella",
"stepTune": "Regola",
"stepInstall": "Aggiungi",
"destinationIntro": "Ogni avviso in questo batch notifica la destinazione scelta qui. I file della libreria indicano le destinazioni dell'organizzazione da cui sono stati esportati, che non esistono nella tua. Questo passaggio è facoltativo: salta e gli avvisi vengono aggiunti senza.",
"destinationLabel": "Destinazione",
"destinationsLoadFailed": "Non è stato possibile caricare le destinazioni esistenti.",
"retry": "Riprova",
"destinationsNoneBody": "Questa organizzazione non ha ancora destinazioni di avviso. Creane una nella pagina Destinazioni quando vuoi.",
"openDestinations": "Apri Destinazioni",
"selectAll": "Seleziona tutto",
"clear": "Cancella",
"selectedCount": "{count} di {total} selezionati",
"folderIntro": "Gli avvisi aggiunti vengono archiviati qui. Crea una cartella dal pulsante + se questo lotto merita una cartella dedicata.",
"tuneToggle": "Imposta questi valori per ogni avviso in questo lotto",
"tuneHint": "Solo “{frequency}” e “{silence}” possono essere impostati in sicurezza per un intero lotto. Le soglie restano per singolo avviso: modificane una aprendo quell'avviso e aggiungendolo singolarmente.",
@@ -13165,7 +13223,6 @@
"next": "Avanti",
"cancel": "Annulla",
"done": "Fatto",
"confirmLarge": "Ho capito che questo crea {count} avvisi in {folder}, ciascuno con notifica a {destination}.",
"tuneThresholdOnly": "Vale solo per gli avvisi a soglia.",
"anomalyTrainingNote": "Gli avvisi di anomalia iniziano a scattare dopo essersi addestrati su una cronologia sufficiente dello stream.",
"anomalyUnavailable": "Gli avvisi di anomalia richiedono OpenObserve Enterprise con il rilevamento anomalie attivo, quindi questo è stato saltato.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "グループ",
"serviceAccounts": "サービスアカウント",
"cipherKeys": "暗号化キー",
"regexPatterns": "正規表現パターン"
"regexPatterns": "正規表現パターン",
"evalJobs": "オンライン評価ジョブ",
"billingGroup": "請求グループ",
"syntheticChecks": "合成モニタリングチェック",
"metrics": "メトリクス",
"databaseMonitoring": "データベース監視",
"workflowFolders": "ワークフローフォルダー",
"promptFolders": "プロンプトフォルダー",
"syntheticEnvironments": "合成モニタリング環境",
"sourceMaps": "ソースマップ",
"alertFolders": "アラートフォルダ",
"serviceStreams": "サービスストリーム",
"fleetManagement": "Fleet 管理",
"onCallResponses": "オンコール対応",
"statusPages": "ステータスページ",
"onCall": "オンコール",
"incidentIntegrations": "インシデント連携",
"incidents": "インシデント",
"scorers": "スコアラー",
"searchJobs": "検索ジョブ",
"enrichmentTables": "エンリッチメントテーブル",
"rumTokens": "RUM トークン",
"traces": "トレース",
"remoteTasks": "リモートタスク",
"reportFolders": "レポートフォルダー",
"llmProviders": "LLM プロバイダー",
"settings": "設定",
"dashboardFolders": "ダッシュボードフォルダー",
"scoreConfigs": "スコア構成",
"workflows": "ワークフロー",
"syntheticFolders": "合成モニタリングフォルダー",
"downtimeFolders": "ダウンタイムフォルダー"
},
"resourcesInline": {
"dashboards": "ダッシュボード",
@@ -663,7 +694,38 @@
"serviceAccounts": "サービスアカウント",
"cipherKeys": "暗号化キー",
"regexPatterns": "正規表現パターン",
"unnamed": "このリソース"
"unnamed": "このリソース",
"onCall": "オンコール",
"incidentIntegrations": "インシデント連携",
"scorers": "スコアラー",
"llmProviders": "LLM プロバイダー",
"enrichmentTables": "エンリッチメントテーブル",
"rumTokens": "RUM トークン",
"settings": "設定",
"traces": "トレース",
"remoteTasks": "リモートタスク",
"reportFolders": "レポートフォルダー",
"dashboardFolders": "ダッシュボードフォルダー",
"scoreConfigs": "スコア構成",
"downtimeFolders": "ダウンタイムフォルダー",
"workflows": "ワークフロー",
"syntheticFolders": "合成モニタリングフォルダー",
"evalJobs": "オンライン評価ジョブ",
"metrics": "メトリクス",
"databaseMonitoring": "データベース監視",
"billingGroup": "請求グループ",
"syntheticChecks": "合成モニタリングチェック",
"workflowFolders": "ワークフローフォルダー",
"promptFolders": "プロンプトフォルダー",
"syntheticEnvironments": "合成モニタリング環境",
"serviceStreams": "サービスストリーム",
"sourceMaps": "ソースマップ",
"statusPages": "ステータスページ",
"alertFolders": "アラートフォルダ",
"fleetManagement": "Fleet 管理",
"incidents": "インシデント",
"onCallResponses": "オンコール対応",
"searchJobs": "検索ジョブ"
},
"denied": {
"view": "{resource} を表示できません",
@@ -674,7 +736,10 @@
"parts": "このページの {count} 箇所を表示できません | このページの {count} 箇所を表示できません",
"missingIntro": "あなたのロールには次の権限がありません:",
"more": "他 {count} 件",
"showLess": "表示を減らす"
"showLess": "表示を減らす",
"actions": "このページの {count} 件の操作がブロックされています | このページの {count} 件の操作がブロックされています",
"askAdmin": "管理者にアクセス権を依頼してください。",
"resources": "{count} 件のリソース | {count} 件のリソース"
},
"chip": {
"label": "アクセス拒否",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "{period} に絞り込む (~{size})",
"autoRunGuardSearchJob": "検索ジョブとして実行",
"autoRunGuardSelectStream": "ストリームを選択",
"searchJobNoPermission": "検索ジョブを作成する権限がありません",
"missingStreamAndNoFts": "1 つ以上のフィルターフィールドが「{missingStreams}」に存在しないため、該当するストリームでは検索が実行されません。\n 未検索: {noFtsStreams}(全文検索フィールドなし)。",
"freeTextChooseFirst": "先にテキストの検索方法を選択してください",
"freeTextScanLogsOnly": "スキャンモードはログビューでのみ動作します。ここでテキスト検索を使用するには、全文検索フィールドを設定してください",
@@ -10631,6 +10695,8 @@
"legendNoData": "データなし"
},
"importAlertTitle": "アラートのインポート",
"importAlert": "アラートをインポート",
"alertLibrary": "アラートライブラリ",
"exportDialogTitle": "アラートをエクスポート | アラートを{count}件エクスポート",
"exportDialogSubtitle": "定義を確認して、JSON または OpenObserve プロバイダー用の Terraform としてダウンロードします。",
"jsonFilesOnlyHint": ".json ファイルのみ",
@@ -13075,7 +13141,6 @@
"statReady": "利用可能",
"statNeedsData": "未取り込み",
"statAll": "すべて",
"notIngested": "未取り込み: {stream}",
"notIngestedHint": "この組織は {stream} を受信していないため、このアラートは発火しません。それでも追加すると、データが到着した時点で動作を開始します。",
"alertCount": "{count}件のアラート | {count}件のアラート",
"noCollectorTitle": "ここに一致するデータはまだありません",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "異常",
"enterprise": "Enterprise",
"enterpriseHint": "異常検知アラートには OpenObserve Enterprise が必要です。",
"card": {
"customizeTooltip": "入力済みのアラートエディターを開きます"
},
"drawer": {
"dataAvailable": "データあり",
"streamNotFound": "ストリームが見つかりません",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "ライブラリからアラートを追加",
"subtitle": "追加するアラート、その保存先、そして任意で通知先を選択します。",
"stepDestination": "宛先",
"subtitle": "これらのアラートの登録先を選択してから、追加してください。",
"stepAlerts": "アラート",
"stepFolder": "フォルダー",
"stepTune": "調整",
"stepInstall": "追加",
"destinationIntro": "このバッチ内のすべてのアラートは、ここで選択した宛先に通知します。ライブラリファイルには、エクスポート元の組織の宛先名が記載されており、それらはお客様の組織には存在しません。この手順は任意です。スキップすると、宛先なしでアラートが追加されます。",
"destinationLabel": "宛先",
"destinationsLoadFailed": "既存の宛先を読み込めませんでした。",
"retry": "再試行",
"destinationsNoneBody": "この組織にはまだアラートの宛先がありません。準備ができたら、宛先ページで作成してください。",
"openDestinations": "宛先を開く",
"selectAll": "すべて選択",
"clear": "クリア",
"selectedCount": "{count} / {total} 選択中",
"folderIntro": "追加されたアラートはここに保存されます。このバッチに専用のフォルダが必要な場合は、+ ボタンからフォルダを作成してください。",
"tuneToggle": "このバッチのすべてのアラートに設定",
"tuneHint": "バッチ全体で安全に設定できるのは「{frequency}」と「{silence}」のみです。しきい値はアラートごとに保持されます。変更するには、そのアラートを開いて個別に追加してください。",
@@ -13165,7 +13223,6 @@
"next": "次へ",
"cancel": "キャンセル",
"done": "完了",
"confirmLarge": "{folder} に {count} 件のアラートを作成し、それぞれが {destination} に通知することを理解しました。",
"tuneThresholdOnly": "しきい値アラートにのみ適用されます。",
"anomalyTrainingNote": "異常アラートは、ストリームの履歴で十分に学習した後に通知を開始します。",
"anomalyUnavailable": "異常アラートには異常検知が有効な OpenObserve Enterprise が必要なため、このアラートはスキップされました。",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "그룹",
"serviceAccounts": "서비스 계정",
"cipherKeys": "암호 키",
"regexPatterns": "정규식 패턴"
"regexPatterns": "정규식 패턴",
"evalJobs": "온라인 평가 작업",
"billingGroup": "결제 그룹",
"syntheticChecks": "Synthetics 체크",
"metrics": "메트릭",
"databaseMonitoring": "데이터베이스 모니터링",
"workflowFolders": "워크플로우 폴더",
"promptFolders": "프롬프트 폴더",
"syntheticEnvironments": "Synthetics 환경",
"sourceMaps": "소스 맵",
"alertFolders": "알림 폴더",
"serviceStreams": "서비스 스트림",
"fleetManagement": "플릿 관리",
"onCallResponses": "온콜 응답",
"statusPages": "상태 페이지",
"onCall": "온콜",
"incidentIntegrations": "인시던트 연동",
"incidents": "인시던트",
"scorers": "스코어러",
"searchJobs": "검색 작업",
"enrichmentTables": "보강 테이블",
"rumTokens": "RUM 토큰",
"traces": "트레이스",
"remoteTasks": "원격 태스크",
"reportFolders": "보고서 폴더",
"llmProviders": "LLM 공급자",
"settings": "설정",
"dashboardFolders": "대시보드 폴더",
"scoreConfigs": "스코어 구성",
"workflows": "워크플로우",
"syntheticFolders": "Synthetics 폴더",
"downtimeFolders": "다운타임 폴더"
},
"resourcesInline": {
"dashboards": "대시보드",
@@ -663,7 +694,38 @@
"serviceAccounts": "서비스 계정",
"cipherKeys": "암호 키",
"regexPatterns": "정규식 패턴",
"unnamed": "이 리소스"
"unnamed": "이 리소스",
"onCall": "온콜",
"incidentIntegrations": "인시던트 연동",
"scorers": "스코어러",
"llmProviders": "LLM 공급자",
"enrichmentTables": "보강 테이블",
"rumTokens": "RUM 토큰",
"settings": "설정",
"traces": "트레이스",
"remoteTasks": "원격 태스크",
"reportFolders": "보고서 폴더",
"dashboardFolders": "대시보드 폴더",
"scoreConfigs": "스코어 구성",
"downtimeFolders": "다운타임 폴더",
"workflows": "워크플로우",
"syntheticFolders": "Synthetics 폴더",
"evalJobs": "온라인 평가 작업",
"metrics": "메트릭",
"databaseMonitoring": "데이터베이스 모니터링",
"billingGroup": "결제 그룹",
"syntheticChecks": "Synthetics 체크",
"workflowFolders": "워크플로우 폴더",
"promptFolders": "프롬프트 폴더",
"syntheticEnvironments": "Synthetics 환경",
"serviceStreams": "서비스 스트림",
"sourceMaps": "소스 맵",
"statusPages": "상태 페이지",
"alertFolders": "알림 폴더",
"fleetManagement": "플릿 관리",
"incidents": "인시던트",
"onCallResponses": "온콜 응답",
"searchJobs": "검색 작업"
},
"denied": {
"view": "{resource}을(를) 볼 수 없습니다",
@@ -674,7 +736,10 @@
"parts": "이 페이지의 {count}개 부분을 볼 수 없습니다 | 이 페이지의 {count}개 부분을 볼 수 없습니다",
"missingIntro": "내 역할에 다음 권한이 없습니다:",
"more": "{count}개 더",
"showLess": "간단히 보기"
"showLess": "간단히 보기",
"actions": "이 페이지에서 {count}개 작업이 차단되었습니다 | 이 페이지에서 {count}개 작업이 차단되었습니다",
"askAdmin": "관리자에게 액세스 권한을 요청하세요.",
"resources": "리소스 {count}개 | 리소스 {count}개"
},
"chip": {
"label": "액세스 거부됨",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "{period}(으)로 좁히기 (~{size})",
"autoRunGuardSearchJob": "검색 작업으로 실행",
"autoRunGuardSelectStream": "스트림 선택",
"searchJobNoPermission": "검색 작업을 생성할 권한이 없습니다",
"missingStreamAndNoFts": "하나 이상의 필터 필드가 \"{missingStreams}\"에 존재하지 않으므로 해당 스트림에서는 검색이 수행되지 않습니다.\n 검색하지 않음: {noFtsStreams}(전체 텍스트 필드 없음).",
"freeTextChooseFirst": "먼저 텍스트 검색 방법을 선택하세요",
"freeTextScanLogsOnly": "스캔 모드는 로그 보기에서만 작동합니다. 여기에서 텍스트 검색을 사용하려면 전체 텍스트 필드를 설정하세요",
@@ -10631,6 +10695,8 @@
"legendNoData": "데이터 없음"
},
"importAlertTitle": "알림 가져오기",
"importAlert": "알림 가져오기",
"alertLibrary": "알림 라이브러리",
"exportDialogTitle": "알림 내보내기 | 알림 {count}개 내보내기",
"exportDialogSubtitle": "정의를 검토한 다음 JSON 또는 OpenObserve 프로바이더용 Terraform으로 다운로드하세요.",
"jsonFilesOnlyHint": ".json 파일만",
@@ -13075,7 +13141,6 @@
"statReady": "사용 준비 완료",
"statNeedsData": "수집되지 않음",
"statAll": "모두",
"notIngested": "수집되지 않음: {stream}",
"notIngestedHint": "이 조직은 {stream}을(를) 수신하지 않으므로 이 알림은 절대 트리거되지 않습니다. 그래도 추가하면 데이터가 도착하는 즉시 작동합니다.",
"alertCount": "{count}개 알림 | {count}개 알림",
"noCollectorTitle": "아직 여기에 데이터와 일치하는 것이 없습니다",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "이상",
"enterprise": "Enterprise",
"enterpriseHint": "이상 탐지 알림에는 OpenObserve Enterprise가 필요합니다.",
"card": {
"customizeTooltip": "미리 채워진 알림 편집기를 엽니다"
},
"drawer": {
"dataAvailable": "데이터 사용 가능",
"streamNotFound": "스트림을 찾을 수 없습니다.",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "라이브러리에서 알림 추가",
"subtitle": "추가할 알림, 알림이 저장될 위치, 그리고 선택적으로 알림이 전송될 대상을 선택하세요.",
"stepDestination": "대상",
"subtitle": "이 알림을 보관할 위치를 선택한 후 추가하세요.",
"stepAlerts": "알림",
"stepFolder": "폴더",
"stepTune": "조정",
"stepInstall": "추가",
"destinationIntro": "이 배치의 모든 알림은 여기서 선택한 대상으로 알림을 전송합니다. 라이브러리 파일에는 내보낸 조직의 대상 이름이 지정되어 있으며, 그 대상은 현재 조직에 존재하지 않습니다. 이 단계는 선택 사항입니다. 건너뛰면 알림이 대상 없이 추가됩니다.",
"destinationLabel": "대상",
"destinationsLoadFailed": "기존 대상을 불러올 수 없습니다.",
"retry": "다시 시도",
"destinationsNoneBody": "이 조직에는 아직 알림 대상이 없습니다. 준비되면 대상 페이지에서 하나를 만드세요.",
"openDestinations": "대상 열기",
"selectAll": "모두 선택",
"clear": "선택 해제",
"selectedCount": "{total}개 중 {count}개 선택됨",
"folderIntro": "추가된 알림은 여기에 보관됩니다. 이 묶음에 별도 폴더가 필요하면 + 버튼에서 폴더를 만드세요.",
"tuneToggle": "이 배치의 모든 알림에 대해 설정",
"tuneHint": "전체 배치에서 안전하게 설정할 수 있는 것은 “{frequency}”와 “{silence}”뿐입니다. 임계값은 알림별로 유지됩니다. 해당 알림을 열고 별도로 추가하여 변경할 수 있습니다.",
@@ -13165,7 +13223,6 @@
"next": "다음",
"cancel": "취소",
"done": "완료",
"confirmLarge": "{folder}에 알림 {count}개를 만들고 각각 {destination}(으)로 알린다는 점을 이해합니다.",
"tuneThresholdOnly": "임계값 알림에만 적용됩니다.",
"anomalyTrainingNote": "이상 알림은 스트림 기록으로 충분히 학습한 후에 알림을 시작합니다.",
"anomalyUnavailable": "이상 알림에는 이상 탐지가 켜진 OpenObserve Enterprise가 필요하므로 이 알림은 건너뛰었습니다.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Groepen",
"serviceAccounts": "Serviceaccounts",
"cipherKeys": "Cipher keys",
"regexPatterns": "Regex-patronen"
"regexPatterns": "Regex-patronen",
"evalJobs": "Online evaluatietaken",
"billingGroup": "Factureringsgroep",
"syntheticChecks": "Synthetics-controles",
"metrics": "Metrieken",
"databaseMonitoring": "Databasemonitoring",
"workflowFolders": "Workflowmappen",
"promptFolders": "Promptmappen",
"syntheticEnvironments": "Synthetics-omgevingen",
"sourceMaps": "Source maps",
"alertFolders": "Alarmmappen",
"serviceStreams": "Servicestreams",
"fleetManagement": "Fleetbeheer",
"onCallResponses": "Piketreacties",
"statusPages": "Statuspagina's",
"onCall": "Piket",
"incidentIntegrations": "Incidentintegraties",
"incidents": "Incidenten",
"scorers": "Scorers",
"searchJobs": "Zoektaken",
"enrichmentTables": "Verrijkingstabellen",
"rumTokens": "RUM-tokens",
"traces": "Traces",
"remoteTasks": "Remote taken",
"reportFolders": "Rapportmappen",
"llmProviders": "LLM-aanbieders",
"settings": "Instellingen",
"dashboardFolders": "Dashboardmappen",
"scoreConfigs": "Score-configuraties",
"workflows": "Workflows",
"syntheticFolders": "Synthetics-mappen",
"downtimeFolders": "Onderhoudsperiodemappen"
},
"resourcesInline": {
"dashboards": "dashboards",
@@ -663,7 +694,38 @@
"serviceAccounts": "serviceaccounts",
"cipherKeys": "cipher keys",
"regexPatterns": "regex-patronen",
"unnamed": "deze resource"
"unnamed": "deze resource",
"onCall": "piket",
"incidentIntegrations": "incidentintegraties",
"scorers": "scorers",
"llmProviders": "LLM-aanbieders",
"enrichmentTables": "verrijkingstabellen",
"rumTokens": "RUM-tokens",
"settings": "instellingen",
"traces": "traces",
"remoteTasks": "remote taken",
"reportFolders": "rapportmappen",
"dashboardFolders": "dashboardmappen",
"scoreConfigs": "score-configuraties",
"downtimeFolders": "onderhoudsperiodemappen",
"workflows": "workflows",
"syntheticFolders": "Synthetics-mappen",
"evalJobs": "online evaluatietaken",
"metrics": "metrieken",
"databaseMonitoring": "databasemonitoring",
"billingGroup": "factureringsgroep",
"syntheticChecks": "Synthetics-controles",
"workflowFolders": "workflowmappen",
"promptFolders": "promptmappen",
"syntheticEnvironments": "Synthetics-omgevingen",
"serviceStreams": "servicestreams",
"sourceMaps": "source maps",
"statusPages": "statuspagina's",
"alertFolders": "alarmmappen",
"fleetManagement": "fleetbeheer",
"incidents": "incidenten",
"onCallResponses": "piketreacties",
"searchJobs": "zoektaken"
},
"denied": {
"view": "U kunt {resource} niet bekijken",
@@ -674,7 +736,10 @@
"parts": "U kunt {count} deel van deze pagina niet bekijken | U kunt {count} delen van deze pagina niet bekijken",
"missingIntro": "Uw rol mist deze machtigingen:",
"more": "{count} meer",
"showLess": "Minder weergeven"
"showLess": "Minder weergeven",
"actions": "{count} actie op deze pagina is geblokkeerd | {count} acties op deze pagina zijn geblokkeerd",
"askAdmin": "Vraag uw beheerder om toegang.",
"resources": "{count} resource | {count} resources"
},
"chip": {
"label": "Toegang geweigerd",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Beperk tot {period} (~{size})",
"autoRunGuardSearchJob": "Uitvoeren als zoektaak",
"autoRunGuardSelectStream": "Selecteer een stream",
"searchJobNoPermission": "Je hebt geen toestemming om zoektaken te maken",
"missingStreamAndNoFts": "Een of meer filtervelden bestaan niet in \"{missingStreams}\", daarom wordt er niet gezocht in de genoemde stream.\n Niet doorzocht: {noFtsStreams} (geen full-text velden).",
"freeTextChooseFirst": "Kies eerst hoe je tekst wilt doorzoeken",
"freeTextScanLogsOnly": "Scanmodus werkt alleen in de logweergave; stel een full-text veld in om hier tekstzoekopdracht te gebruiken",
@@ -10631,6 +10695,8 @@
"legendNoData": "Geen gegevens"
},
"importAlertTitle": "Alarm importeren",
"importAlert": "Alarm importeren",
"alertLibrary": "Alarmbibliotheek",
"exportDialogTitle": "Exporteer alarm | Exporteer {count} alarmen",
"exportDialogSubtitle": "Bekijk de definitie en download deze als JSON of als Terraform voor de OpenObserve-provider.",
"jsonFilesOnlyHint": "Alleen .json-bestanden",
@@ -13075,7 +13141,6 @@
"statReady": "Klaar voor gebruik",
"statNeedsData": "Niet opgenomen",
"statAll": "Alle",
"notIngested": "Niet opgenomen: {stream}",
"notIngestedHint": "Deze organisatie ontvangt geen {stream}, dus dit alarm zou nooit afgaan. Voeg het toch toe en het zal werken zodra de gegevens arriveren.",
"alertCount": "{count} melding | {count} meldingen",
"noCollectorTitle": "Niets hier komt nog overeen met uw gegevens.",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomalie",
"enterprise": "Enterprise",
"enterpriseHint": "Alerts voor anomaliedetectie vereisen OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Opent de waarschuwingseditor vooraf ingevuld"
},
"drawer": {
"dataAvailable": "Gegevens beschikbaar",
"streamNotFound": "Stream niet gevonden",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Alarmen uit de bibliotheek toevoegen",
"subtitle": "Kies welke alarmen je wilt toevoegen, waar ze worden opgeslagen en optioneel waar ze melding van maken.",
"stepDestination": "Bestemming",
"subtitle": "Kies waar deze alarmen worden ondergebracht en voeg ze daarna toe.",
"stepAlerts": "Alarmen",
"stepFolder": "Map",
"stepTune": "Afstemmen",
"stepInstall": "Toevoegen",
"destinationIntro": "Elk alarm in deze batch meldt naar de bestemming die je hier kiest. Bibliotheekbestanden vermelden de bestemmingen van de organisatie waaruit ze zijn geëxporteerd, die niet in de jouwe bestaan. Deze stap is optioneel: sla hem over en de alarmen worden zonder bestemming toegevoegd.",
"destinationLabel": "Bestemming",
"destinationsLoadFailed": "Je bestaande bestemmingen konden niet worden geladen.",
"retry": "Probeer opnieuw",
"destinationsNoneBody": "Deze organisatie heeft nog geen alarmbestemmingen. Maak er een aan op de pagina Bestemmingen wanneer je er klaar voor bent.",
"openDestinations": "Bestemmingen openen",
"selectAll": "Alles selecteren",
"clear": "Wissen",
"selectedCount": "{count} van {total} geselecteerd",
"folderIntro": "Toegevoegde alarmen worden hier opgeslagen. Maak een map met de + knop als deze batch een eigen map verdient.",
"tuneToggle": "Stel deze in voor elk alarm in deze batch",
"tuneHint": "Alleen “{frequency}” en “{silence}” zijn veilig om voor een hele batch in te stellen. Drempelwaarden blijven per alarm — wijzig er een door dat alarm te openen en het afzonderlijk toe te voegen.",
@@ -13165,7 +13223,6 @@
"next": "Volgende",
"cancel": "Annuleren",
"done": "Klaar",
"confirmLarge": "Ik begrijp dat dit {count} meldingen aanmaakt in {folder}, die elk {destination} waarschuwen.",
"tuneThresholdOnly": "Geldt alleen voor drempelalerts.",
"anomalyTrainingNote": "Anomalie-alerts gaan pas af nadat ze op genoeg geschiedenis van de stream zijn getraind.",
"anomalyUnavailable": "Anomalie-alerts vereisen OpenObserve Enterprise met anomaliedetectie ingeschakeld, dus deze is overgeslagen.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Grupy",
"serviceAccounts": "Konta usługi",
"cipherKeys": "Klucze szyfrujące",
"regexPatterns": "Wzorce regex"
"regexPatterns": "Wzorce regex",
"evalJobs": "Zadania ewaluacji online",
"billingGroup": "Grupa rozliczeniowa",
"syntheticChecks": "Sprawdzenia syntetyczne",
"metrics": "Metryki",
"databaseMonitoring": "Monitorowanie baz danych",
"workflowFolders": "Foldery przepływów pracy",
"promptFolders": "Foldery promptów",
"syntheticEnvironments": "Środowiska syntetyków",
"sourceMaps": "Mapy źródłowe",
"alertFolders": "Foldery alertów",
"serviceStreams": "Strumienie usług",
"fleetManagement": "Zarządzanie flotą",
"onCallResponses": "Odpowiedzi dyżurowe",
"statusPages": "Strony statusu",
"onCall": "Dyżury",
"incidentIntegrations": "Integracje incydentów",
"incidents": "Incydenty",
"scorers": "Ewaluatory",
"searchJobs": "Zadania wyszukiwania",
"enrichmentTables": "Tabele wzbogacania",
"rumTokens": "Tokeny RUM",
"traces": "Trace",
"remoteTasks": "Zadania zdalne",
"reportFolders": "Foldery raportów",
"llmProviders": "Dostawcy LLM",
"settings": "Ustawienia",
"dashboardFolders": "Foldery pulpitów",
"scoreConfigs": "Konfiguracje ocen",
"workflows": "Przepływy pracy",
"syntheticFolders": "Foldery syntetyków",
"downtimeFolders": "Foldery okien serwisowych"
},
"resourcesInline": {
"dashboards": "pulpity",
@@ -663,7 +694,38 @@
"serviceAccounts": "konta usługi",
"cipherKeys": "klucze szyfrujące",
"regexPatterns": "wzorce regex",
"unnamed": "ten zasób"
"unnamed": "ten zasób",
"onCall": "dyżury",
"incidentIntegrations": "integracje incydentów",
"scorers": "ewaluatory",
"llmProviders": "dostawcy LLM",
"enrichmentTables": "tabele wzbogacania",
"rumTokens": "tokeny RUM",
"settings": "ustawienia",
"traces": "trace",
"remoteTasks": "zadania zdalne",
"reportFolders": "foldery raportów",
"dashboardFolders": "foldery pulpitów",
"scoreConfigs": "konfiguracje ocen",
"downtimeFolders": "foldery okien serwisowych",
"workflows": "przepływy pracy",
"syntheticFolders": "foldery syntetyków",
"evalJobs": "zadania ewaluacji online",
"metrics": "metryki",
"databaseMonitoring": "monitorowanie baz danych",
"billingGroup": "grupa rozliczeniowa",
"syntheticChecks": "sprawdzenia syntetyczne",
"workflowFolders": "foldery przepływów pracy",
"promptFolders": "foldery promptów",
"syntheticEnvironments": "środowiska syntetyków",
"serviceStreams": "strumienie usług",
"sourceMaps": "mapy źródłowe",
"statusPages": "strony statusu",
"alertFolders": "foldery alertów",
"fleetManagement": "zarządzanie flotą",
"incidents": "incydenty",
"onCallResponses": "odpowiedzi dyżurowe",
"searchJobs": "zadania wyszukiwania"
},
"denied": {
"view": "Nie możesz wyświetlać: {resource}",
@@ -674,7 +736,10 @@
"parts": "Nie możesz wyświetlić {count} części tej strony | Nie możesz wyświetlić części tej strony ({count})",
"missingIntro": "Twojej roli brakuje tych uprawnień:",
"more": "{count} więcej",
"showLess": "Pokaż mniej"
"showLess": "Pokaż mniej",
"actions": "Zablokowano {count} akcję na tej stronie | Zablokowano akcje na tej stronie ({count})",
"askAdmin": "Poproś administratora o dostęp.",
"resources": "{count} zasób | zasoby ({count})"
},
"chip": {
"label": "Odmowa dostępu",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Zawęź do {period} (~{size})",
"autoRunGuardSearchJob": "Uruchom jako zadanie wyszukiwania",
"autoRunGuardSelectStream": "Wybierz strumień",
"searchJobNoPermission": "Nie masz uprawnień do tworzenia zadań wyszukiwania",
"missingStreamAndNoFts": "Co najmniej jedno pole filtra nie istnieje w \"{missingStreams}\", dlatego wyszukiwanie w wymienionym strumieniu nie jest wykonywane.\n Nie przeszukano: {noFtsStreams} (brak pól pełnotekstowych).",
"freeTextChooseFirst": "Najpierw wybierz sposób wyszukiwania tekstu",
"freeTextScanLogsOnly": "Tryb skanowania działa tylko w widoku logów; ustaw pole pełnotekstowe, aby użyć tutaj wyszukiwania tekstowego",
@@ -10631,6 +10695,8 @@
"legendNoData": "Brak danych"
},
"importAlertTitle": "Importuj alert",
"importAlert": "Importuj alert",
"alertLibrary": "Biblioteka alertów",
"exportDialogTitle": "Eksportuj alert | Eksportuj alerty ({count})",
"exportDialogSubtitle": "Przejrzyj definicję, a następnie pobierz ją jako JSON lub jako Terraform dla dostawcy OpenObserve.",
"jsonFilesOnlyHint": "Tylko pliki .json",
@@ -13075,7 +13141,6 @@
"statReady": "Gotowe do użycia",
"statNeedsData": "Nie pozyskane",
"statAll": "Wszystkie",
"notIngested": "Nie pozyskane: {stream}",
"notIngestedHint": "Ta organizacja nie otrzymuje {stream}, więc ten alert nigdy by nie został wyzwolony. Dodaj go mimo to, a zacznie działać, gdy dane dotrą.",
"alertCount": "{count} alert | alerty: {count}",
"noCollectorTitle": "Nic tutaj nie pasuje jeszcze do twoich danych",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomalia",
"enterprise": "Enterprise",
"enterpriseHint": "Alerty wykrywania anomalii wymagają OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Otwiera wstępnie wypełniony edytor alertów"
},
"drawer": {
"dataAvailable": "Dane dostępne",
"streamNotFound": "Nie znaleziono strumienia",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Dodaj alerty z biblioteki",
"subtitle": "Wybierz, które alerty dodać, gdzie mają być zapisane i opcjonalnie gdzie mają powiadamiać.",
"stepDestination": "Miejsce docelowe",
"subtitle": "Wybierz, gdzie mają zostać zapisane te alerty, a następnie dodaj je.",
"stepAlerts": "Alerty",
"stepFolder": "Folder",
"stepTune": "Dostosuj",
"stepInstall": "Dodaj",
"destinationIntro": "Każdy alert w tej partii powiadamia miejsce docelowe wybrane tutaj. Pliki biblioteki wskazują miejsca docelowe organizacji, z której zostały wyeksportowane, a te nie istnieją w Twojej. Ten krok jest opcjonalny: pomiń go, a alerty zostaną dodane bez miejsca docelowego.",
"destinationLabel": "Miejsce docelowe",
"destinationsLoadFailed": "Nie można było wczytać istniejących miejsc docelowych.",
"retry": "Spróbuj ponownie",
"destinationsNoneBody": "Ta organizacja nie ma jeszcze miejsc docelowych alertów. Utwórz jedno na stronie Miejsca docelowe, kiedy będziesz gotowy.",
"openDestinations": "Otwórz miejsca docelowe",
"selectAll": "Zaznacz wszystkie",
"clear": "Wyczyść",
"selectedCount": "Wybrano {count} z {total}",
"folderIntro": "Dodane alerty są tu umieszczane. Utwórz folder za pomocą przycisku +, jeśli ta partia zasługuje na własny.",
"tuneToggle": "Ustaw je dla każdego alertu w tej partii",
"tuneHint": "Tylko „{frequency}” i „{silence}” można bezpiecznie ustawić dla całej partii. Progi pozostają ustawiane per alert — aby zmienić jeden, otwórz ten alert i dodaj go osobno.",
@@ -13165,7 +13223,6 @@
"next": "Dalej",
"cancel": "Anuluj",
"done": "Gotowe",
"confirmLarge": "Rozumiem, że utworzy to alerty ({count}) w {folder}, z których każdy powiadamia {destination}.",
"tuneThresholdOnly": "Dotyczy tylko alertów progowych.",
"anomalyTrainingNote": "Alerty anomalii zaczynają działać, gdy wytrenują się na wystarczającej historii strumienia.",
"anomalyUnavailable": "Alerty anomalii wymagają OpenObserve Enterprise z włączonym wykrywaniem anomalii, więc ten został pominięty.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Grupos",
"serviceAccounts": "Contas de serviço",
"cipherKeys": "Chaves de cifra",
"regexPatterns": "Padrões regex"
"regexPatterns": "Padrões regex",
"evalJobs": "Trabalhos de avaliação online",
"billingGroup": "Grupo de faturação",
"syntheticChecks": "Verificações sintéticas",
"metrics": "Métricas",
"databaseMonitoring": "Monitorização de bases de dados",
"workflowFolders": "Pastas de fluxos de trabalho",
"promptFolders": "Pastas de prompts",
"syntheticEnvironments": "Ambientes de sintéticos",
"sourceMaps": "Source maps",
"alertFolders": "Pastas de alertas",
"serviceStreams": "Streams de serviços",
"fleetManagement": "Gestão de frotas",
"onCallResponses": "Respostas de piquete",
"statusPages": "Páginas de estado",
"onCall": "Piquete",
"incidentIntegrations": "Integrações de incidentes",
"incidents": "Incidentes",
"scorers": "Avaliadores",
"searchJobs": "Trabalhos de pesquisa",
"enrichmentTables": "Tabelas de enriquecimento",
"rumTokens": "Tokens de RUM",
"traces": "Traces",
"remoteTasks": "Tarefas remotas",
"reportFolders": "Pastas de relatórios",
"llmProviders": "Fornecedores de LLM",
"settings": "Definições",
"dashboardFolders": "Pastas de dashboards",
"scoreConfigs": "Configurações de pontuação",
"workflows": "Fluxos de trabalho",
"syntheticFolders": "Pastas de sintéticos",
"downtimeFolders": "Pastas de janelas de manutenção"
},
"resourcesInline": {
"dashboards": "dashboards",
@@ -663,7 +694,38 @@
"serviceAccounts": "contas de serviço",
"cipherKeys": "chaves de cifra",
"regexPatterns": "padrões regex",
"unnamed": "este recurso"
"unnamed": "este recurso",
"onCall": "piquete",
"incidentIntegrations": "integrações de incidentes",
"scorers": "avaliadores",
"llmProviders": "fornecedores de LLM",
"enrichmentTables": "tabelas de enriquecimento",
"rumTokens": "tokens de RUM",
"settings": "definições",
"traces": "traces",
"remoteTasks": "tarefas remotas",
"reportFolders": "pastas de relatórios",
"dashboardFolders": "pastas de dashboards",
"scoreConfigs": "configurações de pontuação",
"downtimeFolders": "pastas de janelas de manutenção",
"workflows": "fluxos de trabalho",
"syntheticFolders": "pastas de sintéticos",
"evalJobs": "trabalhos de avaliação online",
"metrics": "métricas",
"databaseMonitoring": "monitorização de bases de dados",
"billingGroup": "grupo de faturação",
"syntheticChecks": "verificações sintéticas",
"workflowFolders": "pastas de fluxos de trabalho",
"promptFolders": "pastas de prompts",
"syntheticEnvironments": "ambientes de sintéticos",
"serviceStreams": "streams de serviços",
"sourceMaps": "source maps",
"statusPages": "páginas de estado",
"alertFolders": "pastas de alertas",
"fleetManagement": "gestão de frotas",
"incidents": "incidentes",
"onCallResponses": "respostas de piquete",
"searchJobs": "trabalhos de pesquisa"
},
"denied": {
"view": "Não pode ver {resource}",
@@ -674,7 +736,10 @@
"parts": "Não pode ver {count} parte desta página | Não pode ver {count} partes desta página",
"missingIntro": "Faltam à sua função estas permissões:",
"more": "mais {count}",
"showLess": "Mostrar menos"
"showLess": "Mostrar menos",
"actions": "{count} ação nesta página está bloqueada | {count} ações nesta página estão bloqueadas",
"askAdmin": "Peça acesso ao seu administrador.",
"resources": "{count} recurso | {count} recursos"
},
"chip": {
"label": "Acesso negado",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Restringir a {period} (~{size})",
"autoRunGuardSearchJob": "Executar como tarefa de pesquisa",
"autoRunGuardSelectStream": "Selecione um stream",
"searchJobNoPermission": "Não tem permissão para criar tarefas de pesquisa",
"missingStreamAndNoFts": "Um ou mais campos de filtro não existem em \"{missingStreams}\", portanto nenhuma pesquisa é realizada no stream mencionado.\n Não pesquisado: {noFtsStreams} (sem campos de texto completo).",
"freeTextChooseFirst": "Escolha primeiro como pesquisar o texto",
"freeTextScanLogsOnly": "O modo de análise funciona apenas na visualização de logs; defina um campo de texto completo para usar a pesquisa de texto aqui",
@@ -10631,6 +10695,8 @@
"legendNoData": "Sem dados"
},
"importAlertTitle": "Importar alerta",
"importAlert": "Importar alerta",
"alertLibrary": "Biblioteca de alertas",
"exportDialogTitle": "Exportar alerta | Exportar {count} alertas",
"exportDialogSubtitle": "Reveja a definição e transfira-a como JSON ou como Terraform para o fornecedor OpenObserve.",
"jsonFilesOnlyHint": "Apenas ficheiros .json",
@@ -13075,7 +13141,6 @@
"statReady": "Pronto para usar",
"statNeedsData": "Não ingerido",
"statAll": "Todos",
"notIngested": "Não ingerido: {stream}",
"notIngestedHint": "Esta organização não está a receber {stream}, por isso este alerta nunca dispararia. Adicione-o mesmo assim e começará a funcionar quando os dados chegarem.",
"alertCount": "{count} alerta | {count} alertas",
"noCollectorTitle": "Ainda nada aqui corresponde aos seus dados",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomalia",
"enterprise": "Enterprise",
"enterpriseHint": "Os alertas de deteção de anomalias requerem o OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Abre o editor de alertas pré-preenchido"
},
"drawer": {
"dataAvailable": "Dados disponíveis",
"streamNotFound": "Stream não encontrado",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Adicionar alertas da biblioteca",
"subtitle": "Escolha os alertas a adicionar, onde são arquivados e, opcionalmente, onde notificam.",
"stepDestination": "Destino",
"subtitle": "Escolha onde estes alertas são arquivados e, em seguida, adicione-os.",
"stepAlerts": "Alertas",
"stepFolder": "Pasta",
"stepTune": "Ajustar",
"stepInstall": "Adicionar",
"destinationIntro": "Todos os alertas deste lote notificam o destino que escolher aqui. Os ficheiros da biblioteca nomeiam os destinos da organização de onde foram exportados, que não existem na sua. Esta etapa é opcional: salte-a e os alertas serão adicionados sem um destino.",
"destinationLabel": "Destino",
"destinationsLoadFailed": "Não foi possível carregar os seus destinos existentes.",
"retry": "Tentar novamente",
"destinationsNoneBody": "Esta organização ainda não tem destinos de alerta. Crie um na página Destinos quando quiser.",
"openDestinations": "Abrir Destinos",
"selectAll": "Selecionar todos",
"clear": "Limpar",
"selectedCount": "{count} de {total} selecionados",
"folderIntro": "Os alertas adicionados são arquivados aqui. Crie uma pasta pelo botão + se este lote merecer uma própria.",
"tuneToggle": "Defina estes para cada alerta neste lote",
"tuneHint": "Apenas “{frequency}” e “{silence}” são seguros para definir em todo um lote. Os limites permanecem por alerta — altere um abrindo este alerta e adicionando-o separadamente.",
@@ -13165,7 +13223,6 @@
"next": "Avançar",
"cancel": "Cancelar",
"done": "Concluído",
"confirmLarge": "Compreendo que isto cria {count} alertas em {folder}, cada um a notificar {destination}.",
"tuneThresholdOnly": "Aplica-se apenas a alertas de limiar.",
"anomalyTrainingNote": "Os alertas de anomalia começam a disparar depois de treinarem com histórico suficiente do fluxo.",
"anomalyUnavailable": "Os alertas de anomalia requerem o OpenObserve Enterprise com a deteção de anomalias ativada, por isso este foi ignorado.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Группы",
"serviceAccounts": "Сервисные аккаунты",
"cipherKeys": "Ключи шифрования",
"regexPatterns": "Регулярные выражения"
"regexPatterns": "Регулярные выражения",
"evalJobs": "Задания онлайн-оценки",
"billingGroup": "Группа выставления счетов",
"syntheticChecks": "Проверки синтетики",
"metrics": "Метрики",
"databaseMonitoring": "Мониторинг баз данных",
"workflowFolders": "Папки рабочих процессов",
"promptFolders": "Папки промптов",
"syntheticEnvironments": "Среды синтетики",
"sourceMaps": "Исходные карты",
"alertFolders": "Папки оповещений",
"serviceStreams": "Потоки сервисов",
"fleetManagement": "Управление парком",
"onCallResponses": "Ответы на дежурствах",
"statusPages": "Статусные страницы",
"onCall": "Дежурства",
"incidentIntegrations": "Интеграции инцидентов",
"incidents": "Инциденты",
"scorers": "Оценщики",
"searchJobs": "Задания поиска",
"enrichmentTables": "Таблицы обогащения",
"rumTokens": "Токены RUM",
"traces": "Трейсы",
"remoteTasks": "Удаленные задачи",
"reportFolders": "Папки отчётов",
"llmProviders": "LLM-провайдеры",
"settings": "Настройки",
"dashboardFolders": "Папки дашбордов",
"scoreConfigs": "Конфигурации оценок",
"workflows": "Рабочие процессы",
"syntheticFolders": "Папки синтетики",
"downtimeFolders": "Папки окон обслуживания"
},
"resourcesInline": {
"dashboards": "дашборды",
@@ -663,7 +694,38 @@
"serviceAccounts": "сервисные аккаунты",
"cipherKeys": "ключи шифрования",
"regexPatterns": "регулярные выражения",
"unnamed": "этот ресурс"
"unnamed": "этот ресурс",
"onCall": "дежурства",
"incidentIntegrations": "интеграции инцидентов",
"scorers": "оценщики",
"llmProviders": "LLM-провайдеры",
"enrichmentTables": "таблицы обогащения",
"rumTokens": "токены RUM",
"settings": "настройки",
"traces": "трейсы",
"remoteTasks": "удаленные задачи",
"reportFolders": "папки отчётов",
"dashboardFolders": "папки дашбордов",
"scoreConfigs": "конфигурации оценок",
"downtimeFolders": "папки окон обслуживания",
"workflows": "рабочие процессы",
"syntheticFolders": "папки синтетики",
"evalJobs": "задания онлайн-оценки",
"metrics": "метрики",
"databaseMonitoring": "мониторинг баз данных",
"billingGroup": "группа выставления счетов",
"syntheticChecks": "проверки синтетики",
"workflowFolders": "папки рабочих процессов",
"promptFolders": "папки промптов",
"syntheticEnvironments": "среды синтетики",
"serviceStreams": "потоки сервисов",
"sourceMaps": "исходные карты",
"statusPages": "статусные страницы",
"alertFolders": "папки оповещений",
"fleetManagement": "управление парком",
"incidents": "инциденты",
"onCallResponses": "ответы на дежурствах",
"searchJobs": "задания поиска"
},
"denied": {
"view": "Вы не можете просматривать {resource}",
@@ -674,7 +736,10 @@
"parts": "Вы не можете просматривать {count} часть этой страницы | Вы не можете просматривать части этой страницы ({count})",
"missingIntro": "У вашей роли нет этих разрешений:",
"more": "ещё {count}",
"showLess": "Свернуть"
"showLess": "Свернуть",
"actions": "На этой странице заблокировано {count} действие | На этой странице заблокированы действия ({count})",
"askAdmin": "Запросите доступ у администратора.",
"resources": "{count} ресурс | ресурсы ({count})"
},
"chip": {
"label": "Доступ запрещён",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Сузить до {period} (~{size})",
"autoRunGuardSearchJob": "Запустить как задачу поиска",
"autoRunGuardSelectStream": "Выберите поток",
"searchJobNoPermission": "У вас нет прав на создание задач поиска",
"missingStreamAndNoFts": "Одно или несколько полей фильтра отсутствуют в \"{missingStreams}\", поэтому поиск в указанном потоке не выполняется.\n Без поиска: {noFtsStreams} (нет полнотекстовых полей).",
"freeTextChooseFirst": "Сначала выберите способ поиска по тексту",
"freeTextScanLogsOnly": "Режим сканирования работает только в представлении логов; задайте полнотекстовое поле, чтобы использовать текстовый поиск здесь",
@@ -10631,6 +10695,8 @@
"legendNoData": "Нет данных"
},
"importAlertTitle": "Импорт оповещения",
"importAlert": "Импортировать оповещение",
"alertLibrary": "Библиотека оповещений",
"exportDialogTitle": "Экспортировать оповещение | Экспортировать оповещения ({count})",
"exportDialogSubtitle": "Проверьте определение, затем скачайте его в формате JSON или Terraform для провайдера OpenObserve.",
"jsonFilesOnlyHint": "только файлы .json",
@@ -13075,7 +13141,6 @@
"statReady": "Готово к использованию",
"statNeedsData": "Не поступает",
"statAll": "Все",
"notIngested": "Не поступает: {stream}",
"notIngestedHint": "Эта организация не получает {stream}, поэтому это оповещение никогда не сработает. Всё равно добавьте его, и оно заработает, когда данные поступят.",
"alertCount": "Оповещения: {count} | Оповещения: {count}",
"noCollectorTitle": "Пока ничего здесь не соответствует вашим данным.",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Аномалия",
"enterprise": "Enterprise",
"enterpriseHint": "Оповещения об аномалиях требуют OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Открывает редактор оповещений с заполненными полями"
},
"drawer": {
"dataAvailable": "Данные доступны",
"streamNotFound": "Поток не найден",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Добавить оповещения из библиотеки",
"subtitle": "Выберите, какие оповещения добавить, в какую папку их поместить и, при необходимости, куда отправлять уведомления.",
"stepDestination": "Получатель",
"subtitle": "Выберите, где будут храниться эти оповещения, затем добавьте их.",
"stepAlerts": "Оповещения",
"stepFolder": "Папка",
"stepTune": "Настройка",
"stepInstall": "Добавить",
"destinationIntro": "Каждое оповещение из этого набора уведомляет получателя, выбранного здесь. В файлах библиотеки указаны получатели организации, из которой они были экспортированы, а в вашей таких нет. Этот шаг необязателен: пропустите его, и оповещения будут добавлены без получателя.",
"destinationLabel": "Получатель",
"destinationsLoadFailed": "Не удалось загрузить существующих получателей.",
"retry": "Попробуйте снова",
"destinationsNoneBody": "В этой организации пока нет получателей оповещений. Создайте получателя на странице «Получатели», когда будете готовы.",
"openDestinations": "Открыть получателей",
"selectAll": "Выбрать все",
"clear": "Очистить",
"selectedCount": "Выбрано {count} из {total}",
"folderIntro": "Добавленные оповещения помещаются сюда. Создайте папку с помощью кнопки «+», если эта партия заслуживает отдельной.",
"tuneToggle": "Установите эти значения для каждого оповещения в этом пакете.",
"tuneHint": "Только “{frequency}” и “{silence}” можно безопасно задать для всей партии. Пороговые значения остаются индивидуальными для каждого оповещения — измените одно, открыв это оповещение и добавив его отдельно.",
@@ -13165,7 +13223,6 @@
"next": "Далее",
"cancel": "Отмена",
"done": "Готово",
"confirmLarge": "Я понимаю, что в {folder} будет создано оповещений: {count}, и каждое уведомляет {destination}.",
"tuneThresholdOnly": "Применяется только к пороговым оповещениям.",
"anomalyTrainingNote": "Оповещения об аномалиях начинают срабатывать, когда обучатся на достаточной истории потока.",
"anomalyUnavailable": "Оповещения об аномалиях требуют OpenObserve Enterprise с включённым обнаружением аномалий, поэтому это оповещение пропущено.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "กลุ่ม",
"serviceAccounts": "บัญชีบริการ",
"cipherKeys": "คีย์เข้ารหัส",
"regexPatterns": "แพตเทิร์น Regex"
"regexPatterns": "แพตเทิร์น Regex",
"fleetManagement": "การจัดการกลุ่มเครื่อง (Fleet)",
"incidents": "เหตุการณ์ขัดข้อง",
"reportFolders": "โฟลเดอร์รายงาน",
"scoreConfigs": "การกำหนดค่าคะแนน",
"onCall": "เวรประจำการ (On-Call)",
"searchJobs": "งานประมวลผลการค้นหา",
"serviceStreams": "สตรีมบริการ",
"databaseMonitoring": "การตรวจสอบฐานข้อมูล",
"alertFolders": "โฟลเดอร์การแจ้งเตือน",
"statusPages": "หน้าสถานะ",
"scorers": "ตัวประเมินคะแนน",
"syntheticChecks": "การตรวจสอบสังเคราะห์",
"settings": "การตั้งค่า",
"onCallResponses": "การตอบสนองเวรประจำการ (On-Call)",
"llmProviders": "ผู้ให้บริการ LLM",
"sourceMaps": "ซอร์สแมป",
"promptFolders": "โฟลเดอร์พรอมต์",
"remoteTasks": "งานระยะไกล",
"syntheticFolders": "โฟลเดอร์การตรวจสอบสังเคราะห์",
"syntheticEnvironments": "สภาพแวดล้อมการตรวจสอบสังเคราะห์",
"enrichmentTables": "ตารางเสริมข้อมูล",
"workflowFolders": "โฟลเดอร์เวิร์กโฟลว์",
"billingGroup": "กลุ่มการเรียกเก็บเงิน",
"dashboardFolders": "โฟลเดอร์แดชบอร์ด",
"rumTokens": "โทเค็น RUM",
"evalJobs": "งานประมวลผลการประเมินแบบออนไลน์",
"workflows": "เวิร์กโฟลว์ (Workflows)",
"traces": "เทรซ",
"downtimeFolders": "โฟลเดอร์ช่วงเวลาปิดปรับปรุง",
"metrics": "เมทริกซ์",
"incidentIntegrations": "การเชื่อมต่อเหตุการณ์ขัดข้อง"
},
"resourcesInline": {
"dashboards": "แดชบอร์ด",
@@ -663,7 +694,38 @@
"serviceAccounts": "บัญชีบริการ",
"cipherKeys": "คีย์เข้ารหัส",
"regexPatterns": "รูปแบบ regex",
"unnamed": "ทรัพยากรนี้"
"unnamed": "ทรัพยากรนี้",
"statusPages": "หน้าสถานะ",
"scorers": "ตัวประเมินคะแนน",
"syntheticChecks": "การตรวจสอบสังเคราะห์",
"settings": "การตั้งค่า",
"onCallResponses": "การตอบสนองเวรประจำการ (On-Call)",
"llmProviders": "ผู้ให้บริการ LLM",
"sourceMaps": "ซอร์สแมป",
"promptFolders": "โฟลเดอร์พรอมต์",
"remoteTasks": "งานระยะไกล",
"syntheticFolders": "โฟลเดอร์การตรวจสอบสังเคราะห์",
"syntheticEnvironments": "สภาพแวดล้อมการตรวจสอบสังเคราะห์",
"enrichmentTables": "ตารางเสริมข้อมูล",
"workflowFolders": "โฟลเดอร์เวิร์กโฟลว์",
"billingGroup": "กลุ่มการเรียกเก็บเงิน",
"dashboardFolders": "โฟลเดอร์แดชบอร์ด",
"rumTokens": "โทเค็น RUM",
"evalJobs": "งานประมวลผลการประเมินออนไลน์",
"workflows": "เวิร์กโฟลว์ (Workflows)",
"traces": "เทรซ",
"downtimeFolders": "โฟลเดอร์ช่วงเวลาปิดปรับปรุง",
"metrics": "เมทริกซ์",
"incidentIntegrations": "การเชื่อมต่อเหตุการณ์ขัดข้อง",
"fleetManagement": "การจัดการกลุ่มเครื่อง (Fleet)",
"incidents": "เหตุการณ์ขัดข้อง",
"reportFolders": "โฟลเดอร์รายงาน",
"scoreConfigs": "การกำหนดค่าคะแนน",
"onCall": "เวรประจำการ (On-Call)",
"searchJobs": "งานประมวลผลการค้นหา",
"serviceStreams": "สตรีมบริการ",
"databaseMonitoring": "การตรวจสอบฐานข้อมูล",
"alertFolders": "โฟลเดอร์การแจ้งเตือน"
},
"denied": {
"view": "คุณไม่มีสิทธิ์ดู{resource}",
@@ -674,7 +736,10 @@
"parts": "คุณไม่มีสิทธิ์ดู {count} ส่วนของหน้านี้ | คุณไม่มีสิทธิ์ดู {count} ส่วนของหน้านี้",
"missingIntro": "บทบาทของคุณไม่มีสิทธิ์ดังต่อไปนี้:",
"more": "อีก {count} รายการ",
"showLess": "แสดงน้อยลง"
"showLess": "แสดงน้อยลง",
"actions": "มีการบล็อก {count} การดำเนินการในหน้านี้ | มีการบล็อก {count} การดำเนินการในหน้านี้",
"resources": "{count} ทรัพยากร | {count} ทรัพยากร",
"askAdmin": "โปรดขอสิทธิ์การเข้าถึงจากผู้ดูแลระบบของคุณ."
},
"chip": {
"label": "ไม่มีสิทธิ์เข้าถึง",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Narrow to {period} (~{size})",
"autoRunGuardSearchJob": "Run as search job",
"autoRunGuardSelectStream": "Select a stream",
"searchJobNoPermission": "You do not have permission to create search jobs",
"missingStreamAndNoFts": "One or more filter fields do not exist in \"{missingStreams}\", hence no search is performed in the mentioned stream.\n Not searched: {noFtsStreams} (no full-text fields).",
"freeTextChooseFirst": "Choose how to search text first",
"freeTextScanLogsOnly": "Scan mode works in the logs view only; set a full-text field to use text search here",
@@ -13011,7 +13075,9 @@
"firingFor": "firing for {duration}",
"incident": "Incident"
},
"priorityUnsetPagesAs": "ไม่ได้ตั้งค่า (แจ้งเตือนเรียกเวรเป็น P2)"
"priorityUnsetPagesAs": "ไม่ได้ตั้งค่า (แจ้งเตือนเรียกเวรเป็น P2)",
"importAlert": "นำเข้าการแจ้งเตือน",
"alertLibrary": "คลังการแจ้งเตือน"
},
"alert_dependencies": {
"actionOpen": "Open",
@@ -13073,7 +13139,6 @@
"statReady": "Ready to use",
"statNeedsData": "Not ingested",
"statAll": "ทั้งหมด",
"notIngested": "Not ingested: {stream}",
"notIngestedHint": "This organization is not receiving {stream}, so this alert would never fire. Add it anyway and it will start working once the data arrives.",
"alertCount": "{count} alert | {count} alerts",
"noCollectorTitle": "Nothing here matches your data yet",
@@ -13100,6 +13165,9 @@
"alertTypeAnomaly": "Anomaly",
"enterprise": "Enterprise",
"enterpriseHint": "Anomaly detection alerts need OpenObserve Enterprise.",
"card": {
"customizeTooltip": "เปิดตัวแก้ไขการแจ้งเตือนที่กรอกข้อมูลไว้ล่วงหน้า"
},
"drawer": {
"dataAvailable": "Data available",
"streamNotFound": "Stream not found",
@@ -13131,20 +13199,10 @@
"install": {
"title": "Add alerts from the library",
"subtitle": "Choose which alerts to add, where they are filed, and optionally where they notify.",
"stepDestination": "Destination",
"stepAlerts": "Alerts",
"stepFolder": "Folder",
"stepTune": "Tune",
"stepInstall": "เพิ่ม",
"destinationIntro": "Every alert in this batch notifies the destination you choose here. Library files name the destinations of the organization they were exported from, which do not exist in yours. This step is optional: skip it and the alerts are added without one.",
"destinationLabel": "Destination",
"destinationsLoadFailed": "Your existing destinations could not be loaded.",
"retry": "Try again",
"destinationsNoneBody": "This organization has no alert destinations yet. Create one on the Destinations page whenever you are ready.",
"openDestinations": "Open Destinations",
"selectAll": "เลือกทั้งหมด",
"clear": "ล้าง",
"selectedCount": "{count} of {total} selected",
"folderIntro": "Added alerts are filed here. Create a folder from the + button if this batch deserves its own.",
"tuneToggle": "Set these for every alert in this batch",
"tuneHint": "Only “{frequency}” and “{silence}” are safe to set for a whole batch. Thresholds stay per-alert — change one by opening that alert and adding it on its own.",
@@ -13163,7 +13221,6 @@
"next": "ถัดไป",
"cancel": "ยกเลิก",
"done": "Done",
"confirmLarge": "I understand this creates {count} alerts in {folder}, each notifying {destination}.",
"tuneThresholdOnly": "Applies to threshold alerts only.",
"anomalyTrainingNote": "Anomaly alerts start alerting once they have trained on enough of the stream's history.",
"anomalyUnavailable": "Anomaly alerts need OpenObserve Enterprise with anomaly detection turned on, so this one was skipped.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Gruplar",
"serviceAccounts": "Hizmet hesapları",
"cipherKeys": "Şifreleme anahtarları",
"regexPatterns": "Regex desenleri"
"regexPatterns": "Regex desenleri",
"evalJobs": "Çevrimiçi değerlendirme işleri",
"billingGroup": "Faturalandırma grubu",
"syntheticChecks": "Sentetik kontrolleri",
"metrics": "Metrikler",
"databaseMonitoring": "Veritabanı izleme",
"workflowFolders": "İş akışı klasörleri",
"promptFolders": "İstem klasörleri",
"syntheticEnvironments": "Sentetik ortamları",
"sourceMaps": "Kaynak haritaları",
"alertFolders": "Uyarı klasörleri",
"serviceStreams": "Hizmet akışları",
"fleetManagement": "Filo yönetimi",
"onCallResponses": "Nöbet yanıtları",
"statusPages": "Durum sayfaları",
"onCall": "Nöbet",
"incidentIntegrations": "Vaka entegrasyonları",
"incidents": "Vakalar",
"scorers": "Puanlayıcılar",
"searchJobs": "Arama işleri",
"enrichmentTables": "Zenginleştirme tabloları",
"rumTokens": "RUM belirteçleri",
"traces": "İzler",
"remoteTasks": "Uzak görevler",
"reportFolders": "Rapor klasörleri",
"llmProviders": "LLM sağlayıcıları",
"settings": "Ayarlar",
"dashboardFolders": "Pano klasörleri",
"scoreConfigs": "Puan yapılandırmaları",
"workflows": "İş akışları",
"syntheticFolders": "Sentetik klasörleri",
"downtimeFolders": "Bakım penceresi klasörleri"
},
"resourcesInline": {
"dashboards": "panolar",
@@ -663,7 +694,38 @@
"serviceAccounts": "hizmet hesapları",
"cipherKeys": "şifreleme anahtarları",
"regexPatterns": "regex desenleri",
"unnamed": "bu kaynak"
"unnamed": "bu kaynak",
"onCall": "nöbet",
"incidentIntegrations": "vaka entegrasyonları",
"scorers": "puanlayıcılar",
"llmProviders": "LLM sağlayıcıları",
"enrichmentTables": "zenginleştirme tabloları",
"rumTokens": "RUM belirteçleri",
"settings": "ayarlar",
"traces": "izler",
"remoteTasks": "uzak görevler",
"reportFolders": "rapor klasörleri",
"dashboardFolders": "pano klasörleri",
"scoreConfigs": "puan yapılandırmaları",
"downtimeFolders": "bakım penceresi klasörleri",
"workflows": "iş akışları",
"syntheticFolders": "sentetik klasörleri",
"evalJobs": "çevrimiçi değerlendirme işleri",
"metrics": "metrikler",
"databaseMonitoring": "veritabanı izleme",
"billingGroup": "faturalandırma grubu",
"syntheticChecks": "sentetik kontrolleri",
"workflowFolders": "iş akışı klasörleri",
"promptFolders": "istem klasörleri",
"syntheticEnvironments": "sentetik ortamları",
"serviceStreams": "hizmet akışları",
"sourceMaps": "kaynak haritaları",
"statusPages": "durum sayfaları",
"alertFolders": "uyarı klasörleri",
"fleetManagement": "filo yönetimi",
"incidents": "vakalar",
"onCallResponses": "nöbet yanıtları",
"searchJobs": "arama işleri"
},
"denied": {
"view": "{resource} görüntüleyemezsiniz",
@@ -674,7 +736,10 @@
"parts": "Bu sayfanın {count} bölümünü görüntüleyemezsiniz | Bu sayfanın {count} bölümünü görüntüleyemezsiniz",
"missingIntro": "Rolünüzde şu izinler eksik:",
"more": "{count} tane daha",
"showLess": "Daha az göster"
"showLess": "Daha az göster",
"actions": "Bu sayfada {count} işlem engellendi | Bu sayfada {count} işlem engellendi",
"askAdmin": "Erişim için yöneticinize başvurun.",
"resources": "{count} kaynak | {count} kaynak"
},
"chip": {
"label": "Erişim reddedildi",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "{period} aralığına daralt (~{size})",
"autoRunGuardSearchJob": "Arama işi olarak çalıştır",
"autoRunGuardSelectStream": "Bir akış seçin",
"searchJobNoPermission": "Arama işi oluşturma izniniz yok",
"missingStreamAndNoFts": "\"{missingStreams}\" içinde bir veya daha fazla filtre alanı mevcut değil, bu nedenle belirtilen akışta arama yapılmaz.\n Aranmadı: {noFtsStreams} (tam metin alanı yok).",
"freeTextChooseFirst": "Önce metnin nasıl aranacağını seçin",
"freeTextScanLogsOnly": "Tarama modu yalnızca günlükler görünümünde çalışır; burada metin araması kullanmak için bir tam metin alanı ayarlayın",
@@ -10631,6 +10695,8 @@
"legendNoData": "Veri yok"
},
"importAlertTitle": "Uyarıyı İçe Aktar",
"importAlert": "Uyarı içe aktar",
"alertLibrary": "Uyarı kitaplığı",
"exportDialogTitle": "Uyarıyı dışa aktar | {count} uyarıyı dışa aktar",
"exportDialogSubtitle": "Tanımı gözden geçirin, ardından JSON veya OpenObserve sağlayıcısı için Terraform olarak indirin.",
"jsonFilesOnlyHint": "Yalnızca .json dosyaları",
@@ -13075,7 +13141,6 @@
"statReady": "Kullanıma hazır",
"statNeedsData": "Alınmadı",
"statAll": "Tümü",
"notIngested": "Alınmadı: {stream}",
"notIngestedHint": "Bu kuruluş {stream} almıyor, bu nedenle bu uyarı asla tetiklenmez. Yine de ekleyin; veri geldiğinde çalışmaya başlayacaktır.",
"alertCount": "{count} uyarı | {count} uyarı",
"noCollectorTitle": "Burada henüz verilerinizle eşleşen bir şey yok",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Anomali",
"enterprise": "Enterprise",
"enterpriseHint": "Anomali algılama uyarıları OpenObserve Enterprise gerektirir.",
"card": {
"customizeTooltip": "Uyarı düzenleyicisini önceden doldurulmuş olarak açar"
},
"drawer": {
"dataAvailable": "Veri mevcut",
"streamNotFound": "Akış bulunamadı",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Kütüphaneden uyarı ekle",
"subtitle": "Hangi uyarıların ekleneceğini, nereye kaydedileceğini ve isteğe bağlı olarak nereye bildirim göndereceğini seçin.",
"stepDestination": "Hedef",
"subtitle": "Bu uyarıların nereye kaydedileceğini seçin, ardından ekleyin.",
"stepAlerts": "Uyarılar",
"stepFolder": "Klasör",
"stepTune": "Ayarla",
"stepInstall": "Ekle",
"destinationIntro": "Bu gruptaki her uyarı, burada seçtiğiniz hedefe bildirim gönderir. Kitaplık dosyaları, dışa aktarıldıkları kuruluşun hedeflerini adlandırır ve bunlar sizinkinde mevcut değildir. Bu adım isteğe bağlıdır: atlayın ve uyarılar hedef olmadan eklenir.",
"destinationLabel": "Hedef",
"destinationsLoadFailed": "Mevcut hedefleriniz yüklenemedi.",
"retry": "Tekrar dene",
"destinationsNoneBody": "Bu kuruluşun henüz uyarı hedefi yok. Hazır olduğunuzda Hedefler sayfasından bir tane oluşturun.",
"openDestinations": "Hedefleri Aç",
"selectAll": "Tümünü seç",
"clear": "Temizle",
"selectedCount": "{count} / {total} seçildi",
"folderIntro": "Eklenen uyarılar buraya kaydedilir. Bu grup kendi klasörünü hak ediyorsa + düğmesinden bir klasör oluşturun.",
"tuneToggle": "Bu gruptaki her uyarı için bunları ayarlayın",
"tuneHint": "Yalnızca “{frequency}” ve “{silence}” değerlerini tüm bir grup için ayarlamak güvenlidir. Eşikler uyarı başına kalır — birini değiştirmek için ilgili uyarıyı açın ve tek başına ekleyin.",
@@ -13165,7 +13223,6 @@
"next": "İleri",
"cancel": "İptal",
"done": "Bitti",
"confirmLarge": "Bunun {folder} içinde {count} uyarı oluşturacağını ve her birinin {destination} adresine bildirim göndereceğini anlıyorum.",
"tuneThresholdOnly": "Yalnızca eşik uyarılarına uygulanır.",
"anomalyTrainingNote": "Anomali uyarıları, akışın geçmişiyle yeterince eğitildikten sonra tetiklenmeye başlar.",
"anomalyUnavailable": "Anomali uyarıları, anomali algılaması açık OpenObserve Enterprise gerektirir; bu nedenle bu uyarı atlandı.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "Nhóm",
"serviceAccounts": "Tài khoản dịch vụ",
"cipherKeys": "Khóa mã hóa",
"regexPatterns": "Mẫu regex"
"regexPatterns": "Mẫu regex",
"evalJobs": "Tác vụ đánh giá trực tuyến",
"billingGroup": "Nhóm thanh toán",
"syntheticChecks": "Kiểm tra Synthetics",
"metrics": "Chỉ số",
"databaseMonitoring": "Giám sát cơ sở dữ liệu",
"workflowFolders": "Thư mục quy trình làm việc",
"promptFolders": "Thư mục lời nhắc",
"syntheticEnvironments": "Môi trường Synthetics",
"sourceMaps": "Source maps",
"alertFolders": "Thư mục cảnh báo",
"serviceStreams": "Luồng dịch vụ",
"fleetManagement": "Quản lý Fleet",
"onCallResponses": "Phản hồi trực",
"statusPages": "Trang trạng thái",
"onCall": "Trực",
"incidentIntegrations": "Tích hợp sự cố",
"incidents": "Sự cố",
"scorers": "Bộ chấm điểm",
"searchJobs": "Tác vụ tìm kiếm",
"enrichmentTables": "Bảng làm giàu dữ liệu",
"rumTokens": "Token RUM",
"traces": "Trace",
"remoteTasks": "Tác vụ từ xa",
"reportFolders": "Thư mục báo cáo",
"llmProviders": "Nhà cung cấp LLM",
"settings": "Cài đặt",
"dashboardFolders": "Thư mục bảng điều khiển",
"scoreConfigs": "Cấu hình điểm",
"workflows": "Quy trình làm việc",
"syntheticFolders": "Thư mục Synthetics",
"downtimeFolders": "Thư mục thời gian bảo trì"
},
"resourcesInline": {
"dashboards": "bảng điều khiển",
@@ -663,7 +694,38 @@
"serviceAccounts": "tài khoản dịch vụ",
"cipherKeys": "khóa mã hóa",
"regexPatterns": "mẫu regex",
"unnamed": "tài nguyên này"
"unnamed": "tài nguyên này",
"onCall": "trực",
"incidentIntegrations": "tích hợp sự cố",
"scorers": "bộ chấm điểm",
"llmProviders": "nhà cung cấp LLM",
"enrichmentTables": "bảng làm giàu dữ liệu",
"rumTokens": "token RUM",
"settings": "cài đặt",
"traces": "trace",
"remoteTasks": "tác vụ từ xa",
"reportFolders": "thư mục báo cáo",
"dashboardFolders": "thư mục bảng điều khiển",
"scoreConfigs": "cấu hình điểm",
"downtimeFolders": "thư mục thời gian bảo trì",
"workflows": "quy trình làm việc",
"syntheticFolders": "thư mục Synthetics",
"evalJobs": "tác vụ đánh giá trực tuyến",
"metrics": "chỉ số",
"databaseMonitoring": "giám sát cơ sở dữ liệu",
"billingGroup": "nhóm thanh toán",
"syntheticChecks": "kiểm tra Synthetics",
"workflowFolders": "thư mục quy trình làm việc",
"promptFolders": "thư mục lời nhắc",
"syntheticEnvironments": "môi trường Synthetics",
"serviceStreams": "luồng dịch vụ",
"sourceMaps": "source maps",
"statusPages": "trang trạng thái",
"alertFolders": "thư mục cảnh báo",
"fleetManagement": "quản lý Fleet",
"incidents": "sự cố",
"onCallResponses": "phản hồi trực",
"searchJobs": "tác vụ tìm kiếm"
},
"denied": {
"view": "Bạn không thể xem {resource}",
@@ -674,7 +736,10 @@
"parts": "Bạn không thể xem {count} phần của trang này | Bạn không thể xem {count} phần của trang này",
"missingIntro": "Vai trò của bạn thiếu các quyền sau:",
"more": "{count} mục khác",
"showLess": "Thu gọn"
"showLess": "Thu gọn",
"actions": "{count} thao tác trên trang này bị chặn | {count} thao tác trên trang này bị chặn",
"askAdmin": "Hãy yêu cầu quản trị viên cấp quyền truy cập.",
"resources": "{count} tài nguyên | {count} tài nguyên"
},
"chip": {
"label": "Truy cập bị từ chối",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "Thu hẹp về {period} (~{size})",
"autoRunGuardSearchJob": "Chạy dưới dạng tác vụ tìm kiếm",
"autoRunGuardSelectStream": "Chọn một stream",
"searchJobNoPermission": "Bạn không có quyền tạo tác vụ tìm kiếm",
"missingStreamAndNoFts": "Một hoặc nhiều trường lọc không tồn tại trong \"{missingStreams}\", do đó không thực hiện tìm kiếm trong stream được đề cập.\n Không tìm kiếm: {noFtsStreams} (không có trường toàn văn).",
"freeTextChooseFirst": "Trước tiên hãy chọn cách tìm kiếm văn bản",
"freeTextScanLogsOnly": "Chế độ quét chỉ hoạt động trong chế độ xem nhật ký; hãy đặt trường toàn văn để dùng tìm kiếm văn bản ở đây",
@@ -10631,6 +10695,8 @@
"legendNoData": "Không có dữ liệu"
},
"importAlertTitle": "Nhập cảnh báo",
"importAlert": "Nhập cảnh báo",
"alertLibrary": "Thư viện cảnh báo",
"exportDialogTitle": "Xuất cảnh báo | Xuất {count} cảnh báo",
"exportDialogSubtitle": "Xem lại định nghĩa, sau đó tải xuống dưới dạng JSON hoặc Terraform cho nhà cung cấp OpenObserve.",
"jsonFilesOnlyHint": "Chỉ chấp nhận tệp .json",
@@ -13075,7 +13141,6 @@
"statReady": "Sẵn sàng sử dụng",
"statNeedsData": "Chưa được thu thập",
"statAll": "Tất cả",
"notIngested": "Chưa được thu thập: {stream}",
"notIngestedHint": "Tổ chức này không đang tiếp nhận {stream}, nên cảnh báo này sẽ không bao giờ kích hoạt. Bạn vẫn có thể thêm và nó sẽ bắt đầu hoạt động khi dữ liệu đến.",
"alertCount": "{count} cảnh báo | {count} cảnh báo",
"noCollectorTitle": "Chưa có gì ở đây khớp với dữ liệu của bạn",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "Bất thường",
"enterprise": "Enterprise",
"enterpriseHint": "Cảnh báo phát hiện bất thường cần OpenObserve Enterprise.",
"card": {
"customizeTooltip": "Mở trình chỉnh sửa cảnh báo đã điền sẵn"
},
"drawer": {
"dataAvailable": "Có dữ liệu",
"streamNotFound": "Không tìm thấy stream",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "Thêm cảnh báo từ thư viện",
"subtitle": "Chọn những cảnh báo cần thêm, nơi chúng được lưu, và tùy chọn nơi chúng thông báo.",
"stepDestination": "Đích",
"subtitle": "Chọn nơi lưu các cảnh báo này, sau đó thêm chúng.",
"stepAlerts": "Cảnh báo",
"stepFolder": "Thư mục",
"stepTune": "Tinh chỉnh",
"stepInstall": "Thêm",
"destinationIntro": "Mọi cảnh báo trong lô này sẽ thông báo đến đích bạn chọn ở đây. Các tệp thư viện ghi tên những đích thuộc tổ chức mà chúng được xuất ra, những đích này không tồn tại trong tổ chức của bạn. Bước này là tùy chọn: bỏ qua thì các cảnh báo vẫn được thêm mà không có đích nào.",
"destinationLabel": "Đích",
"destinationsLoadFailed": "Không thể tải các đích hiện có của bạn.",
"retry": "Thử lại",
"destinationsNoneBody": "Tổ chức này chưa có đích cảnh báo nào. Hãy tạo một đích trong trang Đích khi bạn sẵn sàng.",
"openDestinations": "Mở Đích",
"selectAll": "Chọn tất cả",
"clear": "Xóa",
"selectedCount": "Đã chọn {count} trong {total}",
"folderIntro": "Các cảnh báo đã thêm được lưu trữ tại đây. Tạo một thư mục từ nút + nếu nhóm này xứng đáng có thư mục riêng.",
"tuneToggle": "Đặt các giá trị này cho mọi cảnh báo trong lô này",
"tuneHint": "Chỉ “{frequency}” và “{silence}” là an toàn để đặt cho cả một nhóm. Ngưỡng vẫn theo từng cảnh báo — thay đổi một cảnh báo bằng cách mở cảnh báo đó và thêm riêng nó.",
@@ -13165,7 +13223,6 @@
"next": "Tiếp theo",
"cancel": "Hủy",
"done": "Hoàn tất",
"confirmLarge": "Tôi hiểu rằng thao tác này tạo {count} cảnh báo trong {folder}, mỗi cảnh báo thông báo tới {destination}.",
"tuneThresholdOnly": "Chỉ áp dụng cho cảnh báo ngưỡng.",
"anomalyTrainingNote": "Cảnh báo bất thường bắt đầu cảnh báo khi đã được huấn luyện trên đủ lịch sử của luồng dữ liệu.",
"anomalyUnavailable": "Cảnh báo bất thường cần OpenObserve Enterprise có bật phát hiện bất thường, nên cảnh báo này đã bị bỏ qua.",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "组",
"serviceAccounts": "服务账号",
"cipherKeys": "加密密钥",
"regexPatterns": "正则表达式模式"
"regexPatterns": "正则表达式模式",
"evalJobs": "在线评估任务",
"billingGroup": "账单组",
"syntheticChecks": "合成监控检查",
"metrics": "指标",
"databaseMonitoring": "数据库监控",
"workflowFolders": "工作流文件夹",
"promptFolders": "提示词文件夹",
"syntheticEnvironments": "合成监控环境",
"sourceMaps": "源代码映射",
"alertFolders": "告警文件夹",
"serviceStreams": "服务流",
"fleetManagement": "Fleet 管理",
"onCallResponses": "值班响应",
"statusPages": "状态页",
"onCall": "值班",
"incidentIntegrations": "事件集成",
"incidents": "事件",
"scorers": "评分器",
"searchJobs": "搜索任务",
"enrichmentTables": "富化表",
"rumTokens": "RUM 令牌",
"traces": "追踪",
"remoteTasks": "远程任务",
"reportFolders": "报表文件夹",
"llmProviders": "LLM 提供商",
"settings": "设置",
"dashboardFolders": "仪表盘文件夹",
"scoreConfigs": "分数配置",
"workflows": "工作流",
"syntheticFolders": "合成监控文件夹",
"downtimeFolders": "停机时间文件夹"
},
"resourcesInline": {
"dashboards": "仪表盘",
@@ -663,7 +694,38 @@
"serviceAccounts": "服务账号",
"cipherKeys": "加密密钥",
"regexPatterns": "正则表达式模式",
"unnamed": "此资源"
"unnamed": "此资源",
"onCall": "值班",
"incidentIntegrations": "事件集成",
"scorers": "评分器",
"llmProviders": "LLM 提供商",
"enrichmentTables": "富化表",
"rumTokens": "RUM 令牌",
"settings": "设置",
"traces": "追踪",
"remoteTasks": "远程任务",
"reportFolders": "报表文件夹",
"dashboardFolders": "仪表盘文件夹",
"scoreConfigs": "分数配置",
"downtimeFolders": "停机时间文件夹",
"workflows": "工作流",
"syntheticFolders": "合成监控文件夹",
"evalJobs": "在线评估任务",
"metrics": "指标",
"databaseMonitoring": "数据库监控",
"billingGroup": "账单组",
"syntheticChecks": "合成监控检查",
"workflowFolders": "工作流文件夹",
"promptFolders": "提示词文件夹",
"syntheticEnvironments": "合成监控环境",
"serviceStreams": "服务流",
"sourceMaps": "源代码映射",
"statusPages": "状态页",
"alertFolders": "告警文件夹",
"fleetManagement": "Fleet 管理",
"incidents": "事件",
"onCallResponses": "值班响应",
"searchJobs": "搜索任务"
},
"denied": {
"view": "您无法查看{resource}",
@@ -674,7 +736,10 @@
"parts": "您无法查看此页面的 {count} 个部分 | 您无法查看此页面的 {count} 个部分",
"missingIntro": "您的角色缺少以下权限:",
"more": "另外 {count} 项",
"showLess": "收起"
"showLess": "收起",
"actions": "此页面上有 {count} 项操作被阻止 | 此页面上有 {count} 项操作被阻止",
"askAdmin": "请向管理员申请访问权限。",
"resources": "{count} 个资源 | {count} 个资源"
},
"chip": {
"label": "访问被拒绝",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "缩小到 {period}(约 {size})",
"autoRunGuardSearchJob": "作为搜索任务运行",
"autoRunGuardSelectStream": "选择数据流",
"searchJobNoPermission": "您没有创建搜索任务的权限",
"missingStreamAndNoFts": "一个或多个过滤器字段在 \"{missingStreams}\" 中不存在,因此不会在提到的数据流中执行搜索。\n 未搜索:{noFtsStreams}(无全文检索字段)。",
"freeTextChooseFirst": "请先选择文本搜索方式",
"freeTextScanLogsOnly": "扫描模式仅在日志视图中可用;请设置全文检索字段以在此处使用文本搜索",
@@ -10631,6 +10695,8 @@
"legendNoData": "无数据"
},
"importAlertTitle": "导入告警",
"importAlert": "导入告警",
"alertLibrary": "告警库",
"exportDialogTitle": "导出告警 | 导出 {count} 条告警",
"exportDialogSubtitle": "查看定义,然后将其下载为 JSON 或用于 OpenObserve provider 的 Terraform。",
"jsonFilesOnlyHint": "仅 .json 文件",
@@ -13075,7 +13141,6 @@
"statReady": "可立即使用",
"statNeedsData": "未摄取",
"statAll": "全部",
"notIngested": "未摄取:{stream}",
"notIngestedHint": "此组织未接收 {stream},因此此告警永远不会触发。仍要添加,数据到达后它将开始工作。",
"alertCount": "{count} 个告警 | {count} 个告警",
"noCollectorTitle": "这里还没有与你的数据匹配的内容",
@@ -13102,6 +13167,9 @@
"alertTypeAnomaly": "异常",
"enterprise": "Enterprise",
"enterpriseHint": "异常检测告警需要 OpenObserve Enterprise。",
"card": {
"customizeTooltip": "打开已预填的告警编辑器"
},
"drawer": {
"dataAvailable": "数据可用",
"streamNotFound": "未找到流",
@@ -13132,21 +13200,11 @@
},
"install": {
"title": "从库中添加告警",
"subtitle": "选择要添加的告警、它们的归档位置,以及可选的通知目标。",
"stepDestination": "目标",
"subtitle": "选择这些告警的归档位置,然后添加它们。",
"stepAlerts": "告警",
"stepFolder": "文件夹",
"stepTune": "调整",
"stepInstall": "添加",
"destinationIntro": "此批次中的每个告警都会通知您在此处选择的目标。库文件中标注的是导出它们的组织中的目标,这些目标在您的组织中并不存在。此步骤为可选:跳过它,这些告警将被添加但不设置目标。",
"destinationLabel": "目标",
"destinationsLoadFailed": "无法加载您现有的目标。",
"retry": "重试",
"destinationsNoneBody": "此组织尚无告警目标。准备就绪后,可在“目标”页面创建一个。",
"openDestinations": "打开目标",
"selectAll": "全选",
"clear": "清除",
"selectedCount": "已选择 {count} / {total}",
"folderIntro": "添加的告警在此归档。如果此批次需要单独文件夹,可使用 + 按钮创建。",
"tuneToggle": "为此批次中的每个告警设置这些",
"tuneHint": "只有“{frequency}”和“{silence}”可安全地整批设置。阈值保持每个告警独立——如需更改,请打开该告警并单独添加。",
@@ -13165,7 +13223,6 @@
"next": "下一步",
"cancel": "取消",
"done": "完成",
"confirmLarge": "我了解此操作会在 {folder} 中创建 {count} 个告警,每个都通知 {destination}。",
"tuneThresholdOnly": "仅适用于阈值告警。",
"anomalyTrainingNote": "异常告警在基于足够的数据流历史完成训练后才开始告警。",
"anomalyUnavailable": "异常告警需要启用了异常检测的 OpenObserve Enterprise,因此已跳过此告警。",
+74 -17
View File
@@ -638,7 +638,38 @@
"groups": "群組",
"serviceAccounts": "服務帳戶",
"cipherKeys": "加密金鑰",
"regexPatterns": "正規表示式模式"
"regexPatterns": "正規表示式模式",
"evalJobs": "線上評估作業",
"billingGroup": "計費群組",
"syntheticChecks": "合成監控檢查",
"metrics": "指標",
"databaseMonitoring": "資料庫監控",
"workflowFolders": "工作流程資料夾",
"promptFolders": "提示詞資料夾",
"syntheticEnvironments": "合成監控環境",
"sourceMaps": "原始碼映射",
"alertFolders": "警示資料夾",
"serviceStreams": "服務串流",
"fleetManagement": "機群管理",
"onCallResponses": "值班回應",
"statusPages": "狀態頁面",
"onCall": "值班",
"incidentIntegrations": "事件整合",
"incidents": "事件",
"scorers": "評分器",
"searchJobs": "搜尋作業",
"enrichmentTables": "擴充表",
"rumTokens": "RUM 權杖",
"traces": "追蹤",
"remoteTasks": "遠端任務",
"reportFolders": "報表資料夾",
"llmProviders": "LLM 供應商",
"settings": "設定",
"dashboardFolders": "儀表板資料夾",
"scoreConfigs": "評分設定",
"workflows": "工作流程",
"syntheticFolders": "合成監控資料夾",
"downtimeFolders": "停機時段資料夾"
},
"resourcesInline": {
"dashboards": "儀表板",
@@ -663,7 +694,38 @@
"serviceAccounts": "服務帳戶",
"cipherKeys": "加密金鑰",
"regexPatterns": "正規表示式模式",
"unnamed": "此資源"
"unnamed": "此資源",
"onCall": "值班",
"incidentIntegrations": "事件整合",
"scorers": "評分器",
"llmProviders": "LLM 供應商",
"enrichmentTables": "擴充表",
"rumTokens": "RUM 權杖",
"settings": "設定",
"traces": "追蹤",
"remoteTasks": "遠端任務",
"reportFolders": "報表資料夾",
"dashboardFolders": "儀表板資料夾",
"scoreConfigs": "評分設定",
"downtimeFolders": "停機時段資料夾",
"workflows": "工作流程",
"syntheticFolders": "合成監控資料夾",
"evalJobs": "線上評估作業",
"metrics": "指標",
"databaseMonitoring": "資料庫監控",
"billingGroup": "計費群組",
"syntheticChecks": "合成監控檢查",
"workflowFolders": "工作流程資料夾",
"promptFolders": "提示詞資料夾",
"syntheticEnvironments": "合成監控環境",
"serviceStreams": "服務串流",
"sourceMaps": "原始碼映射",
"statusPages": "狀態頁面",
"alertFolders": "警示資料夾",
"fleetManagement": "機群管理",
"incidents": "事件",
"onCallResponses": "值班回應",
"searchJobs": "搜尋作業"
},
"denied": {
"view": "您無法檢視{resource}",
@@ -674,7 +736,10 @@
"parts": "您無法檢視此頁面的 {count} 個部分 | 您無法檢視此頁面的 {count} 個部分",
"missingIntro": "您的角色缺少以下權限:",
"more": "另外 {count} 項",
"showLess": "收合"
"showLess": "收合",
"actions": "此頁面上有 {count} 項操作遭到封鎖 | 此頁面上有 {count} 項操作遭到封鎖",
"askAdmin": "請向管理員申請存取權限。",
"resources": "{count} 個資源 | {count} 個資源"
},
"chip": {
"label": "存取遭拒",
@@ -1622,7 +1687,6 @@
"autoRunGuardNarrowWithSize": "縮小至 {period}(約 {size})",
"autoRunGuardSearchJob": "以搜尋作業執行",
"autoRunGuardSelectStream": "選擇串流",
"searchJobNoPermission": "您沒有建立搜尋作業的權限",
"missingStreamAndNoFts": "「{missingStreams}」中有一或多個篩選欄位不存在,因此不會在該串流中執行搜尋。\n未搜尋:{noFtsStreams}(沒有全文欄位)。",
"freeTextChooseFirst": "請先選擇文字搜尋方式",
"freeTextScanLogsOnly": "掃描模式僅適用於日誌檢視;請設定全文欄位以在此使用文字搜尋",
@@ -10626,6 +10690,8 @@
"legendNoData": "無資料"
},
"importAlertTitle": "匯入警示",
"importAlert": "匯入警示",
"alertLibrary": "警示庫",
"exportDialogTitle": "匯出警示 | 匯出 {count} 個警示",
"exportDialogSubtitle": "檢閱定義,然後將其下載為 JSON 或適用於 OpenObserve provider 的 Terraform。",
"jsonFilesOnlyHint": "僅限 .json 檔案",
@@ -13070,7 +13136,6 @@
"statReady": "可立即使用",
"statNeedsData": "未攝取",
"statAll": "全部",
"notIngested": "未攝取:{stream}",
"notIngestedHint": "此組織目前未接收 {stream},因此此警示永遠不會觸發。您仍可將其加入,等數據到達後便會開始運作。",
"alertCount": "{count} 個警示 | {count} 個警示",
"noCollectorTitle": "這裡尚無與您的資料相符的項目",
@@ -13097,6 +13162,9 @@
"alertTypeAnomaly": "異常",
"enterprise": "Enterprise",
"enterpriseHint": "異常偵測警示需要 OpenObserve Enterprise。",
"card": {
"customizeTooltip": "開啟已預先填入的警示編輯器"
},
"drawer": {
"dataAvailable": "資料可用",
"streamNotFound": "找不到串流",
@@ -13127,21 +13195,11 @@
},
"install": {
"title": "從警示庫新增警示",
"subtitle": "選擇要新增哪些警示、它們歸檔的位置,以及選擇性地設定它們通知的目標。",
"stepDestination": "目標",
"subtitle": "選擇這些警示要歸檔的位置,然後新增它們。",
"stepAlerts": "警示",
"stepFolder": "資料夾",
"stepTune": "調整",
"stepInstall": "新增",
"destinationIntro": "此批次中的每個警示都會通知您在此選擇的目標。媒體庫檔案中列出的目標名稱來自匯出它們的組織,您的組織中並不存在這些目標。此步驟為選用:略過後,這些警示將在沒有目標的情況下新增。",
"destinationLabel": "目標",
"destinationsLoadFailed": "無法載入您現有的目標。",
"retry": "再試一次",
"destinationsNoneBody": "此組織尚無警示目標。當您準備好時,請在「目標」頁面建立一個。",
"openDestinations": "開啟目標",
"selectAll": "全選",
"clear": "清除",
"selectedCount": "已選取 {count} / {total}",
"folderIntro": "已新增的警示會儲存在這裡。如果這批警示需要自己的資料夾,請使用 + 按鈕建立。",
"tuneToggle": "為此批次中的每個警示設定這些",
"tuneHint": "只有「{frequency}」和「{silence}」可以安全地為整批設定。閾值仍依每個警示個別設定 — 若要變更某個警示,請開啟該警示並單獨加入。",
@@ -13160,7 +13218,6 @@
"next": "下一步",
"cancel": "取消",
"done": "完成",
"confirmLarge": "我了解此操作會在 {folder} 中建立 {count} 個警示,每個都會通知 {destination}。",
"tuneThresholdOnly": "僅適用於閾值警示。",
"anomalyTrainingNote": "異常警示在以足夠的資料流歷史完成訓練後才開始發出警示。",
"anomalyUnavailable": "異常警示需要已啟用異常偵測的 OpenObserve Enterprise,因此已略過此警示。",
+21 -1
View File
@@ -6,6 +6,8 @@ const isStaleChunkError = vi.hoisted(() => vi.fn());
const toast = vi.hoisted(() => vi.fn());
const logsInit = vi.hoisted(() => vi.fn());
const setInitialVersion = vi.hoisted(() => vi.fn());
const bindAccess = vi.hoisted(() => vi.fn());
const appMount = vi.hoisted(() => vi.fn());
vi.mock("@/services/config.queries", async (importOriginal) => ({
...(await importOriginal<typeof import("@/services/config.queries")>()),
@@ -13,7 +15,7 @@ vi.mock("@/services/config.queries", async (importOriginal) => ({
}));
vi.mock("vue", async (importOriginal) => {
const actual = await importOriginal<typeof import("vue")>();
const app = { use: () => app, mount: vi.fn() };
const app = { use: () => app, mount: appMount };
return { ...actual, createApp: () => app };
});
vi.mock("@tanstack/vue-query", () => ({ VueQueryPlugin: {} }));
@@ -63,6 +65,10 @@ vi.mock("./composables/query/queryClient", () => ({
}));
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast }));
vi.mock("@/utils/themeManager", () => ({ bootstrapTheme: vi.fn() }));
vi.mock("@/composables/useAccessStore", async (importOriginal) => ({
...(await importOriginal<typeof import("@/composables/useAccessStore")>()),
bindAccess,
}));
// main.ts boots the whole app on import, which can outlast the default hook timeout on a busy runner.
beforeAll(async () => {
@@ -72,6 +78,20 @@ beforeAll(async () => {
});
}, 60000);
describe("main.ts access store", () => {
it("binds the access store to the app's router and store before the app mounts", async () => {
const store = (await import("./stores")).default;
expect(bindAccess).toHaveBeenCalledTimes(1);
const [router, boundStore] = bindAccess.mock.calls[0];
expect(router.onError).toBe(routerOnError);
expect(boundStore).toBe(store);
await vi.waitFor(() => expect(appMount).toHaveBeenCalled());
expect(bindAccess.mock.invocationCallOrder[0]).toBeLessThan(
appMount.mock.invocationCallOrder[0],
);
});
});
describe("main.ts RUM", () => {
it("does not start the RUM or logs SDK from the unauthenticated bootstrap config", () => {
expect(rumInit).not.toHaveBeenCalled();
+3
View File
@@ -35,6 +35,7 @@ import { queryClient, setMutationNotifier } from "./composables/query/queryClien
import { toast } from "@/lib/feedback/Toast/useToast";
import { bootstrapTheme } from "@/utils/themeManager";
import { raw } from "@/types/i18n";
import { bindAccess } from "@/composables/useAccessStore";
// Apply the resolved theme synchronously before the app mounts so the first
// paint already uses the correct colors (no flash of the base stylesheet theme).
@@ -50,6 +51,8 @@ app.use(i18n);
// const router = createRouter(store);
app.use(store).use(router);
// Bound before mount: a 403 while the layout still shows its skeleton has no boundary to bind it yet.
bindAccess(router, store);
app.use(VueQueryPlugin, { queryClient });

Some files were not shown because too many files have changed in this diff Show More